From 17be2fcb106ddc9c36628e9f5dece28369affc30 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Fri, 13 Feb 2026 08:45:49 +0100 Subject: 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) --- .gitignore | 2 + Caddyfile.example | 12 + DESIGN.md | 277 +++++++++++++++ gallery.go | 892 +++++++++++++++++++++++++++++++++++++++++++++++++ gallery_test.go | 558 +++++++++++++++++++++++++++++++ go.mod | 15 + go.sum | 14 + static/style.css | 29 ++ templates/gallery.html | 98 ++++++ templates/index.html | 21 ++ templates/login.html | 19 ++ 11 files changed, 1937 insertions(+) create mode 100644 .gitignore create mode 100644 Caddyfile.example create mode 100644 DESIGN.md create mode 100644 gallery.go create mode 100644 gallery_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 static/style.css create mode 100644 templates/gallery.html create mode 100644 templates/index.html create mode 100644 templates/login.html 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 `
` 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 [--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 [--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/ + + 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 @@ + + + + + +Photo Gallery + + + +
+

📷 Photo Gallery

+{{if .Names}} + +{{else}} +

No galleries yet.

+{{end}} +
+ + 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 @@ + + + + + +{{.Name}} — Login + + + +
+

🔒 {{.Name}}

+{{if .Error}}

{{.Error}}

{{end}} + + + + +
+ + -- cgit v1.3