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:
@@ -100,29 +100,47 @@ func makePNG(t *testing.T) []byte {
|
||||
|
||||
// newTestService builds a service backed by the real dev storage. It skips when
|
||||
// S3 env is absent so unit runs stay hermetic.
|
||||
func newTestService(t *testing.T, pool *pgxpool.Pool) (*Service, *storage.Client) {
|
||||
func newTestService(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend) {
|
||||
t.Helper()
|
||||
endpoint := os.Getenv("S3_ENDPOINT")
|
||||
if endpoint == "" {
|
||||
t.Skip("S3_ENDPOINT not set")
|
||||
}
|
||||
cl, err := storage.New(context.Background(), config.S3{
|
||||
signer, err := storage.NewSigner("http://app.test", "test-secret", time.Hour)
|
||||
if err != nil {
|
||||
t.Skipf("signer init: %v", err)
|
||||
}
|
||||
cl, err := storage.NewS3(context.Background(), config.S3{
|
||||
Endpoint: endpoint,
|
||||
Region: testEnvOr("S3_REGION", "garage"),
|
||||
AccessKey: os.Getenv("S3_ACCESS_KEY"),
|
||||
SecretKey: os.Getenv("S3_SECRET_KEY"),
|
||||
Bucket: testEnvOr("S3_BUCKET", "galo-data"),
|
||||
UsePathStyle: testEnvOr("S3_USE_PATH_STYLE", "true") != "false",
|
||||
PresignTTL: time.Hour,
|
||||
})
|
||||
}, signer)
|
||||
if err != nil {
|
||||
t.Skipf("storage init: %v", err)
|
||||
}
|
||||
return NewService(NewStore(pool), stubAlbums{}, cl, sse.NewHub(1)), cl
|
||||
}
|
||||
|
||||
// newTestServiceFS builds a service backed by a temporary local file-system
|
||||
// storage backend so the full flow can run without external infrastructure.
|
||||
func newTestServiceFS(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend) {
|
||||
t.Helper()
|
||||
signer, err := storage.NewSigner("http://app.test", "test-secret", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("storage init: %v", err)
|
||||
}
|
||||
fs, err := storage.NewFS(t.TempDir(), signer)
|
||||
if err != nil {
|
||||
t.Fatalf("storage init: %v", err)
|
||||
}
|
||||
return NewService(NewStore(pool), stubAlbums{}, fs, sse.NewHub(1)), fs
|
||||
}
|
||||
|
||||
// cleanupPhoto removes a photo, its objects (rows and blobs) and bindings.
|
||||
func cleanupPhoto(t *testing.T, ctx context.Context, pool *pgxpool.Pool, cl *storage.Client, photoID string) {
|
||||
func cleanupPhoto(t *testing.T, ctx context.Context, pool *pgxpool.Pool, cl storage.Backend, photoID string) {
|
||||
t.Helper()
|
||||
rows, err := pool.Query(ctx,
|
||||
`SELECT o.id, o.storage_key FROM photo_objects po JOIN objects o ON o.id = po.object_id
|
||||
|
||||
@@ -24,11 +24,11 @@ type AlbumAccess interface {
|
||||
type Service struct {
|
||||
store *Store
|
||||
albums AlbumAccess
|
||||
storage *storage.Client
|
||||
storage storage.Backend
|
||||
hub *sse.Hub
|
||||
}
|
||||
|
||||
func NewService(store *Store, albums AlbumAccess, storage *storage.Client, hub *sse.Hub) *Service {
|
||||
func NewService(store *Store, albums AlbumAccess, storage storage.Backend, hub *sse.Hub) *Service {
|
||||
return &Service{store: store, albums: albums, storage: storage, hub: hub}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
|
||||
|
||||
func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error {
|
||||
if p.StorageKey != "" {
|
||||
url, err := s.storage.PresignGet(ctx, p.StorageKey)
|
||||
url, err := s.storage.URL(ctx, p.StorageKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -126,12 +126,12 @@ func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error {
|
||||
p.DownloadURL = url
|
||||
}
|
||||
if p.ThumbKey != "" {
|
||||
if url, err := s.storage.PresignGet(ctx, p.ThumbKey); err == nil {
|
||||
if url, err := s.storage.URL(ctx, p.ThumbKey); err == nil {
|
||||
p.ThumbURL = url
|
||||
}
|
||||
}
|
||||
if p.PreviewKey != "" {
|
||||
if url, err := s.storage.PresignGet(ctx, p.PreviewKey); err == nil {
|
||||
if url, err := s.storage.URL(ctx, p.PreviewKey); err == nil {
|
||||
p.PreviewURL = url
|
||||
}
|
||||
}
|
||||
@@ -202,13 +202,13 @@ func (s *Service) GetPublic(ctx context.Context, photoID string) (*models.Photo,
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// DownloadPublic returns a presigned original URL for a share link.
|
||||
// DownloadPublic returns a signed original URL for a share link.
|
||||
func (s *Service) DownloadPublic(ctx context.Context, photoID string) (string, error) {
|
||||
p, err := s.GetPublic(ctx, photoID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.storage.PresignGet(ctx, p.StorageKey)
|
||||
return s.storage.URL(ctx, p.StorageKey)
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, photoID, userID string) (*models.Photo, error) {
|
||||
@@ -236,7 +236,7 @@ func (s *Service) Download(ctx context.Context, photoID, userID string) (string,
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
url, err := s.storage.PresignGet(ctx, p.StorageKey)
|
||||
url, err := s.storage.URL(ctx, p.StorageKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -6,12 +6,25 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"shared-album/internal/storage"
|
||||
)
|
||||
|
||||
func TestWorkerGeneratesVariantsAndCollects(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
svc, cl := newTestService(t, pool)
|
||||
runWorkerFlow(t, pool, svc, cl)
|
||||
}
|
||||
|
||||
func TestWorkerGeneratesVariantsWithFS(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
svc, cl := newTestServiceFS(t, pool)
|
||||
runWorkerFlow(t, pool, svc, cl)
|
||||
}
|
||||
|
||||
func runWorkerFlow(t *testing.T, pool *pgxpool.Pool, svc *Service, cl storage.Backend) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
user := createUser(t, ctx, pool)
|
||||
album := createAlbum(t, ctx, pool, user)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user