Merge trunk into trunk-ng
# Conflicts: # CLAUDE.md # README.md # generator.go # main.go # manage.go # manage_test.go # module.nix # server.go # templates/grid.html # templates/manage.html # templates/timeline.html
This commit is contained in:
commit
3614cfe1f8
44 files changed
+3622
-1192
No files matched your search
@@ -19,23 +19,91 @@ 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`, and with `-manage`: `GET /manage` (list of photos), `GET`/`POST /manage/edit/{slug}` (edit caption and date), `GET`/`POST /manage/delete/{slug}` (confirm, then delete).
|
||||
Routes: `GET /upload` (HTML upload form — multi-photo: the page posts one
|
||||
`POST /upload` per selected photo, each with its own caption/date/tags),
|
||||
`POST /upload` (multipart, one image per request),
|
||||
`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` and `/manage` to localhost, htpasswd on both. Note `/health` is **not** proxied — nginx only forwards `location /upload` and `location /manage`, so anything outside the box can only reach those two.
|
||||
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.
|
||||
|
||||
### Managing photos
|
||||
## Auth
|
||||
|
||||
`manage.go` holds the `/manage` handlers, kept apart from the upload code in `server.go`. They are only registered when the binary runs with `-manage` (NixOS `services.photogallery.manage.enable`, which also adds the nginx location); without it the paths 404 and there is no edit or delete code path at all. The pages are rendered on request from the sidecars (not generated into `public/`), and nothing on the public site links to them. Constraints worth knowing before touching it:
|
||||
`-htpasswd <file>` points at an Apache htpasswd file — **bcrypt only**, generate
|
||||
with `htpasswd -B`. Each `POST /upload` and `GET /upload` then needs HTTP Basic
|
||||
credentials matching a line in that file; `GET /health` stays open. `$2y$`
|
||||
(Apache) and `$2a$`/`$2b$` hashes are all accepted; non-bcrypt lines (MD5
|
||||
`$apr1$`, SHA, crypt) are skipped with a warning. Empty `-htpasswd` disables
|
||||
auth entirely (dev default) and logs a warning — `/upload` is then open.
|
||||
|
||||
- An edit only rewrites the `.toml` sidecar. Any key other than `caption` and `date` in a hand-written sidecar is dropped, same as on upload.
|
||||
- Slugs from the URL are matched against the photos actually in `content/`, never joined into a path.
|
||||
- `sameOrigin` compares the `Origin` header against `-base-url`, because behind nginx the `Host` header is the upstream address. A wrong `-base-url` therefore breaks saving in browsers that send no `Sec-Fetch-Site`.
|
||||
- Delete is permanent and takes two steps: `GET /manage/delete/{slug}` only shows a confirmation page, the `POST` does the work. `Generator.Delete` removes the image and sidecar from `content/` first, then the four renditions and `public/photo/<slug>/`, all under the build mutex. There is no trash folder — recovery means restoring from a backup.
|
||||
- Listing pages are put right by the rebuild that follows, which also prunes what a delete or a date change leaves behind: `renderDayPages` drops `public/day/<date>/` for days without photos, and `prunePages` drops pager pages past the last one. Renditions of photos removed from `content/` by hand (not via `/manage`) are still **not** pruned.
|
||||
`GET /api/session` is a deliberately **unguarded** probe (`handleSession`): it
|
||||
reads `r.BasicAuth()`, returns `200 {"authenticated":bool}` and never sends
|
||||
`WWW-Authenticate`, so a public page can ask without triggering the browser's
|
||||
login dialog. To stop it becoming a password oracle that sidesteps the limiter,
|
||||
a request with no credentials is answered cheaply as `false`, while one with
|
||||
*wrong* credentials runs through the same failed-attempt limiter (429 once
|
||||
drained). `static/session.js` (loaded by every page template) calls it and, when
|
||||
authenticated, builds a fixed toolbar with Upload / Manage / "Edit this"
|
||||
(`/manage?edit=<slug>`, from the photo pages' `data-slug`) links and a
|
||||
signed-in indicator. "Log out" is best-effort only — HTTP Basic has no reliable
|
||||
sign-out; a dependable one would need cookie sessions.
|
||||
|
||||
Verification lives in `auth.go`: unknown users still pay for one bcrypt
|
||||
comparison (against a dummy hash) so timing can't enumerate usernames, and
|
||||
**failed** attempts draw from a token-bucket limiter (`golang.org/x/time/rate`,
|
||||
~1/s, burst 10) that returns `429` once drained — successful and bulk uploads
|
||||
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.
|
||||
- `GET /api/thumb/{slug}` — serves that post's thumbnail JPEG from `public/thumbs/`, behind auth. Non-browser clients (the Android app) use this instead of the static `/thumbs/<slug>.jpg` path, because a front-door botwall on `location /` can block a non-browser client from the static site while `/api/` is proxied straight to the app.
|
||||
- `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`
|
||||
- **Go** — stdlib + `BurntSushi/toml`, `fsnotify/fsnotify`, `golang.org/x/image`, `golang.org/x/crypto` (bcrypt), `golang.org/x/time` (rate)
|
||||
- **Templates** — `html/template`, embedded via `embed.FS`
|
||||
- **Images** — four renditions per photo: original copy, 600×600 centre-cropped square thumbnail, a 1600px-longest-side "medium" for the detail page, and a 1200px "og" for link previews. Catmull-Rom resize. Outputs are skipped when newer than the source.
|
||||
- **Feed** — Atom (`encoding/xml`)
|
||||
@@ -46,22 +114,38 @@ In production nginx sits in front: serves `public/` directly, proxies `/upload`
|
||||
```toml
|
||||
caption = "Tempelhof at sunrise"
|
||||
date = "2026-06-14"
|
||||
tags = ["berlin", "sunrise"]
|
||||
```
|
||||
|
||||
Missing sidecar → filename as caption, today as date. Photos sort newest first by that date.
|
||||
|
||||
`tags` is optional, free-text, and defines albums (see below). Tags are
|
||||
normalized to lowercase on load/upload (`parseTags` in `albums.go`); a tag may
|
||||
contain spaces (`"berlin trip"`), and its URL slug is derived with `sanitiseSlug`.
|
||||
|
||||
## Design
|
||||
|
||||
- Gruvbox dark hard colour scheme
|
||||
- Crimson Pro (serif) for headings and captions — configurable via `-serif-family` / `-fonts-url`
|
||||
- Manjari for Malayalam text (bio line), via `-ml-family`
|
||||
- Two views of the same photos, both 3-column square grid → 2-column on mobile, both with infinite-scroll load-more. Page size is configurable via `-page-size` (default 60; NixOS `services.photogallery.pageSize`):
|
||||
- **Timeline is the site root by default** (`/`, `/page/2/`, …). Photos grouped by calendar day: one ISO date heading (`2026-06-14`) + count per day, then the square grid. `groupByDay` collapses the newest-first list into consecutive same-day runs; `chunkDayGroups` packs whole days into pages targeting `pageSize`, never splitting a day across a page boundary (so appended pages never duplicate a heading — the load-more JS relies on this).
|
||||
- **Grid** (`/grid/`, `/grid/page/2/`, …) is the flat square grid, linked from the profile header.
|
||||
- Two views of the same photos, both 3-column square grid → 2-column on mobile, both with infinite-scroll load-more. Page size is configurable via `-page-size` (default 60; NixOS `services.photogallery.pageSize`). Which view sits at the site root is chosen by `-default-view` (`timeline` or `grid`, default `timeline`; NixOS `services.photogallery.defaultView`): the chosen one owns `/` (`/`, `/page/2/`, …) and the other is published under `/<name>/`. `viewBase`/`pageURL` (`generator.go`) derive every page path, pager link and the Timeline↔Grid cross-links (`TimelineURL`/`GridURL` in the template data) from that one setting; `Build` removes both `timeline/` and `grid/` each run so a flipped default leaves no stale root behind.
|
||||
- **Timeline** (the default root): photos grouped by calendar day — one ISO date heading (`2026-06-14`) + count per day, then the square grid. `groupByDay` collapses the newest-first list into consecutive same-day runs; `chunkDayGroups` packs whole days into pages targeting `pageSize`, never splitting a day across a page boundary (so appended pages never duplicate a heading — the load-more JS relies on this).
|
||||
- **Grid** is the flat square grid. At `/grid/` by default, or at the root when `-default-view grid`.
|
||||
- Each timeline date heading links to a **single-day page** (`/day/<YYYY-MM-DD>/`) holding just that day's photos. `renderDayPages` writes one per `groupByDay` run; the ISO label doubles as the URL slug.
|
||||
- Which view owns the site root is set by `-default-view timeline|grid` (NixOS `services.photogallery.defaultView`). The other view moves under `/<name>/`: with `-default-view grid` the grid is `/` and the timeline is `/timeline/`. `viewBase` and `pageURL` in `generator.go` derive every page path and pager link from that, and the templates take the cross-links as `GridURL` / `TimelineURL` — never hardcode `/grid/` or `/`. On each build the directory named after the default view is removed, since it can only be a leftover from before a switch.
|
||||
- Single photo page with sidebar and newer/older navigation
|
||||
|
||||
### Albums (tags)
|
||||
|
||||
A second grouping axis, parallel to days: an album is just a tag (`albums.go`).
|
||||
`groupByTag` mirrors `groupByDay`, and `renderAlbumPages` writes a paginated
|
||||
square-grid album per tag at `/album/<slug>/` (like the grid view). `/albums/`
|
||||
is the index of every tag with counts; `/tags.json` is the machine-readable tag
|
||||
list (`[{tag, slug, count}]`, served statically like `feed.xml`). Tags show on
|
||||
each photo page and link to their album; an **Albums** link sits in the
|
||||
timeline/grid header. Tags are assigned at upload via the `tags` field on
|
||||
`POST /upload` (comma-separated) or by editing a sidecar's `tags`. All derived at
|
||||
build time — no database.
|
||||
|
||||
### Link previews
|
||||
|
||||
Photo pages carry Open Graph + Twitter-card meta tags so a shared link unfurls
|
||||
@@ -108,7 +192,7 @@ go run . --serve
|
||||
|
||||
## Android app
|
||||
|
||||
Kotlin + Jetpack Compose uploader: pick or share a photo, add caption and date, post. Credentials (HTTP Basic) are sealed with an Android Keystore AES-GCM key. See `android/README.md` for the detail.
|
||||
Kotlin + Jetpack Compose uploader: pick or share one or many photos, add caption/date/tags, post via a durable WorkManager queue. It also has a **Manage screen** (from the Post screen's "Manage" button) that lists published posts with thumbnails and edits caption/date/tags or deletes (to trash) via the same `/api/photos`, `/api/edit`, `/api/delete` endpoints the web `/manage` uses. The manage calls go through `UploadClient` (OkHttp + `org.json`, no extra deps); sending no `Origin`/`Sec-Fetch-Site` header, the app passes the server's same-origin CSRF check as a non-browser client. Thumbnails are fetched from `/api/thumb/<slug>` (authed, app-served) rather than the static `/thumbs/` path, so a botwall on `location /` doesn't blank them. The multi-photo picker is constructed with an explicit `maxItems` so it reliably opens in multi-select mode. Credentials (HTTP Basic) are sealed with an Android Keystore AES-GCM key. See `android/README.md` for the detail.
|
||||
|
||||
Its own flake, deliberately separate from the root one so NixOS consumers of `nixosModules.default` don't pull the Android SDK into their lock:
|
||||
|
||||
@@ -130,10 +214,12 @@ inputs.photogallery.nixosModules.default
|
||||
services.photogallery = {
|
||||
enable = true;
|
||||
baseURL = "https://photos.yourdomain.tld";
|
||||
# bcrypt htpasswd (`htpasswd -B`); handed to the service via LoadCredential,
|
||||
# so a root-owned 0400 secret is fine. Omit to leave /upload open.
|
||||
htpasswdFile = "/run/secrets/photogallery-htpasswd";
|
||||
nginx = {
|
||||
enable = true;
|
||||
domain = "photos.yourdomain.tld";
|
||||
htpasswdFile = "/etc/nginx/.htpasswd-gallery";
|
||||
};
|
||||
};
|
||||
```
|
||||
@@ -153,16 +239,20 @@ nix build # succeeds
|
||||
|---|---|
|
||||
| `main.go` | Flags, wires generator + watcher + server |
|
||||
| `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs/mediums |
|
||||
| `albums.go` | Tag parsing/normalization, `groupByTag`, album pages + `/albums/` + `/tags.json` |
|
||||
| `server.go` | Upload API + optional static file serving |
|
||||
| `manage.go` | `/manage` list and edit handlers |
|
||||
| `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 |
|
||||
| `templates/grid.html` | Flat square grid at `/grid/`, paginated |
|
||||
| `templates/day.html` | Single-day page at `/day/<date>/` |
|
||||
| `templates/album.html` | Album (tag) page at `/album/<slug>/`, paginated |
|
||||
| `templates/albums.html` | Album index at `/albums/` |
|
||||
| `templates/photo.html` | Single photo page, neighbour links and prefetch |
|
||||
| `templates/manage.html` | `/manage`: list of photos with edit links |
|
||||
| `templates/edit.html` | `/manage/edit/{slug}`: caption and date form |
|
||||
| `static/photo-nav.js` | Arrow-key and swipe navigation |
|
||||
| `module.nix` | NixOS module (systemd service, tmpfiles, nginx vhost) |
|
||||
| `flake.nix` | `buildGoModule` package + devShell + nixosModules |
|
||||
@@ -175,8 +265,7 @@ nix build # succeeds
|
||||
## What's not done yet
|
||||
|
||||
- ActivityPub federation (planned later — start with RSS/Atom)
|
||||
- Multi-image upload
|
||||
- Pruning renditions and photo pages of photos deleted from `content/` by hand (delete via `/manage` cleans up after itself)
|
||||
- Any auth beyond nginx htpasswd
|
||||
- Edit/delete in the **Qt desktop** app (web `/manage` and the Android app have it; the `/api/` endpoints are there for Qt to adopt). Restoring from trash is still web-only on all clients.
|
||||
- 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
|
||||
@@ -1,12 +1,50 @@
|
||||
# photogallery
|
||||
|
||||
A minimal static photo gallery with an Atom feed and an upload API.
|
||||
A single-user static photo gallery: a Go binary watches a directory of images +
|
||||
`.toml` sidecars and regenerates a static site, with an Atom feed, albums, an
|
||||
authenticated upload/manage API, and Android + desktop apps. No
|
||||
database, no framework.
|
||||
|
||||
## Contributors
|
||||
|
||||
- **[Santhosh Raju](https://git.planet-express.in/fox)** — originated the ideas for the edit/delete ("manage")
|
||||
capability and the configurable default view (`-default-view`, timeline or
|
||||
grid as the site root). Both landed here as independent implementations
|
||||
building on that work.
|
||||
|
||||
## Features
|
||||
|
||||
**Gallery**
|
||||
- Two views of the same photos — a day-grouped **timeline** and a flat **grid** — either one as the site root (`-default-view`); the other lives under `/timeline/` or `/grid/`. 3-column square grid (2 on mobile) with infinite-scroll pagination.
|
||||
- **Single-day pages** (`/day/<date>/`) linked from each timeline date heading.
|
||||
- **Albums**: tag photos with free-text, lowercase tags; each tag is an album at `/album/<tag>/`, indexed at `/albums/`, plus a machine-readable `/tags.json`.
|
||||
- **Single photo pages** with newer/older navigation (arrow keys + touch swipe) and a link to the original.
|
||||
- **Open Graph / Twitter-card link previews** so shared photo links unfurl.
|
||||
- **Atom feed** at `/feed.xml`. Gruvbox-dark theme; configurable fonts and identity.
|
||||
|
||||
**Content & management (no database)**
|
||||
- `.toml` sidecars are the source of truth; the binary watches the content dir and rebuilds on any change. Four renditions per photo (original, 600² thumbnail, 1600px medium, 1200px OG), each skipped when already up to date.
|
||||
- **Web upload** (`/upload`): pick one or many photos, each with its own EXIF-prefilled date, caption and tag-autocompleting tags field; posted with per-photo status and retry.
|
||||
- **Manage UI** (`/manage`): edit caption/date/tags, bulk tag add/remove, and delete — delete is reversible (moves to `content/.trash/`, with restore / empty).
|
||||
- Tag autocomplete across the upload and manage forms, fed by `/tags.json`.
|
||||
|
||||
**Auth & security**
|
||||
- App-side **HTTP Basic auth** (bcrypt htpasswd, `-htpasswd`) on every write endpoint; nginx just terminates TLS and proxies. Failed attempts are rate-limited; state-changing requests get a same-origin (CSRF) check.
|
||||
- A **signed-in indicator** + management toolbar (Upload / Manage / Edit this) appear on every page when authenticated, via a silent `/api/session` probe (no login dialog for anonymous visitors).
|
||||
|
||||
**Clients**
|
||||
- The web upload + manage UI.
|
||||
- **Android** uploader/manager (Kotlin/Compose, `android/`): pick or share photos, multi-photo bulk upload with captions/dates/tags, plus a Manage screen to edit or delete published posts.
|
||||
- **Qt desktop** uploader (`desktop/`): bulk upload with configurable concurrency, HEIC support, credentials in the system keychain.
|
||||
|
||||
**Deployment**
|
||||
- One Go binary. NixOS flake + module (systemd service, tmpfiles, optional nginx vhost with ACME).
|
||||
|
||||
## How it works
|
||||
|
||||
1. Drop `photo.jpg` + `photo.toml` into the content directory (or upload via the web UI at `/upload`).
|
||||
2. The binary watches the directory and rebuilds the static site on any change.
|
||||
3. nginx serves `public/` and proxies `/upload` to the local API, secured with htpasswd.
|
||||
1. Drop `photo.jpg` + `photo.toml` into the content directory (or upload via the web UI at `/upload`, the manage UI, or an app).
|
||||
2. The binary watches the directory and rebuilds the static site on any change (and on every upload/edit/delete).
|
||||
3. nginx terminates TLS, serves `public/`, and proxies the dynamic endpoints (`/upload`, `/manage`, `/api/`) to the local API. **Auth is the app's job** now (bcrypt htpasswd via `-htpasswd`), not nginx's.
|
||||
|
||||
For each photo the generator produces a 600×600 square thumbnail for the grid
|
||||
and an aspect-preserved medium JPEG (longest side 1600) for the detail page.
|
||||
@@ -23,9 +61,13 @@ editable before submitting.
|
||||
```toml
|
||||
caption = "Tempelhof at sunrise"
|
||||
date = "2026-06-14"
|
||||
tags = ["berlin", "sunrise"]
|
||||
```
|
||||
|
||||
If no `.toml` is present the filename is used as the caption and today's date as the date.
|
||||
If no `.toml` is present the filename is used as the caption and today's date as
|
||||
the date. `tags` is optional, free-text and lowercased; each tag becomes an
|
||||
album. A tag may contain spaces (`"berlin trip"`); its URL slug is derived
|
||||
automatically.
|
||||
|
||||
---
|
||||
|
||||
@@ -58,6 +100,14 @@ services.photogallery = {
|
||||
enable = true;
|
||||
baseURL = "https://photos.yourdomain.tld";
|
||||
|
||||
# Auth for the upload/manage endpoints: a bcrypt htpasswd file. Handed to the
|
||||
# service via systemd LoadCredential, so a root-owned 0400 secret is fine.
|
||||
# Omit to leave /upload and /manage open.
|
||||
htpasswdFile = "/run/secrets/photogallery-htpasswd";
|
||||
|
||||
# Optional: which view is the site root ("timeline" or "grid")
|
||||
defaultView = "timeline";
|
||||
|
||||
# Optional: identity shown on the header and copyright footer
|
||||
author = "Ada Lovelace";
|
||||
handle = "@ada@example.com";
|
||||
@@ -69,23 +119,19 @@ services.photogallery = {
|
||||
serifFamily = "EB Garamond";
|
||||
mlFamily = "Manjari";
|
||||
|
||||
# Optional: edit and delete published photos at /manage (off by default)
|
||||
manage.enable = true;
|
||||
|
||||
# Optional: built-in nginx vhost
|
||||
# Optional: built-in nginx vhost (TLS + proxy only; auth is app-side above)
|
||||
nginx = {
|
||||
enable = true;
|
||||
domain = "photos.yourdomain.tld";
|
||||
htpasswdFile = "/etc/nginx/.htpasswd-gallery";
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Create the htpasswd file:
|
||||
Create the htpasswd file (**bcrypt** — the app only accepts `-B` entries):
|
||||
|
||||
```bash
|
||||
nix-shell -p apacheHttpd --run \
|
||||
"htpasswd -c /etc/nginx/.htpasswd-gallery youruser"
|
||||
"htpasswd -B -c /run/secrets/photogallery-htpasswd youruser"
|
||||
```
|
||||
|
||||
### Available options
|
||||
@@ -97,6 +143,9 @@ nix-shell -p apacheHttpd --run \
|
||||
| `contentDir` | `/var/lib/photogallery/content` | Images and .toml sidecars |
|
||||
| `outputDir` | `/var/lib/photogallery/public` | Generated site output |
|
||||
| `port` | `8080` | Upload API port (localhost only) |
|
||||
| `htpasswdFile` | `null` | bcrypt htpasswd guarding write endpoints (null = open) |
|
||||
| `defaultView` | `timeline` | Site-root view: `timeline` or `grid` |
|
||||
| `pageSize` | `60` | Photos per page (timeline/grid) |
|
||||
| `author` | `Ashik Salahudeen` | Name shown on the header, photo pages and copyright footer |
|
||||
| `handle` | `@puttaalu@inflo.ws` | Handle line under the author name |
|
||||
| `bio` | `Personal photo journal.` | Primary bio line |
|
||||
@@ -104,12 +153,8 @@ nix-shell -p apacheHttpd --run \
|
||||
| `fontsURL` | Crimson Pro + Manjari | Stylesheet URL loading both web fonts |
|
||||
| `serifFamily` | `Crimson Pro` | CSS font-family for the primary serif |
|
||||
| `mlFamily` | `Manjari` | CSS font-family for the alt-script bio |
|
||||
| `pageSize` | `60` | Photos per page on the timeline and grid views |
|
||||
| `defaultView` | `timeline` | View at the site root, `timeline` or `grid`; the other is served under `/timeline/` or `/grid/` |
|
||||
| `manage.enable` | `false` | Serve the `/manage` pages (edit, delete) and, with `nginx.enable`, protect them with the same htpasswd |
|
||||
| `nginx.enable` | `false` | Configure an nginx vhost |
|
||||
| `nginx.enable` | `false` | Configure an nginx vhost (TLS + proxy) |
|
||||
| `nginx.domain` | — | Domain for the vhost |
|
||||
| `nginx.htpasswdFile` | — | htpasswd file for `/upload` and `/manage` |
|
||||
| `nginx.enableACME` | `true` | Enable Let's Encrypt |
|
||||
|
||||
---
|
||||
@@ -140,24 +185,23 @@ curl -u user:password \
|
||||
-F "image=@photo.jpg" \
|
||||
-F "caption=Tempelhof at sunrise" \
|
||||
-F "date=2026-06-14" \
|
||||
-F "tags=berlin, sunrise" \
|
||||
https://photos.yourdomain.tld/upload
|
||||
```
|
||||
|
||||
Or open `https://photos.yourdomain.tld/upload` in a browser.
|
||||
Or open `https://photos.yourdomain.tld/upload` in a browser, manage existing
|
||||
posts at `/manage`, or use one of the apps below.
|
||||
|
||||
## Editing and deleting photos
|
||||
## Apps
|
||||
|
||||
The pages are off by default: start the binary with `-manage` (NixOS:
|
||||
`services.photogallery.manage.enable = true`). Then open
|
||||
`https://photos.yourdomain.tld/manage` in a browser. It lists every
|
||||
photo; *Edit* opens a form for the caption and date, and saving rewrites the
|
||||
photo's `.toml` sidecar, which triggers a rebuild.
|
||||
Both use the same API (HTTP Basic auth) and need no server change.
|
||||
|
||||
*Delete* asks for confirmation, then removes the image, its sidecar and
|
||||
everything generated from them. This is permanent — there is no trash folder,
|
||||
so keep a backup of the content directory.
|
||||
|
||||
`/manage` sits behind the same htpasswd as `/upload`, and nothing on the
|
||||
public site links to it. If you run your own nginx instead of the NixOS
|
||||
module, add a `location /manage` block alongside the `/upload` one — without
|
||||
it the path is not proxied and returns 404.
|
||||
- **Android** (`android/`) — Kotlin/Compose. Pick or share one or many photos,
|
||||
set caption/date/tags, uploads run in a durable background queue. A Manage
|
||||
screen lists published posts (with thumbnails) and edits their
|
||||
caption/date/tags or deletes them to the server's trash, over the same
|
||||
`/api/*` endpoints the web `/manage` uses. Has its own flake;
|
||||
`cd android && nix develop && ./gradlew assembleRelease`.
|
||||
- **Qt desktop** (`desktop/`) — Qt6/C++. Bulk upload with configurable
|
||||
concurrency, HEIC support, credentials stored in the system keychain. Builds
|
||||
via its own flake in `desktop/`.
|
||||
@@ -0,0 +1,206 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Tag is a normalized album tag: a lowercase free-text Name and its URL Slug.
|
||||
type Tag struct {
|
||||
Name string
|
||||
Slug string
|
||||
}
|
||||
|
||||
// Album is all photos sharing one tag, newest first.
|
||||
type Album struct {
|
||||
Tag Tag
|
||||
Count int
|
||||
Photos []Photo
|
||||
}
|
||||
|
||||
// parseTags normalizes raw tag strings into unique Tags. Each input may itself
|
||||
// be comma-separated (as typed in the upload form), so entries are split on
|
||||
// commas, lowercased, trimmed, de-duplicated by name, and given a URL slug via
|
||||
// sanitiseSlug. Entries that are empty or slug to nothing are dropped.
|
||||
func parseTags(raw ...string) []Tag {
|
||||
var tags []Tag
|
||||
seen := map[string]bool{}
|
||||
for _, entry := range raw {
|
||||
for _, part := range strings.Split(entry, ",") {
|
||||
name := strings.ToLower(strings.TrimSpace(part))
|
||||
if name == "" || seen[name] {
|
||||
continue
|
||||
}
|
||||
slug := sanitiseSlug(name)
|
||||
if slug == "" {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
tags = append(tags, Tag{Name: name, Slug: slug})
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// groupByTag collapses photos (already sorted newest first) into one Album per
|
||||
// distinct tag name, preserving photo order within each album. Albums are
|
||||
// returned sorted alphabetically by tag name. If two different names produce the
|
||||
// same URL slug, the later one is suffixed (-2, -3, …) and a warning is logged,
|
||||
// so album URLs stay unique.
|
||||
func groupByTag(photos []Photo) []Album {
|
||||
byName := map[string][]Photo{}
|
||||
for _, p := range photos {
|
||||
for _, t := range p.Tags {
|
||||
byName[t.Name] = append(byName[t.Name], p)
|
||||
}
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(byName))
|
||||
for name := range byName {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
slugOwner := map[string]string{}
|
||||
albums := make([]Album, 0, len(names))
|
||||
for _, name := range names {
|
||||
slug := sanitiseSlug(name)
|
||||
if owner, taken := slugOwner[slug]; taken && owner != name {
|
||||
base := slug
|
||||
for n := 2; ; n++ {
|
||||
cand := base + "-" + strconv.Itoa(n)
|
||||
if _, t := slugOwner[cand]; !t {
|
||||
slug = cand
|
||||
break
|
||||
}
|
||||
}
|
||||
log.Printf("album slug collision: %q and %q both slug to %q; using %q for the latter",
|
||||
owner, name, base, slug)
|
||||
}
|
||||
slugOwner[slug] = name
|
||||
ps := byName[name]
|
||||
albums = append(albums, Album{
|
||||
Tag: Tag{Name: name, Slug: slug},
|
||||
Count: len(ps),
|
||||
Photos: ps,
|
||||
})
|
||||
}
|
||||
return albums
|
||||
}
|
||||
|
||||
// baseData returns the profile-header and site-wide fields every page template
|
||||
// expects, so the album templates stay in step with the others.
|
||||
func (g *Generator) baseData() map[string]any {
|
||||
return map[string]any{
|
||||
"BaseURL": g.baseURL,
|
||||
"Year": time.Now().Year(),
|
||||
"Author": g.author,
|
||||
"Handle": g.handle,
|
||||
"Bio": g.bio,
|
||||
"BioAlt": g.bioAlt,
|
||||
"Initial": g.initial(),
|
||||
"FontsURL": g.fontsURL,
|
||||
"SerifFamily": g.serifFamily,
|
||||
"MlFamily": g.mlFamily,
|
||||
"TimelineURL": g.timelineURL(),
|
||||
"GridURL": g.gridURL(),
|
||||
}
|
||||
}
|
||||
|
||||
// renderAlbumPages writes one paginated album per tag under public/album/<slug>/,
|
||||
// a flat square grid like the grid view. total is the site-wide photo count, for
|
||||
// the profile header.
|
||||
func (g *Generator) renderAlbumPages(albums []Album, total int) error {
|
||||
for _, al := range albums {
|
||||
albumDir := filepath.Join(g.outputDir, "album", al.Tag.Slug)
|
||||
if err := os.MkdirAll(albumDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
base := "/album/" + al.Tag.Slug + "/"
|
||||
|
||||
totalPages := (al.Count + g.pageSize - 1) / g.pageSize
|
||||
if totalPages == 0 {
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
for i := 0; i < totalPages; i++ {
|
||||
start := i * g.pageSize
|
||||
end := start + g.pageSize
|
||||
if end > al.Count {
|
||||
end = al.Count
|
||||
}
|
||||
|
||||
dst := filepath.Join(albumDir, "index.html")
|
||||
if i > 0 {
|
||||
dir := filepath.Join(albumDir, "page", strconv.Itoa(i+1))
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
dst = filepath.Join(dir, "index.html")
|
||||
}
|
||||
|
||||
prev := ""
|
||||
next := ""
|
||||
switch i {
|
||||
case 0:
|
||||
// no prev
|
||||
case 1:
|
||||
prev = base
|
||||
default:
|
||||
prev = base + "page/" + strconv.Itoa(i) + "/"
|
||||
}
|
||||
if i+1 < totalPages {
|
||||
next = base + "page/" + strconv.Itoa(i+2) + "/"
|
||||
}
|
||||
|
||||
data := g.baseData()
|
||||
data["Tag"] = al.Tag
|
||||
data["Count"] = al.Count
|
||||
data["Photos"] = al.Photos[start:end]
|
||||
data["Total"] = total
|
||||
data["Page"] = i + 1
|
||||
data["TotalPages"] = totalPages
|
||||
data["PrevURL"] = prev
|
||||
data["NextURL"] = next
|
||||
if err := g.render("album.html", dst, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderAlbumsIndex writes public/albums/index.html — every tag with its count,
|
||||
// linking to each album.
|
||||
func (g *Generator) renderAlbumsIndex(albums []Album, total int) error {
|
||||
data := g.baseData()
|
||||
data["Albums"] = albums
|
||||
data["Total"] = total
|
||||
return g.render("albums.html", filepath.Join(g.outputDir, "albums", "index.html"), data)
|
||||
}
|
||||
|
||||
type tagJSON struct {
|
||||
Tag string `json:"tag"`
|
||||
Slug string `json:"slug"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// renderTagsJSON writes public/tags.json — the machine-readable tag list, served
|
||||
// statically like feed.xml.
|
||||
func (g *Generator) renderTagsJSON(albums []Album) error {
|
||||
out := make([]tagJSON, 0, len(albums))
|
||||
for _, a := range albums {
|
||||
out = append(out, tagJSON{Tag: a.Tag.Name, Slug: a.Tag.Slug, Count: a.Count})
|
||||
}
|
||||
b, err := json.MarshalIndent(out, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(g.outputDir, "tags.json"), b, 0644)
|
||||
}
|
||||
+7
-4
@@ -1,11 +1,14 @@
|
||||
# Gallery Upload — Android client
|
||||
|
||||
Native uploader for the photogallery `POST /upload` endpoint. Pick or share a
|
||||
photo, add a caption and date, post. Credentials are entered once and sealed
|
||||
with a key held in the Android Keystore.
|
||||
Native uploader and manager for the photogallery server. Pick or share one or
|
||||
many photos, add caption/date/tags, and post. A **Manage** screen lists the
|
||||
published posts and edits their caption/date/tags or deletes them (to the
|
||||
server's trash) over the same `/api/*` endpoints the web `/manage` uses.
|
||||
Credentials are entered once and sealed with a key held in the Android Keystore.
|
||||
|
||||
The server is not modified by this app. Everything it needs, it does client
|
||||
side.
|
||||
side; the management calls send no browser headers, so they pass the server's
|
||||
same-origin (CSRF) check as a non-browser client.
|
||||
|
||||
## Build
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import ws.inflo.photogallery.ui.ManageScreen
|
||||
import ws.inflo.photogallery.ui.PhotoGalleryTheme
|
||||
import ws.inflo.photogallery.ui.PostScreen
|
||||
import ws.inflo.photogallery.ui.SettingsScreen
|
||||
@@ -37,9 +38,13 @@ class MainActivity : ComponentActivity() {
|
||||
PhotoGalleryTheme {
|
||||
// Land on settings until there is somewhere to post to.
|
||||
var showSettings by rememberSaveable { mutableStateOf(!viewModel.isConfigured) }
|
||||
var showManage by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Explicit maxItems so the system photo picker reliably opens in
|
||||
// multi-select mode (the no-arg default has been seen to present
|
||||
// single-select on some devices).
|
||||
val picker = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.PickMultipleVisualMedia(),
|
||||
ActivityResultContracts.PickMultipleVisualMedia(maxItems = 50),
|
||||
) { uris -> if (uris.isNotEmpty()) viewModel.selectImages(uris) }
|
||||
|
||||
val notificationPermission = rememberLauncherForActivityResult(
|
||||
@@ -57,8 +62,8 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
if (showSettings) {
|
||||
SettingsScreen(
|
||||
when {
|
||||
showSettings -> SettingsScreen(
|
||||
state = viewModel.config,
|
||||
onBaseUrlChange = viewModel::setBaseUrl,
|
||||
onUsernameChange = viewModel::setUsername,
|
||||
@@ -70,8 +75,17 @@ class MainActivity : ComponentActivity() {
|
||||
null
|
||||
},
|
||||
)
|
||||
} else {
|
||||
PostScreen(
|
||||
|
||||
showManage -> ManageScreen(
|
||||
state = viewModel.manage,
|
||||
onBack = { showManage = false },
|
||||
onRefresh = viewModel::loadManage,
|
||||
onSave = viewModel::saveEdit,
|
||||
onDelete = viewModel::deletePost,
|
||||
onDismissNotice = viewModel::clearManageNotice,
|
||||
)
|
||||
|
||||
else -> PostScreen(
|
||||
state = viewModel.post,
|
||||
onPickImages = {
|
||||
picker.launch(
|
||||
@@ -81,11 +95,16 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
},
|
||||
onCaptionChange = viewModel::setCaption,
|
||||
onTagsChange = viewModel::setTags,
|
||||
onDateChange = viewModel::setDate,
|
||||
onRemove = viewModel::removeItem,
|
||||
onSubmit = viewModel::submit,
|
||||
onReset = viewModel::clear,
|
||||
onOpenSettings = { showSettings = true },
|
||||
onOpenManage = {
|
||||
viewModel.loadManage()
|
||||
showManage = true
|
||||
},
|
||||
onOpenUrl = ::openUrl,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package ws.inflo.photogallery.net
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
@@ -8,6 +11,8 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.Response
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -25,6 +30,22 @@ sealed interface UploadOutcome {
|
||||
data class Transient(val message: String) : UploadOutcome
|
||||
}
|
||||
|
||||
/** One published post, as returned by GET /api/photos. */
|
||||
data class RemotePhoto(
|
||||
val slug: String,
|
||||
val caption: String,
|
||||
val date: String, // YYYY-MM-DD
|
||||
val tags: List<String>,
|
||||
val thumb: String, // site-relative, e.g. "/thumbs/foo.jpg"
|
||||
val url: String, // site-relative, e.g. "/photo/foo/"
|
||||
)
|
||||
|
||||
/** Result of listing posts for the manage screen. */
|
||||
sealed interface ListResult {
|
||||
data class Ok(val photos: List<RemotePhoto>) : ListResult
|
||||
data class Err(val message: String) : ListResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Talks to the gallery's POST /upload endpoint.
|
||||
*
|
||||
@@ -60,6 +81,7 @@ class UploadClient(
|
||||
fileName: String,
|
||||
caption: String,
|
||||
date: String,
|
||||
tags: String,
|
||||
): UploadOutcome {
|
||||
val mediaType = if (fileName.endsWith(".png", ignoreCase = true)) {
|
||||
"image/png".toMediaType()
|
||||
@@ -72,6 +94,7 @@ class UploadClient(
|
||||
.addFormDataPart("image", fileName, file.asRequestBody(mediaType))
|
||||
.addFormDataPart("caption", caption)
|
||||
.addFormDataPart("date", date)
|
||||
.addFormDataPart("tags", tags)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
@@ -120,6 +143,123 @@ class UploadClient(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists every published post for the manage screen. The manage endpoints
|
||||
* sit behind the same Basic auth as /upload; the app sends no Origin or
|
||||
* Sec-Fetch-Site header, so it passes the server's same-origin (CSRF) check
|
||||
* as a non-browser client.
|
||||
*/
|
||||
fun listPhotos(): ListResult {
|
||||
val request = Request.Builder().url("$base/api/photos").get().build()
|
||||
return try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
val text = response.body?.string().orEmpty()
|
||||
when {
|
||||
response.isSuccessful -> parsePhotos(text)
|
||||
response.code == 401 || response.code == 403 ->
|
||||
ListResult.Err("Wrong username or password")
|
||||
else -> ListResult.Err("Server returned ${response.code}")
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
ListResult.Err(e.message ?: "Network error")
|
||||
} catch (e: JSONException) {
|
||||
ListResult.Err("Could not read the server's response")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parsePhotos(body: String): ListResult {
|
||||
val arr = JSONArray(body)
|
||||
val out = ArrayList<RemotePhoto>(arr.length())
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.getJSONObject(i)
|
||||
val tagsArr = o.optJSONArray("tags")
|
||||
val tags = ArrayList<String>()
|
||||
if (tagsArr != null) {
|
||||
for (j in 0 until tagsArr.length()) tags.add(tagsArr.getString(j))
|
||||
}
|
||||
out.add(
|
||||
RemotePhoto(
|
||||
slug = o.getString("slug"),
|
||||
caption = o.optString("caption"),
|
||||
date = o.optString("date"),
|
||||
tags = tags,
|
||||
thumb = o.optString("thumb"),
|
||||
url = o.optString("url"),
|
||||
),
|
||||
)
|
||||
}
|
||||
return ListResult.Ok(out)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites a post's sidecar. The slug (and its URL) never changes. Returns
|
||||
* null on success, or a human-readable error.
|
||||
*/
|
||||
fun editPost(slug: String, caption: String, date: String, tags: String): String? {
|
||||
val form = FormBody.Builder()
|
||||
.add("slug", slug)
|
||||
.add("caption", caption)
|
||||
.add("date", date)
|
||||
.add("tags", tags)
|
||||
.build()
|
||||
val request = Request.Builder().url("$base/api/edit").post(form).build()
|
||||
return simpleResult(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a post to the server's trash (reversible there). Returns null on
|
||||
* success, or a human-readable error — including the case where the server
|
||||
* reports that nothing was deleted.
|
||||
*/
|
||||
fun deletePost(slug: String): String? {
|
||||
val form = FormBody.Builder().add("slug", slug).build()
|
||||
val request = Request.Builder().url("$base/api/delete").post(form).build()
|
||||
return try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
val text = response.body?.string().orEmpty()
|
||||
when {
|
||||
response.isSuccessful -> {
|
||||
val deleted = runCatching {
|
||||
org.json.JSONObject(text).optInt("deleted", 0)
|
||||
}.getOrDefault(0)
|
||||
if (deleted > 0) null else "Nothing deleted — not found on the server"
|
||||
}
|
||||
response.code == 401 || response.code == 403 -> "Wrong username or password"
|
||||
else -> text.trim().ifEmpty { "Server returned ${response.code}" }
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
e.message ?: "Network error"
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches a thumbnail by its site-relative [path], or null on any failure. */
|
||||
fun fetchThumbnail(path: String): Bitmap? {
|
||||
val request = Request.Builder().url("$base$path").get().build()
|
||||
return try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
response.body?.byteStream()?.let { BitmapFactory.decodeStream(it) }
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun simpleResult(request: Request): String? = try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
val text = response.body?.string().orEmpty()
|
||||
when {
|
||||
response.isSuccessful -> null
|
||||
response.code == 401 || response.code == 403 -> "Wrong username or password"
|
||||
else -> text.trim().ifEmpty { "Server returned ${response.code}" }
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
e.message ?: "Network error"
|
||||
}
|
||||
|
||||
/** Server replies "uploaded <slug>.<ext>"; recover the slug from that. */
|
||||
private fun parseSlug(responseBody: String, fallback: String): String {
|
||||
val name = responseBody.trim().removePrefix("uploaded ").trim()
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
package ws.inflo.photogallery.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
private val DISPLAY_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM yyyy")
|
||||
|
||||
@Composable
|
||||
fun ManageScreen(
|
||||
state: ManageUiState,
|
||||
onBack: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onSave: (slug: String, caption: String, date: LocalDate, tags: String) -> Unit,
|
||||
onDelete: (slug: String) -> Unit,
|
||||
onDismissNotice: () -> Unit,
|
||||
) {
|
||||
var editing by remember { mutableStateOf<ManagePost?>(null) }
|
||||
var deleting by remember { mutableStateOf<ManagePost?>(null) }
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(20.dp),
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("Manage posts", style = MaterialTheme.typography.headlineSmall, color = GruvGreen)
|
||||
Row {
|
||||
TextButton(onClick = onRefresh) { Text("Refresh") }
|
||||
TextButton(onClick = onBack) { Text("Back") }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
state.error?.let { msg ->
|
||||
item { Notice(msg, GruvRed, onDismissNotice) }
|
||||
}
|
||||
state.message?.let { msg ->
|
||||
item { Notice(msg, GruvAqua, onDismissNotice) }
|
||||
}
|
||||
|
||||
if (state.loading) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator(color = GruvAqua) }
|
||||
}
|
||||
} else if (state.items.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text("No posts.", color = GruvFg4) }
|
||||
}
|
||||
}
|
||||
|
||||
items(state.items, key = { it.slug }) { post ->
|
||||
PostRow(
|
||||
post = post,
|
||||
onEdit = { editing = post },
|
||||
onDelete = { deleting = post },
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
editing?.let { post ->
|
||||
EditDialog(
|
||||
post = post,
|
||||
onDismiss = { editing = null },
|
||||
onSave = { caption, date, tags ->
|
||||
onSave(post.slug, caption, date, tags)
|
||||
editing = null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
deleting?.let { post ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { deleting = null },
|
||||
title = { Text("Move to trash?") },
|
||||
text = { Text("\"${post.caption.ifBlank { post.slug }}\" will be moved to the server's trash.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onDelete(post.slug)
|
||||
deleting = null
|
||||
}) { Text("Delete", color = GruvRed) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { deleting = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Notice(message: String, color: androidx.compose.ui.graphics.Color, onDismiss: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(GruvBg1)
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(message, color = color, style = MaterialTheme.typography.bodyMedium)
|
||||
TextButton(onClick = onDismiss) { Text("Dismiss") }
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PostRow(post: ManagePost, onEdit: () -> Unit, onDelete: () -> Unit) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.background(GruvBg1),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val preview = post.preview
|
||||
if (preview != null) {
|
||||
Image(
|
||||
bitmap = preview.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
post.caption.ifBlank { post.slug },
|
||||
color = GruvFg,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(post.date.format(DISPLAY_DATE), color = GruvFg4, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall)
|
||||
if (post.tags.isNotEmpty()) {
|
||||
Text(post.tags.joinToString(" · "), color = GruvAqua, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row {
|
||||
if (post.busy) {
|
||||
CircularProgressIndicator(color = GruvAqua, modifier = Modifier.size(18.dp))
|
||||
} else {
|
||||
OutlinedButton(onClick = onEdit) { Text("Edit") }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TextButton(onClick = onDelete) { Text("Delete", color = GruvRed) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditDialog(
|
||||
post: ManagePost,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (caption: String, date: LocalDate, tags: String) -> Unit,
|
||||
) {
|
||||
var caption by remember { mutableStateOf(post.caption) }
|
||||
var tags by remember { mutableStateOf(post.tags.joinToString(", ")) }
|
||||
var date by remember { mutableStateOf(post.date) }
|
||||
var pickingDate by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Edit ${post.slug}") },
|
||||
text = {
|
||||
Column {
|
||||
val preview = post.preview
|
||||
if (preview != null) {
|
||||
Image(
|
||||
bitmap = preview.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(160.dp)
|
||||
.background(GruvBg1),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = caption,
|
||||
onValueChange = { caption = it },
|
||||
label = { Text("Caption") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = tags,
|
||||
onValueChange = { tags = it },
|
||||
label = { Text("Tags") },
|
||||
placeholder = { Text("comma-separated") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedButton(onClick = { pickingDate = true }) {
|
||||
Text(date.format(DISPLAY_DATE))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSave(caption, date, tags) }) { Text("Save") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
|
||||
if (pickingDate) {
|
||||
DatePickerModal(
|
||||
initial = date,
|
||||
onDismiss = { pickingDate = false },
|
||||
onConfirm = {
|
||||
date = it
|
||||
pickingDate = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DatePickerModal(
|
||||
initial: LocalDate,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (LocalDate) -> Unit,
|
||||
) {
|
||||
val pickerState = rememberDatePickerState(
|
||||
initialSelectedDateMillis = initial
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli(),
|
||||
)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val millis = pickerState.selectedDateMillis
|
||||
if (millis != null) {
|
||||
onConfirm(Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate())
|
||||
} else {
|
||||
onDismiss()
|
||||
}
|
||||
}) { Text("OK") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
) {
|
||||
DatePicker(state = pickerState)
|
||||
}
|
||||
}
|
||||
@@ -51,11 +51,13 @@ fun PostScreen(
|
||||
state: PostUiState,
|
||||
onPickImages: () -> Unit,
|
||||
onCaptionChange: (id: String, value: String) -> Unit,
|
||||
onTagsChange: (id: String, value: String) -> Unit,
|
||||
onDateChange: (id: String, value: LocalDate) -> Unit,
|
||||
onRemove: (id: String) -> Unit,
|
||||
onSubmit: () -> Unit,
|
||||
onReset: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenManage: () -> Unit,
|
||||
onOpenUrl: (String) -> Unit,
|
||||
) {
|
||||
// Which item's date is being edited, if any.
|
||||
@@ -77,8 +79,11 @@ fun PostScreen(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("Post photos", style = MaterialTheme.typography.headlineSmall, color = GruvGreen)
|
||||
Row {
|
||||
TextButton(onClick = onOpenManage) { Text("Manage") }
|
||||
TextButton(onClick = onOpenSettings) { Text("Settings") }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
@@ -107,6 +112,7 @@ fun PostScreen(
|
||||
ItemCard(
|
||||
item = item,
|
||||
onCaptionChange = { onCaptionChange(item.id, it) },
|
||||
onTagsChange = { onTagsChange(item.id, it) },
|
||||
onEditDate = { dateEditFor = item },
|
||||
onRemove = { onRemove(item.id) },
|
||||
onOpenUrl = onOpenUrl,
|
||||
@@ -185,6 +191,7 @@ fun PostScreen(
|
||||
private fun ItemCard(
|
||||
item: PickedItem,
|
||||
onCaptionChange: (String) -> Unit,
|
||||
onTagsChange: (String) -> Unit,
|
||||
onEditDate: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onOpenUrl: (String) -> Unit,
|
||||
@@ -220,6 +227,16 @@ private fun ItemCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
OutlinedTextField(
|
||||
value = item.tags,
|
||||
onValueChange = onTagsChange,
|
||||
label = { Text("Tags") },
|
||||
placeholder = { Text("comma-separated") },
|
||||
singleLine = true,
|
||||
enabled = item.status !is ItemStatus.Uploading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedButton(
|
||||
onClick = onEditDate,
|
||||
|
||||
@@ -17,11 +17,14 @@ import kotlinx.coroutines.withContext
|
||||
import ws.inflo.photogallery.data.LastPost
|
||||
import ws.inflo.photogallery.data.SettingsStore
|
||||
import ws.inflo.photogallery.image.ImagePrep
|
||||
import ws.inflo.photogallery.net.ListResult
|
||||
import ws.inflo.photogallery.net.RemotePhoto
|
||||
import ws.inflo.photogallery.net.UploadClient
|
||||
import ws.inflo.photogallery.work.UploadQueue
|
||||
import ws.inflo.photogallery.work.UploadWorker
|
||||
import java.io.File
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.UUID
|
||||
|
||||
/** Per-photo status within a batch. */
|
||||
@@ -41,6 +44,7 @@ data class PickedItem(
|
||||
val source: Uri,
|
||||
val preview: Bitmap? = null,
|
||||
val caption: String = "",
|
||||
val tags: String = "", // raw comma-separated; the server normalizes
|
||||
val date: LocalDate = LocalDate.now(),
|
||||
val status: ItemStatus = ItemStatus.Pending,
|
||||
)
|
||||
@@ -59,6 +63,25 @@ data class PostUiState(
|
||||
items.all { it.status is ItemStatus.Success || it.status is ItemStatus.Error }
|
||||
}
|
||||
|
||||
/** One published post on the manage screen. */
|
||||
data class ManagePost(
|
||||
val slug: String,
|
||||
val caption: String,
|
||||
val date: LocalDate,
|
||||
val tags: List<String>,
|
||||
val thumb: String, // site-relative path
|
||||
val url: String, // site-relative path
|
||||
val preview: Bitmap? = null,
|
||||
val busy: Boolean = false, // a save/delete is in flight
|
||||
)
|
||||
|
||||
data class ManageUiState(
|
||||
val loading: Boolean = false,
|
||||
val items: List<ManagePost> = emptyList(),
|
||||
val error: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
data class SettingsUiState(
|
||||
val baseUrl: String = "",
|
||||
val username: String = "",
|
||||
@@ -114,6 +137,10 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
|
||||
updateItem(id) { it.copy(caption = value) }
|
||||
}
|
||||
|
||||
fun setTags(id: String, value: String) {
|
||||
updateItem(id) { it.copy(tags = value) }
|
||||
}
|
||||
|
||||
fun setDate(id: String, value: LocalDate) {
|
||||
updateItem(id) { it.copy(date = value) }
|
||||
}
|
||||
@@ -159,6 +186,7 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
|
||||
uri = Uri.fromFile(staged),
|
||||
caption = current.caption,
|
||||
date = current.date,
|
||||
tags = current.tags,
|
||||
uniqueSuffix = item.id.take(6),
|
||||
)
|
||||
observe(item.id, workId)
|
||||
@@ -219,6 +247,107 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
|
||||
post = post.copy(items = post.items.map { if (it.id == id) transform(it) else it })
|
||||
}
|
||||
|
||||
// ── Manage screen ───────────────────────────────────────────────────────
|
||||
|
||||
var manage by mutableStateOf(ManageUiState())
|
||||
private set
|
||||
|
||||
private val isoDate: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||
|
||||
private fun client(): UploadClient =
|
||||
UploadClient(settings.baseUrl, settings.username, settings.password)
|
||||
|
||||
/** Loads the post list, then fills in thumbnails in the background. */
|
||||
fun loadManage() {
|
||||
if (!settings.isConfigured) {
|
||||
manage = manage.copy(error = "Set the server URL and credentials first")
|
||||
return
|
||||
}
|
||||
manage = manage.copy(loading = true, error = null, message = null)
|
||||
viewModelScope.launch {
|
||||
val result = withContext(Dispatchers.IO) { client().listPhotos() }
|
||||
when (result) {
|
||||
is ListResult.Ok -> {
|
||||
manage = manage.copy(
|
||||
loading = false,
|
||||
items = result.photos.map { it.toPost() },
|
||||
)
|
||||
loadThumbnails(result.photos)
|
||||
}
|
||||
is ListResult.Err ->
|
||||
manage = manage.copy(loading = false, error = result.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadThumbnails(photos: List<RemotePhoto>) {
|
||||
for (p in photos) {
|
||||
viewModelScope.launch {
|
||||
// Fetch via the authed /api/thumb/<slug> endpoint, not the static
|
||||
// /thumbs/ path: a front-door botwall can block this non-browser
|
||||
// client from the static site while /api/ is proxied to the app.
|
||||
val bmp = withContext(Dispatchers.IO) { client().fetchThumbnail("/api/thumb/" + p.slug) }
|
||||
if (bmp != null) updateManage(p.slug) { it.copy(preview = bmp) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveEdit(slug: String, caption: String, date: LocalDate, tags: String) {
|
||||
updateManage(slug) { it.copy(busy = true) }
|
||||
viewModelScope.launch {
|
||||
val err = withContext(Dispatchers.IO) {
|
||||
client().editPost(slug, caption, date.format(isoDate), tags)
|
||||
}
|
||||
if (err == null) {
|
||||
updateManage(slug) {
|
||||
it.copy(
|
||||
busy = false,
|
||||
caption = caption,
|
||||
date = date,
|
||||
tags = parseTagList(tags),
|
||||
)
|
||||
}
|
||||
manage = manage.copy(message = "Saved")
|
||||
} else {
|
||||
updateManage(slug) { it.copy(busy = false) }
|
||||
manage = manage.copy(error = err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deletePost(slug: String) {
|
||||
updateManage(slug) { it.copy(busy = true) }
|
||||
viewModelScope.launch {
|
||||
val err = withContext(Dispatchers.IO) { client().deletePost(slug) }
|
||||
if (err == null) {
|
||||
manage = manage.copy(
|
||||
items = manage.items.filterNot { it.slug == slug },
|
||||
message = "Moved to trash",
|
||||
)
|
||||
} else {
|
||||
updateManage(slug) { it.copy(busy = false) }
|
||||
manage = manage.copy(error = err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearManageNotice() {
|
||||
manage = manage.copy(error = null, message = null)
|
||||
}
|
||||
|
||||
private fun RemotePhoto.toPost(): ManagePost {
|
||||
val d = runCatching { LocalDate.parse(date, isoDate) }.getOrDefault(LocalDate.now())
|
||||
return ManagePost(slug = slug, caption = caption, date = d, tags = tags, thumb = thumb, url = url)
|
||||
}
|
||||
|
||||
// Local split for optimistic UI only; the server normalizes authoritatively.
|
||||
private fun parseTagList(raw: String): List<String> =
|
||||
raw.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
|
||||
private inline fun updateManage(slug: String, transform: (ManagePost) -> ManagePost) {
|
||||
manage = manage.copy(items = manage.items.map { if (it.slug == slug) transform(it) else it })
|
||||
}
|
||||
|
||||
// ── Settings screen ─────────────────────────────────────────────────────
|
||||
|
||||
fun setBaseUrl(value: String) {
|
||||
|
||||
@@ -34,6 +34,7 @@ object UploadQueue {
|
||||
uri: Uri,
|
||||
caption: String,
|
||||
date: LocalDate,
|
||||
tags: String,
|
||||
uniqueSuffix: String,
|
||||
): UUID {
|
||||
val slug = LocalDateTime.now().format(SLUG_FORMAT) + "-" + uniqueSuffix
|
||||
@@ -45,6 +46,7 @@ object UploadQueue {
|
||||
.putString(UploadWorker.KEY_SLUG, slug)
|
||||
.putString(UploadWorker.KEY_CAPTION, caption)
|
||||
.putString(UploadWorker.KEY_DATE, date.format(ISO_DATE))
|
||||
.putString(UploadWorker.KEY_TAGS, tags)
|
||||
.build(),
|
||||
)
|
||||
.setConstraints(
|
||||
|
||||
@@ -38,6 +38,7 @@ class UploadWorker(
|
||||
val slug = inputData.getString(KEY_SLUG) ?: return@withContext Result.failure()
|
||||
val caption = inputData.getString(KEY_CAPTION).orEmpty()
|
||||
val date = inputData.getString(KEY_DATE).orEmpty()
|
||||
val tags = inputData.getString(KEY_TAGS).orEmpty()
|
||||
|
||||
val settings = SettingsStore(applicationContext)
|
||||
if (!settings.isConfigured) {
|
||||
@@ -66,6 +67,7 @@ class UploadWorker(
|
||||
fileName = prepared.file.name,
|
||||
caption = caption,
|
||||
date = date,
|
||||
tags = tags,
|
||||
)
|
||||
|
||||
// Keep the staged and prepared copies only while a retry might still
|
||||
@@ -147,6 +149,7 @@ class UploadWorker(
|
||||
const val KEY_SLUG = "slug"
|
||||
const val KEY_CAPTION = "caption"
|
||||
const val KEY_DATE = "date"
|
||||
const val KEY_TAGS = "tags"
|
||||
|
||||
const val KEY_RESULT_SLUG = "result_slug"
|
||||
const val KEY_RESULT_URL = "result_url"
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// Authenticator checks HTTP Basic credentials against bcrypt hashes loaded from
|
||||
// an Apache-style htpasswd file. Only bcrypt entries are supported — generate
|
||||
// them with `htpasswd -B`. This replaces nginx's basic_auth so the app can run
|
||||
// behind a plain TLS-terminating reverse proxy with no auth of its own.
|
||||
type Authenticator struct {
|
||||
creds map[string][]byte // username -> bcrypt hash
|
||||
|
||||
// A single bcrypt comparison runs on every failed lookup so that a request
|
||||
// for an unknown user costs the same as one for a known user with a wrong
|
||||
// password, closing a user-enumeration timing side channel.
|
||||
dummyHash []byte
|
||||
}
|
||||
|
||||
// LoadHtpasswd reads user:hash lines from path. Blank lines and #-comments are
|
||||
// skipped, as is any entry whose hash is not bcrypt (with a warning). It is an
|
||||
// error for the file to yield no usable entries.
|
||||
func LoadHtpasswd(path string) (*Authenticator, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
a := &Authenticator{creds: make(map[string][]byte)}
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
user, hash, ok := strings.Cut(line, ":")
|
||||
if !ok || user == "" {
|
||||
continue
|
||||
}
|
||||
// bcrypt hashes start $2a$, $2b$ or $2y$; everything else (MD5 $apr1$,
|
||||
// SHA {SHA}, crypt) is unsupported here.
|
||||
if !strings.HasPrefix(hash, "$2") {
|
||||
log.Printf("auth: skipping non-bcrypt entry for %q — regenerate with `htpasswd -B`", user)
|
||||
continue
|
||||
}
|
||||
a.creds[user] = []byte(hash)
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(a.creds) == 0 {
|
||||
return nil, fmt.Errorf("no bcrypt entries in %s (use `htpasswd -B`)", path)
|
||||
}
|
||||
|
||||
// Cost 10 matches htpasswd -B's default, keeping the dummy comparison the
|
||||
// same price as a real one.
|
||||
a.dummyHash, _ = bcrypt.GenerateFromPassword([]byte("dummy"), 10)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// check reports whether user/pass matches a stored credential.
|
||||
func (a *Authenticator) check(user, pass string) bool {
|
||||
hash, ok := a.creds[user]
|
||||
if !ok {
|
||||
// Spend the same time as a real comparison to avoid leaking which
|
||||
// usernames exist.
|
||||
bcrypt.CompareHashAndPassword(a.dummyHash, []byte(pass))
|
||||
return false
|
||||
}
|
||||
return bcrypt.CompareHashAndPassword(hash, []byte(pass)) == nil
|
||||
}
|
||||
|
||||
// basicAuth wraps next, demanding valid HTTP Basic credentials. On a failed
|
||||
// attempt it consumes a token from limiter; once the limiter is exhausted,
|
||||
// further failures get 429 instead of 401, throttling brute-force guessing.
|
||||
// The limiter is only touched on failure, so legitimate (even bursty, bulk)
|
||||
// uploads are never throttled.
|
||||
func (a *Authenticator) basicAuth(limiter *rate.Limiter, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if ok && a.check(user, pass) {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
if !limiter.Allow() {
|
||||
http.Error(w, "too many failed attempts", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="photog"`)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// bcryptHash returns a cost-10 hash of pw, matching `htpasswd -B`'s default.
|
||||
func bcryptHash(t *testing.T, pw string) string {
|
||||
t.Helper()
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(pw), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateFromPassword: %v", err)
|
||||
}
|
||||
return string(h)
|
||||
}
|
||||
|
||||
func TestLoadHtpasswdAndCheck(t *testing.T) {
|
||||
// alice's hash is rewritten to the $2y$ variant that Apache's htpasswd -B
|
||||
// emits, to prove it is accepted. $2a$ and $2y$ are the same algorithm, so
|
||||
// swapping the prefix on a valid hash yields a valid hash for the same
|
||||
// password. bob keeps the $2a$ form.
|
||||
aliceHash := bcryptHash(t, "s3cret")
|
||||
if !strings.HasPrefix(aliceHash, "$2a$") {
|
||||
t.Fatalf("expected $2a$ prefix, got %q", aliceHash[:4])
|
||||
}
|
||||
aliceHashY := "$2y$" + strings.TrimPrefix(aliceHash, "$2a$")
|
||||
bobHash := bcryptHash(t, "hunter2")
|
||||
|
||||
file := filepath.Join(t.TempDir(), "htpasswd")
|
||||
content := strings.Join([]string{
|
||||
"# a comment",
|
||||
"",
|
||||
"alice:" + aliceHashY,
|
||||
"bob:" + bobHash,
|
||||
"legacy:$apr1$abcd$0123456789", // MD5 — must be skipped
|
||||
}, "\n") + "\n"
|
||||
if err := os.WriteFile(file, []byte(content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a, err := LoadHtpasswd(file)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadHtpasswd: %v", err)
|
||||
}
|
||||
if _, ok := a.creds["legacy"]; ok {
|
||||
t.Error("MD5 entry should have been skipped")
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
user, pass string
|
||||
want bool
|
||||
}{
|
||||
{"alice", "s3cret", true}, // $2y$ accepted
|
||||
{"bob", "hunter2", true}, // $2a$ accepted
|
||||
{"alice", "wrong", false}, // wrong password
|
||||
{"bob", "s3cret", false}, // right password, wrong user
|
||||
{"nobody", "s3cret", false}, // unknown user
|
||||
{"legacy", "whatever", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := a.check(c.user, c.pass); got != c.want {
|
||||
t.Errorf("check(%q, %q) = %v, want %v", c.user, c.pass, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthMiddleware(t *testing.T) {
|
||||
a := &Authenticator{creds: map[string][]byte{"alice": []byte(bcryptHash(t, "s3cret"))}}
|
||||
a.dummyHash, _ = bcrypt.GenerateFromPassword([]byte("dummy"), 10)
|
||||
|
||||
var reached bool
|
||||
// Burst of 2 failures before throttling, so the 429 path is reachable.
|
||||
limiter := rate.NewLimiter(rate.Every(time.Hour), 2)
|
||||
h := a.basicAuth(limiter, func(w http.ResponseWriter, r *http.Request) {
|
||||
reached = true
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
|
||||
do := func(user, pass string, withCreds bool) *httptest.ResponseRecorder {
|
||||
reached = false
|
||||
req := httptest.NewRequest(http.MethodPost, "/upload", nil)
|
||||
if withCreds {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// Good credentials pass through.
|
||||
if rec := do("alice", "s3cret", true); rec.Code != http.StatusCreated || !reached {
|
||||
t.Fatalf("valid creds: code=%d reached=%v", rec.Code, reached)
|
||||
}
|
||||
|
||||
// No credentials → 401 with a challenge.
|
||||
rec := do("", "", false)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("missing creds: code=%d, want 401", rec.Code)
|
||||
}
|
||||
if !strings.HasPrefix(rec.Header().Get("WWW-Authenticate"), "Basic ") {
|
||||
t.Errorf("missing WWW-Authenticate challenge: %q", rec.Header().Get("WWW-Authenticate"))
|
||||
}
|
||||
if reached {
|
||||
t.Error("handler reached despite missing creds")
|
||||
}
|
||||
|
||||
// Wrong password burns the limiter (burst 2: one above + this one), then
|
||||
// the next failure is throttled to 429.
|
||||
if rec := do("alice", "nope", true); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong pass #2: code=%d, want 401", rec.Code)
|
||||
}
|
||||
if rec := do("alice", "nope", true); rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("wrong pass #3: code=%d, want 429", rec.Code)
|
||||
}
|
||||
|
||||
// A valid request still succeeds while failures are throttled — the
|
||||
// limiter only gates failures.
|
||||
if rec := do("alice", "s3cret", true); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("valid creds under throttle: code=%d, want 201", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadHtpasswdErrors(t *testing.T) {
|
||||
if _, err := LoadHtpasswd(filepath.Join(t.TempDir(), "does-not-exist")); err == nil {
|
||||
t.Error("expected error for missing file")
|
||||
}
|
||||
|
||||
empty := filepath.Join(t.TempDir(), "htpasswd")
|
||||
if err := os.WriteFile(empty, []byte("# only comments\n\nlegacy:$apr1$x$y\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadHtpasswd(empty); err == nil {
|
||||
t.Error("expected error when no bcrypt entries are usable")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// buildWithView builds a two-photo site with the given default view and returns
|
||||
// the output dir.
|
||||
func buildWithView(t *testing.T, view string) string {
|
||||
t.Helper()
|
||||
content := t.TempDir()
|
||||
output := t.TempDir()
|
||||
writePNG(t, filepath.Join(content, "a.png"))
|
||||
writePNG(t, filepath.Join(content, "b.png"))
|
||||
|
||||
g := NewGenerator(content, output, "http://x", "A", "@a", "bio", "", "f", "s", "m", 60, view)
|
||||
if err := g.Build(); err != nil {
|
||||
t.Fatalf("build (%s): %v", view, err)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func exists(p string) bool { _, err := os.Stat(p); return err == nil }
|
||||
|
||||
func TestDefaultViewTimeline(t *testing.T) {
|
||||
out := buildWithView(t, viewTimeline)
|
||||
|
||||
// Timeline owns the root; grid is the secondary under /grid/.
|
||||
if !exists(filepath.Join(out, "index.html")) {
|
||||
t.Error("root index.html missing")
|
||||
}
|
||||
if !exists(filepath.Join(out, "grid", "index.html")) {
|
||||
t.Error("/grid/ should exist as the secondary view")
|
||||
}
|
||||
// No /timeline/ dir — timeline is at the root, not under its own name.
|
||||
if exists(filepath.Join(out, "timeline")) {
|
||||
t.Error("/timeline/ should not exist when timeline is the default")
|
||||
}
|
||||
|
||||
// The root (timeline) links to grid at /grid/.
|
||||
root := readFile(t, filepath.Join(out, "index.html"))
|
||||
if !strings.Contains(root, `href="/grid/"`) {
|
||||
t.Error("timeline root should link to /grid/")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultViewGrid(t *testing.T) {
|
||||
out := buildWithView(t, viewGrid)
|
||||
|
||||
// Grid owns the root; timeline is the secondary under /timeline/.
|
||||
if !exists(filepath.Join(out, "index.html")) {
|
||||
t.Error("root index.html missing")
|
||||
}
|
||||
if !exists(filepath.Join(out, "timeline", "index.html")) {
|
||||
t.Error("/timeline/ should exist as the secondary view")
|
||||
}
|
||||
// No /grid/ dir — grid is at the root now.
|
||||
if exists(filepath.Join(out, "grid")) {
|
||||
t.Error("/grid/ should not exist when grid is the default")
|
||||
}
|
||||
|
||||
// The root (grid) links back to the timeline at /timeline/.
|
||||
root := readFile(t, filepath.Join(out, "index.html"))
|
||||
if !strings.Contains(root, `href="/timeline/"`) {
|
||||
t.Error("grid root should link to /timeline/")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultViewSwitchCleansUp confirms that flipping the default view does not
|
||||
// leave the previous root's view directory behind.
|
||||
func TestDefaultViewSwitchCleansUp(t *testing.T) {
|
||||
content := t.TempDir()
|
||||
output := t.TempDir()
|
||||
writePNG(t, filepath.Join(content, "a.png"))
|
||||
|
||||
// First build with grid as root → creates /timeline/.
|
||||
g1 := NewGenerator(content, output, "http://x", "A", "@a", "b", "", "f", "s", "m", 60, viewGrid)
|
||||
if err := g1.Build(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists(filepath.Join(output, "timeline")) {
|
||||
t.Fatal("expected /timeline/ after grid-default build")
|
||||
}
|
||||
|
||||
// Rebuild with timeline as root → /timeline/ must be gone, /grid/ present.
|
||||
g2 := NewGenerator(content, output, "http://x", "A", "@a", "b", "", "f", "s", "m", 60, viewTimeline)
|
||||
if err := g2.Build(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exists(filepath.Join(output, "timeline")) {
|
||||
t.Error("/timeline/ left over after switching default to timeline")
|
||||
}
|
||||
if !exists(filepath.Join(output, "grid", "index.html")) {
|
||||
t.Error("/grid/ should exist after switching default to timeline")
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, p string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -313,7 +313,7 @@ void MainWindow::startItem(PhotoItemWidget *item)
|
||||
refreshPosting();
|
||||
});
|
||||
client->upload(prepared.filePath, QFileInfo(prepared.filePath).fileName(),
|
||||
caption, item->date());
|
||||
caption, item->date(), item->tags());
|
||||
});
|
||||
watcher->setFuture(QtConcurrent::run([source, cacheDir, slug]() {
|
||||
return ImagePrep::prepare(source, cacheDir, slug);
|
||||
|
||||
@@ -47,6 +47,10 @@ PhotoItemWidget::PhotoItemWidget(QString sourcePath, QWidget *parent)
|
||||
m_date->setDisplayFormat(QStringLiteral("yyyy-MM-dd"));
|
||||
fields->addWidget(m_date);
|
||||
|
||||
m_tags = new QLineEdit;
|
||||
m_tags->setPlaceholderText(QStringLiteral("tags (comma-separated)"));
|
||||
fields->addWidget(m_tags);
|
||||
|
||||
m_statusLabel = new QLabel;
|
||||
m_statusLabel->setOpenExternalLinks(true);
|
||||
m_statusLabel->setWordWrap(true);
|
||||
@@ -70,6 +74,11 @@ QString PhotoItemWidget::date() const
|
||||
return m_date->date().toString(QStringLiteral("yyyy-MM-dd"));
|
||||
}
|
||||
|
||||
QString PhotoItemWidget::tags() const
|
||||
{
|
||||
return m_tags->text();
|
||||
}
|
||||
|
||||
void PhotoItemWidget::setStatus(Status status, const QString &message, const QString &url)
|
||||
{
|
||||
m_status = status;
|
||||
@@ -79,6 +88,7 @@ void PhotoItemWidget::setStatus(Status status, const QString &message, const QSt
|
||||
const bool editable = status == Status::Pending || status == Status::Error;
|
||||
m_caption->setEnabled(editable);
|
||||
m_date->setEnabled(editable);
|
||||
m_tags->setEnabled(editable);
|
||||
m_remove->setEnabled(status != Status::Preparing && status != Status::Uploading);
|
||||
|
||||
switch (status) {
|
||||
|
||||
@@ -21,6 +21,7 @@ public:
|
||||
QString sourcePath() const { return m_sourcePath; }
|
||||
QString caption() const;
|
||||
QString date() const; // yyyy-MM-dd
|
||||
QString tags() const; // raw comma-separated; the server normalizes
|
||||
Status status() const { return m_status; }
|
||||
|
||||
void setStatus(Status status, const QString &message = {}, const QString &url = {});
|
||||
@@ -35,6 +36,7 @@ private:
|
||||
QLabel *m_thumb = nullptr;
|
||||
QLineEdit *m_caption = nullptr;
|
||||
QDateEdit *m_date = nullptr;
|
||||
QLineEdit *m_tags = nullptr;
|
||||
QLabel *m_statusLabel = nullptr;
|
||||
QPushButton *m_remove = nullptr;
|
||||
};
|
||||
@@ -26,7 +26,7 @@ QByteArray UploadClient::authHeader() const
|
||||
}
|
||||
|
||||
void UploadClient::upload(const QString &filePath, const QString &fileName,
|
||||
const QString &caption, const QString &date)
|
||||
const QString &caption, const QString &date, const QString &tags)
|
||||
{
|
||||
auto *file = new QFile(filePath);
|
||||
if (!file->open(QIODevice::ReadOnly)) {
|
||||
@@ -57,6 +57,7 @@ void UploadClient::upload(const QString &filePath, const QString &fileName,
|
||||
};
|
||||
multiPart->append(textPart(QStringLiteral("caption"), caption));
|
||||
multiPart->append(textPart(QStringLiteral("date"), date));
|
||||
multiPart->append(textPart(QStringLiteral("tags"), tags));
|
||||
|
||||
QNetworkRequest request(QUrl(m_base + QStringLiteral("/upload")));
|
||||
request.setRawHeader("Authorization", authHeader());
|
||||
|
||||
@@ -29,7 +29,7 @@ public:
|
||||
// The filename matters: server.go derives the slug from it, so re-sending
|
||||
// the same name overwrites the same post instead of duplicating it.
|
||||
void upload(const QString &filePath, const QString &fileName,
|
||||
const QString &caption, const QString &date);
|
||||
const QString &caption, const QString &date, const QString &tags);
|
||||
|
||||
// Credential check via GET /upload (same Basic-auth gate the POST uses).
|
||||
// /health is not usable: nginx only proxies location /upload.
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
# Run `go mod tidy` to generate go.sum, then `nix build`.
|
||||
# Nix will fail and print the correct hash — paste it here.
|
||||
vendorHash = "sha256-SxQDRProQtauWXPvPzbZZChLQd0iey9hnXFLiPYTS98=";
|
||||
vendorHash = "sha256-Q+VeDZX9DlZtOZrYRwZo19eyob8jIu19IRhdvChmVL4=";
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Minimal static photo gallery";
|
||||
|
||||
+93
-113
@@ -27,8 +27,8 @@ const (
|
||||
mediumMaxSize = 1600
|
||||
defaultPageSize = 60
|
||||
|
||||
// The two paginated views. The one chosen as -default-view is published
|
||||
// at the site root, the other under /<name>/.
|
||||
// The two paginated views. The one chosen as -default-view is published at
|
||||
// the site root, the other under /<name>/.
|
||||
viewTimeline = "timeline"
|
||||
viewGrid = "grid"
|
||||
|
||||
@@ -48,6 +48,7 @@ var ogQualityLadder = []int{85, 78, 70, 62}
|
||||
type PhotoMeta struct {
|
||||
Caption string `toml:"caption"`
|
||||
Date string `toml:"date"` // YYYY-MM-DD
|
||||
Tags []string `toml:"tags,omitempty"` // free-text, lowercased; each tag is an album
|
||||
}
|
||||
|
||||
// Photo is the in-memory representation of one image.
|
||||
@@ -61,6 +62,7 @@ type Photo struct {
|
||||
OGHeight int
|
||||
Caption string
|
||||
Date time.Time
|
||||
Tags []Tag // albums this photo belongs to
|
||||
}
|
||||
|
||||
// DayGroup is a run of photos sharing the same calendar day, for the timeline.
|
||||
@@ -113,15 +115,8 @@ func NewGenerator(contentDir, outputDir, baseURL, author, handle, bio, bioAlt, f
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Generator) initial() string {
|
||||
for _, r := range g.author {
|
||||
return strings.ToUpper(string(r))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// viewBase returns the URL prefix a view is published under: "" for the
|
||||
// default view, which owns the site root, and "/<view>" for the other one.
|
||||
// viewBase returns the URL prefix a view is published under: "" for the default
|
||||
// view, which owns the site root, and "/<view>" for the other one.
|
||||
func (g *Generator) viewBase(view string) string {
|
||||
if view == g.defaultView {
|
||||
return ""
|
||||
@@ -137,6 +132,18 @@ func pageURL(base string, n int) string {
|
||||
return base + "/page/" + strconv.Itoa(n) + "/"
|
||||
}
|
||||
|
||||
// timelineURL and gridURL are the roots of each view, for cross-links in the
|
||||
// page headers.
|
||||
func (g *Generator) timelineURL() string { return pageURL(g.viewBase(viewTimeline), 1) }
|
||||
func (g *Generator) gridURL() string { return pageURL(g.viewBase(viewGrid), 1) }
|
||||
|
||||
func (g *Generator) initial() string {
|
||||
for _, r := range g.author {
|
||||
return strings.ToUpper(string(r))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Build performs a full rebuild of the site. It is safe to call concurrently;
|
||||
// concurrent calls are serialised by a mutex.
|
||||
func (g *Generator) Build() error {
|
||||
@@ -148,25 +155,36 @@ func (g *Generator) Build() error {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "day", "static"} {
|
||||
// Drop fully-derived HTML trees before regenerating them, so a deleted
|
||||
// post, a retag, a changed date or a flipped -default-view 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. Both "timeline" and "grid" are removed: whichever is the
|
||||
// default owns the site root and has no dir of its own, so a dir by that
|
||||
// name can only be a leftover from the opposite -default-view.
|
||||
for _, d := range []string{"photo", "day", "album", "page", "timeline", "grid"} {
|
||||
if err := os.RemoveAll(filepath.Join(g.outputDir, d)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "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)
|
||||
}
|
||||
}
|
||||
|
||||
// The default view lives at the site root, so a directory named after it
|
||||
// can only be left over from a run with the other -default-view. Drop it,
|
||||
// or the old pages would stay reachable and go stale.
|
||||
if err := os.RemoveAll(filepath.Join(g.outputDir, g.defaultView)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := g.renderTimelinePages(photos); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -176,6 +194,16 @@ func (g *Generator) Build() error {
|
||||
if err := g.renderDayPages(photos); err != nil {
|
||||
return err
|
||||
}
|
||||
albums := groupByTag(photos)
|
||||
if err := g.renderAlbumPages(albums, len(photos)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := g.renderAlbumsIndex(albums, len(photos)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := g.renderTagsJSON(albums); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range photos {
|
||||
var newer, older *Photo
|
||||
if i > 0 {
|
||||
@@ -199,48 +227,6 @@ func (g *Generator) Build() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a photo for good: its image and sidecar in the content
|
||||
// directory, then everything generated from them. Pages that list the photo
|
||||
// are put right by the next Build. It returns os.ErrNotExist when no photo
|
||||
// has that slug.
|
||||
func (g *Generator) Delete(slug string) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
photos, err := g.loadPhotos()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, p := range photos {
|
||||
if p.Slug != slug {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
// The source goes first: if it cannot be removed, the photo stays
|
||||
// fully intact rather than half deleted.
|
||||
if err := os.Remove(filepath.Join(g.contentDir, filepath.Base(p.File))); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rel := range []string{p.File, p.Thumb, p.Medium, p.OG} {
|
||||
err := os.Remove(filepath.Join(g.outputDir, filepath.FromSlash(rel)))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
|
||||
// slug is safe to join from here on: it matched a directory entry.
|
||||
if err := os.Remove(filepath.Join(g.contentDir, slug+".toml")); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(filepath.Join(g.outputDir, "photo", slug))
|
||||
}
|
||||
|
||||
// loadPhotos scans the content directory and returns photos sorted newest first.
|
||||
func (g *Generator) loadPhotos() ([]Photo, error) {
|
||||
entries, err := os.ReadDir(g.contentDir)
|
||||
@@ -285,6 +271,7 @@ func (g *Generator) loadPhotos() ([]Photo, error) {
|
||||
OG: "og/" + slug + ".jpg",
|
||||
Caption: meta.Caption,
|
||||
Date: date,
|
||||
Tags: parseTags(meta.Tags...),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -294,6 +281,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.
|
||||
@@ -483,10 +499,10 @@ func generateOG(src, dst string, maxDim int) error {
|
||||
return os.WriteFile(dst, buf.Bytes(), 0644)
|
||||
}
|
||||
|
||||
// renderGridPages writes the paginated square-grid view: index.html and
|
||||
// page/N/index.html under the grid's base (the site root when the grid is the
|
||||
// default view, /grid/ otherwise), chunking photos into pageSize slices for
|
||||
// progressive pagination.
|
||||
// renderGridPages writes the paginated square-grid view. When grid is the
|
||||
// -default-view it is the site root (index.html + page/N/); otherwise it lives
|
||||
// under /grid/. Photos are chunked into pageSize slices for progressive
|
||||
// pagination.
|
||||
func (g *Generator) renderGridPages(photos []Photo) error {
|
||||
total := len(photos)
|
||||
totalPages := (total + g.pageSize - 1) / g.pageSize
|
||||
@@ -520,12 +536,13 @@ func (g *Generator) renderGridPages(photos []Photo) error {
|
||||
|
||||
data := map[string]any{
|
||||
"Photos": slice,
|
||||
"TimelineURL": pageURL(g.viewBase(viewTimeline), 1),
|
||||
"Total": total,
|
||||
"Page": i + 1,
|
||||
"TotalPages": totalPages,
|
||||
"PrevURL": prev,
|
||||
"NextURL": next,
|
||||
"TimelineURL": g.timelineURL(),
|
||||
"GridURL": g.gridURL(),
|
||||
"BaseURL": g.baseURL,
|
||||
"Year": time.Now().Year(),
|
||||
"Author": g.author,
|
||||
@@ -541,7 +558,7 @@ func (g *Generator) renderGridPages(photos []Photo) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return g.prunePages(base, totalPages)
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderPhotoPage writes public/photo/<slug>/index.html.
|
||||
@@ -613,14 +630,13 @@ func chunkDayGroups(groups []DayGroup, target int) [][]DayGroup {
|
||||
return pages
|
||||
}
|
||||
|
||||
// renderTimelinePages writes the day-grouped timeline: index.html and
|
||||
// page/N/index.html under the timeline's base (the site root when the
|
||||
// timeline is the default view). Photos are grouped by day, then packed
|
||||
// renderTimelinePages writes the day-grouped timeline. When timeline is the
|
||||
// -default-view (the default) it is the site root (index.html + page/N/);
|
||||
// otherwise it lives under /timeline/. Photos are grouped by day, then packed
|
||||
// into pages of roughly pageSize without splitting a day across pages.
|
||||
func (g *Generator) renderTimelinePages(photos []Photo) error {
|
||||
pages := chunkDayGroups(groupByDay(photos), g.pageSize)
|
||||
totalPages := len(pages)
|
||||
|
||||
base := g.viewBase(viewTimeline)
|
||||
|
||||
for i, groups := range pages {
|
||||
@@ -641,12 +657,13 @@ func (g *Generator) renderTimelinePages(photos []Photo) error {
|
||||
|
||||
data := map[string]any{
|
||||
"Groups": groups,
|
||||
"GridURL": pageURL(g.viewBase(viewGrid), 1),
|
||||
"Total": len(photos),
|
||||
"Page": i + 1,
|
||||
"TotalPages": totalPages,
|
||||
"PrevURL": prev,
|
||||
"NextURL": next,
|
||||
"TimelineURL": g.timelineURL(),
|
||||
"GridURL": g.gridURL(),
|
||||
"BaseURL": g.baseURL,
|
||||
"Year": time.Now().Year(),
|
||||
"Author": g.author,
|
||||
@@ -662,61 +679,24 @@ func (g *Generator) renderTimelinePages(photos []Photo) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return g.prunePages(base, totalPages)
|
||||
}
|
||||
|
||||
// prunePages removes the pager pages of a view past its last one. They are
|
||||
// left behind when the photo count shrinks or the views swap places, and
|
||||
// would keep showing photos that are gone.
|
||||
func (g *Generator) prunePages(base string, totalPages int) error {
|
||||
// Page 1 is the view's own root, never a leftover: start past it even
|
||||
// if a caller reports no pages at all.
|
||||
for n := max(totalPages, 1) + 1; ; n++ {
|
||||
dir := filepath.Join(g.outputDir, filepath.FromSlash(pageURL(base, n)))
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// renderDayPages writes one page per calendar day under public/day/<ISO>/,
|
||||
// holding just that day's photos. Timeline date headings link here. Pages of
|
||||
// days that no longer have photos, after a delete or a date change, are
|
||||
// removed.
|
||||
// holding just that day's photos. Timeline date headings link here.
|
||||
func (g *Generator) renderDayPages(photos []Photo) error {
|
||||
groups := groupByDay(photos)
|
||||
|
||||
live := make(map[string]bool, len(groups))
|
||||
for _, grp := range groups {
|
||||
live[grp.day] = true
|
||||
}
|
||||
dayDir := filepath.Join(g.outputDir, "day")
|
||||
entries, err := os.ReadDir(dayDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && !live[e.Name()] {
|
||||
if err := os.RemoveAll(filepath.Join(dayDir, e.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, grp := range groups {
|
||||
for _, grp := range groupByDay(photos) {
|
||||
dir := filepath.Join(g.outputDir, "day", grp.day)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
err := g.render("day.html", filepath.Join(dir, "index.html"), map[string]any{
|
||||
"Date": grp.Label,
|
||||
"TimelineURL": pageURL(g.viewBase(viewTimeline), 1),
|
||||
"Count": grp.Count,
|
||||
"Photos": grp.Photos,
|
||||
"Total": len(photos),
|
||||
"TimelineURL": g.timelineURL(),
|
||||
"GridURL": g.gridURL(),
|
||||
"BaseURL": g.baseURL,
|
||||
"Year": time.Now().Year(),
|
||||
"Author": g.author,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
module photogallery
|
||||
|
||||
go 1.22
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.4.0
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
golang.org/x/crypto v0.58.0
|
||||
golang.org/x/image v0.21.0
|
||||
golang.org/x/time v0.16.0
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.4.0 // indirect
|
||||
require golang.org/x/sys v0.49.0 // indirect
|
||||
@@ -2,7 +2,11 @@ github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0
|
||||
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
|
||||
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
|
||||
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||
@@ -20,8 +20,8 @@ func main() {
|
||||
serifFamily := flag.String("serif-family", "Crimson Pro", "CSS font-family for the primary serif text")
|
||||
mlFamily := flag.String("ml-family", "Manjari", "CSS font-family for the alt-script (Malayalam) bio line")
|
||||
pageSize := flag.Int("page-size", 60, "photos per page on the timeline and grid views (timeline packs whole days, so pages vary slightly around this)")
|
||||
manage := flag.Bool("manage", false, "serve the /manage pages for editing and deleting published photos (off by default; put them behind auth in nginx)")
|
||||
defaultView := flag.String("default-view", viewTimeline, "view published at the site root: \"timeline\" or \"grid\"; the other one is published under /<name>/")
|
||||
htpasswd := flag.String("htpasswd", "", "path to an htpasswd file (bcrypt, `htpasswd -B`) guarding /upload; empty disables auth")
|
||||
defaultView := flag.String("default-view", viewTimeline, "view published at the site root: \"timeline\" or \"grid\"; the other is published under /<name>/")
|
||||
flag.Parse()
|
||||
|
||||
if *defaultView != viewTimeline && *defaultView != viewGrid {
|
||||
@@ -47,6 +47,17 @@ func main() {
|
||||
defer w.Close()
|
||||
go w.Watch()
|
||||
|
||||
s := NewServer(*port, *contentDir, *outputDir, *serve, *manage, g)
|
||||
var auth *Authenticator
|
||||
if *htpasswd != "" {
|
||||
auth, err = LoadHtpasswd(*htpasswd)
|
||||
if err != nil {
|
||||
log.Fatalf("htpasswd: %v", err)
|
||||
}
|
||||
log.Printf("upload auth: enabled (%s)", *htpasswd)
|
||||
} else {
|
||||
log.Print("upload auth: DISABLED (no -htpasswd); /upload is open")
|
||||
}
|
||||
|
||||
s := NewServer(*port, *contentDir, *outputDir, *serve, g, auth)
|
||||
log.Fatal(s.Listen())
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -13,212 +12,379 @@ import (
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// The /manage pages edit and delete photos that are already published. Like
|
||||
// /upload they carry no auth of their own: in production nginx puts them
|
||||
// behind htpasswd, and nothing on the public site links to them.
|
||||
// contentExts are the image extensions a post may use; the sidecar is always
|
||||
// <slug>.toml alongside.
|
||||
var contentExts = []string{".jpg", ".jpeg", ".png"}
|
||||
|
||||
// maxEditBody caps the edit form. It carries a caption and a date, nothing else.
|
||||
const maxEditBody = 64 << 10
|
||||
// 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 lists every photo with links to edit and delete it.
|
||||
// handleSession reports whether the request carries valid credentials, so the
|
||||
// UI can reveal management links and a signed-in indicator. It always returns
|
||||
// 200 and never sends WWW-Authenticate, so calling it from a public page does
|
||||
// not trigger the browser's Basic-auth dialog.
|
||||
//
|
||||
// It must not become a password-probing oracle that sidesteps the /upload
|
||||
// limiter: a request with no credentials (a logged-out browser) is answered
|
||||
// cheaply as "not authenticated", but one carrying wrong credentials is run
|
||||
// through the same failed-attempt limiter and gets 429 once that is drained.
|
||||
func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
if s.auth == nil { // auth disabled → everything is open
|
||||
writeJSON(w, map[string]bool{"authenticated": true})
|
||||
return
|
||||
}
|
||||
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
writeJSON(w, map[string]bool{"authenticated": false})
|
||||
return
|
||||
}
|
||||
if s.auth.check(user, pass) {
|
||||
writeJSON(w, map[string]bool{"authenticated": true})
|
||||
return
|
||||
}
|
||||
if !s.authLimit.Allow() {
|
||||
http.Error(w, "too many attempts", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]bool{"authenticated": false})
|
||||
}
|
||||
|
||||
// handleThumb serves a photo's thumbnail from the generated output, behind the
|
||||
// same Basic auth as the rest of /api/. Non-browser clients (the Android app)
|
||||
// use this instead of the static /thumbs/<slug>.jpg path, because a front-door
|
||||
// botwall on `location /` can block a non-browser client from the static site
|
||||
// while `/api/` is proxied straight to this server.
|
||||
func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" || slug != filepath.Base(slug) || strings.HasPrefix(slug, ".") {
|
||||
http.Error(w, "bad slug", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
f, err := os.Open(filepath.Join(s.outputDir, "thumbs", slug+".jpg"))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
http.ServeContent(w, r, slug+".jpg", fi.ModTime(), f)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
log.Printf("manage: %v", err)
|
||||
http.Error(w, "could not read photos", http.StatusInternalServerError)
|
||||
http.Error(w, "could not list photos", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := s.managePageData()
|
||||
data["Photos"] = photos
|
||||
if saved := r.URL.Query().Get("saved"); saved != "" {
|
||||
out := make([]photoJSON, 0, len(photos))
|
||||
for _, p := range photos {
|
||||
if p.Slug == saved {
|
||||
data["Saved"] = p.Caption
|
||||
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 + "/",
|
||||
})
|
||||
}
|
||||
}
|
||||
data["Deleted"] = r.URL.Query().Get("deleted")
|
||||
s.renderManage(w, "manage.html", data)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
|
||||
// handleEditPage shows the edit form for one photo, prefilled from its sidecar.
|
||||
func (s *Server) handleEditPage(w http.ResponseWriter, r *http.Request) {
|
||||
photo, ok := s.findPhoto(r.PathValue("slug"))
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data := s.managePageData()
|
||||
data["Photo"] = photo
|
||||
s.renderManage(w, "edit.html", data)
|
||||
}
|
||||
|
||||
// handleEdit accepts the edit form with fields:
|
||||
//
|
||||
// caption — human-readable caption (empty falls back to the slug, as on upload)
|
||||
// date — YYYY-MM-DD
|
||||
//
|
||||
// It rewrites the photo's .toml sidecar; the image itself is never touched.
|
||||
// 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.sameOrigin(r) {
|
||||
http.Error(w, "cross-site request refused", http.StatusForbidden)
|
||||
if s.rejectCrossSite(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
photo, ok := s.findPhoto(r.PathValue("slug"))
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxEditBody)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
if err := parseForm(r); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
caption := strings.TrimSpace(r.PostFormValue("caption"))
|
||||
if caption == "" {
|
||||
caption = photo.Slug
|
||||
slug := r.FormValue("slug")
|
||||
if _, ok := s.resolveSlug(slug); !ok {
|
||||
http.Error(w, "unknown post", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
dateStr := r.PostFormValue("date")
|
||||
if _, err := time.Parse("2006-01-02", dateStr); err != nil {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Encode first and write in one go, so a rebuild racing this request
|
||||
// never reads a half-written sidecar.
|
||||
var buf bytes.Buffer
|
||||
if err := toml.NewEncoder(&buf).Encode(PhotoMeta{Caption: caption, Date: dateStr}); err != nil {
|
||||
http.Error(w, "could not write metadata", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tomlPath := filepath.Join(s.contentDir, photo.Slug+".toml")
|
||||
if err := os.WriteFile(tomlPath, buf.Bytes(), 0644); err != nil {
|
||||
log.Printf("write %s: %v", tomlPath, err)
|
||||
http.Error(w, "could not save metadata", http.StatusInternalServerError)
|
||||
return
|
||||
caption := r.FormValue("caption")
|
||||
if caption == "" {
|
||||
caption = slug
|
||||
}
|
||||
|
||||
// Build() is mutex-guarded so calling it here and from the watcher is safe.
|
||||
go func() {
|
||||
if err := s.generator.Build(); err != nil {
|
||||
log.Printf("rebuild after edit: %v", err)
|
||||
var tags []string
|
||||
for _, t := range parseTags(r.FormValue("tags")) {
|
||||
tags = append(tags, t.Name)
|
||||
}
|
||||
}()
|
||||
|
||||
http.Redirect(w, r, "/manage?saved="+url.QueryEscape(photo.Slug), http.StatusSeeOther)
|
||||
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)
|
||||
}
|
||||
|
||||
// handleDeletePage asks for confirmation before a photo is deleted. Deleting
|
||||
// is a separate page rather than a button in the list so that it always takes
|
||||
// two deliberate steps.
|
||||
func (s *Server) handleDeletePage(w http.ResponseWriter, r *http.Request) {
|
||||
photo, ok := s.findPhoto(r.PathValue("slug"))
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data := s.managePageData()
|
||||
data["Photo"] = photo
|
||||
s.renderManage(w, "delete.html", data)
|
||||
}
|
||||
|
||||
// handleDelete removes a photo for good: image, sidecar and everything
|
||||
// generated from them. There is no undo.
|
||||
// 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.sameOrigin(r) {
|
||||
http.Error(w, "cross-site request refused", http.StatusForbidden)
|
||||
if s.rejectCrossSite(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
photo, ok := s.findPhoto(r.PathValue("slug"))
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
slugs, err := s.formSlugs(r)
|
||||
if err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.generator.Delete(photo.Slug); err != nil {
|
||||
log.Printf("delete %s: %v", photo.Slug, err)
|
||||
http.Error(w, "could not delete photo", http.StatusInternalServerError)
|
||||
return
|
||||
deleted := 0
|
||||
for _, slug := range slugs {
|
||||
if _, ok := s.resolveSlug(slug); !ok {
|
||||
continue
|
||||
}
|
||||
log.Printf("deleted %s (%q)", photo.Slug, photo.Caption)
|
||||
|
||||
// Build() is mutex-guarded so calling it here and from the watcher is safe.
|
||||
go func() {
|
||||
if err := s.generator.Build(); err != nil {
|
||||
log.Printf("rebuild after delete: %v", err)
|
||||
if err := s.moveToTrash(slug); err != nil {
|
||||
log.Printf("delete %s: %v", slug, err)
|
||||
continue
|
||||
}
|
||||
}()
|
||||
|
||||
http.Redirect(w, r, "/manage?deleted="+url.QueryEscape(photo.Caption), http.StatusSeeOther)
|
||||
deleted++
|
||||
}
|
||||
if deleted > 0 {
|
||||
s.rebuild()
|
||||
}
|
||||
writeJSON(w, map[string]int{"deleted": deleted})
|
||||
}
|
||||
|
||||
// findPhoto looks slug up among the photos in the content directory. Matching
|
||||
// against real entries, rather than building a path from the URL, is what
|
||||
// keeps a crafted slug from reaching outside the content directory.
|
||||
func (s *Server) findPhoto(slug string) (Photo, bool) {
|
||||
photos, err := s.generator.loadPhotos()
|
||||
if err != nil {
|
||||
log.Printf("manage: %v", err)
|
||||
return Photo{}, false
|
||||
// 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
|
||||
}
|
||||
for _, p := range photos {
|
||||
if p.Slug == slug {
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
// parseForm reads form values from either an application/x-www-form-urlencoded
|
||||
// or a multipart/form-data body. Browsers posting FormData send multipart,
|
||||
// which r.ParseForm alone does not read from the body; ParseMultipartForm
|
||||
// handles both — it runs ParseForm internally (so query and urlencoded values
|
||||
// are parsed) and only reports ErrNotMultipart for a non-multipart body, by
|
||||
// which point those values are already available.
|
||||
func parseForm(r *http.Request) error {
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil && err != http.ErrNotMultipart {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 Photo{}, false
|
||||
return "", false
|
||||
}
|
||||
|
||||
// sameOrigin reports whether a form 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 submit the edit or
|
||||
// delete form on a logged-in user's behalf.
|
||||
//
|
||||
// The Origin header is compared against -base-url as well as the Host header:
|
||||
// behind nginx the Host that reaches this server is the proxy's upstream
|
||||
// address, not the public name. Requests with neither header come from
|
||||
// non-browser clients such as curl, which cannot be driven cross-site.
|
||||
func (s *Server) sameOrigin(r *http.Request) bool {
|
||||
if site := r.Header.Get("Sec-Fetch-Site"); site != "" {
|
||||
return site == "same-origin" || site == "none"
|
||||
// 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 := parseForm(r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return true
|
||||
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)
|
||||
}
|
||||
if origin == s.generator.baseURL {
|
||||
return true
|
||||
}
|
||||
u, err := url.Parse(origin)
|
||||
return err == nil && u.Host == r.Host
|
||||
for _, v := range r.Form["slug"] {
|
||||
add(v)
|
||||
}
|
||||
for _, v := range strings.Split(r.FormValue("slugs"), ",") {
|
||||
add(v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// managePageData returns the template fields shared by the manage pages.
|
||||
func (s *Server) managePageData() map[string]any {
|
||||
g := s.generator
|
||||
return map[string]any{
|
||||
"FontsURL": g.fontsURL,
|
||||
"SerifFamily": g.serifFamily,
|
||||
"MlFamily": g.mlFamily,
|
||||
// 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
|
||||
}
|
||||
|
||||
// renderManage renders into a buffer first so a template error becomes a
|
||||
// clean 500 instead of a truncated page.
|
||||
func (s *Server) renderManage(w http.ResponseWriter, tmpl string, data map[string]any) {
|
||||
var buf bytes.Buffer
|
||||
if err := s.generator.tmpl.ExecuteTemplate(&buf, tmpl, data); err != nil {
|
||||
log.Printf("render %s: %v", tmpl, err)
|
||||
http.Error(w, "could not render page", http.StatusInternalServerError)
|
||||
// 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
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
// Always reflect the sidecars as they are now, never a cached copy.
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
buf.WriteTo(w)
|
||||
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)
|
||||
}
|
||||
+271
-294
@@ -2,8 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -15,341 +17,316 @@ import (
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// newTestServer returns a server over a content directory holding one photo,
|
||||
// "sunrise", with a sidecar.
|
||||
func newTestServer(t *testing.T) *Server {
|
||||
// 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()
|
||||
|
||||
// Not t.TempDir(): an edit kicks off a background rebuild that can still
|
||||
// be writing when the test ends, and TempDir fails the test if its
|
||||
// cleanup finds the directory busy.
|
||||
root, err := os.MkdirTemp("", "photog-test-")
|
||||
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, viewTimeline)
|
||||
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)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(root) })
|
||||
|
||||
content := filepath.Join(root, "content")
|
||||
output := filepath.Join(root, "public")
|
||||
for _, d := range []string{content, output} {
|
||||
if err := os.MkdirAll(d, 0755); err != nil {
|
||||
defer f.Close()
|
||||
if err := png.Encode(f, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var img bytes.Buffer
|
||||
if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, filepath.Join(content, "sunrise.jpg"), img.Bytes())
|
||||
writeFile(t, filepath.Join(content, "sunrise.toml"),
|
||||
[]byte("caption = \"Tempelhof at sunrise\"\ndate = \"2026-06-14\"\n"))
|
||||
|
||||
g := NewGenerator(content, output, "https://photos.example.com",
|
||||
"Ada", "", "", "", "", "Crimson Pro", "Manjari", 60, viewTimeline)
|
||||
return NewServer("0", content, output, false, true, g)
|
||||
}
|
||||
|
||||
func readMeta(t *testing.T, s *Server, slug string) PhotoMeta {
|
||||
t.Helper()
|
||||
var meta PhotoMeta
|
||||
if _, err := toml.DecodeFile(filepath.Join(s.contentDir, slug+".toml"), &meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
// postEdit submits the edit form for slug and returns the response.
|
||||
func postEdit(s *Server, slug string, form url.Values, headers map[string]string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("POST", "/manage/edit/"+slug, strings.NewReader(form.Encode()))
|
||||
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")
|
||||
for k, v := range headers {
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
r.SetPathValue("slug", slug)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleEdit(w, r)
|
||||
return w
|
||||
return r
|
||||
}
|
||||
|
||||
func TestManagePageListsPhotos(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleManagePage(w, httptest.NewRequest("GET", "/manage", nil))
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"Tempelhof at sunrise", "2026-06-14", `href="/manage/edit/sunrise"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("manage page is missing %q", want)
|
||||
}
|
||||
// multipartForm builds a multipart/form-data request, as a browser's FormData
|
||||
// does — the content type the manage handlers must parse, not just urlencoded.
|
||||
func multipartForm(fields map[string]string) *http.Request {
|
||||
var b bytes.Buffer
|
||||
mw := multipart.NewWriter(&b)
|
||||
for k, v := range fields {
|
||||
mw.WriteField(k, v)
|
||||
}
|
||||
mw.Close()
|
||||
r := httptest.NewRequest(http.MethodPost, "/", &b)
|
||||
r.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
return r
|
||||
}
|
||||
|
||||
func TestEditPagePrefillsForm(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
r := httptest.NewRequest("GET", "/manage/edit/sunrise", nil)
|
||||
r.SetPathValue("slug", "sunrise")
|
||||
w := httptest.NewRecorder()
|
||||
s.handleEditPage(w, r)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{`value="Tempelhof at sunrise"`, `value="2026-06-14"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("edit page is missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRewritesSidecar(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
form := url.Values{"caption": {`Tempelhof, "golden hour"`}, "date": {"2026-06-15"}}
|
||||
w := postEdit(s, "sunrise", form, nil)
|
||||
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303: %s", w.Code, w.Body)
|
||||
}
|
||||
if loc := w.Header().Get("Location"); loc != "/manage?saved=sunrise" {
|
||||
t.Errorf("Location = %q", loc)
|
||||
}
|
||||
meta := readMeta(t, s, "sunrise")
|
||||
if meta.Caption != `Tempelhof, "golden hour"` || meta.Date != "2026-06-15" {
|
||||
t.Errorf("sidecar = %+v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditEmptyCaptionFallsBackToSlug(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
w := postEdit(s, "sunrise", url.Values{"caption": {" "}, "date": {"2026-06-14"}}, nil)
|
||||
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", w.Code)
|
||||
}
|
||||
if meta := readMeta(t, s, "sunrise"); meta.Caption != "sunrise" {
|
||||
t.Errorf("caption = %q, want the slug", meta.Caption)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRejectsBadInput(t *testing.T) {
|
||||
valid := url.Values{"caption": {"changed"}, "date": {"2026-06-15"}}
|
||||
tests := []struct {
|
||||
name string
|
||||
slug string
|
||||
form url.Values
|
||||
headers map[string]string
|
||||
want int
|
||||
}{
|
||||
{"unknown photo", "nope", valid, nil, http.StatusNotFound},
|
||||
{"path traversal", "../sunrise", valid, nil, http.StatusNotFound},
|
||||
{"malformed date", "sunrise", url.Values{"caption": {"changed"}, "date": {"15.06.2026"}}, nil, http.StatusBadRequest},
|
||||
{"missing date", "sunrise", url.Values{"caption": {"changed"}}, nil, http.StatusBadRequest},
|
||||
{"cross-site fetch", "sunrise", valid, map[string]string{"Sec-Fetch-Site": "cross-site"}, http.StatusForbidden},
|
||||
{"foreign origin", "sunrise", valid, map[string]string{"Origin": "https://evil.example"}, http.StatusForbidden},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
w := postEdit(s, tt.slug, tt.form, tt.headers)
|
||||
if w.Code != tt.want {
|
||||
t.Errorf("status = %d, want %d", w.Code, tt.want)
|
||||
}
|
||||
if meta := readMeta(t, s, "sunrise"); meta.Caption != "Tempelhof at sunrise" || meta.Date != "2026-06-14" {
|
||||
t.Errorf("sidecar was changed: %+v", meta)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditAcceptsOwnOrigin(t *testing.T) {
|
||||
form := url.Values{"caption": {"changed"}, "date": {"2026-06-15"}}
|
||||
tests := []struct {
|
||||
name string
|
||||
headers map[string]string
|
||||
}{
|
||||
{"same-origin fetch metadata", map[string]string{"Sec-Fetch-Site": "same-origin"}},
|
||||
// Behind nginx the Host header is the upstream address, so only
|
||||
// -base-url can vouch for the public origin.
|
||||
{"origin equal to base URL", map[string]string{"Origin": "https://photos.example.com"}},
|
||||
{"origin equal to host", map[string]string{"Origin": "http://example.com"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
if w := postEdit(s, "sunrise", form, tt.headers); w.Code != http.StatusSeeOther {
|
||||
t.Errorf("status = %d, want 303: %s", w.Code, w.Body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// twoPhotoServer returns a built site with "sunrise" plus "dusk", which has a
|
||||
// day of its own, so deleting it must also take that day's page away.
|
||||
func twoPhotoServer(t *testing.T) *Server {
|
||||
func readMeta(t *testing.T, content, slug string) PhotoMeta {
|
||||
t.Helper()
|
||||
s := newTestServer(t)
|
||||
|
||||
var img bytes.Buffer
|
||||
if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
var m PhotoMeta
|
||||
if _, err := toml.DecodeFile(filepath.Join(content, slug+".toml"), &m); err != nil {
|
||||
t.Fatalf("decode %s: %v", slug, err)
|
||||
}
|
||||
writeFile(t, filepath.Join(s.contentDir, "dusk.jpg"), img.Bytes())
|
||||
writeFile(t, filepath.Join(s.contentDir, "dusk.toml"),
|
||||
[]byte("caption = \"Dusk\"\ndate = \"2026-07-01\"\n"))
|
||||
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)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// photoFiles lists every file and directory that belongs to one photo.
|
||||
func photoFiles(s *Server, slug, day string) []string {
|
||||
return []string{
|
||||
filepath.Join(s.contentDir, slug+".jpg"),
|
||||
filepath.Join(s.contentDir, slug+".toml"),
|
||||
filepath.Join(s.outputDir, "images", slug+".jpg"),
|
||||
filepath.Join(s.outputDir, "thumbs", slug+".jpg"),
|
||||
filepath.Join(s.outputDir, "medium", slug+".jpg"),
|
||||
filepath.Join(s.outputDir, "og", slug+".jpg"),
|
||||
filepath.Join(s.outputDir, "photo", slug),
|
||||
filepath.Join(s.outputDir, "day", day),
|
||||
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 postDelete(s *Server, slug string, headers map[string]string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("POST", "/manage/delete/"+slug, nil)
|
||||
for k, v := range headers {
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
r.SetPathValue("slug", slug)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleDelete(w, r)
|
||||
return w
|
||||
}
|
||||
// TestMultipartBodies guards the regression where the manage handlers used
|
||||
// r.ParseForm (urlencoded only) and silently ignored the multipart bodies that
|
||||
// browsers send via FormData, returning 200 with deleted:0 / no change.
|
||||
func TestMultipartBodies(t *testing.T) {
|
||||
s, content, _ := newTestServer(t)
|
||||
|
||||
func TestDeletePageAsksFirst(t *testing.T) {
|
||||
s := twoPhotoServer(t)
|
||||
r := httptest.NewRequest("GET", "/manage/delete/dusk", nil)
|
||||
r.SetPathValue("slug", "dusk")
|
||||
w := httptest.NewRecorder()
|
||||
s.handleDeletePage(w, r)
|
||||
// Edit via multipart.
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleEdit(rec, multipartForm(map[string]string{
|
||||
"slug": "foo", "caption": "Multipart", "date": "2021-02-03", "tags": "x,y",
|
||||
}))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("multipart edit: code=%d", rec.Code)
|
||||
}
|
||||
if m := readMeta(t, content, "foo"); m.Caption != "Multipart" || strings.Join(m.Tags, ",") != "x,y" {
|
||||
t.Errorf("multipart edit not applied: %+v", m)
|
||||
}
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `action="/manage/delete/dusk"`) {
|
||||
t.Errorf("confirmation page has no delete form")
|
||||
}
|
||||
// Looking at the confirmation page must not delete anything.
|
||||
for _, f := range photoFiles(s, "dusk", "2026-07-01") {
|
||||
if _, err := os.Stat(f); err != nil {
|
||||
t.Errorf("%s is gone after a GET: %v", f, err)
|
||||
// Delete via multipart actually moves the file.
|
||||
rec = httptest.NewRecorder()
|
||||
s.handleDelete(rec, multipartForm(map[string]string{"slug": "bar"}))
|
||||
var res map[string]int
|
||||
json.Unmarshal(rec.Body.Bytes(), &res)
|
||||
if res["deleted"] != 1 {
|
||||
t.Fatalf("multipart delete: deleted=%d, want 1 (body=%s)", res["deleted"], rec.Body)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(content, "bar.png")); !os.IsNotExist(err) {
|
||||
t.Error("bar.png should have moved to trash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRemovesPhotoEverywhere(t *testing.T) {
|
||||
s := twoPhotoServer(t)
|
||||
w := postDelete(s, "dusk", nil)
|
||||
func TestCSRFGuard(t *testing.T) {
|
||||
s, _, _ := newTestServer(t) // generator.baseURL == "http://x"
|
||||
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303: %s", w.Code, w.Body)
|
||||
}
|
||||
if loc := w.Header().Get("Location"); loc != "/manage?deleted=Dusk" {
|
||||
t.Errorf("Location = %q", loc)
|
||||
}
|
||||
// The handler rebuilds in the background; build here to have a
|
||||
// finished site to look at.
|
||||
if err := s.generator.Build(); err != nil {
|
||||
t.Fatal(err)
|
||||
// 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)
|
||||
}
|
||||
|
||||
for _, f := range photoFiles(s, "dusk", "2026-07-01") {
|
||||
if _, err := os.Stat(f); !os.IsNotExist(err) {
|
||||
t.Errorf("%s survived the delete", f)
|
||||
// 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)
|
||||
}
|
||||
for _, f := range photoFiles(s, "sunrise", "2026-06-14") {
|
||||
if _, err := os.Stat(f); err != nil {
|
||||
t.Errorf("the other photo lost %s: %v", f, err)
|
||||
}
|
||||
}
|
||||
for _, page := range []string{"index.html", "grid/index.html", "feed.xml"} {
|
||||
data, err := os.ReadFile(filepath.Join(s.outputDir, page))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), "dusk") {
|
||||
t.Errorf("%s still mentions the deleted photo", page)
|
||||
}
|
||||
|
||||
// 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 TestDeleteRejectsBadInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
slug string
|
||||
headers map[string]string
|
||||
want int
|
||||
}{
|
||||
{"unknown photo", "nope", nil, http.StatusNotFound},
|
||||
{"path traversal", "../dusk", nil, http.StatusNotFound},
|
||||
{"cross-site fetch", "dusk", map[string]string{"Sec-Fetch-Site": "cross-site"}, http.StatusForbidden},
|
||||
{"foreign origin", "dusk", map[string]string{"Origin": "https://evil.example"}, http.StatusForbidden},
|
||||
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)
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := twoPhotoServer(t)
|
||||
if w := postDelete(s, tt.slug, tt.headers); w.Code != tt.want {
|
||||
t.Errorf("status = %d, want %d", w.Code, tt.want)
|
||||
var photos []photoJSON
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &photos); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
for _, slug := range []string{"dusk", "sunrise"} {
|
||||
if _, err := os.Stat(filepath.Join(s.contentDir, slug+".jpg")); err != nil {
|
||||
t.Errorf("%s was deleted: %v", slug, err)
|
||||
if len(photos) != 2 {
|
||||
t.Fatalf("got %d photos, want 2", len(photos))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManageRoutesNeedTheFlag(t *testing.T) {
|
||||
for _, manage := range []bool{false, true} {
|
||||
s := newTestServer(t)
|
||||
s.manage = manage
|
||||
mux := s.routes()
|
||||
|
||||
want := http.StatusNotFound
|
||||
if manage {
|
||||
want = http.StatusOK
|
||||
}
|
||||
for _, path := range []string{"/manage", "/manage/edit/sunrise", "/manage/delete/sunrise"} {
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
|
||||
if w.Code != want {
|
||||
t.Errorf("manage=%v: GET %s = %d, want %d", manage, path, w.Code, want)
|
||||
}
|
||||
}
|
||||
// The delete POST only does something with the flag: without it the
|
||||
// photo must survive, with it (no Origin header, as from curl) it goes.
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest("POST", "/manage/delete/sunrise", nil))
|
||||
_, err := os.Stat(filepath.Join(s.contentDir, "sunrise.jpg"))
|
||||
present := err == nil
|
||||
if present == manage {
|
||||
t.Errorf("manage=%v: POST delete = %d, photo present afterwards = %v", manage, w.Code, present)
|
||||
}
|
||||
// /upload and /health are there regardless.
|
||||
for _, path := range []string{"/upload", "/health"} {
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("manage=%v: GET %s = %d, want 200", manage, path, w.Code)
|
||||
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)
|
||||
}
|
||||
}
|
||||
+32
-14
@@ -85,10 +85,22 @@ in {
|
||||
defaultView = lib.mkOption {
|
||||
type = lib.types.enum [ "timeline" "grid" ];
|
||||
default = "timeline";
|
||||
description = "View published at the site root. The other one is published under /timeline/ or /grid/.";
|
||||
description = "View published at the site root; the other is published under /timeline/ or /grid/.";
|
||||
};
|
||||
|
||||
manage.enable = lib.mkEnableOption "the /manage pages for editing and deleting published photos (served behind the same htpasswd as /upload)";
|
||||
htpasswdFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
example = "/run/secrets/photogallery-htpasswd";
|
||||
description = ''
|
||||
Path to an Apache htpasswd file (bcrypt only — generate with
|
||||
`htpasswd -B`) guarding the /upload endpoint. The app performs the
|
||||
auth itself, so nginx no longer needs basic_auth. The file is handed
|
||||
to the service via systemd LoadCredential, so it may be root-owned and
|
||||
0400 (e.g. an agenix/sops secret). Leave null to disable auth entirely
|
||||
— then /upload is open and anyone who can reach it can post.
|
||||
'';
|
||||
};
|
||||
|
||||
nginx = {
|
||||
enable = lib.mkEnableOption "nginx virtual host for photogallery";
|
||||
@@ -99,12 +111,6 @@ in {
|
||||
description = "Domain for the nginx virtual host.";
|
||||
};
|
||||
|
||||
htpasswdFile = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
example = "/etc/nginx/.htpasswd-gallery";
|
||||
description = "Path to htpasswd file protecting the /upload and /manage endpoints.";
|
||||
};
|
||||
|
||||
enableACME = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
@@ -157,7 +163,14 @@ in {
|
||||
"--ml-family" cfg.mlFamily
|
||||
"--page-size" (toString cfg.pageSize)
|
||||
"--default-view" cfg.defaultView
|
||||
] ++ lib.optional cfg.manage.enable "--manage");
|
||||
] ++ lib.optionals (cfg.htpasswdFile != null) [
|
||||
# %d is systemd's credentials directory; LoadCredential below drops
|
||||
# the htpasswd file there, readable only by this service.
|
||||
"--htpasswd" "%d/htpasswd"
|
||||
]);
|
||||
|
||||
LoadCredential = lib.optional (cfg.htpasswdFile != null)
|
||||
"htpasswd:${cfg.htpasswdFile}";
|
||||
|
||||
Restart = "on-failure";
|
||||
RestartSec = "5s";
|
||||
@@ -187,17 +200,22 @@ in {
|
||||
tryFiles = "$uri $uri/ =404";
|
||||
};
|
||||
|
||||
# Auth is handled by the app (see services.photogallery.htpasswdFile),
|
||||
# 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}";
|
||||
basicAuth = "gallery";
|
||||
basicAuthFile = cfg.nginx.htpasswdFile;
|
||||
extraConfig = "client_max_body_size 20M;";
|
||||
};
|
||||
|
||||
locations."/manage" = lib.mkIf cfg.manage.enable {
|
||||
locations."/manage" = {
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.port}";
|
||||
basicAuth = "gallery";
|
||||
basicAuthFile = cfg.nginx.htpasswdFile;
|
||||
};
|
||||
|
||||
locations."/api/" = {
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.port}";
|
||||
extraConfig = "client_max_body_size 20M;";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
@@ -18,43 +20,98 @@ type Server struct {
|
||||
contentDir string
|
||||
outputDir string
|
||||
serve bool
|
||||
manage bool
|
||||
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, manage bool, g *Generator) *Server {
|
||||
func NewServer(port, contentDir, outputDir string, serve bool, g *Generator, auth *Authenticator) *Server {
|
||||
return &Server{
|
||||
port: port,
|
||||
contentDir: contentDir,
|
||||
outputDir: outputDir,
|
||||
serve: serve,
|
||||
manage: manage,
|
||||
generator: g,
|
||||
auth: auth,
|
||||
// Brute-force throttle: ~1 failed auth/sec steady, burst of 10. Only
|
||||
// failures draw from it, so legitimate bulk uploads are never slowed.
|
||||
authLimit: rate.NewLimiter(rate.Every(time.Second), 10),
|
||||
}
|
||||
}
|
||||
|
||||
// routes builds the handler. The /manage pages are only registered with
|
||||
// -manage, so a service without the flag has no edit or delete code path at
|
||||
// all and answers 404 there.
|
||||
func (s *Server) routes() *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /upload", s.handleUploadPage)
|
||||
mux.HandleFunc("POST /upload", s.handleUpload)
|
||||
if s.manage {
|
||||
mux.HandleFunc("GET /manage", s.handleManagePage)
|
||||
mux.HandleFunc("GET /manage/edit/{slug}", s.handleEditPage)
|
||||
mux.HandleFunc("POST /manage/edit/{slug}", s.handleEdit)
|
||||
mux.HandleFunc("GET /manage/delete/{slug}", s.handleDeletePage)
|
||||
mux.HandleFunc("POST /manage/delete/{slug}", s.handleDelete)
|
||||
// guard applies HTTP Basic auth to h when an Authenticator is configured, and
|
||||
// passes it through untouched otherwise (local --serve development).
|
||||
func (s *Server) guard(h http.HandlerFunc) http.HandlerFunc {
|
||||
if s.auth == nil {
|
||||
return h
|
||||
}
|
||||
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintln(w, "ok")
|
||||
})
|
||||
return mux
|
||||
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 := s.routes()
|
||||
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/thumb/{slug}", s.guard(s.handleThumb))
|
||||
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))
|
||||
|
||||
// Login-state probe — deliberately NOT guarded, so public pages can ask
|
||||
// without triggering a Basic-auth dialog. It does its own silent check.
|
||||
mux.HandleFunc("GET /api/session", s.handleSession)
|
||||
|
||||
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintln(w, "ok")
|
||||
})
|
||||
|
||||
if s.serve {
|
||||
// Serve the generated site on the same port — for local development only.
|
||||
@@ -84,6 +141,7 @@ func (s *Server) handleUploadPage(w http.ResponseWriter, r *http.Request) {
|
||||
// image — the image file (jpg or png)
|
||||
// caption — human-readable caption (optional, defaults to filename)
|
||||
// date — YYYY-MM-DD (optional, defaults to today)
|
||||
// tags — comma-separated album tags (optional; lowercased, deduped)
|
||||
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
@@ -112,6 +170,13 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
caption = strings.TrimSuffix(header.Filename, ext)
|
||||
}
|
||||
|
||||
// Normalize the tags once here so the sidecar stores clean lowercase names;
|
||||
// the generator derives album slugs from them at build time.
|
||||
var tags []string
|
||||
for _, t := range parseTags(r.FormValue("tags")) {
|
||||
tags = append(tags, t.Name)
|
||||
}
|
||||
|
||||
slug := sanitiseSlug(strings.TrimSuffix(header.Filename, ext))
|
||||
if slug == "" {
|
||||
slug = fmt.Sprintf("photo-%d", time.Now().UnixMilli())
|
||||
@@ -138,7 +203,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer tf.Close()
|
||||
if err := toml.NewEncoder(tf).Encode(PhotoMeta{Caption: caption, Date: dateStr}); err != nil {
|
||||
if err := toml.NewEncoder(tf).Encode(PhotoMeta{Caption: caption, Date: dateStr, Tags: tags}); err != nil {
|
||||
http.Error(w, "could not write metadata", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Shows a management toolbar + signed-in indicator on every page when the
|
||||
// browser holds valid credentials. The public gallery is static and carries no
|
||||
// markup for this; the bar is built here only after /api/session confirms the
|
||||
// viewer is authenticated, so logged-out visitors see nothing and get no login
|
||||
// dialog.
|
||||
//
|
||||
// A page may set <body data-slug="..."> (the photo pages do) to get an
|
||||
// "Edit this" link straight to that post in /manage.
|
||||
(function () {
|
||||
fetch("/api/session", { headers: { Accept: "application/json" }, cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : { authenticated: false }))
|
||||
.then((s) => {
|
||||
if (s && s.authenticated) buildBar();
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
function el(tag, cls, text) {
|
||||
const e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text != null) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
function buildBar() {
|
||||
document.body.classList.add("authed");
|
||||
|
||||
const bar = el("div", "session-bar");
|
||||
const path = location.pathname.replace(/\/$/, "");
|
||||
|
||||
const link = (href, label) => {
|
||||
const a = el("a", null, label);
|
||||
a.href = href;
|
||||
bar.appendChild(a);
|
||||
};
|
||||
|
||||
if (path !== "/upload") link("/upload", "Upload");
|
||||
if (path !== "/manage") link("/manage", "Manage");
|
||||
|
||||
const slug = document.body.dataset.slug;
|
||||
if (slug) link("/manage?edit=" + encodeURIComponent(slug), "Edit this");
|
||||
|
||||
bar.appendChild(el("span", "session-dot", "● signed in"));
|
||||
|
||||
// Best-effort logout. HTTP Basic has no real sign-out — the browser caches
|
||||
// credentials and clears them reliably only when the window closes — so
|
||||
// this overwrites the cached credential with a throwaway one and reloads.
|
||||
// It works in some browsers and not others; a dependable logout would need
|
||||
// cookie sessions.
|
||||
const out = el("a", "session-out", "log out");
|
||||
out.href = "#";
|
||||
out.title = "Best-effort — HTTP Basic auth has no reliable logout";
|
||||
out.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
fetch("/api/session", {
|
||||
headers: { Authorization: "Basic " + btoa("logout:" + Date.now()) },
|
||||
cache: "no-store",
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setTimeout(() => location.reload(), 150));
|
||||
});
|
||||
bar.appendChild(out);
|
||||
|
||||
document.body.appendChild(bar);
|
||||
}
|
||||
})();
|
||||
@@ -422,3 +422,104 @@ body {
|
||||
.dlayout { flex-direction: column; }
|
||||
.dsidebar { width: 100%; border-left: none; border-top: 1px solid var(--line); }
|
||||
}
|
||||
|
||||
/* ── Tags / albums ─────────────────────────────────────────── */
|
||||
|
||||
.dtags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 10px 0 14px;
|
||||
}
|
||||
|
||||
.tag,
|
||||
.album-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
background: var(--bg0);
|
||||
border: 1px solid var(--bg2);
|
||||
color: var(--fg3);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
border-radius: 2px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.tag:hover,
|
||||
.album-chip:hover {
|
||||
color: var(--aqua);
|
||||
border-color: var(--fg4);
|
||||
}
|
||||
|
||||
.album-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.album-chip {
|
||||
font-size: 14px;
|
||||
padding: 7px 14px;
|
||||
}
|
||||
|
||||
.album-chip-count {
|
||||
color: var(--fg4);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.album-empty {
|
||||
color: var(--fg4);
|
||||
font-family: var(--serif);
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Tag autocomplete dropdown (tagcomplete.js), shared by upload and manage. */
|
||||
.tagac-menu {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bg2);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.tagac-item {
|
||||
padding: 7px 11px;
|
||||
font-family: var(--serif);
|
||||
font-size: 14px;
|
||||
color: var(--fg2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tagac-item.sel,
|
||||
.tagac-item:hover {
|
||||
background: var(--bg2);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* Session toolbar (session.js), shown on any page when signed in. */
|
||||
.session-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 7px 14px;
|
||||
background: var(--bg1);
|
||||
border-left: 1px solid var(--bg2);
|
||||
border-bottom: 1px solid var(--bg2);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.session-bar a {
|
||||
color: var(--aqua, #8ec07c);
|
||||
text-decoration: none;
|
||||
}
|
||||
.session-bar a:hover { text-decoration: underline; }
|
||||
.session-dot { color: var(--green, #b8bb26); }
|
||||
.session-out { color: var(--fg4) !important; }
|
||||
@@ -0,0 +1,152 @@
|
||||
// Per-token tag autocomplete for comma-separated tag inputs, shared by the
|
||||
// upload and manage pages. Suggestions come from /tags.json (regenerated on
|
||||
// every build), so new tags appear on the next load. Only the token after the
|
||||
// last comma is completed; earlier tags in the field are left untouched.
|
||||
//
|
||||
// Usage: add data-tagcomplete to an <input>, or call TagComplete.attach(input)
|
||||
// for inputs created dynamically. TagComplete.invalidate() drops the cached
|
||||
// tag list after an edit that may have introduced new tags.
|
||||
(function () {
|
||||
let tags = null; // cached list of tag names
|
||||
let inflight = null;
|
||||
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "tagac-menu";
|
||||
menu.style.display = "none";
|
||||
menu.setAttribute("role", "listbox");
|
||||
document.body.appendChild(menu);
|
||||
|
||||
let items = []; // currently shown suggestions
|
||||
let sel = -1; // highlighted index
|
||||
|
||||
function loadTags() {
|
||||
if (tags) return Promise.resolve(tags);
|
||||
if (inflight) return inflight;
|
||||
inflight = fetch("/tags.json", { headers: { Accept: "application/json" } })
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((list) => {
|
||||
tags = (list || []).map((t) => t.tag);
|
||||
return tags;
|
||||
})
|
||||
.catch(() => {
|
||||
tags = [];
|
||||
return tags;
|
||||
});
|
||||
return inflight;
|
||||
}
|
||||
|
||||
// The token being typed is the text after the last comma.
|
||||
function lastToken(value) {
|
||||
return value.slice(value.lastIndexOf(",") + 1).replace(/^\s+/, "");
|
||||
}
|
||||
|
||||
function replaceLastToken(value, tag) {
|
||||
const i = value.lastIndexOf(",");
|
||||
const head = i < 0 ? "" : value.slice(0, i + 1) + " ";
|
||||
return head + tag + ", ";
|
||||
}
|
||||
|
||||
function presentTags(value) {
|
||||
return value
|
||||
.split(",")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
menu.style.display = "none";
|
||||
sel = -1;
|
||||
items = [];
|
||||
}
|
||||
|
||||
function highlight() {
|
||||
[...menu.children].forEach((c, i) => c.classList.toggle("sel", i === sel));
|
||||
if (sel >= 0 && menu.children[sel]) {
|
||||
menu.children[sel].scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
|
||||
function render(input) {
|
||||
const token = lastToken(input.value).toLowerCase();
|
||||
if (!token) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
const present = new Set(presentTags(input.value));
|
||||
items = (tags || [])
|
||||
.filter((t) => t.toLowerCase().includes(token) && !present.has(t.toLowerCase()))
|
||||
.slice(0, 8);
|
||||
if (items.length === 0) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
sel = 0;
|
||||
menu.innerHTML = "";
|
||||
items.forEach((t, idx) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "tagac-item" + (idx === 0 ? " sel" : "");
|
||||
row.setAttribute("role", "option");
|
||||
row.textContent = t;
|
||||
// mousedown (not click) so it fires before the input's blur.
|
||||
row.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
choose(input, idx);
|
||||
});
|
||||
menu.appendChild(row);
|
||||
});
|
||||
const r = input.getBoundingClientRect();
|
||||
menu.style.left = window.scrollX + r.left + "px";
|
||||
menu.style.top = window.scrollY + r.bottom + "px";
|
||||
menu.style.width = r.width + "px";
|
||||
menu.style.display = "block";
|
||||
}
|
||||
|
||||
function choose(input, idx) {
|
||||
if (idx < 0 || idx >= items.length) return;
|
||||
input.value = replaceLastToken(input.value, items[idx]);
|
||||
hide();
|
||||
input.focus();
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function attach(input) {
|
||||
if (!input || input.dataset.tagacBound) return;
|
||||
input.dataset.tagacBound = "1";
|
||||
input.setAttribute("autocomplete", "off");
|
||||
input.addEventListener("focus", () => loadTags().then(() => render(input)));
|
||||
input.addEventListener("input", () => loadTags().then(() => render(input)));
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (menu.style.display === "none" || items.length === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
sel = (sel + 1) % items.length;
|
||||
highlight();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
sel = (sel - 1 + items.length) % items.length;
|
||||
highlight();
|
||||
} else if (e.key === "Enter" || e.key === "Tab") {
|
||||
if (sel >= 0) {
|
||||
e.preventDefault();
|
||||
choose(input, sel);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
// Delay so a mousedown on the menu is processed before hiding.
|
||||
input.addEventListener("blur", () => setTimeout(hide, 120));
|
||||
}
|
||||
|
||||
window.TagComplete = {
|
||||
attach,
|
||||
invalidate() {
|
||||
tags = null;
|
||||
inflight = null;
|
||||
},
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.querySelectorAll("input[data-tagcomplete]").forEach(attach);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,97 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Tag.Name}} · Photo Gallery</title>
|
||||
<link rel="stylesheet" href="{{.FontsURL}}">
|
||||
<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>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
|
||||
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
|
||||
</head>
|
||||
<body>
|
||||
<header class="profile-hdr">
|
||||
<div class="profile-inner">
|
||||
<div class="avatar-ring">
|
||||
<span class="avatar-initials">{{.Initial}}</span>
|
||||
</div>
|
||||
<div class="pinfo">
|
||||
<h1>{{.Author}}</h1>
|
||||
<p class="handle">{{.Handle}}</p>
|
||||
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
|
||||
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
|
||||
<a href="{{.TimelineURL}}" class="rss-a">Timeline</a>
|
||||
<a href="/albums/" class="rss-a">← Albums</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="grid-wrap">
|
||||
<section class="tl-day">
|
||||
<h2 class="tl-date">{{.Tag.Name}}<span class="tl-count">{{.Count}}</span></h2>
|
||||
<div class="pgrid">
|
||||
{{range .Photos}}
|
||||
<a href="/photo/{{.Slug}}/" class="ptile">
|
||||
<img src="/{{.Thumb}}" alt="{{.Caption}}" loading="lazy">
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if or .PrevURL .NextURL}}
|
||||
<nav class="pager">
|
||||
{{if .PrevURL}}<a class="pager-newer" href="{{.PrevURL}}">← Newer</a>{{end}}
|
||||
{{if .NextURL}}<a class="pager-older" href="{{.NextURL}}" data-load-more>Older →</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
© 2000–{{.Year}} {{.Author}}. All rights reserved.
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const btn = document.querySelector('[data-load-more]');
|
||||
if (!btn) return;
|
||||
const grid = document.querySelector('.pgrid');
|
||||
let loading = false;
|
||||
|
||||
async function loadMore(url) {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return;
|
||||
const html = await res.text();
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
doc.querySelectorAll('.pgrid > a').forEach(t => grid.appendChild(t));
|
||||
const nextBtn = doc.querySelector('[data-load-more]');
|
||||
if (nextBtn) btn.href = nextBtn.getAttribute('href');
|
||||
else btn.remove();
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
loadMore(btn.href);
|
||||
});
|
||||
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && document.body.contains(btn)) {
|
||||
loadMore(btn.href);
|
||||
}
|
||||
}, { rootMargin: '400px' });
|
||||
io.observe(btn);
|
||||
})();
|
||||
</script>
|
||||
<script src="/static/session.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Albums · Photo Gallery</title>
|
||||
<link rel="stylesheet" href="{{.FontsURL}}">
|
||||
<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>:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }</style>
|
||||
<link rel="alternate" type="application/atom+xml" href="/feed.xml" title="Photo Feed">
|
||||
</head>
|
||||
<body>
|
||||
<header class="profile-hdr">
|
||||
<div class="profile-inner">
|
||||
<div class="avatar-ring">
|
||||
<span class="avatar-initials">{{.Initial}}</span>
|
||||
</div>
|
||||
<div class="pinfo">
|
||||
<h1>{{.Author}}</h1>
|
||||
<p class="handle">{{.Handle}}</p>
|
||||
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
|
||||
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
|
||||
<a href="{{.TimelineURL}}" class="rss-a">← Timeline</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="grid-wrap">
|
||||
<section class="tl-day">
|
||||
<h2 class="tl-date">Albums<span class="tl-count">{{len .Albums}}</span></h2>
|
||||
{{if .Albums}}
|
||||
<div class="album-list">
|
||||
{{range .Albums}}
|
||||
<a href="/album/{{.Tag.Slug}}/" class="album-chip">
|
||||
{{.Tag.Name}}<span class="album-chip-count">{{.Count}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="album-empty">No albums yet — add tags to a photo to create one.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
© 2000–{{.Year}} {{.Author}}. All rights reserved.
|
||||
</footer>
|
||||
<script src="/static/session.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -46,5 +46,6 @@
|
||||
<footer class="site-footer">
|
||||
© 2000–{{.Year}} {{.Author}}. All rights reserved.
|
||||
</footer>
|
||||
<script src="/static/session.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,107 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Delete — {{.Photo.Caption}}</title>
|
||||
<link rel="stylesheet" href="{{.FontsURL}}">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<style>
|
||||
:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }
|
||||
.delete-page {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px;
|
||||
}
|
||||
.delete-page .back-btn {
|
||||
color: var(--fg3);
|
||||
text-decoration: none;
|
||||
font-size: 15px;
|
||||
}
|
||||
.delete-page .back-btn:hover { color: var(--fg); }
|
||||
.delete-page h2 {
|
||||
font-family: var(--serif);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
margin: 16px 0 24px;
|
||||
}
|
||||
.delete-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 340px;
|
||||
object-fit: contain;
|
||||
background: var(--bg0);
|
||||
border: 1px solid var(--bg2);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.delete-caption {
|
||||
font-size: 18px;
|
||||
color: var(--fg);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.delete-meta {
|
||||
font-size: 12px;
|
||||
color: var(--fg4);
|
||||
font-family: monospace;
|
||||
margin: 4px 0 24px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.delete-warning {
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
line-height: 1.5;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 20px;
|
||||
background: #2e1d1d;
|
||||
color: #fb4934;
|
||||
border: 1px solid #5a2828;
|
||||
}
|
||||
.delete-btn {
|
||||
width: 100%;
|
||||
background: #2e1d1d;
|
||||
border: 1px solid #5a2828;
|
||||
color: #fb4934;
|
||||
font-family: var(--serif);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.delete-btn:hover { background: #5a2828; color: var(--fg); }
|
||||
.cancel-btn {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bg2);
|
||||
color: var(--fg2);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.cancel-btn:hover { background: var(--bg2); color: var(--fg); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="delete-page">
|
||||
<a href="/manage" class="back-btn">← Manage</a>
|
||||
<h2>Delete photo</h2>
|
||||
|
||||
<img class="delete-img" src="/{{.Photo.Medium}}" alt="">
|
||||
<p class="delete-caption">{{.Photo.Caption}}</p>
|
||||
<p class="delete-meta">{{.Photo.Date.Format "2006-01-02"}} · {{.Photo.Slug}}</p>
|
||||
|
||||
<p class="delete-warning">This removes the photo, its caption and every
|
||||
page and image generated from it. It cannot be undone.</p>
|
||||
|
||||
<form method="post" action="/manage/delete/{{.Photo.Slug}}">
|
||||
<button class="delete-btn" type="submit">Delete permanently</button>
|
||||
</form>
|
||||
<a href="/manage" class="cancel-btn">Cancel</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,110 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Edit — {{.Photo.Caption}}</title>
|
||||
<link rel="stylesheet" href="{{.FontsURL}}">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<style>
|
||||
:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }
|
||||
.edit-page {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px;
|
||||
}
|
||||
.edit-page .back-btn {
|
||||
color: var(--fg3);
|
||||
text-decoration: none;
|
||||
font-size: 15px;
|
||||
}
|
||||
.edit-page .back-btn:hover { color: var(--fg); }
|
||||
.edit-page h2 {
|
||||
font-family: var(--serif);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
margin: 16px 0 24px;
|
||||
}
|
||||
.edit-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 340px;
|
||||
object-fit: contain;
|
||||
background: var(--bg0);
|
||||
border: 1px solid var(--bg2);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.edit-slug {
|
||||
font-size: 12px;
|
||||
color: var(--fg4);
|
||||
font-family: monospace;
|
||||
margin-bottom: 24px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.field { margin-bottom: 18px; }
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--fg4);
|
||||
font-family: monospace;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.field input[type="text"],
|
||||
.field input[type="date"] {
|
||||
width: 100%;
|
||||
background: var(--bg0);
|
||||
border: 1px solid var(--bg2);
|
||||
color: var(--fg);
|
||||
font-size: 16px;
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.field input[type="text"] { font-family: var(--serif); }
|
||||
.field input[type="date"] { font-family: monospace; font-size: 14px; color-scheme: dark; }
|
||||
.field input:focus { border-color: var(--fg4); }
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bg2);
|
||||
color: var(--fg2);
|
||||
font-family: var(--serif);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.submit-btn:hover { background: var(--bg2); color: var(--fg); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="edit-page">
|
||||
<a href="/manage" class="back-btn">← Manage</a>
|
||||
<h2>Edit photo</h2>
|
||||
|
||||
<img class="edit-img" src="/{{.Photo.Medium}}" alt="">
|
||||
<p class="edit-slug">{{.Photo.Slug}}</p>
|
||||
|
||||
<!-- A plain form post, no script: the server answers with a redirect
|
||||
back to /manage, so a reload never resubmits the edit. -->
|
||||
<form method="post" action="/manage/edit/{{.Photo.Slug}}">
|
||||
<div class="field">
|
||||
<label for="caption">Caption</label>
|
||||
<input type="text" id="caption" name="caption" value="{{.Photo.Caption}}" autocomplete="off" autofocus>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="date">Date</label>
|
||||
<input type="date" id="date" name="date" value="{{.Photo.Date.Format "2006-01-02"}}" required>
|
||||
</div>
|
||||
|
||||
<button class="submit-btn" type="submit">Save changes</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+3
-1
@@ -24,13 +24,14 @@
|
||||
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
|
||||
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
|
||||
<a href="{{.TimelineURL}}" class="rss-a">Timeline</a>
|
||||
<a href="/albums/" class="rss-a">Albums</a>
|
||||
<a href="/feed.xml" class="rss-a">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
|
||||
<circle cx="2.5" cy="11.5" r="1.5"/>
|
||||
<path d="M1.5 7.5A5 5 0 0 1 6.5 12.5H8A6.5 6.5 0 0 0 1.5 6V7.5z"/>
|
||||
<path d="M1.5 4A8.5 8.5 0 0 1 10 12.5h1.5A10 10 0 0 0 1.5 2.5V4z"/>
|
||||
</svg>
|
||||
RSS feed
|
||||
Feed
|
||||
</a>
|
||||
<a href="https://git.inflo.ws/aashiks/photog" class="rss-a" target="_blank" rel="noopener">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
@@ -102,5 +103,6 @@
|
||||
io.observe(btn);
|
||||
})();
|
||||
</script>
|
||||
<script src="/static/session.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+340
-91
@@ -3,115 +3,364 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Manage — Photo Gallery</title>
|
||||
<link rel="stylesheet" href="{{.FontsURL}}">
|
||||
<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>
|
||||
:root { --serif: '{{.SerifFamily}}', Georgia, serif; --mj: '{{.MlFamily}}', sans-serif; }
|
||||
.manage-page {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px;
|
||||
.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;
|
||||
}
|
||||
.manage-page .back-btn {
|
||||
color: var(--fg3);
|
||||
text-decoration: none;
|
||||
font-size: 15px;
|
||||
.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;
|
||||
}
|
||||
.manage-page .back-btn:hover { color: var(--fg); }
|
||||
.manage-page h2 {
|
||||
font-family: var(--serif);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
margin: 16px 0 6px;
|
||||
.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;
|
||||
}
|
||||
.manage-count {
|
||||
font-size: 12px;
|
||||
color: var(--fg4);
|
||||
font-family: monospace;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 24px;
|
||||
.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);
|
||||
}
|
||||
.saved {
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
padding: 10px 12px;
|
||||
background: #1d2e1d;
|
||||
color: #b8bb26;
|
||||
border: 1px solid #344d28;
|
||||
.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;
|
||||
}
|
||||
.mlist { list-style: none; }
|
||||
.mrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
.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; }
|
||||
}
|
||||
.mrow img {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
background: var(--bg0);
|
||||
flex: none;
|
||||
|
||||
.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%;
|
||||
}
|
||||
.minfo { flex: 1; min-width: 0; }
|
||||
.mcaption {
|
||||
font-size: 17px;
|
||||
color: var(--fg);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.mdate {
|
||||
font-size: 12px;
|
||||
color: var(--fg4);
|
||||
font-family: monospace;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.mactions {
|
||||
flex: none;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.mactions a { color: var(--fg3); text-decoration: none; }
|
||||
.mactions a:hover { color: var(--fg); }
|
||||
.mactions .medit { color: var(--aqua); }
|
||||
.mactions .mdelete { color: #fb4934; }
|
||||
.mempty { color: var(--fg4); font-size: 15px; }
|
||||
#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; }
|
||||
.row-hl { outline: 2px solid var(--aqua, #8ec07c); outline-offset: 4px; transition: outline-color .4s; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="manage-page">
|
||||
<a href="/" class="back-btn">← Gallery</a>
|
||||
<h2>Manage photos</h2>
|
||||
<p class="manage-count">{{len .Photos}} {{if eq (len .Photos) 1}}photo{{else}}photos{{end}}</p>
|
||||
<div class="manage-head">
|
||||
<h2>Manage posts</h2>
|
||||
<span><a href="/upload">+ Upload</a> <a href="#" id="trash-toggle">Trash</a> <a href="/">← Gallery</a></span>
|
||||
</div>
|
||||
<div id="count">Loading…</div>
|
||||
|
||||
{{if .Saved}}<p class="saved">✓ saved “{{.Saved}}”</p>{{end}}
|
||||
{{if .Deleted}}<p class="saved">✓ deleted “{{.Deleted}}”</p>{{end}}
|
||||
<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>
|
||||
|
||||
{{if .Photos}}
|
||||
<ul class="mlist">
|
||||
{{range .Photos}}
|
||||
<li class="mrow">
|
||||
<img src="/{{.Thumb}}" alt="" loading="lazy">
|
||||
<div class="minfo">
|
||||
<p class="mcaption">{{.Caption}}</p>
|
||||
<p class="mdate">{{.Date.Format "2006-01-02"}}</p>
|
||||
<div class="bulkbar" id="bulkbar">
|
||||
<span class="sel" id="sel">0 selected</span>
|
||||
<input type="text" id="bulk-add" placeholder="add tags" autocomplete="off" data-tagcomplete>
|
||||
<button class="btn" id="btn-add">Add</button>
|
||||
<input type="text" id="bulk-remove" placeholder="remove tags" autocomplete="off" data-tagcomplete>
|
||||
<button class="btn" id="btn-remove">Remove</button>
|
||||
<button class="btn danger" id="btn-del-sel">Delete selected</button>
|
||||
</div>
|
||||
<div class="mactions">
|
||||
<a href="/photo/{{.Slug}}/">View</a>
|
||||
<a href="/manage/edit/{{.Slug}}" class="medit">Edit</a>
|
||||
<a href="/manage/delete/{{.Slug}}" class="mdelete">Delete</a>
|
||||
|
||||
<div id="list"></div>
|
||||
<div id="empty" style="display:none">No posts yet.</div>
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="mempty">No photos yet.</p>
|
||||
{{end}}
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<script src="/static/tagcomplete.js"></script>
|
||||
<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(', ');
|
||||
if (window.TagComplete) window.TagComplete.attach(el.querySelector('.f-tags'));
|
||||
|
||||
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();
|
||||
maybeFocusEdit();
|
||||
} catch (err) {
|
||||
countEl.textContent = '';
|
||||
flash('Could not load posts: ' + err.message, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Deep link from a photo page's "Edit this": /manage?edit=<slug> scrolls to
|
||||
// that row, focuses its caption and highlights it briefly.
|
||||
function maybeFocusEdit() {
|
||||
const slug = new URLSearchParams(location.search).get('edit');
|
||||
if (!slug) return;
|
||||
const row = [...list.querySelectorAll('.row')].find(r => r.dataset.slug === slug);
|
||||
if (!row) return;
|
||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
const cap = row.querySelector('.f-caption');
|
||||
if (cap) cap.focus();
|
||||
row.classList.add('row-hl');
|
||||
setTimeout(() => row.classList.remove('row-hl'), 2000);
|
||||
}
|
||||
|
||||
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);
|
||||
if (window.TagComplete) window.TagComplete.invalidate();
|
||||
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 {
|
||||
const res = await post('/api/delete', form);
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!j.deleted) { flash('Not deleted — "' + slug + '" not found on the server', false); return; }
|
||||
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 {
|
||||
const res = await post('/api/delete', form);
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!j.deleted) { flash('Nothing deleted — slugs not found on the server', false); return; }
|
||||
flash('Moved ' + j.deleted + ' 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 {
|
||||
const res = await post('/api/tags', form);
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!j.updated) { flash('No posts updated', false); return; }
|
||||
if (window.TagComplete) window.TagComplete.invalidate();
|
||||
document.getElementById(field).value = '';
|
||||
flash('Updated tags on ' + j.updated + ' 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>
|
||||
<script src="/static/session.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -35,7 +35,7 @@
|
||||
{{if .Older}}<link rel="prefetch" href="/photo/{{.Older.Slug}}/">
|
||||
<link rel="prefetch" as="image" href="/{{.Older.Medium}}">{{end}}
|
||||
</head>
|
||||
<body>
|
||||
<body data-slug="{{.Photo.Slug}}">
|
||||
<div class="photo-page">
|
||||
<nav class="photo-nav">
|
||||
<a href="/" class="back-btn">← Back</a>
|
||||
@@ -58,6 +58,11 @@
|
||||
</div>
|
||||
<p class="dcaption">{{.Photo.Caption}}</p>
|
||||
<p class="ddate">{{.Photo.Date.Format "Jan 02, 2006"}}</p>
|
||||
{{if .Photo.Tags}}
|
||||
<div class="dtags">
|
||||
{{range .Photo.Tags}}<a href="/album/{{.Slug}}/" class="tag">{{.Name}}</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<p class="dfull"><a href="/{{.Photo.File}}" download>↓ Full size</a></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,5 +73,6 @@
|
||||
</footer>
|
||||
|
||||
<script src="/static/photo-nav.js" defer></script>
|
||||
<script src="/static/session.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -24,13 +24,14 @@
|
||||
<p class="pcount"><strong>{{.Total}}</strong> posts</p>
|
||||
<p class="pbio">{{.Bio}}{{if .BioAlt}}<br><span class="ml">{{.BioAlt}}</span>{{end}}</p>
|
||||
<a href="{{.GridURL}}" class="rss-a">Grid</a>
|
||||
<a href="/albums/" class="rss-a">Albums</a>
|
||||
<a href="/feed.xml" class="rss-a">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
|
||||
<circle cx="2.5" cy="11.5" r="1.5"/>
|
||||
<path d="M1.5 7.5A5 5 0 0 1 6.5 12.5H8A6.5 6.5 0 0 0 1.5 6V7.5z"/>
|
||||
<path d="M1.5 4A8.5 8.5 0 0 1 10 12.5h1.5A10 10 0 0 0 1.5 2.5V4z"/>
|
||||
</svg>
|
||||
RSS feed
|
||||
Feed
|
||||
</a>
|
||||
<a href="https://git.inflo.ws/aashiks/photog" class="rss-a" target="_blank" rel="noopener">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
@@ -110,5 +111,6 @@
|
||||
io.observe(btn);
|
||||
})();
|
||||
</script>
|
||||
<script src="/static/session.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+223
-207
@@ -10,171 +10,246 @@
|
||||
<link rel="icon" type="image/png" href="/static/web-32.png" sizes="32x32">
|
||||
<link rel="apple-touch-icon" href="/static/web-192.png">
|
||||
<style>
|
||||
.upload-page {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px;
|
||||
}
|
||||
.upload-page { max-width: 640px; margin: 0 auto; padding: 32px 20px 80px; }
|
||||
.upload-page h2 {
|
||||
font-family: var(--serif);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
margin-bottom: 28px;
|
||||
font-family: var(--serif); font-size: 22px; font-weight: 600;
|
||||
color: var(--fg); margin-bottom: 20px;
|
||||
}
|
||||
.drop-zone {
|
||||
border: 1px dashed var(--bg2);
|
||||
background: var(--bg0);
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
max-height: 340px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
margin-bottom: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
.pick-btn, .submit-btn {
|
||||
width: 100%; background: var(--bg1); border: 1px solid var(--bg2);
|
||||
color: var(--fg2); font-family: var(--serif); font-size: 16px; font-weight: 600;
|
||||
padding: 12px; cursor: pointer; transition: background .2s, color .2s;
|
||||
}
|
||||
.drop-zone:hover { border-color: var(--fg4); }
|
||||
.drop-zone.has-image { border-style: solid; border-color: var(--bg2); }
|
||||
.drop-zone input[type="file"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.pick-btn:hover:not(:disabled), .submit-btn:hover:not(:disabled) { background: var(--bg2); color: var(--fg); }
|
||||
.submit-btn:disabled { opacity: .35; cursor: not-allowed; }
|
||||
.pick-btn { margin-bottom: 20px; }
|
||||
#file-input { display: none; }
|
||||
|
||||
#empty {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 140px; background: var(--bg0); color: var(--fg4);
|
||||
font-family: var(--serif); margin-bottom: 20px;
|
||||
}
|
||||
.drop-prompt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--fg4);
|
||||
pointer-events: none;
|
||||
|
||||
.card { display: grid; grid-template-columns: 84px 1fr auto; gap: 14px; align-items: start; padding: 14px 0; border-top: 1px solid var(--bg1); }
|
||||
.card .thumb { width: 84px; height: 84px; background: var(--bg1); object-fit: cover; display: block; }
|
||||
.card .fields { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
||||
.card input {
|
||||
width: 100%; box-sizing: border-box; background: var(--bg0);
|
||||
border: 1px solid var(--bg2); color: var(--fg); font-size: 15px; padding: 8px 10px; outline: none;
|
||||
}
|
||||
.drop-prompt p { font-size: 14px; font-family: var(--serif); }
|
||||
.drop-prompt small { font-size: 12px; font-family: monospace; }
|
||||
#preview {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
pointer-events: none;
|
||||
.card input[type="text"] { font-family: var(--serif); }
|
||||
.card input[type="date"] { font-family: monospace; font-size: 13px; color-scheme: dark; width: auto; }
|
||||
.card input:focus { border-color: var(--fg4); }
|
||||
.card input:disabled { opacity: .5; }
|
||||
.c-remove {
|
||||
background: none; border: none; color: var(--fg4); font-size: 16px; cursor: pointer; padding: 4px;
|
||||
}
|
||||
.change-hint {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
padding: 6px 10px;
|
||||
background: rgba(29, 32, 33, 0.75);
|
||||
color: var(--fg2);
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.drop-zone.has-image .change-hint { display: block; }
|
||||
.field { margin-bottom: 18px; }
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--fg4);
|
||||
font-family: monospace;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.field input[type="text"],
|
||||
.field input[type="date"] {
|
||||
width: 100%;
|
||||
background: var(--bg0);
|
||||
border: 1px solid var(--bg2);
|
||||
color: var(--fg);
|
||||
font-size: 16px;
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.field input[type="text"] { font-family: var(--serif); }
|
||||
.field input[type="date"] { font-family: monospace; font-size: 14px; color-scheme: dark; }
|
||||
.field input:focus { border-color: var(--fg4); }
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bg2);
|
||||
color: var(--fg2);
|
||||
font-family: var(--serif);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.submit-btn:hover:not(:disabled) { background: var(--bg2); color: var(--fg); }
|
||||
.submit-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
#feedback {
|
||||
display: none;
|
||||
margin-top: 14px;
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
#feedback.ok { display: block; background: #1d2e1d; color: #b8bb26; border: 1px solid #344d28; }
|
||||
#feedback.err { display: block; background: #2e1d1d; color: #fb4934; border: 1px solid #5a2828; }
|
||||
.c-status { font-family: monospace; font-size: 12px; min-height: 16px; }
|
||||
.c-status.ok { color: #b8bb26; }
|
||||
.c-status.err { color: #fb4934; }
|
||||
.c-status.up { color: var(--fg4); }
|
||||
.c-status a { color: #8ec07c; }
|
||||
|
||||
#bar { margin-top: 20px; }
|
||||
#summary { margin-top: 12px; font-family: monospace; font-size: 13px; color: var(--fg4); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="upload-page">
|
||||
<a href="/" class="back-btn">← Gallery</a>
|
||||
<h2>Upload photo</h2>
|
||||
<h2>Upload photos</h2>
|
||||
|
||||
<div class="drop-zone" id="drop-zone">
|
||||
<input type="file" id="file-input" accept=".jpg,.jpeg,.png" aria-label="Choose photo">
|
||||
<div class="drop-prompt" id="prompt">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="1"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<path d="M21 15l-5-5L5 21"/>
|
||||
</svg>
|
||||
<p>Click to choose a photo</p>
|
||||
<small>jpg or png</small>
|
||||
<input type="file" id="file-input" accept=".jpg,.jpeg,.png" multiple>
|
||||
<button class="pick-btn" id="pick-btn">Choose photos</button>
|
||||
|
||||
<div id="empty">No photos selected</div>
|
||||
<div id="list"></div>
|
||||
|
||||
<div id="bar" style="display:none">
|
||||
<button class="submit-btn" id="post-btn">Post photos</button>
|
||||
<div id="summary"></div>
|
||||
</div>
|
||||
<img id="preview" alt="Preview">
|
||||
<div class="change-hint">Click to change</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="caption">Caption</label>
|
||||
<input type="text" id="caption" placeholder="Describe the photo" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="date">Date</label>
|
||||
<input type="date" id="date">
|
||||
</div>
|
||||
|
||||
<button class="submit-btn" id="submit-btn" disabled>Upload photo</button>
|
||||
<div id="feedback"></div>
|
||||
</div>
|
||||
|
||||
<script src="/static/tagcomplete.js"></script>
|
||||
<script>
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const preview = document.getElementById('preview');
|
||||
const prompt = document.getElementById('prompt');
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const feedback = document.getElementById('feedback');
|
||||
const dateInput = document.getElementById('date');
|
||||
const pickBtn = document.getElementById('pick-btn');
|
||||
const list = document.getElementById('list');
|
||||
const emptyEl = document.getElementById('empty');
|
||||
const bar = document.getElementById('bar');
|
||||
const postBtn = document.getElementById('post-btn');
|
||||
const summary = document.getElementById('summary');
|
||||
|
||||
dateInput.value = new Date().toISOString().slice(0, 10);
|
||||
// One entry per chosen photo. status: pending | uploading | ok | error.
|
||||
let items = [];
|
||||
let seq = 0;
|
||||
let posting = false;
|
||||
|
||||
pickBtn.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => {
|
||||
for (const file of fileInput.files) addItem(file);
|
||||
fileInput.value = ''; // allow re-choosing the same file
|
||||
refresh();
|
||||
});
|
||||
|
||||
// Mirror of the server's sanitiseSlug, so the client can predict the slug a
|
||||
// filename yields and keep two files in one batch from colliding.
|
||||
function slugify(name) {
|
||||
let out = '';
|
||||
for (const ch of name.toLowerCase()) {
|
||||
if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch === '-') out += ch;
|
||||
else if (ch === ' ' || ch === '_') out += '-';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A slug unique among the current cards. The server derives the slug from
|
||||
// the uploaded filename, so two photos named the same would otherwise
|
||||
// overwrite each other; the second (third, …) gets -2, -3, …
|
||||
function uniqueSlug(filename) {
|
||||
const stem = filename.replace(/\.[^.]+$/, '');
|
||||
const base = slugify(stem) || 'photo';
|
||||
let candidate = base;
|
||||
let n = 2;
|
||||
while (items.some((i) => i.slug === candidate)) candidate = base + '-' + (n++);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function addItem(file) {
|
||||
const ext = (file.name.match(/\.[^.]+$/)?.[0] || '.jpg').toLowerCase();
|
||||
const item = { id: ++seq, file, ext, slug: '', status: 'pending', el: null };
|
||||
item.slug = uniqueSlug(file.name); // before push, so it compares against the others
|
||||
items.push(item);
|
||||
item.el = buildCard(item);
|
||||
list.appendChild(item.el);
|
||||
// Preview.
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = item.el.querySelector('.thumb');
|
||||
img.onload = () => URL.revokeObjectURL(url);
|
||||
img.src = url;
|
||||
// Prefill date from EXIF, else file mtime.
|
||||
prefillDate(item, file);
|
||||
// Tag autocomplete on this card's tags field.
|
||||
if (window.TagComplete) window.TagComplete.attach(item.el.querySelector('.c-tags'));
|
||||
}
|
||||
|
||||
function buildCard(item) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'card';
|
||||
el.innerHTML = `
|
||||
<img class="thumb" alt="">
|
||||
<div class="fields">
|
||||
<input type="text" class="c-caption" placeholder="Caption" autocomplete="off">
|
||||
<input type="date" class="c-date">
|
||||
<input type="text" class="c-tags" placeholder="tags, comma-separated" autocomplete="off">
|
||||
<div class="c-status"></div>
|
||||
</div>
|
||||
<button class="c-remove" title="Remove">✕</button>`;
|
||||
el.querySelector('.c-remove').addEventListener('click', () => removeItem(item.id));
|
||||
return el;
|
||||
}
|
||||
|
||||
async function prefillDate(item, file) {
|
||||
let d = null;
|
||||
try { d = await readExifDate(file); } catch (_) {}
|
||||
if (!d && file.lastModified) d = new Date(file.lastModified).toISOString().slice(0, 10);
|
||||
if (!d) d = new Date().toISOString().slice(0, 10);
|
||||
item.el.querySelector('.c-date').value = d;
|
||||
}
|
||||
|
||||
function removeItem(id) {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (!item || item.status === 'uploading') return;
|
||||
item.el.remove();
|
||||
items = items.filter((i) => i.id !== id);
|
||||
refresh();
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
const has = items.length > 0;
|
||||
emptyEl.style.display = has ? 'none' : 'flex';
|
||||
bar.style.display = has ? 'block' : 'none';
|
||||
pickBtn.textContent = has ? 'Add more photos' : 'Choose photos';
|
||||
updateButton();
|
||||
}
|
||||
|
||||
function pending() { return items.filter((i) => i.status !== 'ok'); }
|
||||
|
||||
function updateButton() {
|
||||
const n = pending().length;
|
||||
postBtn.disabled = posting || n === 0;
|
||||
postBtn.textContent = posting
|
||||
? 'Posting…'
|
||||
: n === 0 ? 'All posted' : (n === 1 ? 'Post 1 photo' : `Post ${n} photos`);
|
||||
}
|
||||
|
||||
function setStatus(item, cls, html) {
|
||||
const s = item.el.querySelector('.c-status');
|
||||
s.className = 'c-status ' + cls;
|
||||
s.innerHTML = html;
|
||||
}
|
||||
|
||||
function setDisabled(item, disabled) {
|
||||
item.el.querySelectorAll('input').forEach((i) => (i.disabled = disabled));
|
||||
item.el.querySelector('.c-remove').disabled = disabled;
|
||||
}
|
||||
|
||||
async function postOne(item) {
|
||||
item.status = 'uploading';
|
||||
setDisabled(item, true);
|
||||
setStatus(item, 'up', 'Uploading…');
|
||||
|
||||
const form = new FormData();
|
||||
// Send under the deduped slug so the server (which slugs from the
|
||||
// filename) can't overwrite another photo in the same batch.
|
||||
form.append('image', item.file, item.slug + item.ext);
|
||||
form.append('caption', item.el.querySelector('.c-caption').value);
|
||||
form.append('date', item.el.querySelector('.c-date').value);
|
||||
form.append('tags', item.el.querySelector('.c-tags').value);
|
||||
|
||||
try {
|
||||
const res = await fetch('/upload', { method: 'POST', body: form });
|
||||
const text = (await res.text()).trim();
|
||||
if (res.ok) {
|
||||
item.status = 'ok';
|
||||
const slug = text.replace(/^uploaded\s+/, '').replace(/\.[^.]+$/, '');
|
||||
setStatus(item, 'ok', '✓ posted · <a href="/photo/' + encodeURIComponent(slug) + '/" target="_blank" rel="noopener">view</a>');
|
||||
} else {
|
||||
item.status = 'error';
|
||||
setDisabled(item, false);
|
||||
setStatus(item, 'err', '✗ ' + (text || res.status));
|
||||
}
|
||||
} catch (err) {
|
||||
item.status = 'error';
|
||||
setDisabled(item, false);
|
||||
setStatus(item, 'err', '✗ ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
postBtn.addEventListener('click', async () => {
|
||||
if (posting) return;
|
||||
posting = true;
|
||||
updateButton();
|
||||
if (window.TagComplete) window.TagComplete.invalidate();
|
||||
|
||||
// Sequential: gentler on mobile connections, and the rebuild is debounced
|
||||
// server-side anyway.
|
||||
const todo = pending();
|
||||
let done = 0;
|
||||
for (const item of todo) {
|
||||
await postOne(item);
|
||||
done++;
|
||||
summary.textContent = `${done}/${todo.length} processed`;
|
||||
}
|
||||
|
||||
posting = false;
|
||||
const ok = items.filter((i) => i.status === 'ok').length;
|
||||
const failed = items.filter((i) => i.status === 'error').length;
|
||||
summary.textContent = `${ok} posted` + (failed ? `, ${failed} failed (press Post to retry)` : '');
|
||||
updateButton();
|
||||
});
|
||||
|
||||
// Read DateTimeOriginal from a JPEG's EXIF APP1 segment. Returns "YYYY-MM-DD"
|
||||
// or null if the file isn't a JPEG, has no EXIF, or lacks the tag.
|
||||
@@ -222,66 +297,7 @@
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fileInput.addEventListener('change', async () => {
|
||||
const file = fileInput.files[0];
|
||||
if (!file) return;
|
||||
const url = URL.createObjectURL(file);
|
||||
preview.onload = () => URL.revokeObjectURL(url);
|
||||
preview.src = url;
|
||||
preview.style.display = 'block';
|
||||
prompt.style.display = 'none';
|
||||
dropZone.classList.add('has-image');
|
||||
submitBtn.disabled = false;
|
||||
feedback.className = '';
|
||||
feedback.style.display = 'none';
|
||||
|
||||
let d = null;
|
||||
try { d = await readExifDate(file); } catch (_) {}
|
||||
if (!d && file.lastModified) {
|
||||
d = new Date(file.lastModified).toISOString().slice(0, 10);
|
||||
}
|
||||
if (d) dateInput.value = d;
|
||||
});
|
||||
|
||||
submitBtn.addEventListener('click', async () => {
|
||||
const file = fileInput.files[0];
|
||||
if (!file) return;
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Uploading…';
|
||||
feedback.className = '';
|
||||
|
||||
const form = new FormData();
|
||||
form.append('image', file);
|
||||
form.append('caption', document.getElementById('caption').value);
|
||||
form.append('date', dateInput.value);
|
||||
|
||||
try {
|
||||
const res = await fetch('/upload', { method: 'POST', body: form });
|
||||
const text = (await res.text()).trim();
|
||||
if (res.ok) {
|
||||
feedback.className = 'ok';
|
||||
feedback.textContent = '✓ ' + text;
|
||||
fileInput.value = '';
|
||||
preview.style.display = 'none';
|
||||
prompt.style.display = 'flex';
|
||||
dropZone.classList.remove('has-image');
|
||||
document.getElementById('caption').value = '';
|
||||
submitBtn.disabled = true;
|
||||
} else {
|
||||
feedback.className = 'err';
|
||||
feedback.textContent = '✗ ' + text;
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
feedback.className = 'err';
|
||||
feedback.textContent = '✗ ' + err.message;
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
|
||||
submitBtn.textContent = 'Upload photo';
|
||||
});
|
||||
</script>
|
||||
<script src="/static/session.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 := parseForm(r); 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 := parseForm(r); 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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -30,6 +30,12 @@ func NewWatcher(dir string, g *Generator) (*Watcher, error) {
|
||||
// Watch blocks, watching for changes and triggering rebuilds.
|
||||
// Rapid events (e.g. scp writing an image then its sidecar) are debounced
|
||||
// with a 500 ms window so only one rebuild fires per logical upload.
|
||||
//
|
||||
// Rename is watched alongside Create/Write/Remove: moving a photo out of the
|
||||
// content directory — which is exactly what a /manage delete does when it
|
||||
// sends files to content/.trash/ — arrives as a Rename (inotify IN_MOVED_FROM)
|
||||
// on Linux, not a Remove, so without it a delete (or a manual mv) would leave
|
||||
// the site unrebuilt.
|
||||
func (w *Watcher) Watch() {
|
||||
var timer *time.Timer
|
||||
rebuild := func() {
|
||||
@@ -47,7 +53,8 @@ func (w *Watcher) Watch() {
|
||||
if !isRelevant(event.Name) {
|
||||
continue
|
||||
}
|
||||
if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) || event.Has(fsnotify.Remove) {
|
||||
if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) ||
|
||||
event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) {
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user