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:
1 parent
3291ed67d5
commit
434d2437bf
9 files changed
+338
-23
No files matched your search
@@ -20,12 +20,29 @@ The binary does three things at once:
|
|||||||
- In dev mode (`--serve`), also serves `public/` as static files
|
- In dev mode (`--serve`), also serves `public/` as static files
|
||||||
|
|
||||||
Routes: `GET /upload` (HTML upload form), `POST /upload` (multipart), `GET /health`.
|
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
|
## 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`
|
- **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.
|
- **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`)
|
- **Feed** — Atom (`encoding/xml`)
|
||||||
@@ -136,10 +153,12 @@ inputs.photogallery.nixosModules.default
|
|||||||
services.photogallery = {
|
services.photogallery = {
|
||||||
enable = true;
|
enable = true;
|
||||||
baseURL = "https://photos.yourdomain.tld";
|
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 = {
|
nginx = {
|
||||||
enable = true;
|
enable = true;
|
||||||
domain = "photos.yourdomain.tld";
|
domain = "photos.yourdomain.tld";
|
||||||
htpasswdFile = "/etc/nginx/.htpasswd-gallery";
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
@@ -161,6 +180,7 @@ nix build # succeeds
|
|||||||
| `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs/mediums |
|
| `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs/mediums |
|
||||||
| `albums.go` | Tag parsing/normalization, `groupByTag`, album pages + `/albums/` + `/tags.json` |
|
| `albums.go` | Tag parsing/normalization, `groupByTag`, album pages + `/albums/` + `/tags.json` |
|
||||||
| `server.go` | Upload API + optional static file serving |
|
| `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 |
|
| `watcher.go` | fsnotify watcher with 500ms debounce |
|
||||||
| `assets.go` | `//go:embed` for templates and static |
|
| `assets.go` | `//go:embed` for templates and static |
|
||||||
| `templates/timeline.html` | Site root: photos grouped by day, paginated |
|
| `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)
|
- ActivityPub federation (planned later — start with RSS/Atom)
|
||||||
- Multi-image upload
|
- Multi-image upload
|
||||||
- Delete/edit via UI
|
- 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
|
- 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
|
- Photo-page swipe has never been tested on a real touchscreen; the iOS Safari edge-gesture interaction is the untested part
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Authenticator checks HTTP Basic credentials against bcrypt hashes loaded from
|
||||||
|
// an Apache-style htpasswd file. Only bcrypt entries are supported — generate
|
||||||
|
// them with `htpasswd -B`. This replaces nginx's basic_auth so the app can run
|
||||||
|
// behind a plain TLS-terminating reverse proxy with no auth of its own.
|
||||||
|
type Authenticator struct {
|
||||||
|
creds map[string][]byte // username -> bcrypt hash
|
||||||
|
|
||||||
|
// A single bcrypt comparison runs on every failed lookup so that a request
|
||||||
|
// for an unknown user costs the same as one for a known user with a wrong
|
||||||
|
// password, closing a user-enumeration timing side channel.
|
||||||
|
dummyHash []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadHtpasswd reads user:hash lines from path. Blank lines and #-comments are
|
||||||
|
// skipped, as is any entry whose hash is not bcrypt (with a warning). It is an
|
||||||
|
// error for the file to yield no usable entries.
|
||||||
|
func LoadHtpasswd(path string) (*Authenticator, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
a := &Authenticator{creds: make(map[string][]byte)}
|
||||||
|
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimSpace(sc.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
user, hash, ok := strings.Cut(line, ":")
|
||||||
|
if !ok || user == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// bcrypt hashes start $2a$, $2b$ or $2y$; everything else (MD5 $apr1$,
|
||||||
|
// SHA {SHA}, crypt) is unsupported here.
|
||||||
|
if !strings.HasPrefix(hash, "$2") {
|
||||||
|
log.Printf("auth: skipping non-bcrypt entry for %q — regenerate with `htpasswd -B`", user)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.creds[user] = []byte(hash)
|
||||||
|
}
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(a.creds) == 0 {
|
||||||
|
return nil, fmt.Errorf("no bcrypt entries in %s (use `htpasswd -B`)", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cost 10 matches htpasswd -B's default, keeping the dummy comparison the
|
||||||
|
// same price as a real one.
|
||||||
|
a.dummyHash, _ = bcrypt.GenerateFromPassword([]byte("dummy"), 10)
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// check reports whether user/pass matches a stored credential.
|
||||||
|
func (a *Authenticator) check(user, pass string) bool {
|
||||||
|
hash, ok := a.creds[user]
|
||||||
|
if !ok {
|
||||||
|
// Spend the same time as a real comparison to avoid leaking which
|
||||||
|
// usernames exist.
|
||||||
|
bcrypt.CompareHashAndPassword(a.dummyHash, []byte(pass))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return bcrypt.CompareHashAndPassword(hash, []byte(pass)) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// basicAuth wraps next, demanding valid HTTP Basic credentials. On a failed
|
||||||
|
// attempt it consumes a token from limiter; once the limiter is exhausted,
|
||||||
|
// further failures get 429 instead of 401, throttling brute-force guessing.
|
||||||
|
// The limiter is only touched on failure, so legitimate (even bursty, bulk)
|
||||||
|
// uploads are never throttled.
|
||||||
|
func (a *Authenticator) basicAuth(limiter *rate.Limiter, next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, pass, ok := r.BasicAuth()
|
||||||
|
if ok && a.check(user, pass) {
|
||||||
|
next(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !limiter.Allow() {
|
||||||
|
http.Error(w, "too many failed attempts", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="photog"`)
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bcryptHash returns a cost-10 hash of pw, matching `htpasswd -B`'s default.
|
||||||
|
func bcryptHash(t *testing.T, pw string) string {
|
||||||
|
t.Helper()
|
||||||
|
h, err := bcrypt.GenerateFromPassword([]byte(pw), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateFromPassword: %v", err)
|
||||||
|
}
|
||||||
|
return string(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadHtpasswdAndCheck(t *testing.T) {
|
||||||
|
// alice's hash is rewritten to the $2y$ variant that Apache's htpasswd -B
|
||||||
|
// emits, to prove it is accepted. $2a$ and $2y$ are the same algorithm, so
|
||||||
|
// swapping the prefix on a valid hash yields a valid hash for the same
|
||||||
|
// password. bob keeps the $2a$ form.
|
||||||
|
aliceHash := bcryptHash(t, "s3cret")
|
||||||
|
if !strings.HasPrefix(aliceHash, "$2a$") {
|
||||||
|
t.Fatalf("expected $2a$ prefix, got %q", aliceHash[:4])
|
||||||
|
}
|
||||||
|
aliceHashY := "$2y$" + strings.TrimPrefix(aliceHash, "$2a$")
|
||||||
|
bobHash := bcryptHash(t, "hunter2")
|
||||||
|
|
||||||
|
file := filepath.Join(t.TempDir(), "htpasswd")
|
||||||
|
content := strings.Join([]string{
|
||||||
|
"# a comment",
|
||||||
|
"",
|
||||||
|
"alice:" + aliceHashY,
|
||||||
|
"bob:" + bobHash,
|
||||||
|
"legacy:$apr1$abcd$0123456789", // MD5 — must be skipped
|
||||||
|
}, "\n") + "\n"
|
||||||
|
if err := os.WriteFile(file, []byte(content), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a, err := LoadHtpasswd(file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadHtpasswd: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := a.creds["legacy"]; ok {
|
||||||
|
t.Error("MD5 entry should have been skipped")
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
user, pass string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"alice", "s3cret", true}, // $2y$ accepted
|
||||||
|
{"bob", "hunter2", true}, // $2a$ accepted
|
||||||
|
{"alice", "wrong", false}, // wrong password
|
||||||
|
{"bob", "s3cret", false}, // right password, wrong user
|
||||||
|
{"nobody", "s3cret", false}, // unknown user
|
||||||
|
{"legacy", "whatever", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := a.check(c.user, c.pass); got != c.want {
|
||||||
|
t.Errorf("check(%q, %q) = %v, want %v", c.user, c.pass, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicAuthMiddleware(t *testing.T) {
|
||||||
|
a := &Authenticator{creds: map[string][]byte{"alice": []byte(bcryptHash(t, "s3cret"))}}
|
||||||
|
a.dummyHash, _ = bcrypt.GenerateFromPassword([]byte("dummy"), 10)
|
||||||
|
|
||||||
|
var reached bool
|
||||||
|
// Burst of 2 failures before throttling, so the 429 path is reachable.
|
||||||
|
limiter := rate.NewLimiter(rate.Every(time.Hour), 2)
|
||||||
|
h := a.basicAuth(limiter, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
reached = true
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
})
|
||||||
|
|
||||||
|
do := func(user, pass string, withCreds bool) *httptest.ResponseRecorder {
|
||||||
|
reached = false
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/upload", nil)
|
||||||
|
if withCreds {
|
||||||
|
req.SetBasicAuth(user, pass)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good credentials pass through.
|
||||||
|
if rec := do("alice", "s3cret", true); rec.Code != http.StatusCreated || !reached {
|
||||||
|
t.Fatalf("valid creds: code=%d reached=%v", rec.Code, reached)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No credentials → 401 with a challenge.
|
||||||
|
rec := do("", "", false)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("missing creds: code=%d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(rec.Header().Get("WWW-Authenticate"), "Basic ") {
|
||||||
|
t.Errorf("missing WWW-Authenticate challenge: %q", rec.Header().Get("WWW-Authenticate"))
|
||||||
|
}
|
||||||
|
if reached {
|
||||||
|
t.Error("handler reached despite missing creds")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrong password burns the limiter (burst 2: one above + this one), then
|
||||||
|
// the next failure is throttled to 429.
|
||||||
|
if rec := do("alice", "nope", true); rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("wrong pass #2: code=%d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := do("alice", "nope", true); rec.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("wrong pass #3: code=%d, want 429", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A valid request still succeeds while failures are throttled — the
|
||||||
|
// limiter only gates failures.
|
||||||
|
if rec := do("alice", "s3cret", true); rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("valid creds under throttle: code=%d, want 201", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadHtpasswdErrors(t *testing.T) {
|
||||||
|
if _, err := LoadHtpasswd(filepath.Join(t.TempDir(), "does-not-exist")); err == nil {
|
||||||
|
t.Error("expected error for missing file")
|
||||||
|
}
|
||||||
|
|
||||||
|
empty := filepath.Join(t.TempDir(), "htpasswd")
|
||||||
|
if err := os.WriteFile(empty, []byte("# only comments\n\nlegacy:$apr1$x$y\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := LoadHtpasswd(empty); err == nil {
|
||||||
|
t.Error("expected error when no bcrypt entries are usable")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
# Run `go mod tidy` to generate go.sum, then `nix build`.
|
# Run `go mod tidy` to generate go.sum, then `nix build`.
|
||||||
# Nix will fail and print the correct hash — paste it here.
|
# Nix will fail and print the correct hash — paste it here.
|
||||||
vendorHash = "sha256-SxQDRProQtauWXPvPzbZZChLQd0iey9hnXFLiPYTS98=";
|
vendorHash = "sha256-Q+VeDZX9DlZtOZrYRwZo19eyob8jIu19IRhdvChmVL4=";
|
||||||
|
|
||||||
meta = with pkgs.lib; {
|
meta = with pkgs.lib; {
|
||||||
description = "Minimal static photo gallery";
|
description = "Minimal static photo gallery";
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
module photogallery
|
module photogallery
|
||||||
|
|
||||||
go 1.22
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.4.0
|
github.com/BurntSushi/toml v1.4.0
|
||||||
github.com/fsnotify/fsnotify v1.7.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/image v0.21.0
|
||||||
|
golang.org/x/time v0.16.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/sys v0.4.0 // indirect
|
require golang.org/x/sys v0.49.0 // indirect
|
||||||
@@ -2,7 +2,11 @@ github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0
|
|||||||
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/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 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
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 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
|
||||||
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
|
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.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
|
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||||
|
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||||
@@ -20,6 +20,7 @@ func main() {
|
|||||||
serifFamily := flag.String("serif-family", "Crimson Pro", "CSS font-family for the primary serif text")
|
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")
|
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)")
|
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)")
|
||||||
|
htpasswd := flag.String("htpasswd", "", "path to an htpasswd file (bcrypt, `htpasswd -B`) guarding /upload; empty disables auth")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
for _, d := range []string{*contentDir, *outputDir} {
|
for _, d := range []string{*contentDir, *outputDir} {
|
||||||
@@ -41,6 +42,17 @@ func main() {
|
|||||||
defer w.Close()
|
defer w.Close()
|
||||||
go w.Watch()
|
go w.Watch()
|
||||||
|
|
||||||
s := NewServer(*port, *contentDir, *outputDir, *serve, 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())
|
log.Fatal(s.Listen())
|
||||||
}
|
}
|
||||||
+25
-10
@@ -82,6 +82,20 @@ in {
|
|||||||
description = "Photos per page on the timeline and grid views. The timeline packs whole days, so pages vary slightly around this.";
|
description = "Photos per page on the timeline and grid views. The timeline packs whole days, so pages vary slightly around this.";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = {
|
nginx = {
|
||||||
enable = lib.mkEnableOption "nginx virtual host for photogallery";
|
enable = lib.mkEnableOption "nginx virtual host for photogallery";
|
||||||
|
|
||||||
@@ -91,12 +105,6 @@ in {
|
|||||||
description = "Domain for the nginx virtual host.";
|
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 endpoint.";
|
|
||||||
};
|
|
||||||
|
|
||||||
enableACME = lib.mkOption {
|
enableACME = lib.mkOption {
|
||||||
type = lib.types.bool;
|
type = lib.types.bool;
|
||||||
default = true;
|
default = true;
|
||||||
@@ -134,7 +142,7 @@ in {
|
|||||||
User = "photogallery";
|
User = "photogallery";
|
||||||
Group = "photogallery";
|
Group = "photogallery";
|
||||||
|
|
||||||
ExecStart = lib.escapeShellArgs [
|
ExecStart = lib.escapeShellArgs ([
|
||||||
"${pkg}/bin/photogallery"
|
"${pkg}/bin/photogallery"
|
||||||
"--content" cfg.contentDir
|
"--content" cfg.contentDir
|
||||||
"--output" cfg.outputDir
|
"--output" cfg.outputDir
|
||||||
@@ -148,7 +156,14 @@ in {
|
|||||||
"--serif-family" cfg.serifFamily
|
"--serif-family" cfg.serifFamily
|
||||||
"--ml-family" cfg.mlFamily
|
"--ml-family" cfg.mlFamily
|
||||||
"--page-size" (toString cfg.pageSize)
|
"--page-size" (toString cfg.pageSize)
|
||||||
];
|
] ++ 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";
|
Restart = "on-failure";
|
||||||
RestartSec = "5s";
|
RestartSec = "5s";
|
||||||
@@ -178,10 +193,10 @@ in {
|
|||||||
tryFiles = "$uri $uri/ =404";
|
tryFiles = "$uri $uri/ =404";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Auth is handled by the app (see services.photogallery.htpasswdFile),
|
||||||
|
# so nginx just proxies the upload endpoint.
|
||||||
locations."/upload" = {
|
locations."/upload" = {
|
||||||
proxyPass = "http://127.0.0.1:${toString cfg.port}";
|
proxyPass = "http://127.0.0.1:${toString cfg.port}";
|
||||||
basicAuth = "gallery";
|
|
||||||
basicAuthFile = cfg.nginx.htpasswdFile;
|
|
||||||
extraConfig = "client_max_body_size 20M;";
|
extraConfig = "client_max_body_size 20M;";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
@@ -19,22 +20,37 @@ type Server struct {
|
|||||||
outputDir string
|
outputDir string
|
||||||
serve bool
|
serve bool
|
||||||
generator *Generator
|
generator *Generator
|
||||||
|
auth *Authenticator // nil disables auth (dev only)
|
||||||
|
authLimit *rate.Limiter
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(port, contentDir, outputDir string, serve bool, g *Generator) *Server {
|
func NewServer(port, contentDir, outputDir string, serve bool, g *Generator, auth *Authenticator) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
port: port,
|
port: port,
|
||||||
contentDir: contentDir,
|
contentDir: contentDir,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
serve: serve,
|
serve: serve,
|
||||||
generator: g,
|
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
return s.auth.basicAuth(s.authLimit, h)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) Listen() error {
|
func (s *Server) Listen() error {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /upload", s.handleUploadPage)
|
mux.HandleFunc("GET /upload", s.guard(s.handleUploadPage))
|
||||||
mux.HandleFunc("POST /upload", s.handleUpload)
|
mux.HandleFunc("POST /upload", s.guard(s.handleUpload))
|
||||||
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
fmt.Fprintln(w, "ok")
|
fmt.Fprintln(w, "ok")
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in new issue
Block a user