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:
iu
2026-10-11 09:15:03 +03:00
parent 7ae9bcf305
commit ec8d907565
28 changed files with 1714 additions and 65 deletions
+201
View File
@@ -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"
}
+174
View File
@@ -0,0 +1,174 @@
package storage
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
)
func newTestFS(t *testing.T) *FS {
t.Helper()
signer, err := NewSigner("http://app.test", "test-secret", time.Hour)
if err != nil {
t.Fatalf("signer: %v", err)
}
fs, err := NewFS(t.TempDir(), signer)
if err != nil {
t.Fatalf("new fs: %v", err)
}
return fs
}
func TestFSRoundTrip(t *testing.T) {
ctx := context.Background()
fs := newTestFS(t)
key := "objects/abc/original.jpg"
data := []byte("hello blob")
if err := fs.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "image/jpeg"); err != nil {
t.Fatalf("put: %v", err)
}
got, err := fs.Get(ctx, key)
if err != nil {
t.Fatalf("get: %v", err)
}
if !bytes.Equal(got, data) {
t.Fatalf("get = %q, want %q", got, data)
}
if err := fs.Delete(ctx, key); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := fs.Get(ctx, key); !errors.Is(err, ErrObjectNotFound) {
t.Fatalf("get after delete = %v, want ErrObjectNotFound", err)
}
}
func TestFSPathTraversalRejected(t *testing.T) {
ctx := context.Background()
fs := newTestFS(t)
for _, key := range []string{"../escape", "../../etc/passwd", "/etc/passwd", ""} {
if err := fs.Put(ctx, key, strings.NewReader("x"), 1, "text/plain"); err == nil {
t.Errorf("put %q: expected rejection", key)
}
if _, err := fs.Get(ctx, key); err == nil {
t.Errorf("get %q: expected rejection", key)
}
if _, err := fs.URL(ctx, key); err == nil {
t.Errorf("url %q: expected rejection", key)
}
}
}
func TestFSURLAndServe(t *testing.T) {
ctx := context.Background()
fs := newTestFS(t)
key := "objects/abc/thumb.webp"
if err := fs.Put(ctx, key, strings.NewReader("webp-bytes"), 10, "image/webp"); err != nil {
t.Fatalf("put: %v", err)
}
raw, err := fs.URL(ctx, key)
if err != nil {
t.Fatalf("url: %v", err)
}
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse url: %v", err)
}
if !strings.HasPrefix(u.Path, "/api/objects/objects/abc/thumb.webp") {
t.Fatalf("unexpected path: %s", u.Path)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, raw, nil)
if err := fs.ServeObject(rec, req, strings.TrimPrefix(u.Path, "/api/objects/")); err != nil {
t.Fatalf("serve: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "image/webp" {
t.Fatalf("content-type = %q, want image/webp", ct)
}
if rec.Body.String() != "webp-bytes" {
t.Fatalf("body = %q", rec.Body.String())
}
}
func TestFSServeExpiredAndForged(t *testing.T) {
ctx := context.Background()
fs := newTestFS(t)
key := "objects/abc/original.jpg"
if err := fs.Put(ctx, key, strings.NewReader("x"), 1, "image/jpeg"); err != nil {
t.Fatalf("put: %v", err)
}
past := time.Now().Add(-time.Minute).Unix()
expired := "/api/objects/" + key + "?e=" + strconv.FormatInt(past, 10) + "&s=" + fs.signer.sign(key, past)
rec := httptest.NewRecorder()
err := fs.ServeObject(rec, httptest.NewRequest(http.MethodGet, expired, nil), key)
if !errors.Is(err, ErrSignedURLInvalid) {
t.Fatalf("expired = %v, want ErrSignedURLInvalid", err)
}
future := time.Now().Add(time.Hour).Unix()
forged := "/api/objects/" + key + "?e=" + strconv.FormatInt(future, 10) + "&s=deadbeef"
rec = httptest.NewRecorder()
err = fs.ServeObject(rec, httptest.NewRequest(http.MethodGet, forged, nil), key)
if !errors.Is(err, ErrSignedURLInvalid) {
t.Fatalf("forged = %v, want ErrSignedURLInvalid", err)
}
}
func TestFSServeRangeAndMissing(t *testing.T) {
ctx := context.Background()
fs := newTestFS(t)
key := "objects/abc/original.jpg"
if err := fs.Put(ctx, key, strings.NewReader("0123456789"), 10, "image/jpeg"); err != nil {
t.Fatalf("put: %v", err)
}
exp := time.Now().Add(time.Hour).Unix()
base := "/api/objects/" + key + "?e=" + strconv.FormatInt(exp, 10) + "&s=" + fs.signer.sign(key, exp)
req := httptest.NewRequest(http.MethodGet, base, nil)
req.Header.Set("Range", "bytes=2-5")
rec := httptest.NewRecorder()
if err := fs.ServeObject(rec, req, key); err != nil {
t.Fatalf("serve range: %v", err)
}
if rec.Code != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", rec.Code)
}
if body, _ := io.ReadAll(rec.Body); string(body) != "2345" {
t.Fatalf("range body = %q, want 2345", body)
}
missingKey := "objects/def/original.jpg"
missing := "/api/objects/" + missingKey + "?e=" + strconv.FormatInt(exp, 10) + "&s=" + fs.signer.sign(missingKey, exp)
rec = httptest.NewRecorder()
err := fs.ServeObject(rec, httptest.NewRequest(http.MethodGet, missing, nil), missingKey)
if !errors.Is(err, ErrObjectNotFound) {
t.Fatalf("missing = %v, want ErrObjectNotFound", err)
}
}
func TestContentTypeForKey(t *testing.T) {
cases := map[string]string{
"objects/a/original.jpg": "image/jpeg",
"objects/a/thumb.webp": "image/webp",
"objects/a/odd.unknownext": "application/octet-stream",
}
for key, want := range cases {
if got := contentTypeForKey(key); got != want {
t.Errorf("contentTypeForKey(%q) = %q, want %q", key, got, want)
}
}
}
+142
View File
@@ -0,0 +1,142 @@
package storage
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
"time"
"shared-album/internal/config"
)
func newTestS3(t *testing.T) (*S3, *Signer) {
t.Helper()
endpoint := os.Getenv("S3_ENDPOINT")
if endpoint == "" {
t.Skip("S3_ENDPOINT not set")
}
signer, err := NewSigner("http://app.test", "secret", time.Hour)
if err != nil {
t.Fatalf("signer: %v", err)
}
cl, err := NewS3(context.Background(), config.S3{
Endpoint: endpoint,
Region: os.Getenv("S3_REGION"),
AccessKey: os.Getenv("S3_ACCESS_KEY"),
SecretKey: os.Getenv("S3_SECRET_KEY"),
Bucket: os.Getenv("S3_BUCKET"),
UsePathStyle: true,
}, signer)
if err != nil {
t.Skipf("storage init: %v", err)
}
return cl, signer
}
func signedQuery(signer *Signer, key string, exp int64) string {
return "?e=" + strconv.FormatInt(exp, 10) + "&s=" + signer.sign(key, exp)
}
func TestS3ServeObject(t *testing.T) {
ctx := context.Background()
cl, signer := newTestS3(t)
key := "objects/" + strconv.FormatInt(time.Now().UnixNano(), 10) + "/original.jpg"
data := []byte("0123456789")
if err := cl.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "image/jpeg"); err != nil {
t.Fatalf("put: %v", err)
}
t.Cleanup(func() { _ = cl.Delete(context.Background(), key) })
exp := time.Now().Add(time.Hour).Unix()
base := "/api/objects/" + key + signedQuery(signer, key, exp)
t.Run("full", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, base, nil)
if err := cl.ServeObject(rec, req, key); err != nil {
t.Fatalf("serve: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !bytes.Equal(rec.Body.Bytes(), data) {
t.Fatalf("body = %q", rec.Body.String())
}
})
t.Run("range", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, base, nil)
req.Header.Set("Range", "bytes=2-5")
if err := cl.ServeObject(rec, req, key); err != nil {
t.Fatalf("serve range: %v", err)
}
if rec.Code != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", rec.Code)
}
if body, _ := io.ReadAll(rec.Body); string(body) != "2345" {
t.Fatalf("range body = %q, want 2345", body)
}
})
t.Run("invalid range", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, base, nil)
req.Header.Set("Range", "bytes=1000-2000")
err := cl.ServeObject(rec, req, key)
if !errors.Is(err, ErrRangeNotSatisfiable) {
t.Fatalf("err = %v, want ErrRangeNotSatisfiable", err)
}
})
t.Run("missing", func(t *testing.T) {
missingKey := "objects/" + strconv.FormatInt(time.Now().UnixNano(), 10) + "/original.jpg"
target := "/api/objects/" + missingKey + signedQuery(signer, missingKey, exp)
rec := httptest.NewRecorder()
err := cl.ServeObject(rec, httptest.NewRequest(http.MethodGet, target, nil), missingKey)
if !errors.Is(err, ErrObjectNotFound) {
t.Fatalf("err = %v, want ErrObjectNotFound", err)
}
})
t.Run("expired signature", func(t *testing.T) {
past := time.Now().Add(-time.Minute).Unix()
target := "/api/objects/" + key + signedQuery(signer, key, past)
rec := httptest.NewRecorder()
err := cl.ServeObject(rec, httptest.NewRequest(http.MethodGet, target, nil), key)
if !errors.Is(err, ErrSignedURLInvalid) {
t.Fatalf("err = %v, want ErrSignedURLInvalid", err)
}
})
}
func TestS3ServeObjectHead(t *testing.T) {
ctx := context.Background()
cl, signer := newTestS3(t)
key := "objects/" + strconv.FormatInt(time.Now().UnixNano(), 10) + "/original.jpg"
data := []byte("head-check")
if err := cl.Put(ctx, key, strings.NewReader(string(data)), int64(len(data)), "image/jpeg"); err != nil {
t.Fatalf("put: %v", err)
}
t.Cleanup(func() { _ = cl.Delete(context.Background(), key) })
exp := time.Now().Add(time.Hour).Unix()
target := "/api/objects/" + key + signedQuery(signer, key, exp)
rec := httptest.NewRecorder()
if err := cl.ServeObject(rec, httptest.NewRequest(http.MethodHead, target, nil), key); err != nil {
t.Fatalf("head: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if rec.Body.Len() != 0 {
t.Fatalf("HEAD body should be empty, got %d bytes", rec.Body.Len())
}
}
+80
View File
@@ -0,0 +1,80 @@
package storage
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// Signer produces and verifies the signed app URLs used by the /api/objects
// route. It is shared by every storage backend so delivery is identical: the
// client always talks to the backend, never to object storage directly.
type Signer struct {
secret []byte
baseURL string
ttl time.Duration
}
// NewSigner builds a signer. secret signs the URLs (required); baseURL is the
// public base URL of the app; ttl limits URL lifetime (defaults to one hour).
func NewSigner(baseURL, secret string, ttl time.Duration) (*Signer, error) {
if strings.TrimSpace(secret) == "" {
return nil, errors.New("url secret is required")
}
if ttl <= 0 {
ttl = time.Hour
}
return &Signer{
secret: []byte(secret),
baseURL: strings.TrimRight(baseURL, "/"),
ttl: ttl,
}, nil
}
// URL returns a signed app URL for the given opaque key.
func (s *Signer) URL(key string) string {
exp := time.Now().Add(s.ttl).Unix()
q := url.Values{}
q.Set("e", strconv.FormatInt(exp, 10))
q.Set("s", s.sign(key, exp))
return fmt.Sprintf("%s/api/objects/%s?%s", s.baseURL, escapeKey(key), q.Encode())
}
// VerifyRequest checks the signature and expiry carried by an object request.
// It returns ErrSignedURLInvalid when the request is not authorized.
func (s *Signer) VerifyRequest(key string, r *http.Request) error {
q := r.URL.Query()
exp, err := strconv.ParseInt(q.Get("e"), 10, 64)
if err != nil || !s.verify(key, exp, q.Get("s")) {
return ErrSignedURLInvalid
}
return nil
}
func (s *Signer) sign(key string, exp int64) string {
mac := hmac.New(sha256.New, s.secret)
_, _ = fmt.Fprintf(mac, "%s\n%d", key, exp)
return hex.EncodeToString(mac.Sum(nil))
}
func (s *Signer) verify(key string, exp int64, sig string) bool {
if sig == "" || time.Now().Unix() > exp {
return false
}
return hmac.Equal([]byte(s.sign(key, exp)), []byte(sig))
}
func escapeKey(key string) string {
parts := strings.Split(key, "/")
for i, p := range parts {
parts[i] = url.PathEscape(p)
}
return strings.Join(parts, "/")
}
+162 -22
View File
@@ -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 {