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>
This commit is contained in:
2026-06-02 23:01:02 +12:00
parent 509e7ccb43
commit 45b31be9a7
22 changed files with 1002 additions and 217 deletions

View File

@@ -4,8 +4,10 @@ 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"
@@ -22,6 +24,9 @@ type Server struct {
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.
@@ -43,6 +48,7 @@ func New(imagesDir, staticDir string, mailCfg *mail.Config) (*Server, error) {
}
log.Printf("hero image: %s", hero.RelPath)
}
authCfg := auth.Load()
srv := &Server{
ImagesDir: imagesDir,
StaticDir: staticDir,
@@ -51,20 +57,32 @@ func New(imagesDir, staticDir string, mailCfg *mail.Config) (*Server, error) {
HasHero: hasHero,
Mail: mailCfg,
ContactEnabled: mailCfg != nil,
Auth: authCfg,
Sessions: auth.NewSessions(),
}
go func() {
if err := gallery.EnsureThumbnails(imagesDir); err != nil {
log.Printf("gallery thumbnails: %v", err)
return
}
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")
templates.Home(s.Images, s.Hero, s.HasHero, s.ContactEnabled).Render(r.Context(), 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) {
@@ -107,54 +125,31 @@ func (s *Server) contact(w http.ResponseWriter, r *http.Request) {
templates.ContactSuccess().Render(r.Context(), w)
}
func (s *Server) galleryPartial(w http.ResponseWriter, r *http.Request) {
album := r.URL.Query().Get("album")
collection := r.URL.Query().Get("collection")
filtered := s.filterImages(album, collection)
templates.GalleryGrid(filtered).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
}
img, ok := s.findImage(rel)
images, _, _ := s.snapshot()
img, ok := findImage(images, rel)
if !ok {
http.NotFound(w, r)
return
}
idx := s.indexOf(rel)
idx := indexOf(images, rel)
prev, next := "", ""
if idx > 0 {
prev = s.Images[idx-1].RelPath
prev = images[idx-1].RelPath
}
if idx >= 0 && idx < len(s.Images)-1 {
next = s.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 (s *Server) filterImages(album, collection string) []gallery.Image {
if album == "" && collection == "" {
return s.Images
}
var out []gallery.Image
for _, img := range s.Images {
if album != "" && img.Album != album {
continue
}
if collection != "" && img.CollectionKey != collection {
continue
}
out = append(out, img)
}
return out
}
func (s *Server) findImage(rel string) (gallery.Image, bool) {
for _, img := range s.Images {
func findImage(images []gallery.Image, rel string) (gallery.Image, bool) {
for _, img := range images {
if img.RelPath == rel {
return img, true
}
@@ -162,8 +157,8 @@ func (s *Server) findImage(rel string) (gallery.Image, bool) {
return gallery.Image{}, false
}
func (s *Server) indexOf(rel string) int {
for i, img := range s.Images {
func indexOf(images []gallery.Image, rel string) int {
for i, img := range images {
if img.RelPath == rel {
return i
}
@@ -175,9 +170,15 @@ func (s *Server) indexOf(rel string) int {
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /{$}", s.index)
mux.HandleFunc("POST /contact", s.contact)
mux.HandleFunc("GET /gallery", s.galleryPartial)
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))
}