aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-02-13 08:45:49 +0100
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-02-13 08:45:49 +0100
commit17be2fcb106ddc9c36628e9f5dece28369affc30 (patch)
tree4f3cb1d2fade5467f6c79a9a1abe1da0852a61d0
Extract gallery app from perso/vps into standalone project
Extracted all gallery-related files from ~/perso/vps: - gallery.go, gallery_test.go (Go source + tests) - templates/ (index, login, gallery HTML) - static/style.css - go.mod/go.sum (renamed module to github.com/kagamino/kagapics) - Caddyfile.example (gallery-only Caddy config) - DESIGN.md (from galery.md)
-rw-r--r--.gitignore2
-rw-r--r--Caddyfile.example12
-rw-r--r--DESIGN.md277
-rw-r--r--gallery.go892
-rw-r--r--gallery_test.go558
-rw-r--r--go.mod15
-rw-r--r--go.sum14
-rw-r--r--static/style.css29
-rw-r--r--templates/gallery.html98
-rw-r--r--templates/index.html21
-rw-r--r--templates/login.html19
11 files changed, 1937 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..29b7379
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+gallery
+gallery-data/
diff --git a/Caddyfile.example b/Caddyfile.example
new file mode 100644
index 0000000..fa7fd56
--- /dev/null
+++ b/Caddyfile.example
@@ -0,0 +1,12 @@
+{
+ supervisor {
+ ./gallery serve --addr :8020 --data ./gallery-data {
+ dir /home/ubuntu
+ restart_policy always
+ }
+ }
+}
+
+pics.kagamino.dev {
+ reverse_proxy localhost:8020
+}
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..39f7db4
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,277 @@
+# Photo Gallery — Design Document
+
+## 0. Solution
+
+**Go app (Option B)** — a single static binary reverse-proxied by Caddy.
+
+- Caddy handles TLS and serves the app on a secret URL path (e.g., `https://photos.example.com/trip-crete-2026-a8f3b2c1/`).
+- The Go binary serves an HTML page with multi-file upload (drag-and-drop), a file listing, and a "download all as zip" button.
+- Photos land in a configurable directory (one per trip). When the trip is done, stop the binary, move the folder to cold storage.
+- No database, no runtime deps, no Docker. ~200-400 lines of Go, ~10 MB binary.
+
+**Why this over pure Caddy (Option A):** At this scale (10 friends, 200 photos, ~4 GB max), both work. But the Go app gives a noticeably better mobile upload experience (multi-file, progress feedback) and zip download for free — worth the cost of one extra process.
+
+---
+
+## 1. Context & Scope
+
+After a holiday with friends, everyone has photos scattered across their phones. The goal is a self-hosted, lightweight web application where:
+
+- Friends open a link in their browser, upload their photos.
+- Friends can browse and download photos others have uploaded.
+- Once everyone has contributed, all photos live on the server — a single consolidated archive.
+- The host (you) keeps everything on their own infrastructure. No third-party cloud involved.
+
+**Current landscape:** Caddy is already used as the primary web server/reverse proxy. The solution should integrate naturally with it. The server is personal infrastructure — simplicity and low resource usage matter more than scaling to thousands of users.
+
+---
+
+## 2. Goals & Non-Goals
+
+### Goals
+
+- **Browser-only UX** — no app install, no client software, no account creation.
+- **Upload & download** — friends can push photos and pull the full set.
+- **Lightweight** — minimal dependencies, low memory/CPU footprint, easy to deploy and tear down.
+- **Self-hosted** — all data stays on your server, full ownership.
+- **Simple ops** — ideally a single binary or a Caddyfile tweak, no database.
+- **Caddy-friendly** — works behind or with Caddy with minimal config.
+
+### Non-Goals
+
+- User accounts or authentication beyond a shared secret/link.
+- Image editing, resizing, or transcoding.
+- Long-lived public gallery (this is ephemeral, per-trip).
+- Mobile app.
+- Handling thousands of concurrent users.
+- Video support (nice-to-have at best, not a driver).
+
+---
+
+## 3. Design
+
+The system is intentionally shallow. One server, one directory of files, one web interface.
+
+```mermaid
+graph LR
+ subgraph Friends
+ A[Browser]
+ B[Browser]
+ C[Browser]
+ end
+
+ subgraph Server
+ D[Caddy]
+ E[Application / File Backend]
+ F[/photos directory/]
+ end
+
+ A -- "upload/download via HTTPS" --> D
+ B -- "upload/download via HTTPS" --> D
+ C -- "upload/download via HTTPS" --> D
+ D -- "reverse proxy or direct serve" --> E
+ E -- "read/write" --> F
+```
+
+### Features
+
+#### Friends (end users)
+
+| Feature | Detail |
+|---|---|
+| **Batch upload** | Select multiple photos at once (file picker or drag-and-drop) and upload them in a single action. Progress feedback per file. |
+| **Browse gallery** | View all uploaded photos directly in the browser — no download required. Images are served as compressed/resized previews to keep page load fast. |
+| **Download as zip** | Download the entire directory as a single `.zip` file with one click. |
+| **Password access** | Enter a directory-specific password to access the gallery. No account creation. |
+
+#### Operator (you)
+
+| Feature | Detail |
+|---|---|
+| **Create directory + password** | Create a new photo directory (one per trip/event) and assign it a password. This is the only admin action needed before sharing the link with friends. |
+| **Tear down** | When collection is done, stop the service or remove the route. Archive the photos directory to cold storage. |
+
+### Key design points
+
+- **One directory per event** — each trip/event gets its own directory and its own password. No shared state between directories.
+- **Caddy terminates TLS** — every approach sits behind Caddy for HTTPS + domain.
+- **Access control** — password per directory, set by the operator at creation time. Friends enter the password once to access upload/browse/download.
+- **Ephemeral lifecycle** — spin up before sharing the link, tear down after collection is done. The photos directory persists.
+- **Compressed previews** — the gallery view serves resized/compressed versions of photos for fast browsing. Original full-resolution files are preserved on disk and served in the zip download.
+
+---
+
+## 4. Alternatives
+
+### A. Caddy `file_server` + `upload` module (pure Caddy)
+
+Use Caddy's built-in `file_server` for browsing/downloading, combined with a community Caddy upload handler plugin ([caddyserver/caddy-upload](https://github.com/caddyserver/caddy-upload) or similar).
+
+| Aspect | Detail |
+|---|---|
+| **How it works** | Caddy serves the directory with `browse`. Uploads go through a Caddy handler that accepts `PUT`/`POST` and writes to disk. A minimal HTML page with a `<form>` or JS fetch provides the upload UI. |
+| **Dependencies** | Caddy + one plugin (or a custom build via `xcaddy`). |
+| **Upload UX** | Requires a small static HTML page with a drag-and-drop or file picker form. No server-side code beyond Caddy. |
+| **Download UX** | Caddy's built-in browse UI lists files; click to download individually. Bulk download would need an external script or zip-on-the-fly. |
+
+**Pros:**
+- Zero extra processes — it's just Caddy.
+- Already in your stack; nothing new to learn operationally.
+- TLS, basic auth, rate-limiting all native.
+
+**Cons:**
+- Upload plugin maturity varies; may need `xcaddy build` to include it.
+- Browse UI is functional but bare — no thumbnails, no gallery feel.
+- No bulk zip download out of the box.
+- Multi-file upload from a phone browser can be clunky without a custom HTML page.
+
+---
+
+### B. Minimal Go application (custom binary)
+
+A small, purpose-built Go HTTP server: one binary, no dependencies, serves an HTML page with upload form + file listing.
+
+| Aspect | Detail |
+|---|---|
+| **How it works** | Go binary listens on a local port. Caddy reverse-proxies to it. Handles `POST /upload` (multipart form), serves `GET /` with a gallery page listing files, and `GET /photos/*` to serve files. |
+| **Dependencies** | Go binary (~5-10 MB). No runtime deps, no database. |
+| **Upload UX** | Full control — can build a drag-and-drop multi-file UI, show progress bars, rename duplicates. |
+| **Download UX** | Can serve individual files and generate a zip of all photos on-the-fly for bulk download. Thumbnails possible via Go's image stdlib. |
+
+**Pros:**
+- Total control over UX — can be exactly what you want.
+- Single static binary, trivially deployable. Copy & run.
+- Zip download, thumbnail generation, progress feedback — all easy in Go.
+- No plugin ecosystem dependency; you own the code.
+
+**Cons:**
+- You have to write and maintain it (even if small, ~200-400 lines).
+- Another process to manage alongside Caddy.
+- Feature creep risk — temptation to add more and more.
+
+---
+
+### C. WebDAV via Caddy + Caddy browse
+
+Caddy has a `webdav` plugin. WebDAV is HTTP-native file access — upload, download, directory listing — and modern browsers/OS file managers support it.
+
+| Aspect | Detail |
+|---|---|
+| **How it works** | Caddy serves a WebDAV endpoint for the photos directory. A static HTML page provides a browser-based upload form. File listing served via `file_server browse` on a separate route. |
+| **Dependencies** | Caddy + `webdav` plugin (`xcaddy build`). |
+| **Upload UX** | Friends can use the HTML form in browser, or mount the WebDAV share on their OS and drag files in. |
+| **Download UX** | Browse + click to download. OS-level mount allows bulk copy. |
+
+**Pros:**
+- Standard protocol — works with native OS file managers (macOS Finder, Windows Explorer, Linux Nautilus).
+- Upload and download in one protocol; no custom code.
+- Caddy ecosystem, single process.
+
+**Cons:**
+- WebDAV in browsers is inconsistent — you still need a static HTML upload form for reliable mobile UX.
+- Plugin needed (`xcaddy build`).
+- The "mount a network drive" step is too technical for most friends.
+- No gallery view, no thumbnails.
+
+---
+
+### D. FTP server with web frontend
+
+Run a lightweight FTP server (e.g., `vsftpd`, or a Go-based one like `ftpgrab/ftpserver`) and pair it with a web file manager frontend.
+
+| Aspect | Detail |
+|---|---|
+| **How it works** | FTP server exposes the photos directory. A web-based FTP client (e.g., Monsta FTP, or a minimal custom page) served by Caddy provides the browser UI. |
+| **Dependencies** | FTP daemon + web frontend + Caddy. |
+| **Upload UX** | Through the web frontend's upload widget. |
+| **Download UX** | Through the web frontend's file listing. |
+
+**Pros:**
+- FTP is battle-tested for file transfer.
+- Some web FTP clients provide decent UIs.
+
+**Cons:**
+- **Heaviest approach** — three components (FTP server, web UI, Caddy).
+- FTP is a legacy protocol with known security quirks (passive ports, plaintext by default). FTPS adds complexity.
+- Web FTP clients are often clunky, unmaintained, or PHP-based.
+- Overkill for the problem. Friends don't need FTP; they need a web page.
+- Firewall/NAT unfriendly compared to pure HTTP.
+
+---
+
+### E. Existing lightweight gallery software
+
+Deploy an existing open-source photo gallery (e.g., **PiGallery2**, **Photoview**, **Lychee**, **Photoprism**).
+
+| Aspect | Detail |
+|---|---|
+| **How it works** | Docker container or binary, reverse-proxied by Caddy. Provides web UI for upload, gallery view with thumbnails, download. |
+| **Dependencies** | Varies — some need a database (SQLite at minimum), Node.js, or Docker. |
+| **Upload UX** | Usually polished with drag-and-drop. |
+| **Download UX** | Gallery browsing with thumbnails, album zip downloads. |
+
+**Pros:**
+- Best UX out of the box — thumbnail grids, slideshows, EXIF data.
+- Actively maintained projects with communities.
+- Handles edge cases (duplicate detection, format support).
+
+**Cons:**
+- **Not lightweight** — most pull in a database, runtime, or Docker image.
+- Opinionated features you don't need (user management, albums, face detection).
+- Heavier operational footprint — updates, config, database migrations.
+- Misaligned: these are permanent gallery tools, not ephemeral upload-and-collect tools.
+
+---
+
+### Comparison Summary
+
+| Criterion | A. Pure Caddy | B. Go App | C. WebDAV | D. FTP + Web | E. Existing Gallery |
+|---|:---:|:---:|:---:|:---:|:---:|
+| Lightweight | ★★★★★ | ★★★★ | ★★★★ | ★★ | ★★ |
+| Browser UX | ★★ | ★★★★★ | ★★★ | ★★ | ★★★★★ |
+| Upload ease (mobile) | ★★★ | ★★★★★ | ★★★ | ★★ | ★★★★ |
+| Bulk download | ★★ | ★★★★★ | ★★★ | ★★★ | ★★★★ |
+| Ops simplicity | ★★★★★ | ★★★★ | ★★★★ | ★★ | ★★ |
+| Caddy integration | ★★★★★ | ★★★★ | ★★★★ | ★★★ | ★★★ |
+| Nothing to write | ★★★★ | ★★ | ★★★★ | ★★★★ | ★★★★★ |
+| Time to working MVP | ★★★★ | ★★★ | ★★★ | ★★ | ★★★★ |
+
+---
+
+## 5. Open Questions (Resolved)
+
+1. **Access control model** — Secret/unguessable URL path or basic auth. No accounts.
+2. **Photo volume** — ~10 people, ~200 photos total. Worst case ~4 GB (200 × 20 MB). Zip-on-the-fly is feasible at this scale.
+3. **Retention** — Ephemeral. Spin up for the collection window, then archive photos to personal storage and tear down.
+4. **Duplicate handling** — None. Keep everything as-is.
+5. **Upload size limits** — 20 MB per file as a safety cap.
+6. **Multi-trip reuse** — One directory per trip. Either clean the folder between trips or configure a new folder path. The Go binary takes the photo directory as a flag (e.g., `--dir ./crete-2026`).
+
+---
+
+## 6. Parties Involved
+
+| Role | Who |
+|---|---|
+| **Author** | You (server owner/operator) |
+| **Users** | Friends (non-technical, browser-only) |
+| **Operator** | You — deployment, maintenance, teardown |
+
+---
+
+## 7. Cross-Concerns
+
+### Security
+- **TLS mandatory** — Caddy handles this automatically with HTTPS.
+- **Access control** — at minimum, an unguessable URL path (e.g., `/holiday-crete-2026-a8f3b2c1/`). Optionally, Caddy `basicauth` for a shared password.
+- **Upload validation** — accept only image MIME types; reject executables. Enforce a max file size.
+- **Path traversal** — the application or Caddy handler must sanitize filenames to prevent `../../etc/passwd` style writes.
+
+### Privacy
+- Photos are personal. No analytics, no third-party scripts, no CDN.
+- Server-side: photos stored as flat files with no metadata indexing unless explicitly built.
+- After collection, the upload endpoint should be disabled (remove the route or stop the service).
+
+### Observability
+- Caddy access logs are sufficient. No need for metrics, APM, or dashboards for this scale.
+- Optional: log upload events (filename, size, timestamp) to a simple text file for your own tracking.
diff --git a/gallery.go b/gallery.go
new file mode 100644
index 0000000..0a726bc
--- /dev/null
+++ b/gallery.go
@@ -0,0 +1,892 @@
+package main
+
+import (
+ "archive/zip"
+ "crypto/rand"
+ "crypto/subtle"
+ "embed"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "image"
+ "image/jpeg"
+ _ "image/png"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+ "golang.org/x/image/draw"
+)
+
+// ---------------------------------------------------------------------------
+// Config & constants
+// ---------------------------------------------------------------------------
+
+const (
+ thumbMaxWidth = 1600
+ thumbMaxHeight = 1200
+ thumbQuality = 75
+ maxThumbConcurrency = 4
+ sessionTTL = 24 * time.Hour
+ dataFile = "galleries.json"
+ loginRateWindow = 1 * time.Minute
+ loginRateMaxAttempts = 5
+)
+
+// validGalleryName matches only safe gallery names: alphanumeric, hyphens, underscores.
+var validGalleryName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
+
+// ---------------------------------------------------------------------------
+// Domain types
+// ---------------------------------------------------------------------------
+
+type Gallery struct {
+ Name string `json:"name"`
+ PasswordHash string `json:"password_hash"`
+ Thumbs *bool `json:"thumbs,omitempty"` // nil or true = enabled, false = disabled
+}
+
+// thumbsEnabled returns whether thumbnail generation is active for this gallery.
+func (g *Gallery) thumbsEnabled() bool {
+ return g.Thumbs == nil || *g.Thumbs
+}
+
+type session struct {
+ gallery string
+ expires time.Time
+}
+
+// ---------------------------------------------------------------------------
+// Login rate limiter (per IP + gallery)
+// ---------------------------------------------------------------------------
+
+type loginAttempt struct {
+ timestamps []time.Time
+}
+
+type loginRateLimiter struct {
+ mu sync.Mutex
+ attempts map[string]*loginAttempt // key = "ip:gallery"
+}
+
+func newLoginRateLimiter() *loginRateLimiter {
+ return &loginRateLimiter{
+ attempts: make(map[string]*loginAttempt),
+ }
+}
+
+// allow checks whether a login attempt is permitted for the given key.
+// It prunes old timestamps outside the window and returns true if under the limit.
+// If allowed, the current attempt is recorded.
+func (rl *loginRateLimiter) allow(ip, gallery string) bool {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+
+ key := ip + ":" + gallery
+ now := time.Now()
+ cutoff := now.Add(-loginRateWindow)
+
+ a, ok := rl.attempts[key]
+ if !ok {
+ a = &loginAttempt{}
+ rl.attempts[key] = a
+ }
+
+ // Prune timestamps outside the window
+ valid := a.timestamps[:0]
+ for _, t := range a.timestamps {
+ if t.After(cutoff) {
+ valid = append(valid, t)
+ }
+ }
+ a.timestamps = valid
+
+ if len(a.timestamps) >= loginRateMaxAttempts {
+ return false
+ }
+
+ a.timestamps = append(a.timestamps, now)
+ return true
+}
+
+// startReaper periodically cleans up entries with no recent attempts.
+func (rl *loginRateLimiter) startReaper() {
+ go func() {
+ ticker := time.NewTicker(5 * time.Minute)
+ defer ticker.Stop()
+ for range ticker.C {
+ now := time.Now()
+ cutoff := now.Add(-loginRateWindow)
+ rl.mu.Lock()
+ for key, a := range rl.attempts {
+ // Remove all expired timestamps
+ valid := a.timestamps[:0]
+ for _, t := range a.timestamps {
+ if t.After(cutoff) {
+ valid = append(valid, t)
+ }
+ }
+ if len(valid) == 0 {
+ delete(rl.attempts, key)
+ } else {
+ a.timestamps = valid
+ }
+ }
+ rl.mu.Unlock()
+ }
+ }()
+}
+
+// ---------------------------------------------------------------------------
+// Server — central struct holding all dependencies
+// ---------------------------------------------------------------------------
+
+// Server holds all application state and dependencies, eliminating global
+// mutable state. Each instance is self-contained, making tests fully isolated.
+type Server struct {
+ mu sync.RWMutex
+ galleries map[string]*Gallery // name -> gallery
+ dataDir string
+ sessions map[string]session
+ maxUploadSize int64
+ rateLimiter *loginRateLimiter
+
+ // thumbSemaphore limits concurrent thumbnail generation to avoid
+ // overwhelming the server (CPU/memory).
+ thumbSemaphore chan struct{}
+
+ // peakThumbConcurrency tracks the highest number of concurrent thumbnail
+ // generations observed — used by tests to verify the semaphore works.
+ peakThumbConcurrency atomic.Int32
+ currentThumbConcurrency atomic.Int32
+}
+
+// NewServer creates a fully initialised Server ready to use.
+func NewServer(dataDir string, maxUploadSize int64) *Server {
+ return &Server{
+ galleries: make(map[string]*Gallery),
+ sessions: make(map[string]session),
+ dataDir: dataDir,
+ maxUploadSize: maxUploadSize,
+ rateLimiter: newLoginRateLimiter(),
+ thumbSemaphore: make(chan struct{}, maxThumbConcurrency),
+ }
+}
+
+// Handler returns an http.Handler with all routes registered.
+func (s *Server) Handler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", s.handleIndex)
+ mux.HandleFunc("/g/", s.handleGallery)
+ return mux
+}
+
+// ---------------------------------------------------------------------------
+// Persistence (simple JSON file)
+// ---------------------------------------------------------------------------
+
+func (s *Server) configPath() string {
+ return filepath.Join(s.dataDir, dataFile)
+}
+
+func (s *Server) save() error {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ list := make([]*Gallery, 0, len(s.galleries))
+ for _, g := range s.galleries {
+ list = append(list, g)
+ }
+ data, err := json.MarshalIndent(list, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(s.configPath(), data, 0600)
+}
+
+func (s *Server) load() error {
+ data, err := os.ReadFile(s.configPath())
+ if os.IsNotExist(err) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ var list []*Gallery
+ if err := json.Unmarshal(data, &list); err != nil {
+ return err
+ }
+ for _, g := range list {
+ s.galleries[g.Name] = g
+ }
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// Thumbnails
+// ---------------------------------------------------------------------------
+
+func thumbDir(galleryDir string) string {
+ return filepath.Join(galleryDir, ".thumbs")
+}
+
+func generateThumbGo(srcPath, dstPath string) error {
+ f, err := os.Open(srcPath)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ src, _, err := image.Decode(f)
+ if err != nil {
+ return err
+ }
+
+ bounds := src.Bounds()
+ w, h := bounds.Dx(), bounds.Dy()
+
+ // Scale down to fit within thumbMaxWidth x thumbMaxHeight, keep aspect ratio
+ ratio := 1.0
+ if w > thumbMaxWidth {
+ ratio = float64(thumbMaxWidth) / float64(w)
+ }
+ if float64(h)*ratio > float64(thumbMaxHeight) {
+ ratio = float64(thumbMaxHeight) / float64(h)
+ }
+ if ratio >= 1.0 {
+ // Already small enough — just re-encode at lower quality
+ ratio = 1.0
+ }
+
+ newW := int(float64(w) * ratio)
+ newH := int(float64(h) * ratio)
+
+ dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
+ draw.BiLinear.Scale(dst, dst.Bounds(), src, bounds, draw.Over, nil)
+
+ out, err := os.Create(dstPath)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+
+ return jpeg.Encode(out, dst, &jpeg.Options{Quality: thumbQuality})
+}
+
+// findMagickBinary returns the ImageMagick convert command name.
+// ImageMagick v7 uses "magick" while v6 uses "convert".
+func findMagickBinary() (string, error) {
+ if path, err := exec.LookPath("magick"); err == nil {
+ return path, nil
+ }
+ if path, err := exec.LookPath("convert"); err == nil {
+ return path, nil
+ }
+ return "", fmt.Errorf("imagemagick not found: neither 'magick' nor 'convert' in PATH")
+}
+
+// generateThumbImageMagick shells out to ImageMagick as a fallback for
+// images that Go's stdlib cannot decode (e.g. certain progressive JPEGs,
+// CMYK color spaces, or unusual encoding modes).
+func generateThumbImageMagick(srcPath, dstPath string) error {
+ bin, err := findMagickBinary()
+ if err != nil {
+ return err
+ }
+ geometry := fmt.Sprintf("%dx%d>", thumbMaxWidth, thumbMaxHeight)
+ cmd := exec.Command(bin, srcPath,
+ "-thumbnail", geometry,
+ "-quality", fmt.Sprintf("%d", thumbQuality),
+ "-sampling-factor", "4:2:0",
+ "-auto-orient",
+ dstPath,
+ )
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("imagemagick: %w: %s", err, output)
+ }
+ return nil
+}
+
+func generateThumb(srcPath, dstPath string) error {
+ err := generateThumbGo(srcPath, dstPath)
+ if err == nil {
+ return nil
+ }
+ log.Printf("Go thumbnail failed for %s: %v — trying ImageMagick", srcPath, err)
+
+ if err2 := generateThumbImageMagick(srcPath, dstPath); err2 != nil {
+ return fmt.Errorf("all thumbnail methods failed: go=%v, imagemagick=%v", err, err2)
+ }
+ return nil
+}
+
+func (s *Server) ensureThumb(galleryDir, filename string) (string, error) {
+ td := thumbDir(galleryDir)
+ os.MkdirAll(td, 0755)
+
+ thumbName := strings.TrimSuffix(filename, filepath.Ext(filename)) + ".jpg"
+ thumbPath := filepath.Join(td, thumbName)
+
+ if _, err := os.Stat(thumbPath); err == nil {
+ return thumbPath, nil // already exists
+ }
+
+ // Acquire semaphore to limit concurrent thumbnail generation.
+ s.thumbSemaphore <- struct{}{}
+ cur := s.currentThumbConcurrency.Add(1)
+ // Track peak for observability / testing.
+ for {
+ peak := s.peakThumbConcurrency.Load()
+ if cur <= peak || s.peakThumbConcurrency.CompareAndSwap(peak, cur) {
+ break
+ }
+ }
+ defer func() {
+ s.currentThumbConcurrency.Add(-1)
+ <-s.thumbSemaphore
+ }()
+
+ // Double-check after acquiring the semaphore — another goroutine may have
+ // generated the same thumbnail while we were waiting.
+ if _, err := os.Stat(thumbPath); err == nil {
+ return thumbPath, nil
+ }
+
+ srcPath := filepath.Join(galleryDir, filename)
+ if err := generateThumb(srcPath, thumbPath); err != nil {
+ return "", err
+ }
+ return thumbPath, nil
+}
+
+// ---------------------------------------------------------------------------
+// Sessions
+// ---------------------------------------------------------------------------
+
+func newSessionToken() string {
+ b := make([]byte, 32)
+ rand.Read(b)
+ return hex.EncodeToString(b)
+}
+
+func (s *Server) createSession(gallery string) string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ token := newSessionToken()
+ s.sessions[token] = session{gallery: gallery, expires: time.Now().Add(sessionTTL)}
+ return token
+}
+
+func (s *Server) checkSession(r *http.Request, gallery string) bool {
+ cookie, err := r.Cookie("session_" + gallery)
+ if err != nil {
+ return false
+ }
+ s.mu.RLock()
+ sess, ok := s.sessions[cookie.Value]
+ s.mu.RUnlock()
+ if !ok {
+ return false
+ }
+ if sess.gallery != gallery || time.Now().After(sess.expires) {
+ // Lazily remove the expired/mismatched session
+ s.mu.Lock()
+ delete(s.sessions, cookie.Value)
+ s.mu.Unlock()
+ return false
+ }
+ return true
+}
+
+// startSessionReaper launches a background goroutine that purges expired
+// sessions every 5 minutes, preventing the sessions map from growing
+// unboundedly.
+func (s *Server) startSessionReaper() {
+ go func() {
+ ticker := time.NewTicker(5 * time.Minute)
+ defer ticker.Stop()
+ for range ticker.C {
+ now := time.Now()
+ s.mu.Lock()
+ for token, sess := range s.sessions {
+ if now.After(sess.expires) {
+ delete(s.sessions, token)
+ }
+ }
+ s.mu.Unlock()
+ }
+ }()
+}
+
+// ---------------------------------------------------------------------------
+// Handlers
+// ---------------------------------------------------------------------------
+
+func (s *Server) galleryDir(name string) string {
+ return filepath.Join(s.dataDir, name)
+}
+
+func isAllowedExt(name string) bool {
+ ext := strings.ToLower(filepath.Ext(name))
+ switch ext {
+ case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif":
+ return true
+ }
+ return false
+}
+
+func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+
+ s.mu.RLock()
+ names := make([]string, 0, len(s.galleries))
+ for name := range s.galleries {
+ names = append(names, name)
+ }
+ s.mu.RUnlock()
+ sort.Strings(names)
+
+ indexTmpl.Execute(w, map[string]interface{}{
+ "Names": names,
+ "CSS": css,
+ })
+}
+
+// clientIP extracts the IP address from the request, preferring
+// X-Forwarded-For / X-Real-Ip when behind a reverse proxy.
+func clientIP(r *http.Request) string {
+ if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
+ // Take the first (leftmost) IP — the original client
+ if i := strings.IndexByte(xff, ','); i > 0 {
+ return strings.TrimSpace(xff[:i])
+ }
+ return strings.TrimSpace(xff)
+ }
+ if xri := r.Header.Get("X-Real-Ip"); xri != "" {
+ return strings.TrimSpace(xri)
+ }
+ // Strip port from RemoteAddr (e.g. "192.168.1.1:54321")
+ ip := r.RemoteAddr
+ if i := strings.LastIndex(ip, ":"); i > 0 {
+ ip = ip[:i]
+ }
+ return ip
+}
+
+func (s *Server) handleGallery(w http.ResponseWriter, r *http.Request) {
+ parts := strings.SplitN(strings.Trim(r.URL.Path, "/"), "/", 3)
+ if len(parts) < 2 {
+ http.NotFound(w, r)
+ return
+ }
+ name := parts[1]
+
+ if !validGalleryName.MatchString(name) {
+ http.NotFound(w, r)
+ return
+ }
+
+ s.mu.RLock()
+ g, ok := s.galleries[name]
+ s.mu.RUnlock()
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+
+ // Check auth
+ if !s.checkSession(r, name) {
+ if r.Method == http.MethodPost && len(parts) == 2 {
+ // Login attempt — enforce rate limiting
+ ip := clientIP(r)
+ if !s.rateLimiter.allow(ip, name) {
+ w.WriteHeader(http.StatusTooManyRequests)
+ loginTmpl.Execute(w, map[string]interface{}{
+ "Name": name,
+ "Error": "Too many login attempts. Please wait a minute and try again.",
+ "CSS": css,
+ })
+ return
+ }
+
+ password := r.FormValue("password")
+ if err := bcrypt.CompareHashAndPassword([]byte(g.PasswordHash), []byte(password)); err != nil {
+ loginTmpl.Execute(w, map[string]interface{}{"Name": name, "Error": "Wrong password", "CSS": css})
+ return
+ }
+ token := s.createSession(name)
+ http.SetCookie(w, &http.Cookie{
+ Name: "session_" + name,
+ Value: token,
+ Path: "/g/" + name,
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteStrictMode,
+ MaxAge: int(sessionTTL.Seconds()),
+ })
+ http.Redirect(w, r, "/g/"+name, http.StatusSeeOther)
+ return
+ }
+ loginTmpl.Execute(w, map[string]interface{}{"Name": name, "Error": "", "CSS": css})
+ return
+ }
+
+ // Route sub-paths
+ action := ""
+ if len(parts) == 3 {
+ action = parts[2]
+ }
+
+ switch action {
+ case "":
+ s.handleGalleryView(w, r, name)
+ case "upload":
+ s.handleUpload(w, r, name)
+ case "zip":
+ s.handleZip(w, r, name)
+ default:
+ if strings.HasPrefix(action, "thumb/") {
+ s.handleThumb(w, r, name, strings.TrimPrefix(action, "thumb/"))
+ } else if strings.HasPrefix(action, "photo/") {
+ s.handlePhoto(w, r, name, strings.TrimPrefix(action, "photo/"))
+ } else {
+ http.NotFound(w, r)
+ }
+ }
+}
+
+func (s *Server) handleGalleryView(w http.ResponseWriter, r *http.Request, name string) {
+ dir := s.galleryDir(name)
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ http.Error(w, "cannot read gallery", 500)
+ return
+ }
+
+ var photos []string
+ for _, e := range entries {
+ if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
+ continue
+ }
+ if isAllowedExt(e.Name()) {
+ photos = append(photos, e.Name())
+ }
+ }
+ sort.Strings(photos)
+
+ s.mu.RLock()
+ g := s.galleries[name]
+ s.mu.RUnlock()
+
+ thumbPrefix := "thumb"
+ if !g.thumbsEnabled() {
+ thumbPrefix = "photo"
+ }
+
+ galleryTmpl.Execute(w, map[string]interface{}{
+ "Name": name,
+ "Photos": photos,
+ "ThumbPrefix": thumbPrefix,
+ "CSS": css,
+ })
+}
+
+func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request, name string) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "POST only", 405)
+ return
+ }
+
+ r.Body = http.MaxBytesReader(w, r.Body, s.maxUploadSize*100) // 100 files max per request
+ if err := r.ParseMultipartForm(s.maxUploadSize * 2); err != nil {
+ http.Error(w, "files too large", 413)
+ return
+ }
+
+ dir := s.galleryDir(name)
+ files := r.MultipartForm.File["photos"]
+ uploaded := 0
+
+ for _, fh := range files {
+ if fh.Size > s.maxUploadSize {
+ continue
+ }
+ if !isAllowedExt(fh.Filename) {
+ continue
+ }
+
+ // Sanitize filename
+ safeName := filepath.Base(fh.Filename)
+ safeName = strings.ReplaceAll(safeName, "..", "")
+ if safeName == "" || safeName == "." {
+ continue
+ }
+
+ // Deduplicate: if file exists, prefix with timestamp
+ dstPath := filepath.Join(dir, safeName)
+ if _, err := os.Stat(dstPath); err == nil {
+ ext := filepath.Ext(safeName)
+ base := strings.TrimSuffix(safeName, ext)
+ safeName = fmt.Sprintf("%s_%d%s", base, time.Now().UnixNano(), ext)
+ dstPath = filepath.Join(dir, safeName)
+ }
+
+ src, err := fh.Open()
+ if err != nil {
+ continue
+ }
+
+ if _, err := s.copyUploadFile(dstPath, src); err != nil {
+ src.Close()
+ continue
+ }
+ src.Close()
+ uploaded++
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]int{"uploaded": uploaded})
+}
+
+// copyUploadFile copies from src to a new file at dstPath, reading at most
+// maxUploadSize bytes as defense-in-depth against a lying Content-Length.
+func (s *Server) copyUploadFile(dstPath string, src io.Reader) (int64, error) {
+ dst, err := os.Create(dstPath)
+ if err != nil {
+ return 0, err
+ }
+ defer dst.Close()
+ return io.Copy(dst, io.LimitReader(src, s.maxUploadSize))
+}
+
+func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request, name, filename string) {
+ filename = filepath.Base(filename) // sanitize
+ dir := s.galleryDir(name)
+
+ thumbPath, err := s.ensureThumb(dir, filename)
+ if err != nil {
+ log.Printf("Thumbnail generation failed for %s/%s: %v", name, filename, err)
+ http.Error(w, "thumbnail unavailable", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Cache-Control", "public, max-age=86400")
+ http.ServeFile(w, r, thumbPath)
+}
+
+func (s *Server) handlePhoto(w http.ResponseWriter, r *http.Request, name, filename string) {
+ filename = filepath.Base(filename) // sanitize
+ dir := s.galleryDir(name)
+ path := filepath.Join(dir, filename)
+
+ if _, err := os.Stat(path); os.IsNotExist(err) {
+ http.NotFound(w, r)
+ return
+ }
+ http.ServeFile(w, r, path)
+}
+
+func (s *Server) handleZip(w http.ResponseWriter, r *http.Request, name string) {
+ dir := s.galleryDir(name)
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ http.Error(w, "cannot read gallery", 500)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/zip")
+ w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, name))
+
+ zw := zip.NewWriter(w)
+ defer zw.Close()
+
+ for _, e := range entries {
+ if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
+ continue
+ }
+ if !isAllowedExt(e.Name()) {
+ continue
+ }
+
+ fw, err := zw.Create(e.Name())
+ if err != nil {
+ continue
+ }
+ f, err := os.Open(filepath.Join(dir, e.Name()))
+ if err != nil {
+ continue
+ }
+ io.Copy(fw, f)
+ f.Close()
+ }
+}
+
+// ---------------------------------------------------------------------------
+// CLI: create gallery
+// ---------------------------------------------------------------------------
+
+func (s *Server) createGallery(name, password string) error {
+ return s.createGalleryOpts(name, password, true)
+}
+
+func (s *Server) createGalleryOpts(name, password string, thumbs bool) error {
+ if !validGalleryName.MatchString(name) {
+ return fmt.Errorf("invalid gallery name %q: must match [a-zA-Z0-9_-]+", name)
+ }
+
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return err
+ }
+
+ g := &Gallery{
+ Name: name,
+ PasswordHash: string(hash),
+ }
+ if !thumbs {
+ f := false
+ g.Thumbs = &f
+ }
+
+ s.mu.Lock()
+ s.galleries[name] = g
+ s.mu.Unlock()
+
+ dir := s.galleryDir(name)
+ os.MkdirAll(dir, 0755)
+
+ thumbsLabel := "enabled"
+ if !thumbs {
+ thumbsLabel = "disabled"
+ }
+ log.Printf("Gallery %q created (thumbs: %s)", name, thumbsLabel)
+
+ return s.save()
+}
+
+// ---------------------------------------------------------------------------
+// Embedded templates & static assets
+// ---------------------------------------------------------------------------
+
+//go:embed templates/*.html
+var templateFS embed.FS
+
+//go:embed static/style.css
+var cssBytes []byte
+
+var css = template.CSS(cssBytes)
+
+var indexTmpl = template.Must(template.ParseFS(templateFS, "templates/index.html"))
+var loginTmpl = template.Must(template.ParseFS(templateFS, "templates/login.html"))
+var galleryTmpl = template.Must(template.ParseFS(templateFS, "templates/gallery.html"))
+
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
+
+func printUsage() {
+ fmt.Fprintf(os.Stderr, `Usage:
+ gallery serve [--addr :8080] [--data ./data]
+ gallery create <name> <password> [--data ./data] [--no-thumbs]
+
+Options:
+ --no-thumbs Disable thumbnail generation; serve source images in the grid.
+
+Examples:
+ gallery create crete-2026 "MySecret123" --data ./gallery-data
+ gallery create big-photos "pass" --data ./gallery-data --no-thumbs
+ gallery serve --addr :8080 --data ./gallery-data
+`)
+}
+
+func parseFlags(args []string, defaults map[string]string) map[string]string {
+ result := make(map[string]string)
+ for k, v := range defaults {
+ result[k] = v
+ }
+ for i := 0; i < len(args); i++ {
+ if strings.HasPrefix(args[i], "--") {
+ key := strings.TrimPrefix(args[i], "--")
+ // Boolean flags (--no-thumbs) have no value argument.
+ if i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") {
+ result[key] = args[i+1]
+ i++
+ } else {
+ result[key] = "true"
+ }
+ }
+ }
+ return result
+}
+
+func main() {
+ if len(os.Args) < 2 {
+ printUsage()
+ os.Exit(1)
+ }
+
+ cmd := os.Args[1]
+
+ switch cmd {
+ case "create":
+ if len(os.Args) < 4 {
+ fmt.Fprintln(os.Stderr, "Usage: gallery create <name> <password> [--data ./data] [--no-thumbs]")
+ os.Exit(1)
+ }
+ name := os.Args[2]
+ password := os.Args[3]
+ flags := parseFlags(os.Args[4:], map[string]string{"data": "./data"})
+
+ srv := NewServer(flags["data"], 20<<20)
+ os.MkdirAll(srv.dataDir, 0755)
+ if err := srv.load(); err != nil {
+ log.Fatalf("Failed to load state: %v", err)
+ }
+
+ thumbs := flags["no-thumbs"] != "true"
+ if err := srv.createGalleryOpts(name, password, thumbs); err != nil {
+ log.Fatalf("Failed to create gallery: %v", err)
+ }
+ fmt.Printf("Gallery %q created in %s\n", name, filepath.Join(flags["data"], name))
+
+ case "serve":
+ flags := parseFlags(os.Args[2:], map[string]string{
+ "addr": ":8080",
+ "data": "./data",
+ })
+
+ srv := NewServer(flags["data"], 20<<20)
+ os.MkdirAll(srv.dataDir, 0755)
+ if err := srv.load(); err != nil {
+ log.Fatalf("Failed to load state: %v", err)
+ }
+
+ srv.startSessionReaper()
+ srv.rateLimiter.startReaper()
+
+ log.Printf("Listening on %s (data: %s, galleries: %d)", flags["addr"], flags["data"], len(srv.galleries))
+ if err := http.ListenAndServe(flags["addr"], srv.Handler()); err != nil {
+ log.Fatal(err)
+ }
+
+ default:
+ printUsage()
+ os.Exit(1)
+ }
+}
+
+// Ensure subtle is used (for constant-time comparison if needed later)
+var _ = subtle.ConstantTimeCompare
diff --git a/gallery_test.go b/gallery_test.go
new file mode 100644
index 0000000..125a19a
--- /dev/null
+++ b/gallery_test.go
@@ -0,0 +1,558 @@
+package main
+
+import (
+ "archive/zip"
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "image"
+ "image/color"
+ "image/jpeg"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+func setupTestServer(t *testing.T) (*httptest.Server, *Server) {
+ t.Helper()
+ dir := t.TempDir()
+ srv := NewServer(dir, 20<<20)
+
+ require.NoError(t, srv.createGallery("test-gallery", "secret123"))
+
+ ts := httptest.NewServer(srv.Handler())
+ t.Cleanup(ts.Close)
+ return ts, srv
+}
+
+// noRedirectClient returns an *http.Client that does NOT follow redirects,
+// so we can inspect 303 responses and Set-Cookie headers.
+func noRedirectClient() *http.Client {
+ return &http.Client{
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+}
+
+// login performs a POST login and returns the session cookies on success.
+func login(t *testing.T, ts *httptest.Server, gallery, password string) []*http.Cookie {
+ t.Helper()
+ client := noRedirectClient()
+
+ resp, err := client.PostForm(ts.URL+"/g/"+gallery, url.Values{"password": {password}})
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ require.Equal(t, http.StatusSeeOther, resp.StatusCode, "login should redirect on success")
+ return resp.Cookies()
+}
+
+// authedGet performs a GET with the given cookies attached.
+func authedGet(t *testing.T, rawURL string, cookies []*http.Cookie) *http.Response {
+ t.Helper()
+ req, err := http.NewRequest(http.MethodGet, rawURL, nil)
+ require.NoError(t, err)
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ return resp
+}
+
+// makeTestJPEG creates a small valid JPEG in memory.
+func makeTestJPEG() []byte {
+ img := image.NewRGBA(image.Rect(0, 0, 10, 10))
+ for y := 0; y < 10; y++ {
+ for x := 0; x < 10; x++ {
+ img.Set(x, y, color.RGBA{R: 255, A: 255})
+ }
+ }
+ var buf bytes.Buffer
+ jpeg.Encode(&buf, img, nil)
+ return buf.Bytes()
+}
+
+// uploadPhoto uploads a single JPEG to the gallery via multipart POST.
+func uploadPhoto(t *testing.T, ts *httptest.Server, gallery string, cookies []*http.Cookie, filename string, data []byte) *http.Response {
+ t.Helper()
+
+ var body bytes.Buffer
+ mw := multipart.NewWriter(&body)
+ part, err := mw.CreateFormFile("photos", filename)
+ require.NoError(t, err)
+ _, err = part.Write(data)
+ require.NoError(t, err)
+ require.NoError(t, mw.Close())
+
+ req, err := http.NewRequest(http.MethodPost, ts.URL+"/g/"+gallery+"/upload", &body)
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ return resp
+}
+
+// bodyString reads and returns the full response body as a string.
+func bodyString(t *testing.T, resp *http.Response) string {
+ t.Helper()
+ defer resp.Body.Close()
+ b, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ return string(b)
+}
+
+// setupWithPhoto creates a server, logs in, uploads one photo, and returns
+// everything needed for subsequent assertions.
+func setupWithPhoto(t *testing.T, filename string) (*httptest.Server, *Server, []*http.Cookie) {
+ t.Helper()
+ ts, srv := setupTestServer(t)
+ cookies := login(t, ts, "test-gallery", "secret123")
+
+ resp := uploadPhoto(t, ts, "test-gallery", cookies, filename, makeTestJPEG())
+ defer resp.Body.Close()
+
+ var result map[string]int
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
+ require.Equal(t, 1, result["uploaded"])
+
+ return ts, srv, cookies
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+func TestIndexListsGalleries(t *testing.T) {
+ ts, _ := setupTestServer(t)
+
+ resp, err := http.Get(ts.URL + "/")
+ require.NoError(t, err)
+ body := bodyString(t, resp)
+
+ assert.Equal(t, 200, resp.StatusCode)
+ assert.Contains(t, body, "test-gallery")
+}
+
+func TestLoginFlow(t *testing.T) {
+ ts, _ := setupTestServer(t)
+ client := noRedirectClient()
+
+ resp, err := client.PostForm(ts.URL+"/g/test-gallery", url.Values{"password": {"secret123"}})
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
+
+ var found bool
+ for _, c := range resp.Cookies() {
+ if c.Name == "session_test-gallery" && c.Value != "" {
+ found = true
+ }
+ }
+ assert.True(t, found, "session cookie should be set after login")
+}
+
+func TestSessionCookieHasSecureFlag(t *testing.T) {
+ ts, _ := setupTestServer(t)
+ client := noRedirectClient()
+
+ resp, err := client.PostForm(ts.URL+"/g/test-gallery", url.Values{"password": {"secret123"}})
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ require.Equal(t, http.StatusSeeOther, resp.StatusCode)
+
+ var sessionCookie *http.Cookie
+ for _, c := range resp.Cookies() {
+ if c.Name == "session_test-gallery" {
+ sessionCookie = c
+ break
+ }
+ }
+ require.NotNil(t, sessionCookie, "session cookie must be present")
+ assert.True(t, sessionCookie.Secure, "session cookie must have the Secure flag set")
+}
+
+func TestLoginWrongPassword(t *testing.T) {
+ ts, _ := setupTestServer(t)
+
+ resp, err := http.PostForm(ts.URL+"/g/test-gallery", url.Values{"password": {"wrong"}})
+ require.NoError(t, err)
+ body := bodyString(t, resp)
+
+ assert.Equal(t, 200, resp.StatusCode)
+ assert.Contains(t, body, "Wrong password")
+}
+
+func TestGalleryViewEmpty(t *testing.T) {
+ ts, _ := setupTestServer(t)
+ cookies := login(t, ts, "test-gallery", "secret123")
+
+ resp := authedGet(t, ts.URL+"/g/test-gallery", cookies)
+ body := bodyString(t, resp)
+
+ assert.Equal(t, 200, resp.StatusCode)
+ assert.Contains(t, body, "No photos yet")
+}
+
+func TestUploadAndView(t *testing.T) {
+ ts, _, cookies := setupWithPhoto(t, "holiday.jpg")
+
+ resp := authedGet(t, ts.URL+"/g/test-gallery", cookies)
+ body := bodyString(t, resp)
+
+ assert.Contains(t, body, "holiday.jpg")
+}
+
+func TestThumbServing(t *testing.T) {
+ ts, _, cookies := setupWithPhoto(t, "thumb-test.jpg")
+
+ resp := authedGet(t, ts.URL+"/g/test-gallery/thumb/thumb-test.jpg", cookies)
+ defer resp.Body.Close()
+
+ assert.Equal(t, 200, resp.StatusCode)
+ assert.Contains(t, resp.Header.Get("Content-Type"), "image/jpeg")
+
+ _, err := jpeg.Decode(resp.Body)
+ assert.NoError(t, err, "thumbnail should be a decodable JPEG")
+}
+
+func TestPhotoServing(t *testing.T) {
+ ts, _, cookies := setupWithPhoto(t, "original.jpg")
+
+ resp := authedGet(t, ts.URL+"/g/test-gallery/photo/original.jpg", cookies)
+ body, err := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ require.NoError(t, err)
+
+ assert.Equal(t, 200, resp.StatusCode)
+ assert.Equal(t, makeTestJPEG(), body, "served photo should match uploaded content")
+}
+
+func TestZipDownload(t *testing.T) {
+ ts, _, cookies := setupWithPhoto(t, "zip-pic.jpg")
+
+ resp := authedGet(t, ts.URL+"/g/test-gallery/zip", cookies)
+ zipBody, err := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ require.NoError(t, err)
+
+ assert.Equal(t, 200, resp.StatusCode)
+ assert.Equal(t, "application/zip", resp.Header.Get("Content-Type"))
+
+ zr, err := zip.NewReader(bytes.NewReader(zipBody), int64(len(zipBody)))
+ require.NoError(t, err)
+
+ names := make([]string, len(zr.File))
+ for i, f := range zr.File {
+ names[i] = f.Name
+ }
+ assert.Contains(t, names, "zip-pic.jpg")
+}
+
+func TestUnauthenticatedAccessBlocked(t *testing.T) {
+ ts, _, _ := setupWithPhoto(t, "secret.jpg")
+
+ paths := []string{
+ "/g/test-gallery",
+ "/g/test-gallery/photo/secret.jpg",
+ "/g/test-gallery/thumb/secret.jpg",
+ "/g/test-gallery/zip",
+ }
+
+ for _, p := range paths {
+ resp, err := http.Get(ts.URL + p)
+ require.NoError(t, err)
+ body := bodyString(t, resp)
+
+ assert.Contains(t, body, `name="password"`, "path %q should show login form", p)
+ assert.NotContains(t, body, "secret.jpg", "path %q should not leak photo filename", p)
+ }
+}
+
+func TestRateLimiting(t *testing.T) {
+ ts, _ := setupTestServer(t)
+
+ for i := 0; i < loginRateMaxAttempts; i++ {
+ resp, err := http.PostForm(ts.URL+"/g/test-gallery", url.Values{"password": {"wrong"}})
+ require.NoError(t, err)
+ resp.Body.Close()
+ assert.Equal(t, 200, resp.StatusCode, "attempt %d should be allowed", i+1)
+ }
+
+ // The next attempt should be rate-limited
+ resp, err := http.PostForm(ts.URL+"/g/test-gallery", url.Values{"password": {"wrong"}})
+ require.NoError(t, err)
+ body := bodyString(t, resp)
+
+ assert.Equal(t, http.StatusTooManyRequests, resp.StatusCode)
+ assert.Contains(t, body, "Too many login attempts")
+}
+
+func TestUploadSizeEnforcedAtReadTime(t *testing.T) {
+ ts, srv := setupTestServer(t)
+ srv.maxUploadSize = 256
+ cookies := login(t, ts, "test-gallery", "secret123")
+
+ // Upload a file of exactly maxUploadSize bytes (passes the fh.Size
+ // check: fh.Size <= maxUploadSize). The file must be saved to disk.
+ exactData := bytes.Repeat([]byte{0xFF}, int(srv.maxUploadSize))
+ resp := uploadPhoto(t, ts, "test-gallery", cookies, "exact.jpg", exactData)
+ var result map[string]int
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
+ resp.Body.Close()
+ require.Equal(t, 1, result["uploaded"], "file at exactly maxUploadSize should be accepted")
+
+ // Verify every file on disk is at most maxUploadSize bytes.
+ // This is the invariant that io.LimitReader enforces as defense-in-depth:
+ // even if the fh.Size check were somehow bypassed, the copy itself
+ // must never write more than maxUploadSize bytes.
+ dir := srv.galleryDir("test-gallery")
+ entries, err := os.ReadDir(dir)
+ require.NoError(t, err)
+
+ for _, e := range entries {
+ if e.IsDir() || !isAllowedExt(e.Name()) {
+ continue
+ }
+ info, err := e.Info()
+ require.NoError(t, err)
+ assert.LessOrEqual(t, info.Size(), srv.maxUploadSize,
+ "file %q on disk (%d bytes) must not exceed maxUploadSize (%d)",
+ e.Name(), info.Size(), srv.maxUploadSize)
+ }
+
+ // Now test copyUploadFile directly: given a reader with more data than
+ // maxUploadSize, the copy must be capped at maxUploadSize bytes.
+ oversized := bytes.NewReader(bytes.Repeat([]byte{0xAB}, int(srv.maxUploadSize)+500))
+ tmpPath := filepath.Join(t.TempDir(), "test-copy.dat")
+ n, err := srv.copyUploadFile(tmpPath, oversized)
+ require.NoError(t, err)
+ assert.LessOrEqual(t, n, srv.maxUploadSize,
+ "copyUploadFile should write at most maxUploadSize bytes, got %d", n)
+}
+
+// makeUnsupportedJPEG creates a JPEG that Go's stdlib cannot decode
+// (unsupported luma/chroma subsampling ratio with H=3) but ImageMagick can.
+// This simulates real-world JPEGs that use features outside Go's support.
+func makeUnsupportedJPEG() []byte {
+ img := image.NewRGBA(image.Rect(0, 0, 200, 200))
+ for y := 0; y < 200; y++ {
+ for x := 0; x < 200; x++ {
+ img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 128, A: 255})
+ }
+ }
+ var buf bytes.Buffer
+ jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90})
+ data := buf.Bytes()
+
+ // Find the SOF0 marker (FF C0) and change luma sampling factor to H=3, V=1.
+ // Go's image/jpeg rejects h=3 or v=3 as "unsupported subsampling ratio",
+ // but ImageMagick handles it gracefully.
+ for i := 0; i < len(data)-1; i++ {
+ if data[i] == 0xFF && data[i+1] == 0xC0 {
+ // SOF0 header: marker(2) + length(2) + precision(1) + height(2) + width(2) + nf(1) + component(3*nf)
+ compStart := i + 2 + 2 + 1 + 2 + 2 + 1
+ data[compStart+1] = 0x31 // H=3, V=1
+ break
+ }
+ }
+ return data
+}
+
+func TestThumbFallbackToImageMagick(t *testing.T) {
+ ts, srv := setupTestServer(t)
+ cookies := login(t, ts, "test-gallery", "secret123")
+
+ // Create a JPEG that Go's stdlib can't decode but ImageMagick can.
+ unsupportedJPEG := makeUnsupportedJPEG()
+
+ // Write directly to the gallery dir (bypass upload, test thumbnail generation).
+ dir := srv.galleryDir("test-gallery")
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "tricky.jpg"), unsupportedJPEG, 0644))
+
+ // Request the thumbnail — should use ImageMagick fallback and return a valid JPEG.
+ resp := authedGet(t, ts.URL+"/g/test-gallery/thumb/tricky.jpg", cookies)
+ defer resp.Body.Close()
+
+ assert.Equal(t, 200, resp.StatusCode)
+ assert.Contains(t, resp.Header.Get("Content-Type"), "image/jpeg")
+
+ // The thumbnail should be a valid, fully decodable JPEG.
+ thumbData, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+
+ _, err = jpeg.Decode(bytes.NewReader(thumbData))
+ assert.NoError(t, err, "thumbnail should be a fully decodable JPEG even when Go's stdlib can't decode the original")
+
+ // The thumbnail must not be the raw original served as fallback.
+ assert.NotEqual(t, unsupportedJPEG, thumbData,
+ "thumbnail must not be the broken original served as fallback")
+}
+
+// makeLargeTestJPEG creates a larger JPEG that takes more time to thumbnail.
+func makeLargeTestJPEG() []byte {
+ img := image.NewRGBA(image.Rect(0, 0, 3000, 2000))
+ for y := 0; y < 2000; y++ {
+ for x := 0; x < 3000; x++ {
+ img.Set(x, y, color.RGBA{R: uint8(x % 256), G: uint8(y % 256), B: 128, A: 255})
+ }
+ }
+ var buf bytes.Buffer
+ jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90})
+ return buf.Bytes()
+}
+
+func TestConcurrentThumbnailGeneration(t *testing.T) {
+ ts, srv := setupTestServer(t)
+ cookies := login(t, ts, "test-gallery", "secret123")
+
+ // Upload 40 photos directly to the gallery dir (bypass upload handler for speed).
+ dir := srv.galleryDir("test-gallery")
+ largeJPEG := makeLargeTestJPEG()
+ numPhotos := 40
+ for i := 0; i < numPhotos; i++ {
+ filename := fmt.Sprintf("photo_%03d.jpg", i)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, filename), largeJPEG, 0644))
+ }
+
+ // Fire all thumbnail requests concurrently, like a browser would.
+ var wg sync.WaitGroup
+ var failures atomic.Int32
+ deadline := 30 * time.Second
+
+ for i := 0; i < numPhotos; i++ {
+ wg.Add(1)
+ go func(idx int) {
+ defer wg.Done()
+ filename := fmt.Sprintf("photo_%03d.jpg", idx)
+
+ client := &http.Client{Timeout: deadline}
+ req, err := http.NewRequest(http.MethodGet, ts.URL+"/g/test-gallery/thumb/"+filename, nil)
+ if err != nil {
+ failures.Add(1)
+ return
+ }
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ failures.Add(1)
+ return
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ failures.Add(1)
+ return
+ }
+
+ // Must be a valid JPEG
+ _, err = jpeg.Decode(resp.Body)
+ if err != nil {
+ failures.Add(1)
+ }
+ }(i)
+ }
+
+ wg.Wait()
+ assert.Equal(t, int32(0), failures.Load(),
+ "all %d concurrent thumbnail requests should succeed, but %d failed", numPhotos, failures.Load())
+
+ // Verify that thumbnail generation concurrency was bounded.
+ // With thumbSemaphore in place, peak concurrency must not exceed the limit.
+ peak := srv.peakThumbConcurrency.Load()
+ t.Logf("peak thumbnail generation concurrency: %d", peak)
+ assert.LessOrEqual(t, peak, int32(maxThumbConcurrency),
+ "peak concurrency (%d) should not exceed maxThumbConcurrency (%d)", peak, maxThumbConcurrency)
+}
+
+func setupTestServerNoThumbs(t *testing.T) (*httptest.Server, *Server) {
+ t.Helper()
+ dir := t.TempDir()
+ srv := NewServer(dir, 20<<20)
+
+ require.NoError(t, srv.createGalleryOpts("test-gallery", "secret123", false))
+
+ ts := httptest.NewServer(srv.Handler())
+ t.Cleanup(ts.Close)
+ return ts, srv
+}
+
+func TestNoThumbsGalleryServesSourceInGrid(t *testing.T) {
+ ts, srv := setupTestServerNoThumbs(t)
+ cookies := login(t, ts, "test-gallery", "secret123")
+
+ // Upload a photo directly.
+ dir := srv.galleryDir("test-gallery")
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "pic.jpg"), makeTestJPEG(), 0644))
+
+ // The gallery HTML should use /photo/ URLs, not /thumb/ URLs.
+ resp := authedGet(t, ts.URL+"/g/test-gallery", cookies)
+ body := bodyString(t, resp)
+
+ assert.Contains(t, body, "/g/test-gallery/photo/pic.jpg",
+ "gallery grid should use photo URLs when thumbnails are disabled")
+ assert.NotContains(t, body, "/g/test-gallery/thumb/pic.jpg",
+ "gallery grid should NOT use thumb URLs when thumbnails are disabled")
+}
+
+func TestThumbsEnabledGalleryServesThumbsInGrid(t *testing.T) {
+ ts, srv := setupTestServer(t) // default: thumbs enabled
+ cookies := login(t, ts, "test-gallery", "secret123")
+
+ dir := srv.galleryDir("test-gallery")
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "pic.jpg"), makeTestJPEG(), 0644))
+
+ resp := authedGet(t, ts.URL+"/g/test-gallery", cookies)
+ body := bodyString(t, resp)
+
+ assert.Contains(t, body, "/g/test-gallery/thumb/pic.jpg",
+ "gallery grid should use thumb URLs when thumbnails are enabled")
+}
+
+func TestInvalidGalleryName404(t *testing.T) {
+ ts, _ := setupTestServer(t)
+
+ cases := []struct {
+ name string
+ path string
+ }{
+ {"path traversal", "/g/../../etc"},
+ {"spaces", "/g/foo bar"},
+ {"dots", "/g/hello.world"},
+ {"xss", "/g/<script>"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ resp, err := http.Get(ts.URL + tc.path)
+ require.NoError(t, err)
+ resp.Body.Close()
+ assert.Equal(t, 404, resp.StatusCode)
+ })
+ }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..07eb64f
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,15 @@
+module github.com/kagamino/kagapics
+
+go 1.25.6
+
+require (
+ golang.org/x/crypto v0.47.0
+ golang.org/x/image v0.35.0
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/stretchr/testify v1.11.1
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..7766c2d
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,14 @@
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
+golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
+golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
+golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..e8967db
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,29 @@
+*{box-sizing:border-box;margin:0;padding:0}
+body{font-family:system-ui,-apple-system,sans-serif;background:#0f0f0f;color:#e0e0e0;min-height:100vh}
+.container{max-width:1200px;margin:0 auto;padding:1.5rem}
+h1{margin-bottom:1rem;font-size:1.5rem}
+.gallery-list{list-style:none;display:flex;flex-direction:column;gap:.5rem}
+.gallery-list a{color:#7cacf8;text-decoration:none;font-size:1.1rem}
+.gallery-list a:hover{text-decoration:underline}
+.error{color:#f87171;margin-bottom:.5rem}
+form{display:flex;gap:.5rem;flex-wrap:wrap}
+form input[type=password]{padding:.5rem .75rem;border-radius:6px;border:1px solid #333;background:#1a1a1a;color:#e0e0e0;font-size:1rem;flex:1;min-width:200px}
+form button{padding:.5rem 1.25rem;border-radius:6px;border:none;background:#3b82f6;color:#fff;font-size:1rem;cursor:pointer}
+form button:hover{background:#2563eb}
+.actions{display:flex;gap:.75rem;margin-bottom:1rem;flex-wrap:wrap}
+.btn{display:inline-block;padding:.5rem 1rem;border-radius:6px;background:#1e293b;color:#e0e0e0;text-decoration:none;font-size:.9rem;border:1px solid #334155}
+.btn:hover{background:#334155}
+.upload-area{border:2px dashed #334155;border-radius:8px;padding:2rem;text-align:center;margin-bottom:1.5rem;transition:border-color .2s}
+.upload-area.dragover{border-color:#3b82f6;background:rgba(59,130,246,.05)}
+.upload-area .link{color:#7cacf8;cursor:pointer;text-decoration:underline}
+#upload-progress{margin-top:.5rem;color:#93c5fd;font-size:.9rem}
+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:.5rem}
+.grid-item{cursor:pointer;border-radius:6px;overflow:hidden;aspect-ratio:1;background:#1a1a1a}
+.grid-item img{width:100%;height:100%;object-fit:cover;transition:transform .2s}
+.grid-item:hover img{transform:scale(1.03)}
+.empty{color:#888;text-align:center;padding:3rem 0}
+.lightbox{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.95);justify-content:center;align-items:center;z-index:100;cursor:pointer}
+.lightbox.active{display:flex}
+.lightbox img{max-width:95vw;max-height:95vh;object-fit:contain;border-radius:4px}
+.lightbox-download{position:fixed;bottom:1.5rem;right:1.5rem;background:#3b82f6;color:#fff;width:48px;height:48px;border-radius:50%;display:flex;align-items:center;justify-content:center;text-decoration:none;font-size:1.3rem;box-shadow:0 2px 8px rgba(0,0,0,.5)}
+.lightbox-download:hover{background:#2563eb}
diff --git a/templates/gallery.html b/templates/gallery.html
new file mode 100644
index 0000000..4991a11
--- /dev/null
+++ b/templates/gallery.html
@@ -0,0 +1,98 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{.Name}}</title>
+<style>{{.CSS}}</style>
+</head>
+<body>
+<div class="container">
+<h1>📷 {{.Name}}</h1>
+
+<div class="actions">
+<a href="/" class="btn btn-back">← Back</a>
+<a href="/g/{{.Name}}/zip" class="btn">⬇ Download all as ZIP</a>
+</div>
+
+<!-- Upload -->
+<div class="upload-area" id="upload-area">
+<p>Drag & drop photos here, or <label for="file-input" class="link">browse</label></p>
+<input type="file" id="file-input" multiple accept="image/*" hidden>
+<div id="upload-progress"></div>
+</div>
+
+<!-- Gallery grid -->
+<div class="grid">
+{{range .Photos}}
+<div class="grid-item" onclick="openLightbox('/g/{{$.Name}}/photo/{{.}}')">
+<img src="/g/{{$.Name}}/{{$.ThumbPrefix}}/{{.}}" alt="{{.}}" loading="lazy">
+</div>
+{{end}}
+</div>
+{{if not .Photos}}<p class="empty">No photos yet. Upload some!</p>{{end}}
+</div>
+
+<!-- Lightbox -->
+<div id="lightbox" class="lightbox" onclick="closeLightbox()">
+<img id="lightbox-img" src="">
+<a id="lightbox-download" class="lightbox-download" download onclick="event.stopPropagation()">⬇</a>
+</div>
+
+<script>
+const galleryName = {{.Name}};
+const uploadArea = document.getElementById('upload-area');
+const fileInput = document.getElementById('file-input');
+const progress = document.getElementById('upload-progress');
+
+uploadArea.addEventListener('dragover', e => { e.preventDefault(); uploadArea.classList.add('dragover'); });
+uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('dragover'));
+uploadArea.addEventListener('drop', e => {
+ e.preventDefault();
+ uploadArea.classList.remove('dragover');
+ uploadFiles(e.dataTransfer.files);
+});
+fileInput.addEventListener('change', () => uploadFiles(fileInput.files));
+
+function uploadFiles(files) {
+ if (!files.length) return;
+ const fd = new FormData();
+ for (const f of files) fd.append('photos', f);
+
+ progress.textContent = 'Uploading ' + files.length + ' file(s)…';
+
+ const xhr = new XMLHttpRequest();
+ xhr.open('POST', '/g/' + galleryName + '/upload');
+
+ xhr.upload.onprogress = e => {
+ if (e.lengthComputable) {
+ const pct = Math.round(e.loaded / e.total * 100);
+ progress.textContent = 'Uploading… ' + pct + '%';
+ }
+ };
+ xhr.onload = () => {
+ if (xhr.status === 200) {
+ const resp = JSON.parse(xhr.responseText);
+ progress.textContent = resp.uploaded + ' file(s) uploaded ✓';
+ setTimeout(() => location.reload(), 800);
+ } else {
+ progress.textContent = 'Upload failed: ' + xhr.statusText;
+ }
+ };
+ xhr.onerror = () => { progress.textContent = 'Upload failed (network error)'; };
+ xhr.send(fd);
+}
+
+function openLightbox(src) {
+ document.getElementById('lightbox-img').src = src;
+ document.getElementById('lightbox-download').href = src;
+ document.getElementById('lightbox').classList.add('active');
+}
+function closeLightbox() {
+ document.getElementById('lightbox').classList.remove('active');
+ document.getElementById('lightbox-img').src = '';
+}
+document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox(); });
+</script>
+</body>
+</html>
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..357a8a8
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Photo Gallery</title>
+<style>{{.CSS}}</style>
+</head>
+<body>
+<div class="container">
+<h1>📷 Photo Gallery</h1>
+{{if .Names}}
+<ul class="gallery-list">
+{{range .Names}}<li><a href="/g/{{.}}">{{.}}</a></li>{{end}}
+</ul>
+{{else}}
+<p>No galleries yet.</p>
+{{end}}
+</div>
+</body>
+</html>
diff --git a/templates/login.html b/templates/login.html
new file mode 100644
index 0000000..7c06fbc
--- /dev/null
+++ b/templates/login.html
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{.Name}} — Login</title>
+<style>{{.CSS}}</style>
+</head>
+<body>
+<div class="container">
+<h1>🔒 {{.Name}}</h1>
+{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+<form method="POST" action="/g/{{.Name}}">
+<input type="password" name="password" placeholder="Password" autofocus required>
+<button type="submit">Enter</button>
+</form>
+</div>
+</body>
+</html>