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:
puttaalu committed 2026-10-19 17:04:58 +02:00
commit 3614cfe1f8
44 files changed
+3627 -1197

No files matched your search

+111 -22
View File
@@ -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
+76 -32
View File
@@ -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/`.
+206
View File
@@ -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
View File
@@ -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"
+102
View File
@@ -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
View File
@@ -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")
}
}
+108
View File
@@ -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)
}
+1 -1
View File
@@ -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);
+10
View File
@@ -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) {
+2
View File
@@ -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;
};
+2 -1
View File
@@ -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());
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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,
+4 -2
View File
@@ -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
+6 -2
View File
@@ -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=
+14 -3
View File
@@ -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())
}
+329 -163
View File
@@ -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)
caption := r.FormValue("caption")
if caption == "" {
caption = slug
}
var tags []string
for _, t := range parseTags(r.FormValue("tags")) {
tags = append(tags, t.Name)
}
if err := s.writeSidecar(slug, PhotoMeta{Caption: caption, Date: date, Tags: tags}); err != nil {
log.Printf("edit %s: %v", slug, err)
http.Error(w, "could not save", http.StatusInternalServerError)
return
}
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
s.rebuild()
w.WriteHeader(http.StatusOK)
}
// 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)
}
}()
http.Redirect(w, r, "/manage?saved="+url.QueryEscape(photo.Slug), http.StatusSeeOther)
}
// 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)
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
}
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)
}
}()
http.Redirect(w, r, "/manage?deleted="+url.QueryEscape(photo.Caption), http.StatusSeeOther)
}
// 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()
slugs, err := s.formSlugs(r)
if err != nil {
log.Printf("manage: %v", err)
return Photo{}, false
http.Error(w, "bad request", http.StatusBadRequest)
return
}
for _, p := range photos {
if p.Slug == slug {
deleted := 0
for _, slug := range slugs {
if _, ok := s.resolveSlug(slug); !ok {
continue
}
if err := s.moveToTrash(slug); err != nil {
log.Printf("delete %s: %v", slug, err)
continue
}
deleted++
}
if deleted > 0 {
s.rebuild()
}
writeJSON(w, map[string]int{"deleted": deleted})
}
// handleBulkTags adds and/or removes tags across the given posts, leaving each
// post's caption and date untouched. `add` and `remove` are comma-separated.
func (s *Server) handleBulkTags(w http.ResponseWriter, r *http.Request) {
if s.rejectCrossSite(w, r) {
return
}
slugs, err := s.formSlugs(r)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
add := parseTags(r.FormValue("add"))
remove := make(map[string]bool)
for _, t := range parseTags(r.FormValue("remove")) {
remove[t.Name] = true
}
if len(add) == 0 && len(remove) == 0 {
http.Error(w, "nothing to add or remove", http.StatusBadRequest)
return
}
updated := 0
for _, slug := range slugs {
if _, ok := s.resolveSlug(slug); !ok {
continue
}
meta := s.readSidecar(slug)
// Start from the current tags, append the additions, drop the removals,
// then let parseTags normalise and dedupe the result.
names := append([]string{}, meta.Tags...)
for _, t := range add {
names = append(names, t.Name)
}
var kept []string
for _, t := range parseTags(strings.Join(names, ",")) {
if !remove[t.Name] {
kept = append(kept, t.Name)
}
}
meta.Tags = kept
if err := s.writeSidecar(slug, meta); err != nil {
log.Printf("bulk tags %s: %v", slug, err)
continue
}
updated++
}
if updated > 0 {
s.rebuild()
}
writeJSON(w, map[string]int{"updated": updated})
}
// ── helpers ────────────────────────────────────────────────────────────────
// 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)
}
+269 -292
View File
@@ -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)
}
return m
}
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"))
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
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)
}
// 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),
}
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)
// 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)
// 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)
}
r.SetPathValue("slug", slug)
w := httptest.NewRecorder()
s.handleDelete(w, r)
return w
if m := readMeta(t, content, "foo"); m.Caption != "Multipart" || strings.Join(m.Tags, ",") != "x,y" {
t.Errorf("multipart edit not applied: %+v", m)
}
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)
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
View File
@@ -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;";
};
};
};
+87 -22
View File
@@ -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
}
+65
View File
@@ -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);
}
})();
+101
View File
@@ -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; }
+152
View File
@@ -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);
});
})();
+97
View File
@@ -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>
+55
View File
@@ -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>
+1
View File
@@ -46,5 +46,6 @@
<footer class="site-footer">
© 2000–{{.Year}} {{.Author}}. All rights reserved.
</footer>
<script src="/static/session.js"></script>
</body>
</html>
-107
View File
@@ -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>
-110
View File
@@ -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
View File
@@ -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
View File
@@ -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> &nbsp; <a href="#" id="trash-toggle">Trash</a> &nbsp; <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>
+7 -1
View File
@@ -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>
+3 -1
View File
@@ -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
View File
@@ -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>
+206
View File
@@ -0,0 +1,206 @@
package main
import (
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// trashStamp is the timestamp moveToTrash appends: 20060102-150405.
var trashStamp = regexp.MustCompile(`^\d{8}-\d{6}$`)
// trashItem is one recoverable post in content/.trash/, grouping the image and
// its sidecar that were trashed together (same slug, same timestamp).
type trashItem struct {
ID string `json:"id"` // "<slug>.<stamp>", the restore/empty handle
Slug string `json:"slug"` // original slug it would restore to
DeletedAt string `json:"deletedAt"`
Image string `json:"image"` // trash filename of the image, "" if only a sidecar
HasMeta bool `json:"hasMeta"` // whether a sidecar is present
}
func (s *Server) trashDir() string { return filepath.Join(s.contentDir, ".trash") }
// parseTrashName splits a trash filename "<slug>.<stamp><ext>" into its parts.
// slug is a bare sanitised name (no dots) and stamp is a fixed timestamp, so
// the extension and the stamp peel off unambiguously from the right.
func parseTrashName(name string) (slug, stamp, ext string, ok bool) {
ext = filepath.Ext(name)
rest := strings.TrimSuffix(name, ext) // "<slug>.<stamp>"
i := strings.LastIndex(rest, ".")
if i < 0 {
return "", "", "", false
}
slug, stamp = rest[:i], rest[i+1:]
if slug == "" || slug != filepath.Base(slug) || strings.Contains(slug, ".") {
return "", "", "", false
}
if !trashStamp.MatchString(stamp) {
return "", "", "", false
}
return slug, stamp, ext, true
}
func isImageExt(ext string) bool {
for _, e := range contentExts {
if ext == e {
return true
}
}
return false
}
// handleListTrash returns trashed items, newest first.
func (s *Server) handleListTrash(w http.ResponseWriter, r *http.Request) {
entries, err := os.ReadDir(s.trashDir())
if err != nil {
// No trash dir yet → empty list, not an error.
writeJSON(w, []trashItem{})
return
}
byID := make(map[string]*trashItem)
for _, e := range entries {
if e.IsDir() {
continue
}
slug, stamp, ext, ok := parseTrashName(e.Name())
if !ok {
continue
}
id := slug + "." + stamp
it := byID[id]
if it == nil {
when := stamp
if t, err := time.ParseInLocation("20060102-150405", stamp, time.Local); err == nil {
when = t.Format("2006-01-02 15:04")
}
it = &trashItem{ID: id, Slug: slug, DeletedAt: when}
byID[id] = it
}
if ext == ".toml" {
it.HasMeta = true
} else if isImageExt(ext) {
it.Image = e.Name()
}
}
items := make([]trashItem, 0, len(byID))
for _, it := range byID {
items = append(items, *it)
}
// Newest first: the stamp sorts lexically in time order, and it is the tail
// of the ID.
sort.Slice(items, func(i, j int) bool { return items[i].ID > items[j].ID })
writeJSON(w, items)
}
// splitTrashID validates a "<slug>.<stamp>" handle and returns its parts.
func splitTrashID(id string) (slug, stamp string, ok bool) {
i := strings.LastIndex(id, ".")
if i < 0 {
return "", "", false
}
slug, stamp = id[:i], id[i+1:]
if slug == "" || slug != filepath.Base(slug) || strings.Contains(slug, ".") {
return "", "", false
}
if !trashStamp.MatchString(stamp) {
return "", "", false
}
return slug, stamp, true
}
// trashFiles returns the files in .trash belonging to one id (image + sidecar).
func (s *Server) trashFiles(slug, stamp string) []string {
var out []string
prefix := slug + "." + stamp
for _, ext := range append(append([]string{}, contentExts...), ".toml") {
p := filepath.Join(s.trashDir(), prefix+ext)
if _, err := os.Stat(p); err == nil {
out = append(out, p)
}
}
return out
}
// handleRestore moves a trashed post back into content/, stripping the
// timestamp. It refuses to overwrite a live post with the same slug.
func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
if s.rejectCrossSite(w, r) {
return
}
if err := 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
View File
@@ -0,0 +1,139 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
)
func listTrash(t *testing.T, s *Server) []trashItem {
t.Helper()
rec := httptest.NewRecorder()
s.handleListTrash(rec, httptest.NewRequest(http.MethodGet, "/api/trash", nil))
if rec.Code != http.StatusOK {
t.Fatalf("list trash: code=%d", rec.Code)
}
var items []trashItem
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
t.Fatalf("decode trash: %v", err)
}
return items
}
func TestTrashListAndRestore(t *testing.T) {
s, content, _ := newTestServer(t)
// Delete foo, then it should show up in the trash.
rec := httptest.NewRecorder()
s.handleDelete(rec, form(url.Values{"slug": {"foo"}}))
if rec.Code != http.StatusOK {
t.Fatalf("delete: %d", rec.Code)
}
items := listTrash(t, s)
if len(items) != 1 {
t.Fatalf("trash has %d items, want 1", len(items))
}
it := items[0]
if it.Slug != "foo" || !it.HasMeta || it.Image == "" {
t.Errorf("trash item wrong: %+v", it)
}
// Restore it.
rec = httptest.NewRecorder()
s.handleRestore(rec, form(url.Values{"id": {it.ID}}))
if rec.Code != http.StatusOK {
t.Fatalf("restore: code=%d body=%s", rec.Code, rec.Body)
}
for _, p := range []string{"foo.png", "foo.toml"} {
if _, err := os.Stat(filepath.Join(content, p)); err != nil {
t.Errorf("restored %s missing: %v", p, err)
}
}
if items := listTrash(t, s); len(items) != 0 {
t.Errorf("trash not empty after restore: %+v", items)
}
// Caption survived the round trip.
if readMeta(t, content, "foo").Caption != "Foo" {
t.Error("restored sidecar lost its caption")
}
}
func TestRestoreRefusesLiveCollision(t *testing.T) {
s, content, _ := newTestServer(t)
rec := httptest.NewRecorder()
s.handleDelete(rec, form(url.Values{"slug": {"foo"}}))
if rec.Code != http.StatusOK {
t.Fatalf("delete: %d", rec.Code)
}
id := listTrash(t, s)[0].ID
// A new live post now occupies the slug.
writePNG(t, filepath.Join(content, "foo.png"))
rec = httptest.NewRecorder()
s.handleRestore(rec, form(url.Values{"id": {id}}))
if rec.Code != http.StatusConflict {
t.Errorf("restore over live post: code=%d, want 409", rec.Code)
}
// The trashed copy is left intact for a manual decision.
if len(listTrash(t, s)) != 1 {
t.Error("trashed item should remain after refused restore")
}
}
func TestEmptyTrash(t *testing.T) {
s, _, _ := newTestServer(t)
// Trash both posts.
rec := httptest.NewRecorder()
s.handleDelete(rec, form(url.Values{"slugs": {"foo,bar"}}))
if rec.Code != http.StatusOK {
t.Fatalf("delete: %d", rec.Code)
}
items := listTrash(t, s)
if len(items) != 2 {
t.Fatalf("trash has %d, want 2", len(items))
}
// Empty one by id.
rec = httptest.NewRecorder()
s.handleEmptyTrash(rec, form(url.Values{"id": {items[0].ID}}))
if rec.Code != http.StatusOK {
t.Fatalf("empty one: %d", rec.Code)
}
if len(listTrash(t, s)) != 1 {
t.Error("expected 1 item left after deleting one")
}
// Empty all.
rec = httptest.NewRecorder()
s.handleEmptyTrash(rec, form(url.Values{"all": {"1"}}))
if rec.Code != http.StatusOK {
t.Fatalf("empty all: %d", rec.Code)
}
if len(listTrash(t, s)) != 0 {
t.Error("trash not empty after all")
}
}
func TestSplitTrashIDRejectsJunk(t *testing.T) {
cases := map[string]bool{
"foo.20260102-150405": true,
"foo.bar.20260102-150405": false, // slug "foo.bar" has a dot → rejected
"../x.20260102-150405": false, // traversal
"foo.2026": false, // malformed stamp
"foo": false, // no stamp
"": false,
}
for id, want := range cases {
if _, _, ok := splitTrashID(id); ok != want {
t.Errorf("splitTrashID(%q) = %v, want %v", id, ok, want)
}
}
}
+8 -1
View File
@@ -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()
}