From dda20f82e6cf285a776d6900f717ba2c3321767d Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Mon, 2 Mar 2026 22:14:12 +0000 Subject: [PATCH] Serve asset downloads via backend instead of redirecting to storage. The download endpoint now streams object bytes from storage on the same API URL so clients never get redirected to MinIO/internal hosts, while preserving public/private access checks. Made-with: Cursor --- internal/app/service.go | 15 +++++++++++++++ internal/http/api_test.go | 21 +++++++++++++++------ internal/http/handlers.go | 16 ++++++++++++++-- internal/storage/s3_signer.go | 17 +++++++++++++++++ 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/internal/app/service.go b/internal/app/service.go index c7ea14a..e00efe0 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -39,6 +39,7 @@ type Config struct { type AssetURLSigner interface { SignedGetObjectURL(ctx context.Context, objectKey string, expiry time.Duration) (string, error) PutObject(ctx context.Context, objectKey, contentType string, body io.Reader, size int64) error + GetObject(ctx context.Context, objectKey string) (io.ReadCloser, string, int64, error) } type Service struct { @@ -551,3 +552,17 @@ func (s *Service) SignedDownloadURL(requesterKey, assetID string) (string, error } return url, nil } + +func (s *Service) OpenAssetDownload(requesterKey, assetID string) (io.ReadCloser, string, int64, error) { + if s.assetSigner == nil { + return nil, "", 0, ErrStorageNotConfigured + } + asset, err := s.store.GetAsset(assetID) + if err != nil { + return nil, "", 0, ErrAssetMiss + } + if asset.OwnerKey != requesterKey && !asset.IsPublic { + return nil, "", 0, ErrForbidden + } + return s.assetSigner.GetObject(context.Background(), asset.ObjectKey) +} diff --git a/internal/http/api_test.go b/internal/http/api_test.go index 222e8bd..4c527f9 100644 --- a/internal/http/api_test.go +++ b/internal/http/api_test.go @@ -41,6 +41,11 @@ func (fakeSigner) PutObject(_ context.Context, _ string, _ string, _ io.Reader, return nil } +func (fakeSigner) GetObject(_ context.Context, objectKey string) (io.ReadCloser, string, int64, error) { + payload := []byte("fake-download:" + objectKey) + return io.NopCloser(bytes.NewReader(payload)), "application/octet-stream", int64(len(payload)), nil +} + func mustJSON(t *testing.T, value interface{}) []byte { t.Helper() b, err := json.Marshal(value) @@ -314,7 +319,6 @@ func TestAssetLifecycleAndVisibility(t *testing.T) { server := newTestServer(adminPubB64) defer server.Close() client := server.Client() - client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } adminToken := loginUser(t, client, server.URL, adminPubB64, adminPriv) @@ -418,12 +422,17 @@ func TestAssetLifecycleAndVisibility(t *testing.T) { if err != nil { t.Fatalf("download public request failed: %v", err) } - if downloadPublicResp.StatusCode != http.StatusFound { - t.Fatalf("expected public asset redirect status, got %d", downloadPublicResp.StatusCode) + defer downloadPublicResp.Body.Close() + if downloadPublicResp.StatusCode != http.StatusOK { + t.Fatalf("expected public asset stream status, got %d", downloadPublicResp.StatusCode) } - expectedLocation := fmt.Sprintf("http://files.local/download/%s/%s.%s", user1PubB64, "abcdef1234", "glb") - if downloadPublicResp.Header.Get("Location") != expectedLocation { - t.Fatalf("unexpected redirect location: %s", downloadPublicResp.Header.Get("Location")) + body, err := io.ReadAll(downloadPublicResp.Body) + if err != nil { + t.Fatalf("read public download body: %v", err) + } + expectedBody := fmt.Sprintf("fake-download:%s/%s.%s", user1PubB64, "abcdef1234", "glb") + if string(body) != expectedBody { + t.Fatalf("unexpected download body: %q", string(body)) } patchResp, patchData := patchJSON(t, client, server.URL+"/v1/assets/"+assetID, map[string]interface{}{ diff --git a/internal/http/handlers.go b/internal/http/handlers.go index 9b78b59..2546195 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -3,6 +3,8 @@ package httpapi import ( "encoding/json" "errors" + "fmt" + "io" "net/http" "strings" "time" @@ -492,10 +494,20 @@ func (a *API) downloadAsset(w http.ResponseWriter, r *http.Request) { return } assetID := r.PathValue("id") - url, err := a.service.SignedDownloadURL(user, assetID) + reader, contentType, size, err := a.service.OpenAssetDownload(user, assetID) if err != nil { writeErr(w, err) return } - http.Redirect(w, r, url, http.StatusFound) + defer reader.Close() + if contentType != "" { + w.Header().Set("Content-Type", contentType) + } + if size >= 0 { + w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) + } + if _, err := io.Copy(w, reader); err != nil { + // Response stream may be interrupted by client disconnects; ignore write errors here. + return + } } diff --git a/internal/storage/s3_signer.go b/internal/storage/s3_signer.go index 5759cbf..5ed4ba5 100644 --- a/internal/storage/s3_signer.go +++ b/internal/storage/s3_signer.go @@ -62,3 +62,20 @@ func (s *S3Signer) PutObject(ctx context.Context, objectKey, contentType string, }) return err } + +func (s *S3Signer) GetObject(ctx context.Context, objectKey string) (io.ReadCloser, string, int64, error) { + obj, err := s.client.GetObject(ctx, s.bucket, objectKey, minio.GetObjectOptions{}) + if err != nil { + return nil, "", 0, err + } + info, err := obj.Stat() + if err != nil { + _ = obj.Close() + return nil, "", 0, err + } + contentType := info.ContentType + if contentType == "" { + contentType = "application/octet-stream" + } + return obj, contentType, info.Size, nil +}