Files
volumen/internal/store/media.go
T

142 lines
4.0 KiB
Go
Raw Normal View History

2026-09-18 12:03:35 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package store
import (
"fmt"
"io"
"path"
"path/filepath"
"slices"
"strings"
"time"
"uuid"
"sourcedock.dev/petrbalvin/volumen/internal/imagefile"
)
// MediaPath returns the absolute path of a media file, for a caller that
// serves it. The name must carry an allowed image extension, and the file
// is opened through the store's root, so a name that would escape the
// content directory is refused rather than checked for.
func (s *Store) MediaPath(name string) (string, error) {
if !imagefile.Allowed(name) {
return "", fmt.Errorf("%q is not an allowed image name", name)
}
base := filepath.Base(name)
root, err := s.openRoot()
if err != nil {
return "", err
}
handle, err := root.Open(path.Join(MediaDirName, base))
if err != nil {
return "", err
}
defer handle.Close()
info, err := handle.Stat()
if err != nil {
return "", err
}
if !info.Mode().IsRegular() {
return "", fmt.Errorf("%q is not a regular file", base)
}
return filepath.Join(s.ContentDir, MediaDirName, base), nil
}
// StoreUpload persists an uploaded image and returns its public URL. The
// extension comes from the byte signature, so a stored file always
// matches the type it is served as; data that carries no image signature
// is refused. The caller is responsible for size validation.
func (s *Store) StoreUpload(originalName string, data []byte) (string, error) {
ext := imagefile.Detect(data)
if ext == "" {
return "", fmt.Errorf("unsupported image format in %q", filepath.Base(originalName))
}
root, err := s.openRoot()
if err != nil {
return "", err
}
if err := root.MkdirAll(MediaDirName, 0o755); err != nil {
return "", fmt.Errorf("create media directory: %w", err)
}
name := uuid.NewV4().String() + ext
if err := atomicWriteIn(root, path.Join(MediaDirName, name), data); err != nil {
return "", err
}
return "/media/" + name, nil
}
// DeleteMedia removes a media file by its public URL and reports whether a
// file was removed.
func (s *Store) DeleteMedia(url string) bool {
if url == "" || !strings.HasPrefix(url, "/media/") {
return false
}
root, err := s.openRoot()
if err != nil {
return false
}
return root.Remove(path.Join(MediaDirName, filepath.Base(url))) == nil
}
// Media is one file in the media library.
type Media struct {
Name string
URL string
Size int64
MTime time.Time
// Width and Height are the pixel dimensions the image header
// carries, or 0 when the header does not yield them. Only a short
// prefix of the file is read to learn them.
Width int
Height int
}
// headerPrefix is how much of a media file is read to find the headers
// that carry the pixel dimensions: the WebP chunks, the AVIF `ispe` box,
// or the size attributes of an SVG root element.
const headerPrefix = 64 << 10
// ListMedia returns metadata for every file in the media directory,
// newest first, with the pixel dimensions the headers carry.
func (s *Store) ListMedia() []Media {
root, err := s.openRoot()
if err != nil {
return nil
}
var names []string
for _, entry := range readDirIn(root, MediaDirName) {
if !entry.IsDir() {
names = append(names, entry.Name())
}
}
slices.Sort(names)
out := make([]Media, 0, len(names))
for _, name := range names {
info, ok := statIn(root, MediaDirName, name)
if !ok {
continue
}
item := Media{
Name: name,
URL: "/media/" + name,
Size: info.Size(),
MTime: info.ModTime(),
}
// A header that cannot be read or parsed leaves the dimensions
// at zero; the tile simply shows no size then.
if handle, err := root.Open(path.Join(MediaDirName, name)); err == nil {
head := make([]byte, min(headerPrefix, info.Size()))
if n, err := io.ReadFull(handle, head); n > 0 && (err == nil || err == io.ErrUnexpectedEOF) {
item.Width, item.Height, _ = imagefile.Dimensions(head[:n])
}
handle.Close()
}
out = append(out, item)
}
slices.SortStableFunc(out, func(a, b Media) int { return b.MTime.Compare(a.MTime) })
return out
}