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