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.
250 lines
6.1 KiB
Go
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"
|
|
}
|