Serve asset downloads via backend instead of redirecting to storage.
CI / test (push) Successful in 3s

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
This commit is contained in:
2026-03-02 22:14:12 +00:00
parent 111ed726d8
commit dda20f82e6
4 changed files with 61 additions and 8 deletions
+15
View File
@@ -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)
}
+15 -6
View File
@@ -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{}{
+14 -2
View File
@@ -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
}
}
+17
View File
@@ -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
}