Serve asset downloads via backend instead of redirecting to storage.
CI / test (push) Successful in 3s
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:
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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{}{
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user