Files
ds-galo/backend/internal/storage/fs.go
T
iu d38ede0201 feat(video): add video upload with remux/transcode playback
Add video as a media kind in photos: streaming upload with per-kind
limits, ffprobe metadata, poster frame reusing the image variant
pipeline, and a playback object (remux when web-compatible, else
h264/yuv420p/aac transcode). Video jobs run on a separate pool with a
long lease. Frontend gains a video viewer, grid badge/duration and
video upload.
2026-10-11 20:37:53 +03:00

250 lines
6.1 KiB
Go

package storage
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
)
// errInvalidKey is returned for object keys that are empty, absolute or attempt
// to escape the storage root.
var errInvalidKey = errors.New("fs: invalid object key")
// FS is a Backend backed by a local directory tree. Blobs live under Root at the
// path formed by the opaque key. Because a local directory has no external URL,
// FS serves blobs through the app using the shared Signer.
type FS struct {
root string
signer *Signer
}
// NewFS builds a local file-system storage backend rooted at root. signer issues
// and validates the app URLs used to serve blobs.
func NewFS(root string, signer *Signer) (*FS, error) {
if strings.TrimSpace(root) == "" {
return nil, errors.New("fs: root is required")
}
if signer == nil {
return nil, errors.New("fs: signer is required")
}
abs, err := filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("fs: resolve root: %w", err)
}
if err := os.MkdirAll(abs, 0o755); err != nil {
return nil, fmt.Errorf("fs: create root: %w", err)
}
return &FS{root: abs, signer: signer}, nil
}
func (f *FS) Bucket() string { return f.root }
func (f *FS) EnsureBucket(context.Context) error {
return os.MkdirAll(f.root, 0o755)
}
func (f *FS) Ping(context.Context) error {
info, err := os.Stat(f.root)
if err != nil {
return err
}
if !info.IsDir() {
return errors.New("fs: root is not a directory")
}
return nil
}
// objectPath maps an opaque key to an absolute path inside the root, rejecting
// absolute keys and any key that resolves outside the root.
func (f *FS) objectPath(key string) (string, error) {
if key == "" || filepath.IsAbs(key) {
return "", errInvalidKey
}
clean := filepath.Clean(key)
if clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) {
return "", errInvalidKey
}
p := filepath.Join(f.root, clean)
rel, err := filepath.Rel(f.root, p)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", errInvalidKey
}
return p, nil
}
func (f *FS) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) error {
p, err := f.objectPath(key)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return fmt.Errorf("fs: mkdir: %w", err)
}
tmp, err := os.CreateTemp(filepath.Dir(p), ".upload-*")
if err != nil {
return fmt.Errorf("fs: temp file: %w", err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if _, err := io.Copy(tmp, body); err != nil {
_ = tmp.Close()
return fmt.Errorf("fs: write: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("fs: close: %w", err)
}
if err := os.Rename(tmpName, p); err != nil {
return fmt.Errorf("fs: rename: %w", err)
}
return nil
}
func (f *FS) Get(_ context.Context, key string) ([]byte, error) {
p, err := f.objectPath(key)
if err != nil {
return nil, err
}
data, err := os.ReadFile(p)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrObjectNotFound
}
return nil, err
}
return data, nil
}
// Open streams a blob from disk for processing without buffering it fully.
func (f *FS) Open(_ context.Context, key string) (io.ReadCloser, error) {
p, err := f.objectPath(key)
if err != nil {
return nil, err
}
file, err := os.Open(p)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrObjectNotFound
}
return nil, err
}
return file, nil
}
func (f *FS) Delete(_ context.Context, keys ...string) error {
var errs []error
for _, key := range keys {
p, err := f.objectPath(key)
if err != nil {
errs = append(errs, err)
continue
}
if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// URL returns a signed app URL served by this process.
func (f *FS) URL(_ context.Context, key string) (string, error) {
if _, err := f.objectPath(key); err != nil {
return "", err
}
return f.signer.URL(key), nil
}
// List returns every blob under prefix as a key relative to the storage root.
func (f *FS) List(_ context.Context, prefix string) ([]ObjectInfo, error) {
var out []ObjectInfo
err := filepath.WalkDir(f.root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(f.root, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if prefix != "" && !strings.HasPrefix(rel, prefix) {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
out = append(out, ObjectInfo{Key: rel, Size: info.Size(), ModTime: info.ModTime()})
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
// ServeObject validates the signed URL and serves the blob. It supports range
// requests and sets the content type from the key extension. It writes the
// response itself and returns nil on success.
func (f *FS) ServeObject(w http.ResponseWriter, r *http.Request, key string) error {
if unescaped, err := url.PathUnescape(key); err == nil {
key = unescaped
}
if err := f.signer.VerifyRequest(key, r); err != nil {
return err
}
p, err := f.objectPath(key)
if err != nil {
return ErrSignedURLInvalid
}
file, err := os.Open(p)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ErrObjectNotFound
}
return err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return err
}
w.Header().Set("Content-Type", contentTypeForKey(key))
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
return nil
}
var imageContentTypes = map[string]string{
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".heic": "image/heic",
".heif": "image/heif",
".tiff": "image/tiff",
".tif": "image/tiff",
".bmp": "image/bmp",
".avif": "image/avif",
}
func contentTypeForKey(key string) string {
ext := strings.ToLower(filepath.Ext(key))
if ct, ok := imageContentTypes[ext]; ok {
return ct
}
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
return "application/octet-stream"
}