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
+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())
}
}