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
|
2026-09-29 23:47:27 +02:00
|
|
|
// serves it. The name is either a flat file of the media directory or
|
|
|
|
|
// "avatars/<file>", the one namespace below it the route serves; anything
|
|
|
|
|
// else, and a name that carries no allowed image extension, is refused
|
|
|
|
|
// rather than checked for. The file is opened through the store's root, so
|
|
|
|
|
// a path that would escape it cannot be named.
|
2026-09-18 12:03:35 +02:00
|
|
|
func (s *Store) MediaPath(name string) (string, error) {
|
2026-09-29 23:47:27 +02:00
|
|
|
cleaned := path.Clean(name)
|
|
|
|
|
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "..") {
|
|
|
|
|
return "", fmt.Errorf("%q is not a media name", name)
|
|
|
|
|
}
|
|
|
|
|
dir, base := path.Split(cleaned)
|
|
|
|
|
dir = path.Clean(dir) // "." for a flat name, "avatars" for an avatar
|
|
|
|
|
if base == "." || base == ".." || base == "" {
|
|
|
|
|
return "", fmt.Errorf("%q is not a media name", name)
|
|
|
|
|
}
|
|
|
|
|
if !imagefile.Allowed(base) {
|
|
|
|
|
return "", fmt.Errorf("%q is not an allowed image name", base)
|
|
|
|
|
}
|
|
|
|
|
rel := base
|
|
|
|
|
if dir != "." {
|
|
|
|
|
if dir != AvatarDirName {
|
|
|
|
|
return "", fmt.Errorf("%q is not a served media path", name)
|
|
|
|
|
}
|
|
|
|
|
rel = path.Join(AvatarDirName, base)
|
2026-09-18 12:03:35 +02:00
|
|
|
}
|
|
|
|
|
root, err := s.openRoot()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
2026-09-29 23:47:27 +02:00
|
|
|
handle, err := root.Open(path.Join(MediaDirName, rel))
|
2026-09-18 12:03:35 +02:00
|
|
|
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)
|
|
|
|
|
}
|
2026-09-29 23:47:27 +02:00
|
|
|
return filepath.Join(s.ContentDir, MediaDirName, rel), nil
|
2026-09-18 12:03:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 23:47:27 +02:00
|
|
|
// StoreAvatar persists an account profile photo and returns its public
|
|
|
|
|
// URL, under avatars/ inside the media directory: the photo is account
|
|
|
|
|
// state and not library content, so it never appears as a media tile.
|
|
|
|
|
// The extension comes from the byte signature as StoreUpload does.
|
|
|
|
|
func (s *Store) StoreAvatar(data []byte) (string, error) {
|
|
|
|
|
ext := imagefile.Detect(data)
|
|
|
|
|
if ext == "" {
|
|
|
|
|
return "", fmt.Errorf("unsupported image format")
|
|
|
|
|
}
|
|
|
|
|
root, err := s.openRoot()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
dir := path.Join(MediaDirName, AvatarDirName)
|
|
|
|
|
if err := root.MkdirAll(dir, 0o755); err != nil {
|
|
|
|
|
return "", fmt.Errorf("create avatar directory: %w", err)
|
|
|
|
|
}
|
|
|
|
|
name := uuid.NewV4().String() + ext
|
|
|
|
|
if err := atomicWriteIn(root, path.Join(dir, name), data); err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
return "/media/" + AvatarDirName + "/" + name, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-18 12:03:35 +02:00
|
|
|
// DeleteMedia removes a media file by its public URL and reports whether a
|
2026-09-29 23:47:27 +02:00
|
|
|
// file was removed. The URL names either a flat file of the media
|
|
|
|
|
// directory or an avatar below avatars/; nothing else is accepted, and a
|
|
|
|
|
// URL that names no file at all removes nothing.
|
2026-09-18 12:03:35 +02:00
|
|
|
func (s *Store) DeleteMedia(url string) bool {
|
|
|
|
|
if url == "" || !strings.HasPrefix(url, "/media/") {
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-09-29 23:47:27 +02:00
|
|
|
name := path.Clean(strings.TrimPrefix(url, "/media/"))
|
|
|
|
|
if name == "." || name == ".." || strings.HasPrefix(name, "..") {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
dir, base := path.Split(name)
|
|
|
|
|
dir = path.Clean(dir)
|
|
|
|
|
var rel string
|
|
|
|
|
switch {
|
|
|
|
|
case dir == "." && base != "" && base != "." && base != "..":
|
|
|
|
|
rel = base
|
|
|
|
|
case dir == AvatarDirName && base != "" && base != "." && base != "..":
|
|
|
|
|
rel = path.Join(AvatarDirName, base)
|
|
|
|
|
default:
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-09-18 12:03:35 +02:00
|
|
|
root, err := s.openRoot()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-09-29 23:47:27 +02:00
|
|
|
target := path.Join(MediaDirName, rel)
|
|
|
|
|
// Only a regular file is removed: a URL that resolves to the media
|
|
|
|
|
// directory, the avatar directory or any other directory is refused,
|
|
|
|
|
// so a delete can never empty a namespace.
|
|
|
|
|
if info, err := root.Stat(target); err != nil || !info.Mode().IsRegular() {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return root.Remove(target) == nil
|
2026-09-18 12:03:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|