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:
@@ -5,8 +5,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
@@ -18,14 +19,49 @@ import (
|
||||
"shared-album/internal/config"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
s3 *s3.Client
|
||||
presign *s3.PresignClient
|
||||
bucket string
|
||||
presignTTL time.Duration
|
||||
// ErrSignedURLInvalid is returned when an app-served object URL has a missing,
|
||||
// malformed, expired or forged signature.
|
||||
var ErrSignedURLInvalid = errors.New("invalid or expired object signature")
|
||||
|
||||
// ErrObjectNotFound is returned when the requested blob does not exist.
|
||||
var ErrObjectNotFound = errors.New("object not found")
|
||||
|
||||
// ErrRangeNotSatisfiable is returned when a requested byte range is invalid.
|
||||
var ErrRangeNotSatisfiable = errors.New("requested range not satisfiable")
|
||||
|
||||
// Backend is the object storage abstraction. Keys are opaque and independent of
|
||||
// the backend: every implementation stores and serves the same logical blob.
|
||||
// Blobs are always delivered through the app.
|
||||
type Backend interface {
|
||||
Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error
|
||||
Delete(ctx context.Context, keys ...string) error
|
||||
Get(ctx context.Context, key string) ([]byte, error)
|
||||
// URL returns a time-limited signed app URL the client can use to fetch the blob.
|
||||
URL(ctx context.Context, key string) (string, error)
|
||||
// ServeObject validates the signed URL and writes the blob, including
|
||||
// Content-Type and range handling, to the response.
|
||||
ServeObject(w http.ResponseWriter, r *http.Request, key string) error
|
||||
Ping(ctx context.Context) error
|
||||
EnsureBucket(ctx context.Context) error
|
||||
// Bucket returns a human-readable identifier for the storage location.
|
||||
Bucket() string
|
||||
}
|
||||
|
||||
func New(ctx context.Context, cfg config.S3) (*Client, error) {
|
||||
// S3 is a Backend backed by an S3-compatible object store (Garage). Blobs are
|
||||
// delivered through the app: the client receives a signed app URL and the bytes
|
||||
// are streamed from S3 over the internal network.
|
||||
type S3 struct {
|
||||
s3 *s3.Client
|
||||
bucket string
|
||||
signer *Signer
|
||||
}
|
||||
|
||||
// NewS3 builds an S3-compatible storage backend. signer issues the app URLs used
|
||||
// to serve blobs.
|
||||
func NewS3(ctx context.Context, cfg config.S3, signer *Signer) (*S3, error) {
|
||||
if signer == nil {
|
||||
return nil, errors.New("s3: signer is required")
|
||||
}
|
||||
awsCfg, err := awsconfig.LoadDefaultConfig(ctx,
|
||||
awsconfig.WithRegion(cfg.Region),
|
||||
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
||||
@@ -41,17 +77,38 @@ func New(ctx context.Context, cfg config.S3) (*Client, error) {
|
||||
o.UsePathStyle = cfg.UsePathStyle
|
||||
})
|
||||
|
||||
return &Client{
|
||||
s3: client,
|
||||
presign: s3.NewPresignClient(client),
|
||||
bucket: cfg.Bucket,
|
||||
presignTTL: cfg.PresignTTL,
|
||||
return &S3{
|
||||
s3: client,
|
||||
bucket: cfg.Bucket,
|
||||
signer: signer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Bucket() string { return c.bucket }
|
||||
func (c *S3) Bucket() string { return c.bucket }
|
||||
|
||||
func (c *Client) EnsureBucket(ctx context.Context) error {
|
||||
// Open builds the storage backend selected by configuration: S3-compatible by
|
||||
// default, or the local file system when STORAGE_BACKEND=fs. Every backend
|
||||
// delivers blobs through the app using a shared Signer.
|
||||
func Open(ctx context.Context, cfg *config.Config) (Backend, error) {
|
||||
secret := cfg.Storage.URLSecret
|
||||
if secret == "" {
|
||||
secret = cfg.Auth.JWTSecret
|
||||
}
|
||||
signer, err := NewSigner(cfg.Server.BaseURL, secret, cfg.Storage.URLTTL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch cfg.Storage.Backend {
|
||||
case "", "s3":
|
||||
return NewS3(ctx, cfg.S3, signer)
|
||||
case "fs":
|
||||
return NewFS(cfg.Storage.FSRoot, signer)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown storage backend %q", cfg.Storage.Backend)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *S3) EnsureBucket(ctx context.Context) error {
|
||||
_, err := c.s3.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(c.bucket)})
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -66,12 +123,12 @@ func (c *Client) EnsureBucket(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Ping(ctx context.Context) error {
|
||||
func (c *S3) Ping(ctx context.Context) error {
|
||||
_, err := c.s3.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(c.bucket)})
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
|
||||
func (c *S3) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
|
||||
in := &s3.PutObjectInput{
|
||||
Bucket: aws.String(c.bucket),
|
||||
Key: aws.String(key),
|
||||
@@ -87,7 +144,7 @@ func (c *Client) Put(ctx context.Context, key string, body io.Reader, size int64
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Delete(ctx context.Context, keys ...string) error {
|
||||
func (c *S3) Delete(ctx context.Context, keys ...string) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -104,7 +161,7 @@ func (c *Client) Delete(ctx context.Context, keys ...string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Get(ctx context.Context, key string) ([]byte, error) {
|
||||
func (c *S3) Get(ctx context.Context, key string) ([]byte, error) {
|
||||
out, err := c.s3.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(c.bucket),
|
||||
Key: aws.String(key),
|
||||
@@ -120,14 +177,97 @@ func (c *Client) Get(ctx context.Context, key string) ([]byte, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (c *Client) PresignGet(ctx context.Context, key string) (string, error) { out, err := c.presign.PresignGetObject(ctx, &s3.GetObjectInput{
|
||||
// URL returns a signed app URL. The blob itself is streamed by ServeObject, so
|
||||
// the S3 endpoint is never exposed to the client.
|
||||
func (c *S3) URL(_ context.Context, key string) (string, error) {
|
||||
return c.signer.URL(key), nil
|
||||
}
|
||||
|
||||
// ServeObject validates the signed URL and streams the blob from S3. It passes
|
||||
// the client's Range header through to S3, so byte ranges are handled natively,
|
||||
// and answers HEAD via HeadObject. It writes the response itself.
|
||||
func (c *S3) ServeObject(w http.ResponseWriter, r *http.Request, key string) error {
|
||||
if err := c.signer.VerifyRequest(key, r); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Method == http.MethodHead {
|
||||
return c.headObject(w, r, key)
|
||||
}
|
||||
in := &s3.GetObjectInput{Bucket: aws.String(c.bucket), Key: aws.String(key)}
|
||||
if rng := r.Header.Get("Range"); rng != "" {
|
||||
in.Range = aws.String(rng)
|
||||
}
|
||||
out, err := c.s3.GetObject(r.Context(), in)
|
||||
if err != nil {
|
||||
if isNoSuchKey(err) {
|
||||
return ErrObjectNotFound
|
||||
}
|
||||
if isInvalidRange(err) {
|
||||
return ErrRangeNotSatisfiable
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer out.Body.Close()
|
||||
if ct := aws.ToString(out.ContentType); ct != "" {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
} else {
|
||||
w.Header().Set("Content-Type", contentTypeForKey(key))
|
||||
}
|
||||
if out.ContentLength != nil {
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(*out.ContentLength, 10))
|
||||
}
|
||||
if out.ContentRange != nil {
|
||||
w.Header().Set("Content-Range", *out.ContentRange)
|
||||
}
|
||||
status := http.StatusOK
|
||||
if out.ContentRange != nil {
|
||||
status = http.StatusPartialContent
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
if _, err := io.Copy(w, out.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *S3) headObject(w http.ResponseWriter, r *http.Request, key string) error {
|
||||
out, err := c.s3.HeadObject(r.Context(), &s3.HeadObjectInput{
|
||||
Bucket: aws.String(c.bucket),
|
||||
Key: aws.String(key),
|
||||
}, s3.WithPresignExpires(c.presignTTL))
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("presign %s: %w", key, err)
|
||||
if isNoSuchKey(err) {
|
||||
return ErrObjectNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return out.URL, nil
|
||||
if ct := aws.ToString(out.ContentType); ct != "" {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
} else {
|
||||
w.Header().Set("Content-Type", contentTypeForKey(key))
|
||||
}
|
||||
if out.ContentLength != nil {
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(*out.ContentLength, 10))
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return nil
|
||||
}
|
||||
|
||||
func isNoSuchKey(err error) bool {
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
code := apiErr.ErrorCode()
|
||||
return strings.EqualFold(code, "NoSuchKey") || strings.EqualFold(code, "NotFound")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isInvalidRange(err error) bool {
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
return strings.EqualFold(apiErr.ErrorCode(), "InvalidRange")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isNoSuchBucket(err error) bool {
|
||||
|
||||
Reference in New Issue
Block a user