Files
website/app/internal/handlers/handlers.go
jimmy 45b31be9a7 Add gallery admin and video media support.
This updates gallery handling to support video playback with generated poster thumbnails, adds authenticated admin upload/delete flows, and improves dev/runtime behavior including reliable thumbnail generation and media-safe response handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 23:01:02 +12:00

185 lines
4.8 KiB
Go

package handlers
import (
"log"
"net/http"
"strings"
"sync"
"time"
"technical.kiwi/website/internal/auth"
"technical.kiwi/website/internal/contact"
"technical.kiwi/website/internal/gallery"
"technical.kiwi/website/internal/mail"
"technical.kiwi/website/internal/middleware"
"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
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(),
}
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")
}
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")
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 strings.TrimSpace(r.FormValue("website")) != "" {
templates.ContactSuccess().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))
}