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:
124
app/internal/gallery/files.go
Normal file
124
app/internal/gallery/files.go
Normal 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)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
36
app/internal/gallery/media.go
Normal file
36
app/internal/gallery/media.go
Normal 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"
|
||||
}
|
||||
44
app/internal/gallery/media_test.go
Normal file
44
app/internal/gallery/media_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user