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

+54 -6
View File
@@ -19,10 +19,18 @@ The binary does three things at once:
- Serves a multipart upload API at `POST /upload` - Serves a multipart upload API at `POST /upload`
- In dev mode (`--serve`), also serves `public/` as static files - In dev mode (`--serve`), also serves `public/` as static files
Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart), `GET /health`. Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart),
Both `/upload` routes are gated by HTTP Basic auth when `-htpasswd` is set (see Auth); `/health` is always open. `GET /manage` (edit/delete UI), the manage API under `/api/` (see Manage),
`GET /health`. Every route except `/health` is gated by HTTP Basic auth when
`-htpasswd` is set (see Auth).
In production nginx sits in front: serves `public/` directly, proxies `/upload` to localhost, terminates TLS. Auth is **no longer nginx's job** — the app checks credentials itself (`auth.go`), so the nginx vhost just proxies. Note `/health` is **not** proxied — nginx only forwards `location /upload`, so anything outside the box can only reach `/upload`. In production nginx sits in front: serves `public/` directly, proxies the
dynamic endpoints (`/upload`, `/manage`, `/api/`) to localhost, terminates TLS.
Auth is **no longer nginx's job** — the app checks credentials itself
(`auth.go`), so the nginx vhost just proxies. `/health` is **not** proxied, so
only `/upload`, `/manage` and `/api/` are reachable from outside the box;
anything new the app serves needs a matching `location` in `module.nix` or
nginx will 404 it as a missing static file.
## Auth ## Auth
@@ -40,6 +48,44 @@ comparison (against a dummy hash) so timing can't enumerate usernames, and
never touch it. Both apps (Android, Qt desktop) already send Basic auth, so no never touch it. Both apps (Android, Qt desktop) already send Basic auth, so no
client change was needed. Deps: `golang.org/x/crypto/bcrypt`, `golang.org/x/time/rate`. client change was needed. Deps: `golang.org/x/crypto/bcrypt`, `golang.org/x/time/rate`.
## Manage (edit / delete)
`GET /manage` is an auth-gated web page (`templates/manage.html`) for editing
and deleting posts; it is **not linked from the public site** (reach it by URL).
Its API, all behind the same Basic auth and handled in `manage.go`:
- `GET /api/photos` — every post as JSON (`slug, caption, date, tags, thumb, url`), newest first.
- `POST /api/edit` — rewrite one post's sidecar (`slug, caption, date, tags`). The slug (and URL) never changes; editing only touches the `.toml`.
- `POST /api/tags` — bulk add/remove tags across posts (`slugs` CSV + `add`/`remove` CSV), leaving each post's caption and date intact.
- `POST /api/delete` — move posts (`slug` repeated, or `slugs` CSV) to `content/.trash/` (timestamp-suffixed, reversible), **not** a hard delete.
- `GET /api/trash` — list trashed items (`id` = `<slug>.<stamp>`, `slug`, `deletedAt`, `image`, `hasMeta`), newest first.
- `POST /api/restore` — move a trashed item (`id`) back to `content/`, stripping the timestamp. Refuses with `409` if a live post already holds that slug.
- `POST /api/trash/empty` — **hard delete** trashed items: specific `id`s (repeated), or everything with `all=1`. The only non-reversible delete in the system.
The manage page has a Trash panel (header toggle) for restore / delete-forever /
empty. `trash.go` handles these; filenames parse as `<slug>.<stamp><ext>` (slug
is dot-free and the stamp is a fixed `20060102-150405`, so parts peel off
unambiguously), and `id`s are validated the same way to block traversal.
Each non-trash mutation triggers the same async rebuild as upload. Sidecars are written
atomically (temp + rename) so a concurrent build never sees a half-written
file. Slugs are validated (`resolveSlug`: bare filename only, must name an
existing content image) which closes path traversal and dotfile access.
Every state-changing POST (`edit`, `delete`, `tags`, `restore`, `trash/empty`)
first passes `sameOrigin` (`server.go`): because Basic-auth credentials are
replayed by the browser on every request to the host, a cross-site page could
otherwise drive a logged-in user into a delete (CSRF). The check honours
`Sec-Fetch-Site`, falls back to comparing `Origin` against `-base-url`/`Host`,
and allows header-less non-browser clients (curl). Cross-site → `403`. (Ported
from the independent `/manage` on `plex/trunk-ng`.)
**Delete relies on `Build`'s prune step** (`generator.go`): a rebuild drops the
derived HTML trees (`photo/ day/ album/ page/ grid/page/`) and regenerates them,
and `pruneRenditions` removes `images|thumbs|medium|og` files with no backing
photo. Without this a deleted post would linger at its old URL and in stale
day/album pages. This also cleans up after a sidecar removed by hand.
## Stack ## Stack
- **Go** — stdlib + `BurntSushi/toml`, `fsnotify/fsnotify`, `golang.org/x/image`, `golang.org/x/crypto` (bcrypt), `golang.org/x/time` (rate) - **Go** — stdlib + `BurntSushi/toml`, `fsnotify/fsnotify`, `golang.org/x/image`, `golang.org/x/crypto` (bcrypt), `golang.org/x/time` (rate)
@@ -181,6 +227,9 @@ nix build # succeeds
| `albums.go` | Tag parsing/normalization, `groupByTag`, album pages + `/albums/` + `/tags.json` | | `albums.go` | Tag parsing/normalization, `groupByTag`, album pages + `/albums/` + `/tags.json` |
| `server.go` | Upload API + optional static file serving | | `server.go` | Upload API + optional static file serving |
| `auth.go` | htpasswd (bcrypt) HTTP Basic auth + failed-attempt rate limit for `/upload` | | `auth.go` | htpasswd (bcrypt) HTTP Basic auth + failed-attempt rate limit for `/upload` |
| `manage.go` | Edit/delete API (`/api/photos`, `/api/edit`, `/api/tags`, `/api/delete`) + trash move + atomic sidecar writes |
| `trash.go` | Trash API: list (`/api/trash`), `/api/restore`, `/api/trash/empty` (hard delete) |
| `templates/manage.html` | Auth-gated edit/delete web UI at `/manage`, with a Trash panel |
| `watcher.go` | fsnotify watcher with 500ms debounce | | `watcher.go` | fsnotify watcher with 500ms debounce |
| `assets.go` | `//go:embed` for templates and static | | `assets.go` | `//go:embed` for templates and static |
| `templates/timeline.html` | Site root: photos grouped by day, paginated | | `templates/timeline.html` | Site root: photos grouped by day, paginated |
@@ -201,8 +250,7 @@ nix build # succeeds
## What's not done yet ## What's not done yet
- ActivityPub federation (planned later — start with RSS/Atom) - ActivityPub federation (planned later — start with RSS/Atom)
- Multi-image upload - Edit/delete/trash in the Android and Qt apps (web `/manage` only so far; the `/api/` endpoints are there for the apps to adopt)
- Delete/edit via UI - Any auth beyond the app's htpasswd Basic auth (no sessions, no per-user scopes)
- Any auth beyond the app's htpasswd Basic auth on `/upload` (no sessions, no per-user scopes)
- The Android app has never been run on real hardware - The Android app has never been run on real hardware
- Photo-page swipe has never been tested on a real touchscreen; the iOS Safari edge-gesture interaction is the untested part - Photo-page swipe has never been tested on a real touchscreen; the iOS Safari edge-gesture interaction is the untested part
+47
View File
@@ -123,12 +123,30 @@ func (g *Generator) Build() error {
return err return err
} }
// Drop fully-derived HTML trees before regenerating them, so a deleted
// post, a retag or a changed date leaves no orphaned page behind. These are
// cheap to rewrite (no up-to-date skipping applies to HTML), unlike the
// image renditions, which are pruned by slug just below.
for _, d := range []string{"photo", "day", "album", "page"} {
if err := os.RemoveAll(filepath.Join(g.outputDir, d)); err != nil {
return err
}
}
if err := os.RemoveAll(filepath.Join(g.outputDir, "grid", "page")); err != nil {
return err
}
for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "grid", "day", "album", "albums", "static"} { for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "grid", "day", "album", "albums", "static"} {
if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil { if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
return err return err
} }
} }
// Remove rendition files whose source photo is gone (e.g. after a delete),
// keeping the expensive resize outputs of surviving photos in place so
// up-to-date skipping still works.
g.pruneRenditions(photos)
for i := range photos { for i := range photos {
if err := g.processImage(&photos[i]); err != nil { if err := g.processImage(&photos[i]); err != nil {
log.Printf("processImage %s: %v", photos[i].Slug, err) log.Printf("processImage %s: %v", photos[i].Slug, err)
@@ -231,6 +249,35 @@ func (g *Generator) loadPhotos() ([]Photo, error) {
return photos, nil return photos, nil
} }
// pruneRenditions deletes files in the image output directories that no longer
// have a backing photo in content/. Called on every build, it is what makes a
// delete (and the stale sidecar left by any removed source) actually disappear
// from public/ rather than linger as an orphan reachable by direct URL.
func (g *Generator) pruneRenditions(photos []Photo) {
valid := make(map[string]bool, len(photos))
for _, p := range photos {
valid[p.Slug] = true
}
for _, d := range []string{"images", "thumbs", "medium", "og"} {
full := filepath.Join(g.outputDir, d)
entries, err := os.ReadDir(full)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() {
continue
}
slug := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
if !valid[slug] {
if err := os.Remove(filepath.Join(full, e.Name())); err != nil {
log.Printf("prune %s/%s: %v", d, e.Name(), err)
}
}
}
}
}
// processImage copies the original to public/images/ and writes a square // processImage copies the original to public/images/ and writes a square
// thumbnail to public/thumbs/ plus an aspect-preserved medium image to // thumbnail to public/thumbs/ plus an aspect-preserved medium image to
// public/medium/. Outputs are skipped when already newer than the source. // public/medium/. Outputs are skipped when already newer than the source.
+318
View File
@@ -0,0 +1,318 @@
package main
import (
"encoding/json"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/BurntSushi/toml"
)
// contentExts are the image extensions a post may use; the sidecar is always
// <slug>.toml alongside.
var contentExts = []string{".jpg", ".jpeg", ".png"}
// photoJSON is one row of GET /api/photos, consumed by the manage page.
type photoJSON struct {
Slug string `json:"slug"`
Caption string `json:"caption"`
Date string `json:"date"` // YYYY-MM-DD
Tags []string `json:"tags"`
Thumb string `json:"thumb"`
URL string `json:"url"`
}
// handleManagePage serves the (auth-gated) edit/delete UI.
func (s *Server) handleManagePage(w http.ResponseWriter, r *http.Request) {
data, err := assets.ReadFile("templates/manage.html")
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(data)
}
// handleListPhotos returns every post as JSON, newest first, for the manage UI.
func (s *Server) handleListPhotos(w http.ResponseWriter, r *http.Request) {
photos, err := s.generator.loadPhotos()
if err != nil {
http.Error(w, "could not list photos", http.StatusInternalServerError)
return
}
out := make([]photoJSON, 0, len(photos))
for _, p := range photos {
tags := make([]string, 0, len(p.Tags))
for _, t := range p.Tags {
tags = append(tags, t.Name)
}
out = append(out, photoJSON{
Slug: p.Slug,
Caption: p.Caption,
Date: p.Date.Format("2006-01-02"),
Tags: tags,
Thumb: "/" + p.Thumb,
URL: "/photo/" + p.Slug + "/",
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
}
// handleEdit rewrites one post's sidecar (caption, date, tags). The slug — and
// therefore the URL — never changes.
func (s *Server) handleEdit(w http.ResponseWriter, r *http.Request) {
if s.rejectCrossSite(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
slug := r.FormValue("slug")
if _, ok := s.resolveSlug(slug); !ok {
http.Error(w, "unknown post", http.StatusNotFound)
return
}
date := r.FormValue("date")
if date == "" {
date = time.Now().Format("2006-01-02")
}
if _, err := time.Parse("2006-01-02", date); err != nil {
http.Error(w, "date must be YYYY-MM-DD", http.StatusBadRequest)
return
}
caption := r.FormValue("caption")
if caption == "" {
caption = slug
}
var tags []string
for _, t := range parseTags(r.FormValue("tags")) {
tags = append(tags, t.Name)
}
if err := s.writeSidecar(slug, PhotoMeta{Caption: caption, Date: date, Tags: tags}); err != nil {
log.Printf("edit %s: %v", slug, err)
http.Error(w, "could not save", http.StatusInternalServerError)
return
}
s.rebuild()
w.WriteHeader(http.StatusOK)
}
// handleDelete moves one or more posts (image + sidecar) to content/.trash/.
// Slugs come as repeated `slug` fields or a comma-separated `slugs` field.
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
if s.rejectCrossSite(w, r) {
return
}
slugs, err := s.formSlugs(r)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
deleted := 0
for _, slug := range slugs {
if _, ok := s.resolveSlug(slug); !ok {
continue
}
if err := s.moveToTrash(slug); err != nil {
log.Printf("delete %s: %v", slug, err)
continue
}
deleted++
}
if deleted > 0 {
s.rebuild()
}
writeJSON(w, map[string]int{"deleted": deleted})
}
// handleBulkTags adds and/or removes tags across the given posts, leaving each
// post's caption and date untouched. `add` and `remove` are comma-separated.
func (s *Server) handleBulkTags(w http.ResponseWriter, r *http.Request) {
if s.rejectCrossSite(w, r) {
return
}
slugs, err := s.formSlugs(r)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
add := parseTags(r.FormValue("add"))
remove := make(map[string]bool)
for _, t := range parseTags(r.FormValue("remove")) {
remove[t.Name] = true
}
if len(add) == 0 && len(remove) == 0 {
http.Error(w, "nothing to add or remove", http.StatusBadRequest)
return
}
updated := 0
for _, slug := range slugs {
if _, ok := s.resolveSlug(slug); !ok {
continue
}
meta := s.readSidecar(slug)
// Start from the current tags, append the additions, drop the removals,
// then let parseTags normalise and dedupe the result.
names := append([]string{}, meta.Tags...)
for _, t := range add {
names = append(names, t.Name)
}
var kept []string
for _, t := range parseTags(strings.Join(names, ",")) {
if !remove[t.Name] {
kept = append(kept, t.Name)
}
}
meta.Tags = kept
if err := s.writeSidecar(slug, meta); err != nil {
log.Printf("bulk tags %s: %v", slug, err)
continue
}
updated++
}
if updated > 0 {
s.rebuild()
}
writeJSON(w, map[string]int{"updated": updated})
}
// ── helpers ────────────────────────────────────────────────────────────────
// resolveSlug reports whether slug names an existing post and returns the image
// path. It rejects anything that is not a bare, safe filename, closing path
// traversal and access to dotfiles such as the .trash directory.
func (s *Server) resolveSlug(slug string) (string, bool) {
if slug == "" || slug != filepath.Base(slug) || strings.HasPrefix(slug, ".") {
return "", false
}
for _, ext := range contentExts {
p := filepath.Join(s.contentDir, slug+ext)
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
return p, true
}
}
return "", false
}
// formSlugs collects slugs from repeated `slug` fields and/or a comma-separated
// `slugs` field, de-duplicated.
func (s *Server) formSlugs(r *http.Request) ([]string, error) {
if err := r.ParseForm(); err != nil {
return nil, err
}
seen := make(map[string]bool)
var out []string
add := func(v string) {
v = strings.TrimSpace(v)
if v != "" && !seen[v] {
seen[v] = true
out = append(out, v)
}
}
for _, v := range r.Form["slug"] {
add(v)
}
for _, v := range strings.Split(r.FormValue("slugs"), ",") {
add(v)
}
return out, nil
}
// readSidecar loads a post's existing metadata, falling back to the same
// defaults loadPhotos uses when the sidecar is missing or unreadable.
func (s *Server) readSidecar(slug string) PhotoMeta {
meta := PhotoMeta{Caption: slug, Date: time.Now().Format("2006-01-02")}
path := filepath.Join(s.contentDir, slug+".toml")
if _, err := os.Stat(path); err == nil {
if _, err := toml.DecodeFile(path, &meta); err != nil {
log.Printf("read sidecar %s: %v", path, err)
}
}
return meta
}
// writeSidecar writes <slug>.toml atomically (temp file + rename) so a
// concurrent build can never observe a half-written sidecar.
func (s *Server) writeSidecar(slug string, meta PhotoMeta) error {
path := filepath.Join(s.contentDir, slug+".toml")
tmp, err := os.CreateTemp(s.contentDir, ".tmp-sidecar-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // no-op once the rename below succeeds
if err := toml.NewEncoder(tmp).Encode(meta); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}
// moveToTrash relocates a post's image and sidecar into content/.trash/, so a
// delete is reversible. A timestamp keeps re-deleted slugs from colliding.
func (s *Server) moveToTrash(slug string) error {
trash := filepath.Join(s.contentDir, ".trash")
if err := os.MkdirAll(trash, 0755); err != nil {
return err
}
stamp := time.Now().Format("20060102-150405")
// Move every file the post owns: its image (whichever extension) and its
// sidecar. Missing pieces are not an error.
var names []string
for _, ext := range contentExts {
names = append(names, slug+ext)
}
names = append(names, slug+".toml")
for _, name := range names {
src := filepath.Join(s.contentDir, name)
if _, err := os.Stat(src); err != nil {
continue
}
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
dst := filepath.Join(trash, base+"."+stamp+ext)
if err := os.Rename(src, dst); err != nil {
return err
}
}
return nil
}
// rebuild kicks off an asynchronous site rebuild, matching the upload handler.
// Build is mutex-guarded, so overlapping calls are safe. Tests may install a
// rebuildHook to make this deterministic.
func (s *Server) rebuild() {
if s.rebuildHook != nil {
s.rebuildHook()
return
}
go func() {
if err := s.generator.Build(); err != nil {
log.Printf("rebuild after manage op: %v", err)
}
}()
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
+285
View File
@@ -0,0 +1,285 @@
package main
import (
"encoding/json"
"image"
"image/png"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/BurntSushi/toml"
)
// newTestServer builds a Server over fresh temp content/output dirs with a
// couple of posts, runs one build, and returns it.
func newTestServer(t *testing.T) (*Server, string, string) {
t.Helper()
content := t.TempDir()
output := t.TempDir()
writePNG(t, filepath.Join(content, "foo.png"))
if err := os.WriteFile(filepath.Join(content, "foo.toml"),
[]byte("caption = \"Foo\"\ndate = \"2026-01-02\"\ntags = [\"berlin\", \"street\"]\n"), 0600); err != nil {
t.Fatal(err)
}
writePNG(t, filepath.Join(content, "bar.png")) // no sidecar
g := NewGenerator(content, output, "http://x", "A", "@a", "bio", "", "f", "s", "m", 60)
if err := g.Build(); err != nil {
t.Fatalf("initial build: %v", err)
}
s := NewServer("0", content, output, false, g, nil)
// Make rebuilds a no-op; tests call Build explicitly when they need to
// assert on generated output, so no background goroutine races teardown.
s.rebuildHook = func() {}
return s, content, output
}
func writePNG(t *testing.T, path string) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := png.Encode(f, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
t.Fatal(err)
}
}
func form(v url.Values) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(v.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return r
}
func readMeta(t *testing.T, content, slug string) PhotoMeta {
t.Helper()
var m PhotoMeta
if _, err := toml.DecodeFile(filepath.Join(content, slug+".toml"), &m); err != nil {
t.Fatalf("decode %s: %v", slug, err)
}
return m
}
func TestResolveSlug(t *testing.T) {
s, _, _ := newTestServer(t)
cases := map[string]bool{
"foo": true,
"bar": true,
"nope": false,
"../server": false,
"foo/../foo": false,
".trash": false,
"": false,
"sub/foo": false,
}
for slug, want := range cases {
if _, ok := s.resolveSlug(slug); ok != want {
t.Errorf("resolveSlug(%q) = %v, want %v", slug, ok, want)
}
}
}
func TestHandleEdit(t *testing.T) {
s, content, _ := newTestServer(t)
rec := httptest.NewRecorder()
s.handleEdit(rec, form(url.Values{
"slug": {"foo"},
"caption": {"New caption"},
"date": {"2020-05-06"},
"tags": {"Paris, , night"},
}))
if rec.Code != http.StatusOK {
t.Fatalf("edit: code=%d body=%s", rec.Code, rec.Body)
}
m := readMeta(t, content, "foo")
if m.Caption != "New caption" || m.Date != "2020-05-06" {
t.Errorf("caption/date not saved: %+v", m)
}
if strings.Join(m.Tags, ",") != "paris,night" { // lowercased, empty dropped
t.Errorf("tags = %v, want [paris night]", m.Tags)
}
// Bad date rejected.
rec = httptest.NewRecorder()
s.handleEdit(rec, form(url.Values{"slug": {"foo"}, "date": {"06-05-2020"}}))
if rec.Code != http.StatusBadRequest {
t.Errorf("bad date: code=%d, want 400", rec.Code)
}
// Unknown slug rejected (and cannot escape content dir).
rec = httptest.NewRecorder()
s.handleEdit(rec, form(url.Values{"slug": {"../evil"}, "date": {"2020-01-01"}}))
if rec.Code != http.StatusNotFound {
t.Errorf("unknown slug: code=%d, want 404", rec.Code)
}
}
func TestHandleBulkTags(t *testing.T) {
s, content, _ := newTestServer(t)
// Add to bar (no sidecar yet) and foo (already has tags).
rec := httptest.NewRecorder()
s.handleBulkTags(rec, form(url.Values{"slugs": {"foo,bar"}, "add": {"aqua, berlin"}}))
if rec.Code != http.StatusOK {
t.Fatalf("bulk add: code=%d body=%s", rec.Code, rec.Body)
}
foo := readMeta(t, content, "foo")
// berlin already present → not duplicated; aqua appended.
if got := strings.Join(foo.Tags, ","); got != "berlin,street,aqua" {
t.Errorf("foo tags = %q, want berlin,street,aqua", got)
}
if got := strings.Join(readMeta(t, content, "bar").Tags, ","); got != "aqua,berlin" {
t.Errorf("bar tags = %q, want aqua,berlin", got)
}
// Remove across both.
rec = httptest.NewRecorder()
s.handleBulkTags(rec, form(url.Values{"slugs": {"foo,bar"}, "remove": {"berlin"}}))
if rec.Code != http.StatusOK {
t.Fatalf("bulk remove: code=%d", rec.Code)
}
if got := strings.Join(readMeta(t, content, "foo").Tags, ","); got != "street,aqua" {
t.Errorf("foo tags after remove = %q, want street,aqua", got)
}
// Empty add+remove is a 400.
rec = httptest.NewRecorder()
s.handleBulkTags(rec, form(url.Values{"slugs": {"foo"}}))
if rec.Code != http.StatusBadRequest {
t.Errorf("empty bulk: code=%d, want 400", rec.Code)
}
}
func TestHandleDeleteMovesToTrashAndPrunes(t *testing.T) {
s, content, output := newTestServer(t)
// Pre-condition: foo's rendition and page exist.
for _, p := range []string{"thumbs/foo.jpg", "images/foo.png", "photo/foo/index.html"} {
if _, err := os.Stat(filepath.Join(output, p)); err != nil {
t.Fatalf("expected %s before delete: %v", p, err)
}
}
rec := httptest.NewRecorder()
s.handleDelete(rec, form(url.Values{"slug": {"foo"}}))
if rec.Code != http.StatusOK {
t.Fatalf("delete: code=%d", rec.Code)
}
var res map[string]int
json.Unmarshal(rec.Body.Bytes(), &res)
if res["deleted"] != 1 {
t.Errorf("deleted = %d, want 1", res["deleted"])
}
// Source files moved out of content/ ...
if _, err := os.Stat(filepath.Join(content, "foo.png")); !os.IsNotExist(err) {
t.Error("foo.png should be gone from content")
}
// ... and into .trash/ (timestamp-suffixed).
trash, _ := os.ReadDir(filepath.Join(content, ".trash"))
var png, tml bool
for _, e := range trash {
if strings.HasPrefix(e.Name(), "foo.") && strings.HasSuffix(e.Name(), ".png") {
png = true
}
if strings.HasPrefix(e.Name(), "foo.") && strings.HasSuffix(e.Name(), ".toml") {
tml = true
}
}
if !png || !tml {
t.Errorf("trash missing files: png=%v toml=%v entries=%v", png, tml, trash)
}
// The handler rebuilds asynchronously; run a deterministic build, then
// confirm the prune removed foo's output and kept bar's.
if err := s.generator.Build(); err != nil {
t.Fatal(err)
}
for _, p := range []string{"thumbs/foo.jpg", "images/foo.png", "photo/foo"} {
if _, err := os.Stat(filepath.Join(output, p)); !os.IsNotExist(err) {
t.Errorf("%s should have been pruned", p)
}
}
if _, err := os.Stat(filepath.Join(output, "thumbs/bar.jpg")); err != nil {
t.Errorf("bar's thumb should survive: %v", err)
}
}
func TestCSRFGuard(t *testing.T) {
s, _, _ := newTestServer(t) // generator.baseURL == "http://x"
// A cross-site Origin is refused before any mutation runs.
r := form(url.Values{"slug": {"foo"}})
r.Header.Set("Origin", "http://evil.example")
rec := httptest.NewRecorder()
s.handleDelete(rec, r)
if rec.Code != http.StatusForbidden {
t.Errorf("cross-site Origin: code=%d, want 403", rec.Code)
}
// Sec-Fetch-Site: cross-site is likewise refused.
r = form(url.Values{"slug": {"foo"}})
r.Header.Set("Sec-Fetch-Site", "cross-site")
rec = httptest.NewRecorder()
s.handleDelete(rec, r)
if rec.Code != http.StatusForbidden {
t.Errorf("Sec-Fetch cross-site: code=%d, want 403", rec.Code)
}
// Same-origin is allowed through (no slugs → deletes nothing, 200).
r = form(url.Values{})
r.Header.Set("Sec-Fetch-Site", "same-origin")
rec = httptest.NewRecorder()
s.handleDelete(rec, r)
if rec.Code != http.StatusOK {
t.Errorf("same-origin: code=%d, want 200", rec.Code)
}
// A matching Origin is allowed.
r = form(url.Values{})
r.Header.Set("Origin", "http://x")
rec = httptest.NewRecorder()
s.handleDelete(rec, r)
if rec.Code != http.StatusOK {
t.Errorf("matching Origin: code=%d, want 200", rec.Code)
}
}
func TestHandleListPhotos(t *testing.T) {
s, _, _ := newTestServer(t)
rec := httptest.NewRecorder()
s.handleListPhotos(rec, httptest.NewRequest(http.MethodGet, "/api/photos", nil))
if rec.Code != http.StatusOK {
t.Fatalf("list: code=%d", rec.Code)
}
var photos []photoJSON
if err := json.Unmarshal(rec.Body.Bytes(), &photos); err != nil {
t.Fatalf("decode: %v", err)
}
if len(photos) != 2 {
t.Fatalf("got %d photos, want 2", len(photos))
}
var foo *photoJSON
for i := range photos {
if photos[i].Slug == "foo" {
foo = &photos[i]
}
}
if foo == nil {
t.Fatal("foo not in list")
}
if foo.Thumb != "/thumbs/foo.jpg" || foo.URL != "/photo/foo/" {
t.Errorf("foo paths wrong: %+v", foo)
}
if strings.Join(foo.Tags, ",") != "berlin,street" {
t.Errorf("foo tags = %v", foo.Tags)
}
}
+12 -1
View File
@@ -194,11 +194,22 @@ in {
}; };
# Auth is handled by the app (see services.photogallery.htpasswdFile), # Auth is handled by the app (see services.photogallery.htpasswdFile),
# so nginx just proxies the upload endpoint. # so nginx just proxies the dynamic endpoints: the upload form/API,
# the manage (edit/delete) page and its JSON API. Everything else is
# the static site served from root above.
locations."/upload" = { locations."/upload" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}"; proxyPass = "http://127.0.0.1:${toString cfg.port}";
extraConfig = "client_max_body_size 20M;"; extraConfig = "client_max_body_size 20M;";
}; };
locations."/manage" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}";
};
locations."/api/" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}";
extraConfig = "client_max_body_size 20M;";
};
}; };
}; };
}) })
+53
View File
@@ -5,6 +5,7 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -22,6 +23,11 @@ type Server struct {
generator *Generator generator *Generator
auth *Authenticator // nil disables auth (dev only) auth *Authenticator // nil disables auth (dev only)
authLimit *rate.Limiter 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 { 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) 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 { func (s *Server) Listen() error {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /upload", s.guard(s.handleUploadPage)) mux.HandleFunc("GET /upload", s.guard(s.handleUploadPage))
mux.HandleFunc("POST /upload", s.guard(s.handleUpload)) 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) { mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok") fmt.Fprintln(w, "ok")
}) })
+339
View File
@@ -0,0 +1,339 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manage — Photo Gallery</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Crimson+Pro:ital,wght@0,400;0,600;0,700;1,400&family=Manjari:wght@100;400;700&display=swap">
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="/static/favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/web-192.png">
<style>
.manage-page { max-width: 760px; margin: 0 auto; padding: 28px 20px 80px; }
.manage-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.manage-head h2 { font-family: var(--serif); font-size: 22px; font-weight: 600; color: var(--fg); }
.manage-head a { font-family: monospace; font-size: 13px; color: var(--fg4); text-decoration: none; }
.manage-head a:hover { color: var(--fg2); }
#count { font-family: monospace; font-size: 12px; color: var(--fg4); margin-bottom: 20px; }
/* Bulk action bar, sticky at the top once posts are selected. */
.bulkbar {
position: sticky; top: 0; z-index: 5;
display: none; flex-wrap: wrap; align-items: center; gap: 10px;
background: var(--bg1); border: 1px solid var(--bg2);
padding: 12px 14px; margin-bottom: 20px;
}
.bulkbar.show { display: flex; }
.bulkbar .sel { font-family: monospace; font-size: 12px; color: var(--fg2); margin-right: auto; }
.bulkbar input[type="text"] {
background: var(--bg0); border: 1px solid var(--bg2); color: var(--fg);
font-family: var(--serif); font-size: 14px; padding: 7px 9px; width: 150px; outline: none;
}
.bulkbar input:focus { border-color: var(--fg4); }
.btn {
background: var(--bg1); border: 1px solid var(--bg2); color: var(--fg2);
font-family: var(--serif); font-size: 14px; padding: 7px 12px; cursor: pointer;
transition: background .2s, color .2s;
}
.btn:hover:not(:disabled) { background: var(--bg2); color: var(--fg); }
.btn:disabled { opacity: .35; cursor: not-allowed; }
.btn.danger { color: #fb4934; border-color: #5a2828; }
.btn.danger:hover:not(:disabled) { background: #2e1d1d; color: #fb4934; }
.row {
display: grid; grid-template-columns: 22px 72px 1fr auto; gap: 14px;
align-items: start; padding: 16px 0; border-top: 1px solid var(--bg1);
}
.row input[type="checkbox"] { margin-top: 28px; width: 16px; height: 16px; accent-color: var(--aqua, #8ec07c); }
.row .thumb { width: 72px; height: 72px; object-fit: cover; background: var(--bg1); display: block; }
.fields { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.fields label { font-size: 11px; color: var(--fg4); font-family: monospace; text-transform: uppercase; letter-spacing: .06em; }
.fields input {
width: 100%; background: var(--bg0); border: 1px solid var(--bg2); color: var(--fg);
font-size: 15px; padding: 8px 10px; outline: none; box-sizing: border-box;
}
.fields input[type="text"] { font-family: var(--serif); }
.fields input[type="date"] { font-family: monospace; font-size: 13px; color-scheme: dark; width: auto; }
.fields input:focus { border-color: var(--fg4); }
.rowactions { display: flex; flex-direction: column; gap: 8px; }
@media (max-width: 560px) {
.row { grid-template-columns: 22px 56px 1fr; }
.row .rowactions { grid-column: 2 / 4; flex-direction: row; }
.row input[type="checkbox"] { margin-top: 20px; }
}
.trashpanel { display: none; border: 1px solid var(--bg2); background: var(--bg0); padding: 14px; margin-bottom: 24px; }
.trashpanel.show { display: block; }
.trashhead { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
.trashhead strong { font-family: var(--serif); color: var(--fg); }
.trashhead #trash-count { font-family: monospace; font-size: 12px; color: var(--fg4); margin-right: auto; }
.trashrow { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-top: 1px solid var(--bg1); }
.trashrow .meta { margin-right: auto; min-width: 0; }
.trashrow .slug { font-family: var(--serif); color: var(--fg2); }
.trashrow .when { font-family: monospace; font-size: 11px; color: var(--fg4); }
#trash-empty-msg { color: var(--fg4); font-family: var(--serif); padding: 8px 0; }
#toast {
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
display: none; font-family: monospace; font-size: 13px; padding: 10px 16px; max-width: 90%;
}
#toast.ok { display: block; background: #1d2e1d; color: #b8bb26; border: 1px solid #344d28; }
#toast.err { display: block; background: #2e1d1d; color: #fb4934; border: 1px solid #5a2828; }
#empty { color: var(--fg4); font-family: var(--serif); padding: 40px 0; }
</style>
</head>
<body>
<div class="manage-page">
<div class="manage-head">
<h2>Manage posts</h2>
<span><a href="/upload">+ Upload</a> &nbsp; <a href="#" id="trash-toggle">Trash</a> &nbsp; <a href="/">← Gallery</a></span>
</div>
<div id="count">Loading…</div>
<div class="trashpanel" id="trashpanel">
<div class="trashhead">
<strong>Trash</strong>
<span id="trash-count"></span>
<button class="btn danger" id="btn-empty-all">Empty trash</button>
</div>
<div id="trashlist"></div>
<div id="trash-empty-msg" style="display:none">Trash is empty.</div>
</div>
<div class="bulkbar" id="bulkbar">
<span class="sel" id="sel">0 selected</span>
<input type="text" id="bulk-add" placeholder="add tags" autocomplete="off">
<button class="btn" id="btn-add">Add</button>
<input type="text" id="bulk-remove" placeholder="remove tags" autocomplete="off">
<button class="btn" id="btn-remove">Remove</button>
<button class="btn danger" id="btn-del-sel">Delete selected</button>
</div>
<div id="list"></div>
<div id="empty" style="display:none">No posts yet.</div>
</div>
<div id="toast"></div>
<script>
const list = document.getElementById('list');
const countEl = document.getElementById('count');
const emptyEl = document.getElementById('empty');
const bulkbar = document.getElementById('bulkbar');
const selEl = document.getElementById('sel');
const toast = document.getElementById('toast');
let toastTimer;
function flash(msg, ok) {
toast.textContent = msg;
toast.className = ok ? 'ok' : 'err';
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toast.className = ''; }, 3500);
}
function selectedSlugs() {
return [...list.querySelectorAll('input.pick:checked')].map(c => c.dataset.slug);
}
function refreshBulk() {
const n = selectedSlugs().length;
selEl.textContent = n + ' selected';
bulkbar.classList.toggle('show', n > 0);
}
function row(p) {
const el = document.createElement('div');
el.className = 'row';
el.dataset.slug = p.slug;
el.innerHTML = `
<input type="checkbox" class="pick" data-slug="${p.slug}" aria-label="Select">
<a href="${p.url}" target="_blank" rel="noopener"><img class="thumb" src="${p.thumb}" alt=""></a>
<div class="fields">
<label>Caption</label>
<input type="text" class="f-caption" value="">
<label>Date</label>
<input type="date" class="f-date" value="${p.date}">
<label>Tags (comma-separated)</label>
<input type="text" class="f-tags" value="">
</div>
<div class="rowactions">
<button class="btn f-save">Save</button>
<button class="btn danger f-del">Delete</button>
</div>`;
// Set values via property to avoid HTML-escaping issues in attributes.
el.querySelector('.f-caption').value = p.caption;
el.querySelector('.f-tags').value = (p.tags || []).join(', ');
el.querySelector('.pick').addEventListener('change', refreshBulk);
el.querySelector('.f-save').addEventListener('click', () => save(el, p.slug));
el.querySelector('.f-del').addEventListener('click', () => delOne(el, p.slug));
return el;
}
async function load() {
try {
const res = await fetch('/api/photos', { headers: { 'Accept': 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
const photos = await res.json();
list.innerHTML = '';
photos.forEach(p => list.appendChild(row(p)));
countEl.textContent = photos.length + (photos.length === 1 ? ' post' : ' posts');
emptyEl.style.display = photos.length ? 'none' : 'block';
refreshBulk();
} catch (err) {
countEl.textContent = '';
flash('Could not load posts: ' + err.message, false);
}
}
async function post(url, body) {
const res = await fetch(url, { method: 'POST', body });
if (!res.ok) throw new Error((await res.text()).trim() || ('HTTP ' + res.status));
return res;
}
async function save(el, slug) {
const form = new FormData();
form.append('slug', slug);
form.append('caption', el.querySelector('.f-caption').value);
form.append('date', el.querySelector('.f-date').value);
form.append('tags', el.querySelector('.f-tags').value);
try {
await post('/api/edit', form);
flash('Saved ' + slug, true);
} catch (err) { flash('Save failed: ' + err.message, false); }
}
async function delOne(el, slug) {
if (!confirm('Move "' + slug + '" to trash?')) return;
const form = new FormData();
form.append('slug', slug);
try {
await post('/api/delete', form);
el.remove();
flash('Moved ' + slug + ' to trash', true);
refreshBulk();
updateCount();
} catch (err) { flash('Delete failed: ' + err.message, false); }
}
function updateCount() {
const n = list.querySelectorAll('.row').length;
countEl.textContent = n + (n === 1 ? ' post' : ' posts');
emptyEl.style.display = n ? 'none' : 'block';
}
document.getElementById('btn-del-sel').addEventListener('click', async () => {
const slugs = selectedSlugs();
if (!slugs.length) return;
if (!confirm('Move ' + slugs.length + ' post(s) to trash?')) return;
const form = new FormData();
form.append('slugs', slugs.join(','));
try {
await post('/api/delete', form);
flash('Moved ' + slugs.length + ' to trash', true);
await load();
} catch (err) { flash('Delete failed: ' + err.message, false); }
});
async function bulkTags(field, label) {
const slugs = selectedSlugs();
const val = document.getElementById(field).value.trim();
if (!slugs.length || !val) return;
const form = new FormData();
form.append('slugs', slugs.join(','));
form.append(label, val);
try {
await post('/api/tags', form);
document.getElementById(field).value = '';
flash('Updated tags on ' + slugs.length + ' post(s)', true);
await load();
} catch (err) { flash('Tag update failed: ' + err.message, false); }
}
document.getElementById('btn-add').addEventListener('click', () => bulkTags('bulk-add', 'add'));
document.getElementById('btn-remove').addEventListener('click', () => bulkTags('bulk-remove', 'remove'));
// ── Trash ────────────────────────────────────────────────────────────────
const trashpanel = document.getElementById('trashpanel');
const trashlist = document.getElementById('trashlist');
const trashCount = document.getElementById('trash-count');
const trashEmpty = document.getElementById('trash-empty-msg');
function trashRow(it) {
const el = document.createElement('div');
el.className = 'trashrow';
const parts = [];
if (it.image) parts.push('image');
if (it.hasMeta) parts.push('caption/tags');
el.innerHTML = `
<div class="meta">
<div class="slug"></div>
<div class="when">deleted ${it.deletedAt} · ${parts.join(' + ') || 'empty'}</div>
</div>
<button class="btn t-restore">Restore</button>
<button class="btn danger t-del">Delete forever</button>`;
el.querySelector('.slug').textContent = it.slug;
el.querySelector('.t-restore').addEventListener('click', () => restore(it));
el.querySelector('.t-del').addEventListener('click', () => deleteForever(it));
return el;
}
async function loadTrash() {
try {
const res = await fetch('/api/trash', { headers: { 'Accept': 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
const items = await res.json();
trashlist.innerHTML = '';
items.forEach(it => trashlist.appendChild(trashRow(it)));
trashCount.textContent = items.length + (items.length === 1 ? ' item' : ' items');
trashEmpty.style.display = items.length ? 'none' : 'block';
} catch (err) {
flash('Could not load trash: ' + err.message, false);
}
}
async function restore(it) {
const form = new FormData();
form.append('id', it.id);
try {
await post('/api/restore', form);
flash('Restored ' + it.slug, true);
await Promise.all([load(), loadTrash()]);
} catch (err) { flash('Restore failed: ' + err.message, false); }
}
async function deleteForever(it) {
if (!confirm('Permanently delete "' + it.slug + '"? This cannot be undone.')) return;
const form = new FormData();
form.append('id', it.id);
try {
await post('/api/trash/empty', form);
flash('Deleted ' + it.slug + ' forever', true);
await loadTrash();
} catch (err) { flash('Delete failed: ' + err.message, false); }
}
document.getElementById('trash-toggle').addEventListener('click', (e) => {
e.preventDefault();
const showing = trashpanel.classList.toggle('show');
if (showing) loadTrash();
});
document.getElementById('btn-empty-all').addEventListener('click', async () => {
if (!confirm('Permanently delete everything in the trash? This cannot be undone.')) return;
const form = new FormData();
form.append('all', '1');
try {
await post('/api/trash/empty', form);
flash('Trash emptied', true);
await loadTrash();
} catch (err) { flash('Empty failed: ' + err.message, false); }
});
load();
</script>
</body>
</html>
+206
View File
@@ -0,0 +1,206 @@
package main
import (
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// trashStamp is the timestamp moveToTrash appends: 20060102-150405.
var trashStamp = regexp.MustCompile(`^\d{8}-\d{6}$`)
// trashItem is one recoverable post in content/.trash/, grouping the image and
// its sidecar that were trashed together (same slug, same timestamp).
type trashItem struct {
ID string `json:"id"` // "<slug>.<stamp>", the restore/empty handle
Slug string `json:"slug"` // original slug it would restore to
DeletedAt string `json:"deletedAt"`
Image string `json:"image"` // trash filename of the image, "" if only a sidecar
HasMeta bool `json:"hasMeta"` // whether a sidecar is present
}
func (s *Server) trashDir() string { return filepath.Join(s.contentDir, ".trash") }
// parseTrashName splits a trash filename "<slug>.<stamp><ext>" into its parts.
// slug is a bare sanitised name (no dots) and stamp is a fixed timestamp, so
// the extension and the stamp peel off unambiguously from the right.
func parseTrashName(name string) (slug, stamp, ext string, ok bool) {
ext = filepath.Ext(name)
rest := strings.TrimSuffix(name, ext) // "<slug>.<stamp>"
i := strings.LastIndex(rest, ".")
if i < 0 {
return "", "", "", false
}
slug, stamp = rest[:i], rest[i+1:]
if slug == "" || slug != filepath.Base(slug) || strings.Contains(slug, ".") {
return "", "", "", false
}
if !trashStamp.MatchString(stamp) {
return "", "", "", false
}
return slug, stamp, ext, true
}
func isImageExt(ext string) bool {
for _, e := range contentExts {
if ext == e {
return true
}
}
return false
}
// handleListTrash returns trashed items, newest first.
func (s *Server) handleListTrash(w http.ResponseWriter, r *http.Request) {
entries, err := os.ReadDir(s.trashDir())
if err != nil {
// No trash dir yet → empty list, not an error.
writeJSON(w, []trashItem{})
return
}
byID := make(map[string]*trashItem)
for _, e := range entries {
if e.IsDir() {
continue
}
slug, stamp, ext, ok := parseTrashName(e.Name())
if !ok {
continue
}
id := slug + "." + stamp
it := byID[id]
if it == nil {
when := stamp
if t, err := time.ParseInLocation("20060102-150405", stamp, time.Local); err == nil {
when = t.Format("2006-01-02 15:04")
}
it = &trashItem{ID: id, Slug: slug, DeletedAt: when}
byID[id] = it
}
if ext == ".toml" {
it.HasMeta = true
} else if isImageExt(ext) {
it.Image = e.Name()
}
}
items := make([]trashItem, 0, len(byID))
for _, it := range byID {
items = append(items, *it)
}
// Newest first: the stamp sorts lexically in time order, and it is the tail
// of the ID.
sort.Slice(items, func(i, j int) bool { return items[i].ID > items[j].ID })
writeJSON(w, items)
}
// splitTrashID validates a "<slug>.<stamp>" handle and returns its parts.
func splitTrashID(id string) (slug, stamp string, ok bool) {
i := strings.LastIndex(id, ".")
if i < 0 {
return "", "", false
}
slug, stamp = id[:i], id[i+1:]
if slug == "" || slug != filepath.Base(slug) || strings.Contains(slug, ".") {
return "", "", false
}
if !trashStamp.MatchString(stamp) {
return "", "", false
}
return slug, stamp, true
}
// trashFiles returns the files in .trash belonging to one id (image + sidecar).
func (s *Server) trashFiles(slug, stamp string) []string {
var out []string
prefix := slug + "." + stamp
for _, ext := range append(append([]string{}, contentExts...), ".toml") {
p := filepath.Join(s.trashDir(), prefix+ext)
if _, err := os.Stat(p); err == nil {
out = append(out, p)
}
}
return out
}
// handleRestore moves a trashed post back into content/, stripping the
// timestamp. It refuses to overwrite a live post with the same slug.
func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
if s.rejectCrossSite(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
slug, stamp, ok := splitTrashID(r.FormValue("id"))
if !ok {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
if _, live := s.resolveSlug(slug); live {
http.Error(w, "a post with that slug already exists", http.StatusConflict)
return
}
files := s.trashFiles(slug, stamp)
if len(files) == 0 {
http.Error(w, "not in trash", http.StatusNotFound)
return
}
for _, src := range files {
name := filepath.Base(src)
trashPrefix := slug + "." + stamp // "<slug>.<stamp>"
// "<slug>.<stamp>.png" → "<slug>.png"; "<slug>.<stamp>.toml" → "<slug>.toml"
dstName := slug + strings.TrimPrefix(name, trashPrefix)
if err := os.Rename(src, filepath.Join(s.contentDir, dstName)); err != nil {
log.Printf("restore %s: %v", name, err)
http.Error(w, "could not restore", http.StatusInternalServerError)
return
}
}
s.rebuild()
w.WriteHeader(http.StatusOK)
}
// handleEmptyTrash permanently deletes trashed items — specific ids (repeated
// `id` fields), or everything with `all=1`. This is the only hard delete.
func (s *Server) handleEmptyTrash(w http.ResponseWriter, r *http.Request) {
if s.rejectCrossSite(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if r.FormValue("all") == "1" {
if err := os.RemoveAll(s.trashDir()); err != nil {
log.Printf("empty trash: %v", err)
http.Error(w, "could not empty trash", http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"emptied": "all"})
return
}
removed := 0
for _, id := range r.Form["id"] {
slug, stamp, ok := splitTrashID(id)
if !ok {
continue
}
for _, p := range s.trashFiles(slug, stamp) {
if err := os.Remove(p); err == nil {
removed++
}
}
}
writeJSON(w, map[string]int{"removed": removed})
}
+139
View File
@@ -0,0 +1,139 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
)
func listTrash(t *testing.T, s *Server) []trashItem {
t.Helper()
rec := httptest.NewRecorder()
s.handleListTrash(rec, httptest.NewRequest(http.MethodGet, "/api/trash", nil))
if rec.Code != http.StatusOK {
t.Fatalf("list trash: code=%d", rec.Code)
}
var items []trashItem
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
t.Fatalf("decode trash: %v", err)
}
return items
}
func TestTrashListAndRestore(t *testing.T) {
s, content, _ := newTestServer(t)
// Delete foo, then it should show up in the trash.
rec := httptest.NewRecorder()
s.handleDelete(rec, form(url.Values{"slug": {"foo"}}))
if rec.Code != http.StatusOK {
t.Fatalf("delete: %d", rec.Code)
}
items := listTrash(t, s)
if len(items) != 1 {
t.Fatalf("trash has %d items, want 1", len(items))
}
it := items[0]
if it.Slug != "foo" || !it.HasMeta || it.Image == "" {
t.Errorf("trash item wrong: %+v", it)
}
// Restore it.
rec = httptest.NewRecorder()
s.handleRestore(rec, form(url.Values{"id": {it.ID}}))
if rec.Code != http.StatusOK {
t.Fatalf("restore: code=%d body=%s", rec.Code, rec.Body)
}
for _, p := range []string{"foo.png", "foo.toml"} {
if _, err := os.Stat(filepath.Join(content, p)); err != nil {
t.Errorf("restored %s missing: %v", p, err)
}
}
if items := listTrash(t, s); len(items) != 0 {
t.Errorf("trash not empty after restore: %+v", items)
}
// Caption survived the round trip.
if readMeta(t, content, "foo").Caption != "Foo" {
t.Error("restored sidecar lost its caption")
}
}
func TestRestoreRefusesLiveCollision(t *testing.T) {
s, content, _ := newTestServer(t)
rec := httptest.NewRecorder()
s.handleDelete(rec, form(url.Values{"slug": {"foo"}}))
if rec.Code != http.StatusOK {
t.Fatalf("delete: %d", rec.Code)
}
id := listTrash(t, s)[0].ID
// A new live post now occupies the slug.
writePNG(t, filepath.Join(content, "foo.png"))
rec = httptest.NewRecorder()
s.handleRestore(rec, form(url.Values{"id": {id}}))
if rec.Code != http.StatusConflict {
t.Errorf("restore over live post: code=%d, want 409", rec.Code)
}
// The trashed copy is left intact for a manual decision.
if len(listTrash(t, s)) != 1 {
t.Error("trashed item should remain after refused restore")
}
}
func TestEmptyTrash(t *testing.T) {
s, _, _ := newTestServer(t)
// Trash both posts.
rec := httptest.NewRecorder()
s.handleDelete(rec, form(url.Values{"slugs": {"foo,bar"}}))
if rec.Code != http.StatusOK {
t.Fatalf("delete: %d", rec.Code)
}
items := listTrash(t, s)
if len(items) != 2 {
t.Fatalf("trash has %d, want 2", len(items))
}
// Empty one by id.
rec = httptest.NewRecorder()
s.handleEmptyTrash(rec, form(url.Values{"id": {items[0].ID}}))
if rec.Code != http.StatusOK {
t.Fatalf("empty one: %d", rec.Code)
}
if len(listTrash(t, s)) != 1 {
t.Error("expected 1 item left after deleting one")
}
// Empty all.
rec = httptest.NewRecorder()
s.handleEmptyTrash(rec, form(url.Values{"all": {"1"}}))
if rec.Code != http.StatusOK {
t.Fatalf("empty all: %d", rec.Code)
}
if len(listTrash(t, s)) != 0 {
t.Error("trash not empty after all")
}
}
func TestSplitTrashIDRejectsJunk(t *testing.T) {
cases := map[string]bool{
"foo.20260102-150405": true,
"foo.bar.20260102-150405": false, // slug "foo.bar" has a dot → rejected
"../x.20260102-150405": false, // traversal
"foo.2026": false, // malformed stamp
"foo": false, // no stamp
"": false,
}
for id, want := range cases {
if _, _, ok := splitTrashID(id); ok != want {
t.Errorf("splitTrashID(%q) = %v, want %v", id, ok, want)
}
}
}