diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-02-13 08:45:49 +0100 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-02-13 08:45:49 +0100 |
| commit | 17be2fcb106ddc9c36628e9f5dece28369affc30 (patch) | |
| tree | 4f3cb1d2fade5467f6c79a9a1abe1da0852a61d0 /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.go | 892 |
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 |
