Move upload auth from nginx into the app

Add HTTP Basic auth on /upload, reading an Apache htpasswd file (bcrypt
only, `htpasswd -B`) via a new -htpasswd flag. This lets nginx drop to a
plain TLS-terminating reverse proxy with no auth of its own; the existing
bcrypt htpasswd file is reused unchanged.

auth.go:
- LoadHtpasswd parses user:hash lines, keeps bcrypt entries ($2a/$2b/$2y,
  so Apache's $2y$ works), skips MD5/SHA/crypt with a warning.
- Unknown users still pay for one bcrypt comparison against a dummy hash,
  so response timing can't enumerate valid usernames.
- Failed attempts draw from a token-bucket limiter (x/time/rate, ~1/s,
  burst 10) returning 429 once drained; successful and bulk uploads never
  touch it.

server.go guards both /upload routes (nil authenticator = passthrough for
local --serve). /health stays open. Empty -htpasswd disables auth and logs
a warning.

module.nix: new top-level services.photogallery.htpasswdFile, delivered to
the service via systemd LoadCredential (so a root-owned 0400 secret works).
Removed nginx.htpasswdFile and basic_auth from the vhost.

Both uploader apps already send Basic credentials, so no client change.

Deps: golang.org/x/crypto/bcrypt, golang.org/x/time/rate (vendorHash bump).
This commit is contained in:
puttaalu committed 2026-10-18 14:45:33 +02:00
1 parent 3291ed67d5
commit 434d2437bf
9 files changed
+342 -27

No files matched your search

+26 -6
View File
@@ -20,12 +20,29 @@ The binary does three things at once:
- In dev mode (`--serve`), also serves `public/` as static files
Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart), `GET /health`.
Both `/upload` routes are gated by HTTP Basic auth when `-htpasswd` is set (see Auth); `/health` is always open.
In production nginx sits in front: serves `public/` directly, proxies `/upload` to localhost, htpasswd on the upload endpoint. Note `/health` is **not** proxied — nginx only forwards `location /upload`, so anything outside the box can only reach `/upload`.
In production nginx sits in front: serves `public/` directly, proxies `/upload` to localhost, terminates TLS. Auth is **no longer nginx's job** — the app checks credentials itself (`auth.go`), so the nginx vhost just proxies. Note `/health` is **not** proxied — nginx only forwards `location /upload`, so anything outside the box can only reach `/upload`.
## Auth
`-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.
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`.
## 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`)
@@ -136,10 +153,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";
enable = true;
domain = "photos.yourdomain.tld";
};
};
```
@@ -161,6 +180,7 @@ nix build # succeeds
| `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 |
| `auth.go` | htpasswd (bcrypt) HTTP Basic auth + failed-attempt rate limit for `/upload` |
| `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 |
@@ -183,6 +203,6 @@ nix build # succeeds
- ActivityPub federation (planned later — start with RSS/Atom)
- Multi-image upload
- Delete/edit via UI
- Any auth beyond nginx htpasswd
- Any auth beyond the app's htpasswd Basic auth on `/upload` (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