// Copyright (c) 2026 Petr BalvĂ­n (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 is either a flat file of the media directory or // "avatars/", 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. func (s *Store) MediaPath(name string) (string, error) { 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) } root, err := s.openRoot() if err != nil { return "", err } handle, err := root.Open(path.Join(MediaDirName, rel)) 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, rel), 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 } // 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 } // DeleteMedia removes a media file by its public URL and reports whether a // 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. func (s *Store) DeleteMedia(url string) bool { if url == "" || !strings.HasPrefix(url, "/media/") { return false } 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 } root, err := s.openRoot() if err != nil { return false } 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 } // 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 }