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