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>
125 lines
3.3 KiB
Go
125 lines
3.3 KiB
Go
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)
|
|
}
|