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