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

@@ -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 {