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 { type AssetURLSigner interface {
SignedGetObjectURL(ctx context.Context, objectKey string, expiry time.Duration) (string, error) SignedGetObjectURL(ctx context.Context, objectKey string, expiry time.Duration) (string, error)
PutObject(ctx context.Context, objectKey, contentType string, body io.Reader, size int64) 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 { type Service struct {
@@ -551,3 +552,17 @@ func (s *Service) SignedDownloadURL(requesterKey, assetID string) (string, error
} }
return url, nil 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 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 { func mustJSON(t *testing.T, value interface{}) []byte {
t.Helper() t.Helper()
b, err := json.Marshal(value) b, err := json.Marshal(value)
@@ -314,7 +319,6 @@ func TestAssetLifecycleAndVisibility(t *testing.T) {
server := newTestServer(adminPubB64) server := newTestServer(adminPubB64)
defer server.Close() defer server.Close()
client := server.Client() client := server.Client()
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
adminToken := loginUser(t, client, server.URL, adminPubB64, adminPriv) adminToken := loginUser(t, client, server.URL, adminPubB64, adminPriv)
@@ -418,12 +422,17 @@ func TestAssetLifecycleAndVisibility(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("download public request failed: %v", err) t.Fatalf("download public request failed: %v", err)
} }
if downloadPublicResp.StatusCode != http.StatusFound { defer downloadPublicResp.Body.Close()
t.Fatalf("expected public asset redirect status, got %d", downloadPublicResp.StatusCode) 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") body, err := io.ReadAll(downloadPublicResp.Body)
if downloadPublicResp.Header.Get("Location") != expectedLocation { if err != nil {
t.Fatalf("unexpected redirect location: %s", downloadPublicResp.Header.Get("Location")) 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{}{ patchResp, patchData := patchJSON(t, client, server.URL+"/v1/assets/"+assetID, map[string]interface{}{
+14 -2
View File
@@ -3,6 +3,8 @@ package httpapi
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -492,10 +494,20 @@ func (a *API) downloadAsset(w http.ResponseWriter, r *http.Request) {
return return
} }
assetID := r.PathValue("id") assetID := r.PathValue("id")
url, err := a.service.SignedDownloadURL(user, assetID) reader, contentType, size, err := a.service.OpenAssetDownload(user, assetID)
if err != nil { if err != nil {
writeErr(w, err) writeErr(w, err)
return 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 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
}