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:
201
app/internal/handlers/admin.go
Normal file
201
app/internal/handlers/admin.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"technical.kiwi/website/internal/auth"
|
||||
"technical.kiwi/website/internal/gallery"
|
||||
"technical.kiwi/website/templates"
|
||||
)
|
||||
|
||||
const maxUploadBytes = 32 << 20 // 32 MiB
|
||||
|
||||
func (s *Server) adminEnabled() bool {
|
||||
return s.Auth.Enabled
|
||||
}
|
||||
|
||||
func (s *Server) sessionToken(r *http.Request) string {
|
||||
c, err := r.Cookie(auth.CookieName())
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
func (s *Server) isAdmin(r *http.Request) bool {
|
||||
return s.Sessions.Valid(s.sessionToken(r))
|
||||
}
|
||||
|
||||
func (s *Server) setSession(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.CookieName(),
|
||||
Value: token,
|
||||
Path: "/admin",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int((24 * time.Hour).Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) clearSession(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.CookieName(),
|
||||
Value: "",
|
||||
Path: "/admin",
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) requireAdmin(fn http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adminEnabled() {
|
||||
http.Error(w, "admin disabled", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if !s.isAdmin(r) {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
fn(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) reloadGallery() error {
|
||||
s.galleryMu.Lock()
|
||||
defer s.galleryMu.Unlock()
|
||||
|
||||
images, err := gallery.List(s.ImagesDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hero, hasHero := gallery.SelectHero(images)
|
||||
s.Images = images
|
||||
s.Hero = hero
|
||||
s.HasHero = hasHero
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) adminLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adminEnabled() {
|
||||
http.Error(w, "admin disabled: set ADMIN_USER and ADMIN_PASSWORD", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if s.isAdmin(r) {
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
templates.AdminLogin("").Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (s *Server) adminLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.adminEnabled() {
|
||||
http.Error(w, "admin disabled", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
templates.AdminLogin("Invalid request.").Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
user := r.FormValue("username")
|
||||
pass := r.FormValue("password")
|
||||
if !auth.CheckCredentials(s.Auth, user, pass) {
|
||||
templates.AdminLogin("Invalid username or password.").Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
token, err := s.Sessions.Create()
|
||||
if err != nil {
|
||||
http.Error(w, "session error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.setSession(w, token)
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) adminLogout(w http.ResponseWriter, r *http.Request) {
|
||||
s.Sessions.Delete(s.sessionToken(r))
|
||||
s.clearSession(w)
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) adminIndex(w http.ResponseWriter, r *http.Request) {
|
||||
images, _, _ := s.snapshot()
|
||||
albums, _ := gallery.AlbumsOnDisk(s.ImagesDir)
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
templates.AdminDashboard(images, albums, "").Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (s *Server) adminUpload(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
|
||||
if err := r.ParseMultipartForm(maxUploadBytes); err != nil {
|
||||
s.adminIndexError(w, r, "Upload too large or invalid.")
|
||||
return
|
||||
}
|
||||
|
||||
album := r.FormValue("album")
|
||||
if album == "new" {
|
||||
album = r.FormValue("album_new")
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("image")
|
||||
if err != nil {
|
||||
s.adminIndexError(w, r, "Choose a JPEG image to upload.")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
rel, err := gallery.SaveUpload(s.ImagesDir, album, header.Filename, file)
|
||||
if err != nil {
|
||||
s.adminIndexError(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := gallery.EnsureDerivatives(s.ImagesDir, rel); err != nil {
|
||||
log.Printf("admin upload derivatives: %v", err)
|
||||
}
|
||||
if err := s.reloadGallery(); err != nil {
|
||||
log.Printf("admin reload gallery: %v", err)
|
||||
}
|
||||
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
images, _, _ := s.snapshot()
|
||||
templates.AdminImageTable(images).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) adminDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
rel := r.FormValue("path")
|
||||
if err := gallery.DeleteImage(s.ImagesDir, rel); err != nil {
|
||||
s.adminIndexError(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.reloadGallery(); err != nil {
|
||||
log.Printf("admin reload gallery: %v", err)
|
||||
}
|
||||
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
images, _, _ := s.snapshot()
|
||||
templates.AdminImageTable(images).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) adminIndexError(w http.ResponseWriter, r *http.Request, msg string) {
|
||||
images, _, _ := s.snapshot()
|
||||
albums, _ := gallery.AlbumsOnDisk(s.ImagesDir)
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates.AdminFlash(msg).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
templates.AdminDashboard(images, albums, msg).Render(r.Context(), w)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user