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`
- In dev mode (`--serve`), also serves `public/` as static files
Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart), `GET /health`.
Both `/upload` routes are gated by HTTP Basic auth when `-htpasswd` is set (see Auth); `/health` is always open.
Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart),
`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
@@ -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
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
- **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` |
| `server.go` | Upload API + optional static file serving |
| `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 |
| `assets.go` | `//go:embed` for templates and static |
| `templates/timeline.html` | Site root: photos grouped by day, paginated |
@@ -201,8 +250,7 @@ nix build # succeeds
## What's not done yet
- ActivityPub federation (planned later — start with RSS/Atom)
- Multi-image upload
- Delete/edit via UI
- Any auth beyond the app's htpasswd Basic auth on `/upload` (no sessions, no per-user scopes)
- Edit/delete/trash in the Android and Qt apps (web `/manage` only so far; the `/api/` endpoints are there for the apps to adopt)
- Any auth beyond the app's htpasswd Basic auth (no sessions, no per-user scopes)
- 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
+47
View File
@@ -123,12 +123,30 @@ func (g *Generator) Build() error {
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"} {
if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
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 {
if err := g.processImage(&photos[i]); err != nil {
log.Printf("processImage %s: %v", photos[i].Slug, err)
@@ -231,6 +249,35 @@ func (g *Generator) loadPhotos() ([]Photo, error) {
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
// thumbnail to public/thumbs/ plus an aspect-preserved medium image to
// 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),
# 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" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}";
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"
"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")
})
+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)
}
}
}