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

99
app/internal/auth/auth.go Normal file
View File

@@ -0,0 +1,99 @@
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"os"
"sync"
"time"
)
const sessionCookie = "tk_admin_session"
// Config holds admin credentials from the environment.
type Config struct {
Username string
Password string
Enabled bool
}
// Load reads admin auth settings. Admin is enabled when both user and password are set.
func Load() Config {
user := os.Getenv("ADMIN_USER")
pass := os.Getenv("ADMIN_PASSWORD")
return Config{
Username: user,
Password: pass,
Enabled: user != "" && pass != "",
}
}
// Sessions tracks active login tokens in memory.
type Sessions struct {
mu sync.RWMutex
tokens map[string]time.Time
ttl time.Duration
}
// NewSessions creates a session store.
func NewSessions() *Sessions {
return &Sessions{
tokens: make(map[string]time.Time),
ttl: 24 * time.Hour,
}
}
// Create issues a new session token.
func (s *Sessions) Create() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
token := base64.RawURLEncoding.EncodeToString(b)
exp := time.Now().Add(s.ttl)
s.mu.Lock()
s.tokens[token] = exp
s.mu.Unlock()
return token, nil
}
// Valid reports whether a session token is still active.
func (s *Sessions) Valid(token string) bool {
if token == "" {
return false
}
s.mu.RLock()
exp, ok := s.tokens[token]
s.mu.RUnlock()
if !ok || time.Now().After(exp) {
return false
}
return true
}
// Delete removes a session token.
func (s *Sessions) Delete(token string) {
s.mu.Lock()
delete(s.tokens, token)
s.mu.Unlock()
}
// CookieName returns the session cookie name.
func CookieName() string {
return sessionCookie
}
// CheckCredentials compares username and password to config using constant-time compare.
func CheckCredentials(cfg Config, username, password string) bool {
if !cfg.Enabled {
return false
}
uOK := subtle.ConstantTimeCompare([]byte(username), []byte(cfg.Username)) == 1
pOK := subtle.ConstantTimeCompare([]byte(password), []byte(cfg.Password)) == 1
return uOK && pOK
}
// ErrDisabled is returned when admin auth is not configured.
var ErrDisabled = errors.New("admin auth is not configured")

View File

@@ -0,0 +1,124 @@
package gallery
import (
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
)
var albumNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
// AlbumsOnDisk returns top-level album folder names (excluding thumbs/hero).
func AlbumsOnDisk(imagesDir string) ([]string, error) {
entries, err := os.ReadDir(imagesDir)
if err != nil {
return nil, err
}
var albums []string
for _, e := range entries {
if !e.IsDir() || e.Name() == thumbDirName || e.Name() == heroDirName {
continue
}
if e.Name()[0] == '.' {
continue
}
albums = append(albums, e.Name())
}
return albums, nil
}
// SanitizeAlbum normalizes a folder name for new uploads.
func SanitizeAlbum(name string) (string, error) {
name = strings.ToLower(strings.TrimSpace(name))
name = strings.ReplaceAll(name, " ", "-")
if !albumNameRe.MatchString(name) {
return "", fmt.Errorf("invalid album name")
}
return name, nil
}
// SanitizeUploadName keeps a safe JPEG filename.
func SanitizeUploadName(name string) (string, error) {
name = filepath.Base(strings.TrimSpace(name))
name = strings.ReplaceAll(name, " ", "_")
if !isJPEG(name) {
return "", fmt.Errorf("only .jpg and .jpeg files are allowed")
}
if strings.Contains(name, "..") {
return "", fmt.Errorf("invalid filename")
}
return name, nil
}
// SaveUpload writes an uploaded JPEG into the gallery tree.
func SaveUpload(imagesDir, album, filename string, r io.Reader) (string, error) {
album, err := SanitizeAlbum(album)
if err != nil {
return "", err
}
filename, err = SanitizeUploadName(filename)
if err != nil {
return "", err
}
dir := filepath.Join(imagesDir, album)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
dst := filepath.Join(dir, filename)
f, err := os.Create(dst)
if err != nil {
return "", err
}
defer f.Close()
if _, err := io.Copy(f, r); err != nil {
os.Remove(dst)
return "", err
}
rel := filepath.ToSlash(filepath.Join(album, filename))
return rel, nil
}
// DeleteImage removes an image and its derivatives.
func DeleteImage(imagesDir, rel string) error {
if !safeRelPath(rel) {
return fmt.Errorf("invalid path")
}
paths := []string{filepath.Join(imagesDir, filepath.FromSlash(rel))}
if isVideo(filepath.Base(rel)) {
paths = append(paths, filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(videoPosterRel(rel))))
} else {
paths = append(paths,
filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(rel)),
filepath.Join(imagesDir, heroDirName, filepath.FromSlash(rel)),
)
}
for _, p := range paths {
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
// EnsureDerivatives rebuilds thumb and hero for one image.
func EnsureDerivatives(imagesDir, rel string) error {
if !safeRelPath(rel) {
return fmt.Errorf("invalid path")
}
src := filepath.Join(imagesDir, filepath.FromSlash(rel))
thumbDst := filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(rel))
heroDst := filepath.Join(imagesDir, heroDirName, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(thumbDst), 0o755); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(heroDst), 0o755); err != nil {
return err
}
if err := writeThumb(src, thumbDst, gridThumbMaxEdge, gridThumbQuality); err != nil {
return err
}
return writeThumb(src, heroDst, heroThumbMaxEdge, heroThumbQuality)
}

View File

@@ -19,11 +19,12 @@ type Image struct {
URL string
ThumbURL string
HeroURL string
IsVideo bool
Date time.Time
Year int
}
// List returns JPEG images from dir and subfolders (newest first).
// List returns gallery JPEGs and videos from dir and subfolders (newest first).
// Skips generated thumbs/ and hero/ directories.
func List(dir string) ([]Image, error) {
var images []Image
@@ -37,7 +38,7 @@ func List(dir string) ([]Image, error) {
}
return nil
}
if !isJPEG(d.Name()) {
if !isGalleryMedia(d.Name()) {
return nil
}
@@ -53,6 +54,7 @@ func List(dir string) ([]Image, error) {
album := albumFromRel(rel)
cKey, cLabel := collectionFromRel(rel)
date, year := imageDate(path, d.Name())
video := isVideo(d.Name())
images = append(images, Image{
RelPath: rel,
Album: album,
@@ -62,6 +64,7 @@ func List(dir string) ([]Image, error) {
URL: "/images/" + URLPath(rel),
ThumbURL: derivativeURL(dir, thumbDirName, rel),
HeroURL: derivativeURL(dir, heroDirName, rel),
IsVideo: video,
Date: date,
Year: year,
})
@@ -158,8 +161,15 @@ func safeRelPath(rel string) bool {
}
func derivativeURL(imagesDir, subdir, rel string) string {
if _, err := os.Stat(filepath.Join(imagesDir, subdir, filepath.FromSlash(rel))); err == nil {
return "/images/" + subdir + "/" + URLPath(rel)
lookup := rel
if subdir == thumbDirName || subdir == heroDirName {
lookup = thumbLookupRel(rel)
}
if isVideo(filepath.Base(rel)) && subdir == heroDirName {
return ""
}
if _, err := os.Stat(filepath.Join(imagesDir, subdir, filepath.FromSlash(lookup))); err == nil {
return "/images/" + subdir + "/" + URLPath(lookup)
}
return ""
}

View File

@@ -19,12 +19,17 @@ func HeroRelPath() string {
func SelectHero(images []Image) (Image, bool) {
want := HeroRelPath()
for _, img := range images {
if img.IsVideo {
continue
}
if img.RelPath == want {
return img, true
}
}
if len(images) > 0 {
return images[0], true
for _, img := range images {
if !img.IsVideo {
return img, true
}
}
return Image{}, false
}

View File

@@ -0,0 +1,36 @@
package gallery
import (
"path/filepath"
"strings"
)
func isVideo(name string) bool {
switch strings.ToLower(filepath.Ext(name)) {
case ".mp4", ".webm", ".mov":
return true
default:
return false
}
}
func isGalleryMedia(name string) bool {
return isJPEG(name) || isVideo(name)
}
// thumbLookupRel is the path under thumbs/ (and hero/) used to find derivatives.
// Videos use a JPEG poster: clip.mp4 → clip.jpg.
func thumbLookupRel(rel string) string {
if isVideo(filepath.Base(rel)) {
return videoPosterRel(rel)
}
return rel
}
func videoPosterRel(rel string) string {
ext := filepath.Ext(rel)
if ext == "" {
return rel + ".jpg"
}
return strings.TrimSuffix(rel, ext) + ".jpg"
}

View File

@@ -0,0 +1,44 @@
package gallery
import (
"os"
"path/filepath"
"testing"
)
func TestVideoPosterRel(t *testing.T) {
if got := videoPosterRel("portal/clip.mp4"); got != "portal/clip.jpg" {
t.Fatalf("got %q", got)
}
}
func TestList_includesVideo(t *testing.T) {
dir := t.TempDir()
album := filepath.Join(dir, "portal")
if err := os.MkdirAll(album, 0o755); err != nil {
t.Fatal(err)
}
name := "showreel.mp4"
if err := os.WriteFile(filepath.Join(album, name), []byte("fake"), 0o644); err != nil {
t.Fatal(err)
}
images, err := List(dir)
if err != nil {
t.Fatal(err)
}
if len(images) != 1 || !images[0].IsVideo || images[0].RelPath != "portal/"+name {
t.Fatalf("got %+v", images)
}
}
func TestSelectHero_skipsVideo(t *testing.T) {
images := []Image{
{RelPath: "portal/showreel.mp4", IsVideo: true},
{RelPath: "portal/photo.jpg", IsVideo: false},
}
hero, ok := SelectHero(images)
if !ok || hero.RelPath != "portal/photo.jpg" {
t.Fatalf("got %+v ok=%v", hero, ok)
}
}

View File

@@ -1,10 +1,13 @@
package gallery
import (
"bytes"
"fmt"
"image"
"image/jpeg"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -29,7 +32,8 @@ func EnsureThumbnails(imagesDir string) error {
return err
}
return filepath.WalkDir(imagesDir, func(path string, d fs.DirEntry, err error) error {
var failures []string
walkErr := filepath.WalkDir(imagesDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
@@ -39,7 +43,7 @@ func EnsureThumbnails(imagesDir string) error {
}
return nil
}
if !isJPEG(d.Name()) {
if !isGalleryMedia(d.Name()) {
return nil
}
@@ -52,19 +56,43 @@ func EnsureThumbnails(imagesDir string) error {
return nil
}
if isVideo(d.Name()) {
thumbDst := filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(videoPosterRel(rel)))
if err := os.MkdirAll(filepath.Dir(thumbDst), 0o755); err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", rel, err))
return nil
}
if err := writeVideoPoster(path, thumbDst, gridThumbMaxEdge); err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", rel, err))
}
return nil
}
thumbDst := filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(rel))
heroDst := filepath.Join(imagesDir, heroDirName, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(thumbDst), 0o755); err != nil {
return err
failures = append(failures, fmt.Sprintf("%s: %v", rel, err))
return nil
}
if err := os.MkdirAll(filepath.Dir(heroDst), 0o755); err != nil {
return err
failures = append(failures, fmt.Sprintf("%s: %v", rel, err))
return nil
}
if err := writeThumb(path, thumbDst, gridThumbMaxEdge, gridThumbQuality); err != nil {
return err
failures = append(failures, fmt.Sprintf("%s thumb: %v", rel, err))
}
return writeThumb(path, heroDst, heroThumbMaxEdge, heroThumbQuality)
if err := writeThumb(path, heroDst, heroThumbMaxEdge, heroThumbQuality); err != nil {
failures = append(failures, fmt.Sprintf("%s hero: %v", rel, err))
}
return nil
})
if walkErr != nil {
return walkErr
}
if len(failures) > 0 {
return fmt.Errorf("%d file(s): %s", len(failures), strings.Join(failures, "; "))
}
return nil
}
// EnsurePriority generates thumbnails for one image before first page load.
@@ -87,6 +115,41 @@ func EnsurePriority(imagesDir, relPath string) error {
return writeThumb(src, thumbDst, gridThumbMaxEdge, gridThumbQuality)
}
func writeVideoPoster(src, dst string, maxEdge int) error {
srcInfo, err := os.Stat(src)
if err != nil {
return err
}
if dstInfo, err := os.Stat(dst); err == nil && !dstInfo.ModTime().Before(srcInfo.ModTime()) {
return nil
}
if _, err := exec.LookPath("ffmpeg"); err != nil {
return nil
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
scale := fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease", maxEdge, maxEdge)
cmd := exec.Command("ffmpeg",
"-hide_banner", "-loglevel", "error", "-y",
"-i", src,
"-vf", scale,
"-frames:v", "1",
"-q:v", "5",
dst,
)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
return err
}
return fmt.Errorf("%w: %s", err, msg)
}
return nil
}
func writeThumb(src, dst string, maxEdge, quality int) error {
srcInfo, err := os.Stat(src)
if err != nil {

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

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

View File

@@ -3,7 +3,9 @@ package middleware
import (
"compress/gzip"
"io"
"mime"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
@@ -21,7 +23,7 @@ func (w *gzipResponseWriter) Write(b []byte) (int, error) {
// Gzip compresses responses when the client accepts it.
func Gzip(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") || shouldSkipGzip(r) {
next.ServeHTTP(w, r)
return
}
@@ -59,3 +61,20 @@ func formatMaxAge(d time.Duration) string {
return strconv.Itoa(int(d.Seconds()))
}
func shouldSkipGzip(r *http.Request) bool {
// Range requests must stay byte-accurate for media seeking/playback.
if r.Header.Get("Range") != "" {
return true
}
ext := strings.ToLower(filepath.Ext(r.URL.Path))
if ext == "" {
return false
}
ct := mime.TypeByExtension(ext)
return strings.HasPrefix(ct, "image/") ||
strings.HasPrefix(ct, "video/") ||
strings.HasPrefix(ct, "audio/")
}