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" "time" "golang.org/x/crypto/bcrypt" "golang.org/x/image/draw" ) // --------------------------------------------------------------------------- // Config & constants // --------------------------------------------------------------------------- const ( thumbMaxWidth = 400 thumbMaxHeight = 400 thumbQuality = 65 thumbDirName = ".thumbs-v2" oldThumbDirName = ".thumbs" thumbScanInterval = 10 * time.Second 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{} // thumbTrigger receives gallery names to trigger an immediate scan // for that gallery (e.g. after an upload). Non-blocking send. thumbTrigger chan string } // 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), thumbTrigger: make(chan string, 16), } } // 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, thumbDirName) } // thumbResult holds metadata about a generated thumbnail for logging. type thumbResult struct { srcWidth int srcHeight int } func generateThumbGo(srcPath, dstPath string) (thumbResult, error) { f, err := os.Open(srcPath) if err != nil { return thumbResult{}, err } defer f.Close() src, _, err := image.Decode(f) if err != nil { return thumbResult{}, 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 thumbResult{}, err } defer out.Close() if err := jpeg.Encode(out, dst, &jpeg.Options{Quality: thumbQuality}); err != nil { return thumbResult{}, err } return thumbResult{srcWidth: w, srcHeight: h}, nil } // 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) (thumbResult, error) { res, err := generateThumbGo(srcPath, dstPath) if err == nil { return res, nil } log.Printf("Go thumbnail failed for %s: %v — trying ImageMagick", srcPath, err) if err2 := generateThumbImageMagick(srcPath, dstPath); err2 != nil { return thumbResult{}, fmt.Errorf("all thumbnail methods failed: go=%v, imagemagick=%v", err, err2) } // ImageMagick succeeded — dimensions unknown (Go couldn't decode the source). return thumbResult{}, nil } // thumbPath returns the expected thumbnail path for a photo, or "" if it // doesn't exist yet. func thumbPath(galleryDir, filename string) string { thumbName := strings.TrimSuffix(filename, filepath.Ext(filename)) + ".jpg" p := filepath.Join(thumbDir(galleryDir), thumbName) if _, err := os.Stat(p); err == nil { return p } return "" } // --------------------------------------------------------------------------- // Background thumbnail worker // --------------------------------------------------------------------------- // triggerThumbScan sends a gallery name on the trigger channel to request // an immediate scan. Non-blocking: if the channel is full, the periodic // scan will pick it up. func (s *Server) triggerThumbScan(gallery string) { select { case s.thumbTrigger <- gallery: default: } } // startThumbWorker launches a background goroutine that periodically scans // all galleries and generates missing thumbnails. func (s *Server) startThumbWorker() { go func() { ticker := time.NewTicker(thumbScanInterval) defer ticker.Stop() for { select { case gallery := <-s.thumbTrigger: // Triggered scan for a specific gallery (e.g. after upload). s.mu.RLock() g, ok := s.galleries[gallery] s.mu.RUnlock() if ok && g.thumbsEnabled() { s.scanGalleryThumbs(gallery) } case <-ticker.C: // Periodic full scan. s.scanAllThumbs() } } }() } // scanAllThumbs scans every gallery and generates missing thumbnails. func (s *Server) scanAllThumbs() { s.mu.RLock() names := make([]string, 0, len(s.galleries)) for name, g := range s.galleries { if g.thumbsEnabled() { names = append(names, name) } } s.mu.RUnlock() for _, name := range names { s.scanGalleryThumbs(name) } } // formatBytes formats a byte count as a human-readable string. func formatBytes(b int64) string { switch { case b >= 1<<20: return fmt.Sprintf("%.1fMB", float64(b)/float64(1<<20)) case b >= 1<<10: return fmt.Sprintf("%.0fKB", float64(b)/float64(1<<10)) default: return fmt.Sprintf("%dB", b) } } // scanGalleryThumbs generates missing thumbnails for a single gallery. func (s *Server) scanGalleryThumbs(name string) { dir := s.galleryDir(name) entries, err := os.ReadDir(dir) if err != nil { return } td := thumbDir(dir) generated := 0 galleryStart := time.Now() for _, e := range entries { if e.IsDir() || strings.HasPrefix(e.Name(), ".") { continue } if !isAllowedExt(e.Name()) { continue } thumbName := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())) + ".jpg" tp := filepath.Join(td, thumbName) if _, err := os.Stat(tp); err == nil { continue // already exists } // Create thumbs dir if needed. os.MkdirAll(td, 0755) srcPath := filepath.Join(dir, e.Name()) // Get source file size. srcInfo, err := os.Stat(srcPath) if err != nil { continue } srcSize := srcInfo.Size() // Acquire semaphore to limit CPU usage. s.thumbSemaphore <- struct{}{} start := time.Now() res, err := generateThumb(srcPath, tp) elapsed := time.Since(start) <-s.thumbSemaphore if err != nil { log.Printf("[thumbs] failed %s/%s: %v", name, e.Name(), err) continue } // Get thumb file size. var thumbSize int64 if ti, err := os.Stat(tp); err == nil { thumbSize = ti.Size() } generated++ if res.srcWidth > 0 { log.Printf("[thumbs] generated %s/%s (%dx%d, %s → %s, %s)", name, e.Name(), res.srcWidth, res.srcHeight, formatBytes(srcSize), formatBytes(thumbSize), elapsed.Round(time.Millisecond)) } else { log.Printf("[thumbs] generated %s/%s (%s → %s, %s)", name, e.Name(), formatBytes(srcSize), formatBytes(thumbSize), elapsed.Round(time.Millisecond)) } } if generated > 0 { log.Printf("[thumbs] gallery %q: %d new thumbnails in %s", name, generated, time.Since(galleryStart).Round(time.Millisecond)) } } // cleanupOldThumbs removes legacy .thumbs directories from all galleries. func (s *Server) cleanupOldThumbs() { s.mu.RLock() names := make([]string, 0, len(s.galleries)) for name := range s.galleries { names = append(names, name) } s.mu.RUnlock() for _, name := range names { oldDir := filepath.Join(s.galleryDir(name), oldThumbDirName) if _, err := os.Stat(oldDir); err == nil { if err := os.RemoveAll(oldDir); err != nil { log.Printf("[thumbs] failed to remove old %s: %v", oldDir, err) } else { log.Printf("[thumbs] removed legacy %s", oldDir) } } } } // --------------------------------------------------------------------------- // 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++ } if uploaded > 0 { s.triggerThumbScan(name) } 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) // Serve pre-generated thumbnail if available, otherwise fallback to source. if tp := thumbPath(dir, filename); tp != "" { w.Header().Set("Cache-Control", "public, max-age=86400") http.ServeFile(w, r, tp) return } // Thumbnail not ready yet — serve the original as fallback. srcPath := filepath.Join(dir, filename) if _, err := os.Stat(srcPath); os.IsNotExist(err) { http.NotFound(w, r) return } http.ServeFile(w, r, srcPath) } 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() } // --------------------------------------------------------------------------- // CLI: delete gallery // --------------------------------------------------------------------------- func (s *Server) deleteGallery(name string) error { if !validGalleryName.MatchString(name) { return fmt.Errorf("invalid gallery name %q", name) } s.mu.Lock() _, ok := s.galleries[name] if !ok { s.mu.Unlock() return fmt.Errorf("gallery %q not found", name) } delete(s.galleries, name) s.mu.Unlock() if err := s.save(); err != nil { return fmt.Errorf("failed to update config: %w", err) } dir := s.galleryDir(name) if err := os.RemoveAll(dir); err != nil { return fmt.Errorf("config removed but failed to delete directory %s: %w", dir, err) } log.Printf("Gallery %q deleted", name) return nil } // --------------------------------------------------------------------------- // 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] gallery delete [--data ./data] 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 gallery delete crete-2026 --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 "delete": if len(os.Args) < 3 { fmt.Fprintln(os.Stderr, "Usage: gallery delete [--data ./data]") os.Exit(1) } name := os.Args[2] flags := parseFlags(os.Args[3:], map[string]string{"data": "./data"}) srv := NewServer(flags["data"], 20<<20) if err := srv.load(); err != nil { log.Fatalf("Failed to load state: %v", err) } fmt.Printf("Delete gallery %q and all its photos from %s? [y/N] ", name, srv.galleryDir(name)) var answer string fmt.Scanln(&answer) if strings.ToLower(strings.TrimSpace(answer)) != "y" { fmt.Println("Aborted.") os.Exit(0) } if err := srv.deleteGallery(name); err != nil { log.Fatalf("Failed to delete gallery: %v", err) } fmt.Printf("Gallery %q deleted.\n", 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() srv.cleanupOldThumbs() srv.startThumbWorker() 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