feat(storage): serve media through backend and add local FS backend
- introduce storage.Backend with S3 and local file-system implementations - deliver all blobs via signed GET/HEAD /api/objects/<key> (Range, Content-Type) - stream S3 objects through the backend; remove presigned URL path - share shared HMAC Signer between backends - remove S3_PUBLIC_URL; rename S3_PRESIGN_TTL to OBJECT_URL_TTL - make storage label in health dynamic; nginx proxy_buffering off for objects
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
Reference in New Issue
Block a user