aboutsummaryrefslogtreecommitdiff
path: root/gallery.go
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 /gallery.go
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)
Diffstat (limited to 'gallery.go')
-rw-r--r--gallery.go892
1 files changed, 892 insertions, 0 deletions
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