package handlers import ( "log" "net/http" "sync" "time" "technical.kiwi/website/internal/auth" "technical.kiwi/website/internal/contact" "technical.kiwi/website/internal/contactcheck" "technical.kiwi/website/internal/gallery" "technical.kiwi/website/internal/mail" "technical.kiwi/website/internal/middleware" "technical.kiwi/website/internal/ratelimit" "technical.kiwi/website/templates" ) // Server holds shared state for HTTP handlers. type Server struct { ImagesDir string StaticDir string Images []gallery.Image Hero gallery.Image HasHero bool Mail *mail.Config ContactEnabled bool Auth auth.Config Sessions *auth.Sessions contactRL *ratelimit.IPWindow galleryMu sync.RWMutex } // New builds a Server after loading the image gallery. func New(imagesDir, staticDir string, mailCfg *mail.Config) (*Server, error) { images, err := gallery.List(imagesDir) if err != nil { return nil, err } hero, hasHero := gallery.SelectHero(images) if hasHero { if err := gallery.EnsurePriority(imagesDir, hero.RelPath); err != nil { log.Printf("hero image: %v", err) } else { images, err = gallery.List(imagesDir) if err != nil { return nil, err } hero, hasHero = gallery.SelectHero(images) } log.Printf("hero image: %s", hero.RelPath) } authCfg := auth.Load() srv := &Server{ ImagesDir: imagesDir, StaticDir: staticDir, Images: images, Hero: hero, HasHero: hasHero, Mail: mailCfg, ContactEnabled: mailCfg != nil, Auth: authCfg, Sessions: auth.NewSessions(), contactRL: ratelimit.NewIPWindow(5, time.Hour), } if authCfg.Enabled { log.Print("admin: enabled at /admin/") } else { log.Print("admin: disabled (set ADMIN_USER and ADMIN_PASSWORD)") } if err := gallery.EnsureThumbnails(imagesDir); err != nil { log.Printf("gallery thumbnails: %v", err) } else { log.Print("gallery thumbnails: ready") } if refreshed, err := gallery.List(imagesDir); err != nil { log.Printf("gallery refresh after thumbnails: %v", err) } else { srv.Images = refreshed if hero, hasHero := gallery.SelectHero(refreshed); hasHero { srv.Hero = hero srv.HasHero = true } } return srv, nil } func (s *Server) snapshot() ([]gallery.Image, gallery.Image, bool) { s.galleryMu.RLock() defer s.galleryMu.RUnlock() return s.Images, s.Hero, s.HasHero } func (s *Server) index(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache") if s.ContactEnabled { setContactFormSeen(w) } images, hero, hasHero := s.snapshot() templates.Home(images, hero, hasHero, s.ContactEnabled).Render(r.Context(), w) } func (s *Server) contact(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if contactcheck.SpamHoneypot(r.FormValue(contactcheck.HoneypotField)) { templates.ContactSuccess().Render(r.Context(), w) return } if contactcheck.FormFilledTooFast(contactFormSeenUnix(r), time.Now()) { templates.ContactSuccess().Render(r.Context(), w) return } if !s.contactRL.Allow(clientIP(r)) { templates.ContactRateLimited().Render(r.Context(), w) return } sub, errs := contact.Parse( r.FormValue("name"), r.FormValue("email"), r.FormValue("message"), ) if errs.Any() { templates.ContactValidationAlert(errs).Render(r.Context(), w) return } if s.Mail == nil { templates.ContactSendError().Render(r.Context(), w) return } if err := s.Mail.ContactEmail(sub.Name, sub.Email, sub.Message); err != nil { log.Printf("contact mail: %v", err) templates.ContactSendError().Render(r.Context(), w) return } templates.ContactSuccess().Render(r.Context(), w) } func (s *Server) imageModal(w http.ResponseWriter, r *http.Request) { rel := r.PathValue("path") if !gallery.SafeRelPath(rel) { http.NotFound(w, r) return } images, _, _ := s.snapshot() img, ok := findImage(images, rel) if !ok { http.NotFound(w, r) return } idx := indexOf(images, rel) prev, next := "", "" if idx > 0 { prev = images[idx-1].RelPath } if idx >= 0 && idx < len(images)-1 { next = images[idx+1].RelPath } templates.ImageModal(img, prev, next).Render(r.Context(), w) } func findImage(images []gallery.Image, rel string) (gallery.Image, bool) { for _, img := range images { if img.RelPath == rel { return img, true } } return gallery.Image{}, false } func indexOf(images []gallery.Image, rel string) int { for i, img := range images { if img.RelPath == rel { return i } } return -1 } // RegisterRoutes attaches handlers to mux. func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /{$}", s.index) mux.HandleFunc("POST /contact", s.contact) mux.HandleFunc("GET /gallery/{path...}", s.imageModal) staticHandler := middleware.CacheStatic(s.StaticDir, 7*24*time.Hour) mux.Handle("GET /static/", http.StripPrefix("/static/", staticHandler)) mux.Handle("GET /images/", http.StripPrefix("/images/", middleware.CacheImages(s.ImagesDir))) mux.HandleFunc("GET /admin/login", s.adminLogin) mux.HandleFunc("POST /admin/login", s.adminLoginPost) mux.HandleFunc("POST /admin/logout", s.adminLogout) mux.HandleFunc("GET /admin/", s.requireAdmin(s.adminIndex)) mux.HandleFunc("POST /admin/upload", s.requireAdmin(s.adminUpload)) mux.HandleFunc("POST /admin/delete", s.requireAdmin(s.adminDelete)) }