Add a /manage page to edit, delete and restore posts
A new auth-gated web UI at /manage lists every post for inline editing of caption, date and tags, with per-row and bulk delete and bulk tag add/remove. It is backed by a small JSON API (all behind the same htpasswd Basic auth as /upload, and unlinked from the public site): GET /api/photos list posts (newest first) POST /api/edit rewrite one sidecar (caption, date, tags) POST /api/tags bulk add/remove tags across posts POST /api/delete move posts to content/.trash/ (reversible) GET /api/trash list trashed items POST /api/restore move a trashed item back (409 on live-slug clash) POST /api/trash/empty hard delete trashed items (id… or all=1) Design notes: - Editing only rewrites the .toml; the slug (and URL) never changes. - Delete is reversible: image + sidecar move to content/.trash/ with a timestamp suffix, excluded from the site (loadPhotos skips dirs). The only hard delete is emptying the trash. - Build now prunes: it drops the derived HTML trees (photo/ day/ album/ page/ grid/page/) and regenerates them, and pruneRenditions removes orphaned images/thumbs/medium/og. Without this a deleted post would linger at its old URL and on stale day/album pages; this also cleans up after a hand-removed sidecar. - Sidecars are written atomically (temp + rename) so a concurrent build never observes a half-written file. - Slugs and trash ids are validated as bare filenames, closing path traversal and dotfile access. - Every state-changing POST passes a sameOrigin (CSRF) check, since Basic-auth credentials are replayed by the browser on every request to the host. It honours Sec-Fetch-Site, falls back to Origin vs base-url/Host, and allows header-less non-browser clients. Ported from the independent /manage on plex/trunk-ng. module.nix proxies /manage and /api/ to the app (nginx otherwise serves them as missing static files). The apps are unchanged; the API is there for them to adopt edit/delete later.
This commit is contained in:
1 parent
434d2437bf
commit
a7f2afc3ad
9 files changed
+1453
-7
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user