Files
website/app/internal/handlers/handlers.go
Jimmy 3f5235daaf Improve gallery video UX and add upload-to-publish media workflow.
Stage raw files in upload/, publish with make sync-media/publish, and polish the lightbox: autoplay, remembered volume, Escape to close, and image/video icons without poster or caption clutter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 23:55:43 +12:00

210 lines
5.5 KiB
Go

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))
}