oss请求处理

This commit is contained in:
andy committed 2026-08-19 10:52:55 +08:00
1 parent 18cb51670c
commit a9a4cdf125
18 files changed
+450 -29

No files matched your search

+1 -1
View File
@@ -306,7 +306,7 @@ func New(options Options) (*App, error) {
} }
webhookBridge := orchestration.NewWebhookBridge(webhook.NewDeliverer(webhookSender, getenv("ZHINIAN_WEBHOOK_SECRET"), nil)) webhookBridge := orchestration.NewWebhookBridge(webhook.NewDeliverer(webhookSender, getenv("ZHINIAN_WEBHOOK_SECRET"), nil))
outputs := orchestration.NewAssetOutputRegistrar(assetService, orchestration.ResolveProviderOutputURLs) outputs := orchestration.NewAssetOutputRegistrar(assetService, orchestration.ResolveProviderOutputURLs)
providerProcessor := jobs.ProviderProcessor{Providers: providerResolver, Store: jobStore} providerProcessor := jobs.ProviderProcessor{Providers: providerResolver, Store: jobStore, AssetURLs: assetService, AssetURLTTL: assets.MaximumSignedURLTTL}
settlementProcessor := orchestration.NewSettlementProcessor(providerProcessor, ledger, settlementState, nil) settlementProcessor := orchestration.NewSettlementProcessor(providerProcessor, ledger, settlementState, nil)
processor := orchestration.NewOutputRegisteringProcessor(settlementProcessor, outputs, jobState) processor := orchestration.NewOutputRegisteringProcessor(settlementProcessor, outputs, jobState)
artifacts := orchestration.NewAssetArtifacts(assetService) artifacts := orchestration.NewAssetArtifacts(assetService)
+10 -1
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -480,6 +481,14 @@ func (store prefixedBlobStore) Delete(ctx context.Context, key string) error {
return store.store.Delete(ctx, path.Join(store.prefix, key)) return store.store.Delete(ctx, path.Join(store.prefix, key))
} }
func (store prefixedBlobStore) SignReadURL(key string, ttl time.Duration) (string, error) {
signer, ok := store.store.(assets.BlobURLSigner)
if !ok {
return "", errors.New("blob store does not support signed URLs")
}
return signer.SignReadURL(path.Join(store.prefix, key), ttl)
}
func configuredOSSBlobStore(getenv postgres.Getenv) (assets.BlobStore, bool, error) { func configuredOSSBlobStore(getenv postgres.Getenv) (assets.BlobStore, bool, error) {
endpoint, bucket := strings.TrimSpace(getenv("ALI_OSS_ENDPOINT")), strings.TrimSpace(getenv("ALI_OSS_BUCKET")) endpoint, bucket := strings.TrimSpace(getenv("ALI_OSS_ENDPOINT")), strings.TrimSpace(getenv("ALI_OSS_BUCKET"))
accessKeyID, secret := strings.TrimSpace(getenv("ALI_OSS_ACCESS_KEY_ID")), strings.TrimSpace(getenv("ALI_OSS_ACCESS_KEY_SECRET")) accessKeyID, secret := strings.TrimSpace(getenv("ALI_OSS_ACCESS_KEY_ID")), strings.TrimSpace(getenv("ALI_OSS_ACCESS_KEY_SECRET"))
@@ -491,7 +500,7 @@ func configuredOSSBlobStore(getenv postgres.Getenv) (assets.BlobStore, bool, err
if err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
store, err := assets.NewOSS(assets.OSSConfig{Endpoint: endpoint, Bucket: bucket, PublicBaseURL: publicURL, PublicRead: true}, client) store, err := assets.NewOSS(assets.OSSConfig{Endpoint: endpoint, Bucket: bucket, PublicBaseURL: publicURL, PublicRead: false}, client)
if err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
+39 -1
View File
@@ -12,6 +12,7 @@ import (
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
"time"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/assets" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/assets"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/billing" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/billing"
@@ -320,9 +321,42 @@ func TestPrefixedBlobStoreKeepsApplicationStoragePathStable(t *testing.T) {
if !reflect.DeepEqual([]string{inner.readKey, inner.deleteKey}, []string{"tenant-prefix/uploads/day/file.png", "tenant-prefix/uploads/day/file.png"}) { if !reflect.DeepEqual([]string{inner.readKey, inner.deleteKey}, []string{"tenant-prefix/uploads/day/file.png", "tenant-prefix/uploads/day/file.png"}) {
t.Fatalf("read/delete=%q/%q", inner.readKey, inner.deleteKey) t.Fatalf("read/delete=%q/%q", inner.readKey, inner.deleteKey)
} }
if _, err := store.SignReadURL(stored.Key, time.Hour); err != nil || inner.signedKey != "tenant-prefix/uploads/day/file.png" || inner.signedTTL != time.Hour {
t.Fatalf("signed URL delegation = %q / %s, err=%v", inner.signedKey, inner.signedTTL, err)
}
} }
type recordingBlobStore struct{ putKey, readKey, deleteKey string } func TestConfiguredOSSBlobStoreDoesNotRequestPublicObjectACL(t *testing.T) {
var requests []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
requests = append(requests, request.Method+" "+request.URL.RequestURI())
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
values := map[string]string{
"ALI_OSS_ENDPOINT": server.URL + "/private-bucket",
"ALI_OSS_BUCKET": "private-bucket",
"ALI_OSS_ACCESS_KEY_ID": "test-access-key",
"ALI_OSS_ACCESS_KEY_SECRET": "test-access-secret",
"ALI_OSS_PUBLIC_BASE_URL": server.URL + "/private-bucket",
}
store, configured, err := configuredOSSBlobStore(func(name string) string { return values[name] })
if err != nil || !configured {
t.Fatalf("configured store = %T, %v, configured=%v", store, err, configured)
}
if _, err := store.Put(context.Background(), "uploads/day/private.png", bytes.NewReader([]byte("png")), 3, "image/png"); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(requests, []string{"PUT /private-bucket/zhinian/uploads/day/private.png"}) {
t.Fatalf("OSS requests = %#v, want one private PutObject request", requests)
}
}
type recordingBlobStore struct {
putKey, readKey, deleteKey, signedKey string
signedTTL time.Duration
}
type applicationRuntimeSettingsRepository struct { type applicationRuntimeSettingsRepository struct {
values map[string]string values map[string]string
@@ -360,3 +394,7 @@ func (s *recordingBlobStore) Delete(_ context.Context, key string) error {
s.deleteKey = key s.deleteKey = key
return nil return nil
} }
func (s *recordingBlobStore) SignReadURL(key string, ttl time.Duration) (string, error) {
s.signedKey, s.signedTTL = key, ttl
return "https://signed.example/" + key, nil
}
+49
View File
@@ -126,6 +126,9 @@ type BlobStore interface {
Read(context.Context, string) (Blob, error) Read(context.Context, string) (Blob, error)
Delete(context.Context, string) error Delete(context.Context, string) error
} }
type BlobURLSigner interface {
SignReadURL(string, time.Duration) (string, error)
}
type RemoteFetcher interface { type RemoteFetcher interface {
Fetch(context.Context, string) (Blob, error) Fetch(context.Context, string) (Blob, error)
} }
@@ -364,6 +367,52 @@ func (s *Service) Download(ctx context.Context, scope Scope, id string) (Blob, e
return s.remote.Fetch(ctx, a.URL) return s.remote.Fetch(ctx, a.URL)
} }
// SignedDownloadURL returns a short-lived direct URL only when the configured
// blob store supports it. Callers can otherwise fall back to Download without
// exposing storage credentials or assuming that every backend is OSS.
func (s *Service) SignedDownloadURL(ctx context.Context, scope Scope, id string, ttl time.Duration) (string, bool, error) {
a, err := s.Get(ctx, scope, id)
if err != nil {
return "", false, err
}
return s.signedDownloadURL(a, ttl)
}
func (s *Service) signedDownloadURL(a Asset, ttl time.Duration) (string, bool, error) {
if a.StoragePath == "" || s.blobs == nil {
return "", false, nil
}
signer, ok := s.blobs.(BlobURLSigner)
if !ok {
return "", false, nil
}
signed, err := signer.SignReadURL(a.StoragePath, ttl)
if err != nil {
return "", false, err
}
return signed, true, nil
}
// ResolveProviderAssetURL implements the jobs package's narrow resolver seam
// without importing jobs. The stable source URL is used only to replace the
// matching value in a transient provider request; the signed URL is never
// persisted in the asset catalog or job payload.
func (s *Service) ResolveProviderAssetURL(ctx context.Context, ownerID, id string, ttl time.Duration) (string, string, error) {
scope := PlatformScope(ownerID)
a, err := s.Get(ctx, scope, id)
if err != nil {
return "", "", err
}
signed, ok, err := s.signedDownloadURL(a, ttl)
if err != nil {
return "", "", err
}
if ok {
return a.URL, signed, nil
}
return a.URL, a.URL, nil
}
// DownloadPath serves a stored object only after its metadata has been found // DownloadPath serves a stored object only after its metadata has been found
// in the requesting owner's catalog. Catalogs that support HTTP file serving // in the requesting owner's catalog. Catalogs that support HTTP file serving
// implement the optional storage-path lookup without widening other callers. // implement the optional storage-path lookup without widening other callers.
+17
View File
@@ -9,6 +9,7 @@ import (
"net/url" "net/url"
"path" "path"
"strings" "strings"
"time"
) )
const OSSACLPublicRead = "public-read" const OSSACLPublicRead = "public-read"
@@ -37,6 +38,10 @@ type OSSClient interface {
Delete(context.Context, OSSObjectRequest) error Delete(context.Context, OSSObjectRequest) error
} }
type OSSURLSigner interface {
SignGetURL(OSSObjectRequest, time.Duration) (string, error)
}
type OSSError struct { type OSSError struct {
Status int Status int
Code string Code string
@@ -116,6 +121,17 @@ func (s *OSS) Delete(ctx context.Context, key string) error {
return mapOSSError(err) return mapOSSError(err)
} }
func (s *OSS) SignReadURL(key string, ttl time.Duration) (string, error) {
if err := validOSSKey(key); err != nil {
return "", err
}
signer, ok := s.client.(OSSURLSigner)
if !ok {
return "", errors.New("OSS client does not support signed URLs")
}
return signer.SignGetURL(OSSObjectRequest{Endpoint: s.config.PublicBaseURL, Bucket: s.config.Bucket, Key: key}, ttl)
}
type ossOperationDiagnostic struct { type ossOperationDiagnostic struct {
Operation string Operation string
Status int Status int
@@ -182,3 +198,4 @@ func escapeOSSKey(key string) string {
} }
var _ BlobStore = (*OSS)(nil) var _ BlobStore = (*OSS)(nil)
var _ BlobURLSigner = (*OSS)(nil)
+32
View File
@@ -16,6 +16,7 @@ import (
"net/url" "net/url"
"path" "path"
"sort" "sort"
"strconv"
"strings" "strings"
"syscall" "syscall"
"time" "time"
@@ -23,6 +24,11 @@ import (
const maxOSSErrorBody = 64 << 10 const maxOSSErrorBody = 64 << 10
const (
DefaultSignedURLTTL = time.Hour
MaximumSignedURLTTL = 9 * time.Hour
)
// OSSHTTPClient implements OSSClient using Aliyun OSS's HTTP authorization // OSSHTTPClient implements OSSClient using Aliyun OSS's HTTP authorization
// protocol. The supplied HTTP client owns timeout and transport policy. // protocol. The supplied HTTP client owns timeout and transport policy.
type OSSHTTPClient struct { type OSSHTTPClient struct {
@@ -112,6 +118,31 @@ func (c *OSSHTTPClient) Delete(ctx context.Context, request OSSObjectRequest) er
return consumeOSSResponse(response) return consumeOSSResponse(response)
} }
// SignGetURL creates an OSS V1 query-signed GET URL. The expiration is bounded
// to OSS's documented maximum and the operation is local: no network request is
// performed and no secret is embedded in the resulting URL.
func (c *OSSHTTPClient) SignGetURL(request OSSObjectRequest, ttl time.Duration) (string, error) {
if ttl <= 0 || ttl > MaximumSignedURLTTL {
return "", errors.New("OSS signed URL lifetime must be between zero and nine hours")
}
requestURL, err := ossObjectURL(request.Endpoint, request.Bucket, request.Key, "")
if err != nil {
return "", err
}
expires := c.now().UTC().Add(ttl).Unix()
canonicalResource := "/" + request.Bucket + "/" + request.Key
stringToSign := strings.Join([]string{http.MethodGet, "", "", strconv.FormatInt(expires, 10), canonicalResource}, "\n")
mac := hmac.New(sha1.New, []byte(c.accessKeySecret))
_, _ = mac.Write([]byte(stringToSign))
signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
query := requestURL.Query()
query.Set("OSSAccessKeyId", c.accessKeyID)
query.Set("Expires", strconv.FormatInt(expires, 10))
query.Set("Signature", signature)
requestURL.RawQuery = query.Encode()
return requestURL.String(), nil
}
func (c *OSSHTTPClient) newRequest(ctx context.Context, method, endpoint, bucket, key, subresource string, body io.Reader) (*http.Request, error) { func (c *OSSHTTPClient) newRequest(ctx context.Context, method, endpoint, bucket, key, subresource string, body io.Reader) (*http.Request, error) {
requestURL, err := ossObjectURL(endpoint, bucket, key, subresource) requestURL, err := ossObjectURL(endpoint, bucket, key, subresource)
if err != nil { if err != nil {
@@ -307,3 +338,4 @@ func readOSSError(response *http.Response) error {
} }
var _ OSSClient = (*OSSHTTPClient)(nil) var _ OSSClient = (*OSSHTTPClient)(nil)
var _ OSSURLSigner = (*OSSHTTPClient)(nil)
+30
View File
@@ -48,6 +48,36 @@ func TestOSSHTTPClientSignsPutAndBuildsVirtualHostURL(t *testing.T) {
} }
} }
func TestOSSHTTPClientPresignsPrivateGetURL(t *testing.T) {
client := newTestOSSHTTPClient(t, func(*http.Request) *http.Response {
t.Fatal("presigning must not perform a network request")
return nil
})
signed, err := client.SignGetURL(OSSObjectRequest{
Endpoint: "https://oss-cn-test.aliyuncs.com",
Bucket: "bucket-a",
Key: "photos/cat.png",
}, time.Hour)
if err != nil {
t.Fatal(err)
}
parsed, err := url.Parse(signed)
if err != nil {
t.Fatal(err)
}
if parsed.Scheme != "https" || parsed.Host != "bucket-a.oss-cn-test.aliyuncs.com" || parsed.EscapedPath() != "/photos/cat.png" {
t.Fatalf("signed URL target = %s", parsed)
}
query := parsed.Query()
if query.Get("OSSAccessKeyId") != "access-key" || query.Get("Expires") != "1786582800" || query.Get("Signature") != "uA2xP4TwgB6YB17MaALkharfEmM=" {
t.Fatalf("signed URL query = %#v", query)
}
if _, err := client.SignGetURL(OSSObjectRequest{Endpoint: "https://oss-cn-test.aliyuncs.com", Bucket: "bucket-a", Key: "photos/cat.png"}, 10*time.Hour); err == nil {
t.Fatal("expected excessive signed URL lifetime to fail")
}
}
func TestOSSHTTPClientSignsACLGetDeleteAndStreamsGetMetadata(t *testing.T) { func TestOSSHTTPClientSignsACLGetDeleteAndStreamsGetMetadata(t *testing.T) {
var calls int var calls int
client := newTestOSSHTTPClient(t, func(r *http.Request) *http.Response { client := newTestOSSHTTPClient(t, func(r *http.Request) *http.Response {
+25
View File
@@ -9,6 +9,7 @@ import (
"log" "log"
"strings" "strings"
"testing" "testing"
"time"
) )
type ossClientStub struct { type ossClientStub struct {
@@ -19,6 +20,9 @@ type ossClientStub struct {
body []byte body []byte
contentType string contentType string
getErr, errorToReturn error getErr, errorToReturn error
signedRequest OSSObjectRequest
signedTTL time.Duration
signedURL string
} }
func (s *ossClientStub) Put(_ context.Context, r OSSPutRequest) error { func (s *ossClientStub) Put(_ context.Context, r OSSPutRequest) error {
@@ -41,6 +45,10 @@ func (s *ossClientStub) Delete(_ context.Context, r OSSObjectRequest) error {
s.deletes = append(s.deletes, r) s.deletes = append(s.deletes, r)
return s.errorToReturn return s.errorToReturn
} }
func (s *ossClientStub) SignGetURL(request OSSObjectRequest, ttl time.Duration) (string, error) {
s.signedRequest, s.signedTTL = request, ttl
return s.signedURL, s.errorToReturn
}
func TestOSSPutUsesConfiguredAddressContentTypeAndPublicACL(t *testing.T) { func TestOSSPutUsesConfiguredAddressContentTypeAndPublicACL(t *testing.T) {
client := &ossClientStub{} client := &ossClientStub{}
@@ -85,6 +93,23 @@ func TestOSSReadDeleteAndMissingMapping(t *testing.T) {
t.Fatalf("delete missing=%v", err) t.Fatalf("delete missing=%v", err)
} }
} }
func TestOSSSignsExternalReadsAgainstPublicBucketBaseURL(t *testing.T) {
client := &ossClientStub{signedURL: "https://bucket-a.oss-cn-test.aliyuncs.com/uploads/a.png?Signature=signed"}
store, err := NewOSS(OSSConfig{
Endpoint: "https://oss-cn-test-internal.aliyuncs.com", Bucket: "bucket-a", PublicBaseURL: "https://bucket-a.oss-cn-test.aliyuncs.com",
}, client)
if err != nil {
t.Fatal(err)
}
got, err := store.SignReadURL("uploads/a.png", time.Hour)
if err != nil || got != client.signedURL {
t.Fatalf("signed URL = %q, err=%v", got, err)
}
if client.signedRequest.Endpoint != "https://bucket-a.oss-cn-test.aliyuncs.com" || client.signedRequest.Bucket != "bucket-a" || client.signedRequest.Key != "uploads/a.png" || client.signedTTL != time.Hour {
t.Fatalf("signed request = %#v, ttl=%s", client.signedRequest, client.signedTTL)
}
}
func TestOSSRejectsUnsafeKeysAndIncompleteConfiguration(t *testing.T) { func TestOSSRejectsUnsafeKeysAndIncompleteConfiguration(t *testing.T) {
if _, err := NewOSS(OSSConfig{Endpoint: "e", Bucket: "b"}, &ossClientStub{}); err == nil { if _, err := NewOSS(OSSConfig{Endpoint: "e", Bucket: "b"}, &ossClientStub{}); err == nil {
t.Fatal("expected config error") t.Fatal("expected config error")
+32
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"io" "io"
"reflect" "reflect"
"strings"
"testing" "testing"
"time" "time"
) )
@@ -132,6 +133,25 @@ func TestDownloadUsesBlobBeforeRemoteFetcher(t *testing.T) {
} }
} }
func TestProviderAssetURLSignsStoredObjectWithoutPersistingSignature(t *testing.T) {
cat := &memoryCatalog{assets: []Asset{{
ID: "asset-private", OwnerID: "owner-a", URL: "https://private-bucket.test/uploads/a.png", StoragePath: "uploads/a.png",
}}}
blobs := &signedMemoryBlobs{memoryBlobs: &memoryBlobs{}, signedURL: "https://private-bucket.test/uploads/a.png?Signature=temporary"}
svc := NewService(cat, blobs, nil, time.Now, nil)
sourceURL, accessURL, err := svc.ResolveProviderAssetURL(context.Background(), "owner-a", "asset-private", time.Hour)
if err != nil {
t.Fatal(err)
}
if sourceURL != cat.assets[0].URL || accessURL != blobs.signedURL || blobs.signedKey != "uploads/a.png" || blobs.signedTTL != time.Hour {
t.Fatalf("resolved URLs = %q / %q, signer = %q / %s", sourceURL, accessURL, blobs.signedKey, blobs.signedTTL)
}
if strings.Contains(cat.assets[0].URL, "Signature=") {
t.Fatalf("temporary signature was persisted: %q", cat.assets[0].URL)
}
}
type memoryCatalog struct { type memoryCatalog struct {
assets []Asset assets []Asset
createErr error createErr error
@@ -205,6 +225,18 @@ type memoryBlobs struct {
deleteErr error deleteErr error
} }
type signedMemoryBlobs struct {
*memoryBlobs
signedURL string
signedKey string
signedTTL time.Duration
}
func (m *signedMemoryBlobs) SignReadURL(key string, ttl time.Duration) (string, error) {
m.signedKey, m.signedTTL = key, ttl
return m.signedURL, nil
}
func (m *memoryBlobs) Put(_ context.Context, key string, body io.Reader, _ int64, contentType string) (StoredObject, error) { func (m *memoryBlobs) Put(_ context.Context, key string, body io.Reader, _ int64, contentType string) (StoredObject, error) {
m.putKey = key m.putKey = key
m.putBody, _ = io.ReadAll(body) m.putBody, _ = io.ReadAll(body)
+26 -2
View File
@@ -271,6 +271,20 @@ func (h *assetsHandler) download(w http.ResponseWriter, r *http.Request, public
} }
return return
} }
inline := r.URL.Query().Get("inline") == "1"
if inline {
signedURL, signed, signErr := h.service.SignedDownloadURL(r.Context(), scope, id, assets.DefaultSignedURLTTL)
if signErr != nil {
writeAssetError(w, signErr, public, "")
return
}
if signed {
w.Header().Set("Cache-Control", "private, no-store")
w.Header().Set("Location", signedURL)
w.WriteHeader(http.StatusTemporaryRedirect)
return
}
}
blob, err := h.service.Download(r.Context(), scope, id) blob, err := h.service.Download(r.Context(), scope, id)
if err != nil { if err != nil {
if public { if public {
@@ -281,7 +295,11 @@ func (h *assetsHandler) download(w http.ResponseWriter, r *http.Request, public
return return
} }
defer blob.Body.Close() defer blob.Body.Close()
writeBlob(w, blob, "private, no-store", contentDisposition(a.Name)) disposition := contentDisposition(a.Name)
if inline {
disposition = inlineContentDisposition(a.Name)
}
writeBlob(w, blob, "private, no-store", disposition)
} }
func (h *assetsHandler) serveStored(w http.ResponseWriter, r *http.Request, key string) { func (h *assetsHandler) serveStored(w http.ResponseWriter, r *http.Request, key string) {
@@ -383,6 +401,12 @@ func writeBlob(w http.ResponseWriter, blob assets.Blob, cache, disposition strin
_, _ = io.Copy(w, blob.Body) _, _ = io.Copy(w, blob.Body)
} }
func contentDisposition(name string) string { func contentDisposition(name string) string {
return namedContentDisposition("attachment", name)
}
func inlineContentDisposition(name string) string {
return namedContentDisposition("inline", name)
}
func namedContentDisposition(kind, name string) string {
clean := strings.TrimSpace(strings.NewReplacer("\r", "_", "\n", "_", "/", "_", "\\", "_").Replace(name)) clean := strings.TrimSpace(strings.NewReplacer("\r", "_", "\n", "_", "/", "_", "\\", "_").Replace(name))
if clean == "" { if clean == "" {
clean = "download" clean = "download"
@@ -395,7 +419,7 @@ func contentDisposition(name string) string {
ascii.WriteByte('_') ascii.WriteByte('_')
} }
} }
return `attachment; filename="` + ascii.String() + `"; filename*=UTF-8''` + url.PathEscape(clean) return kind + `; filename="` + ascii.String() + `"; filename*=UTF-8''` + url.PathEscape(clean)
} }
func requestOrigin(r *http.Request) string { func requestOrigin(r *http.Request) string {
scheme := "http" scheme := "http"
+25
View File
@@ -81,6 +81,13 @@ func (c *assetCatalog) DeleteOwner(_ context.Context, owner, id string) (assets.
type assetBlobs struct{ values map[string][]byte } type assetBlobs struct{ values map[string][]byte }
type signedAssetBlobs struct {
*assetBlobs
url string
}
func (b *signedAssetBlobs) SignReadURL(string, time.Duration) (string, error) { return b.url, nil }
func (b *assetBlobs) Put(_ context.Context, key string, r io.Reader, _ int64, _ string) (assets.StoredObject, error) { func (b *assetBlobs) Put(_ context.Context, key string, r io.Reader, _ int64, _ string) (assets.StoredObject, error) {
p, _ := io.ReadAll(r) p, _ := io.ReadAll(r)
b.values[key] = p b.values[key] = p
@@ -164,6 +171,24 @@ func TestAssetsMultipartDownloadServingAndMethods(t *testing.T) {
} }
} }
func TestAssetInlineReadRedirectsToShortLivedSignedURL(t *testing.T) {
cat := &assetCatalog{values: []assets.Asset{{
ID: "asset-private", OwnerID: "demo-merchant", Name: "private.png", URL: "https://private.test/private.png", StoragePath: "uploads/private.png",
}}}
blobs := &signedAssetBlobs{
assetBlobs: &assetBlobs{values: map[string][]byte{"uploads/private.png": []byte("png")}},
url: "https://private.test/private.png?OSSAccessKeyId=test&Expires=1&Signature=signed",
}
svc := assets.NewService(cat, blobs, nil, time.Now, nil)
platform, _ := httpapi.NewPlatformAuthorizer(httpapi.AuthState{}, nil)
h, _ := httpapi.NewAssetsHandler(svc, platform, publicapi.NewAuthenticator(publicapi.Config{APIKeys: "a:k"}), httpapi.AssetsConfig{})
inline := request(t, h, http.MethodGet, "/api/assets/asset-private/download?inline=1", nil, nil)
if inline.Code != http.StatusTemporaryRedirect || inline.Header().Get("Location") != blobs.url || inline.Header().Get("Cache-Control") != "private, no-store" || inline.Header().Get("Content-Disposition") != "" {
t.Fatalf("inline response = %d %#v", inline.Code, inline.Header())
}
}
func TestAssetsLimitsAndInfrastructureErrorsDoNotLeak(t *testing.T) { func TestAssetsLimitsAndInfrastructureErrorsDoNotLeak(t *testing.T) {
cat := &assetCatalog{} cat := &assetCatalog{}
svc := assets.NewService(cat, &assetBlobs{values: map[string][]byte{}}, nil, time.Now, nil) svc := assets.NewService(cat, &assetBlobs{values: map[string][]byte{}}, nil, time.Now, nil)
+55 -2
View File
@@ -20,13 +20,19 @@ type ProviderResolver interface {
Resolve(context.Context, string) (providers.Adapter, error) Resolve(context.Context, string) (providers.Adapter, error)
} }
type ProviderAssetURLResolver interface {
ResolveProviderAssetURL(context.Context, string, string, time.Duration) (sourceURL string, accessURL string, err error)
}
func (registry ProviderRegistry) Resolve(_ context.Context, name string) (providers.Adapter, error) { func (registry ProviderRegistry) Resolve(_ context.Context, name string) (providers.Adapter, error) {
return registry[name], nil return registry[name], nil
} }
type ProviderProcessor struct { type ProviderProcessor struct {
Providers ProviderResolver Providers ProviderResolver
Store Store Store Store
AssetURLs ProviderAssetURLResolver
AssetURLTTL time.Duration
} }
func (p ProviderProcessor) Advance(ctx context.Context, job Job) (Job, error) { func (p ProviderProcessor) Advance(ctx context.Context, job Job) (Job, error) {
@@ -49,6 +55,12 @@ func (p ProviderProcessor) Advance(ctx context.Context, job Job) (Job, error) {
if err := json.Unmarshal(job.RequestPayload, &request); err != nil { if err := json.Unmarshal(job.RequestPayload, &request); err != nil {
return Job{}, errors.New("invalid provider request payload") return Job{}, errors.New("invalid provider request payload")
} }
if job.ProviderTaskID == "" && job.ProviderDispatchStartedAt == nil {
request, err = p.refreshAssetURLs(ctx, job, request)
if err != nil {
return Job{}, fmt.Errorf("prepare provider asset URLs: %w", err)
}
}
var result providers.Result var result providers.Result
expectedStatus := job.Status expectedStatus := job.Status
if job.ProviderTaskID == "" { if job.ProviderTaskID == "" {
@@ -128,6 +140,47 @@ func (p ProviderProcessor) Advance(ctx context.Context, job Job) (Job, error) {
return job, nil return job, nil
} }
func (p ProviderProcessor) refreshAssetURLs(ctx context.Context, job Job, request providers.Request) (providers.Request, error) {
if p.AssetURLs == nil || len(job.InputAssetIDs) == 0 {
return request, nil
}
ttl := p.AssetURLTTL
if ttl <= 0 {
ttl = time.Hour
}
replacements := make(map[string]string, len(job.InputAssetIDs))
seen := make(map[string]struct{}, len(job.InputAssetIDs))
for _, assetID := range job.InputAssetIDs {
assetID = strings.TrimSpace(assetID)
if assetID == "" {
continue
}
if _, ok := seen[assetID]; ok {
continue
}
seen[assetID] = struct{}{}
sourceURL, accessURL, err := p.AssetURLs.ResolveProviderAssetURL(ctx, job.OwnerID, assetID, ttl)
if err != nil {
return providers.Request{}, err
}
if strings.TrimSpace(sourceURL) == "" || strings.TrimSpace(accessURL) == "" {
return providers.Request{}, errors.New("provider asset URL is unavailable")
}
replacements[sourceURL] = accessURL
}
for index, inputURL := range request.InputURLs {
if accessURL, ok := replacements[inputURL]; ok {
request.InputURLs[index] = accessURL
}
}
for index := range request.Materials {
if accessURL, ok := replacements[request.Materials[index].URL]; ok {
request.Materials[index].URL = accessURL
}
}
return request, nil
}
type ProviderJobBuilder struct { type ProviderJobBuilder struct {
ImageProvider, VideoProvider string ImageProvider, VideoProvider string
ImageModel, VideoModel string ImageModel, VideoModel string
+43 -1
View File
@@ -203,6 +203,35 @@ func TestProviderBuilderAndProcessor(t *testing.T) {
} }
} }
func TestProviderProcessorRefreshesPrivateAssetURLImmediatelyBeforeSubmit(t *testing.T) {
store := newMemoryJobStore()
job := Job{
ID: "job-private", OwnerID: "owner-a", Provider: "fixture", ReqKey: "model-a", Capability: "image.generate", Status: StatusQueued,
InputAssetIDs: []string{"asset-private"}, InputURLs: []string{"https://private.test/uploads/a.png"},
RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"model-a","prompt":"draw","inputUrls":["https://private.test/uploads/a.png"],"materials":[{"url":"https://private.test/uploads/a.png","type":"image"}]}`),
}
store.jobs[job.ID] = job
adapter := &countingProvider{result: providers.Result{TaskID: "provider-task", Status: providers.StatusQueued}}
resolver := &recordingProviderAssetURLResolver{
sourceURL: "https://private.test/uploads/a.png",
accessURL: "https://private.test/uploads/a.png?OSSAccessKeyId=test&Signature=temporary",
}
processor := ProviderProcessor{Providers: ProviderRegistry{"fixture": adapter}, Store: store, AssetURLs: resolver}
if _, err := processor.Advance(context.Background(), job); err != nil {
t.Fatal(err)
}
if resolver.owner != "owner-a" || resolver.assetID != "asset-private" || resolver.ttl != time.Hour {
t.Fatalf("resolver call = owner %q asset %q ttl %s", resolver.owner, resolver.assetID, resolver.ttl)
}
if !reflect.DeepEqual(adapter.request.InputURLs, []string{resolver.accessURL}) || len(adapter.request.Materials) != 1 || adapter.request.Materials[0].URL != resolver.accessURL {
t.Fatalf("submitted request = %#v", adapter.request)
}
if strings.Contains(string(job.RequestPayload), "Signature=temporary") || strings.Contains(string(store.jobs[job.ID].RequestPayload), "Signature=temporary") {
t.Fatal("temporary signature must not be persisted in the job request")
}
}
func TestProviderProcessorNeverResubmitsAfterPersistedDispatchIntent(t *testing.T) { func TestProviderProcessorNeverResubmitsAfterPersistedDispatchIntent(t *testing.T) {
store := newMemoryJobStore() store := newMemoryJobStore()
started := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC) started := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
@@ -290,6 +319,18 @@ func TestProviderJobBuilderClampsPriorityToPublicContract(t *testing.T) {
type countingProvider struct { type countingProvider struct {
submits int submits int
result providers.Result result providers.Result
request providers.Request
}
type recordingProviderAssetURLResolver struct {
sourceURL, accessURL string
owner, assetID string
ttl time.Duration
}
func (resolver *recordingProviderAssetURLResolver) ResolveProviderAssetURL(_ context.Context, owner, assetID string, ttl time.Duration) (string, string, error) {
resolver.owner, resolver.assetID, resolver.ttl = owner, assetID, ttl
return resolver.sourceURL, resolver.accessURL, nil
} }
type modelQueryProvider struct { type modelQueryProvider struct {
@@ -308,8 +349,9 @@ func (provider *modelQueryProvider) QueryModel(_ context.Context, _ string, mode
return provider.result, nil return provider.result, nil
} }
func (p *countingProvider) Submit(context.Context, providers.Request) (providers.Result, error) { func (p *countingProvider) Submit(_ context.Context, request providers.Request) (providers.Result, error) {
p.submits++ p.submits++
p.request = request
return p.result, nil return p.result, nil
} }
func (p *countingProvider) Query(context.Context, string) (providers.Result, error) { func (p *countingProvider) Query(context.Context, string) (providers.Result, error) {
+1 -1
View File
@@ -585,7 +585,7 @@ func definitions() []Group {
{ID: "evolink", Title: "EvoLink 图片 API", Description: "GPT Image 2 图片生成。", Fields: []Field{{Key: "EVOLINK_API_KEY", Label: "EvoLink API Key", Secret: true, Type: "password"}, {Key: "EVOLINK_BASE_URL", Label: "Base URL", DefaultValue: "https://api.evolink.ai"}, {Key: "EVOLINK_IMAGE_MODEL", Label: "图片模型", DefaultValue: "gpt-image-2"}, {Key: "EVOLINK_IMAGE_QUALITY", Label: "质量", DefaultValue: "medium"}}}, {ID: "evolink", Title: "EvoLink 图片 API", Description: "GPT Image 2 图片生成。", Fields: []Field{{Key: "EVOLINK_API_KEY", Label: "EvoLink API Key", Secret: true, Type: "password"}, {Key: "EVOLINK_BASE_URL", Label: "Base URL", DefaultValue: "https://api.evolink.ai"}, {Key: "EVOLINK_IMAGE_MODEL", Label: "图片模型", DefaultValue: "gpt-image-2"}, {Key: "EVOLINK_IMAGE_QUALITY", Label: "质量", DefaultValue: "medium"}}},
{ID: "seedance", Title: "Seedance 视频 API", Description: "火山方舟 API Key。", Fields: []Field{{Key: "SEEDANCE_API_KEY", Label: "方舟 API Key", Secret: true, Type: "password"}}}, {ID: "seedance", Title: "Seedance 视频 API", Description: "火山方舟 API Key。", Fields: []Field{{Key: "SEEDANCE_API_KEY", Label: "方舟 API Key", Secret: true, Type: "password"}}},
{ID: "bailian", Title: "阿里云百炼 API", Description: "万相图片与视频。", Fields: []Field{{Key: "BAILIAN_API_KEY", Label: "百炼 API Key", Secret: true, Type: "password"}, {Key: "BAILIAN_BASE_URL", Label: "Base URL", DefaultValue: "https://llm-126wneubbdo6dbr5.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"}, {Key: "BAILIAN_IMAGE_MODEL", Label: "图片模型", DefaultValue: "wan2.7-image-pro"}, {Key: "BAILIAN_VIDEO_MODEL", Label: "视频模型", DefaultValue: "wan2.7-i2v-2026-04-25"}}}, {ID: "bailian", Title: "阿里云百炼 API", Description: "万相图片与视频。", Fields: []Field{{Key: "BAILIAN_API_KEY", Label: "百炼 API Key", Secret: true, Type: "password"}, {Key: "BAILIAN_BASE_URL", Label: "Base URL", DefaultValue: "https://llm-126wneubbdo6dbr5.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"}, {Key: "BAILIAN_IMAGE_MODEL", Label: "图片模型", DefaultValue: "wan2.7-image-pro"}, {Key: "BAILIAN_VIDEO_MODEL", Label: "视频模型", DefaultValue: "wan2.7-i2v-2026-04-25"}}},
{ID: "oss", Title: "OSS 资产存储", Description: "共享资产存储。", Fields: []Field{{Key: "ALI_OSS_ENDPOINT", Label: "Endpoint"}, {Key: "ALI_OSS_BUCKET", Label: "Bucket"}, {Key: "ALI_OSS_ACCESS_KEY_ID", Label: "Access Key ID", Secret: true, Type: "password"}, {Key: "ALI_OSS_ACCESS_KEY_SECRET", Label: "Access Key Secret", Secret: true, Type: "password"}, {Key: "ALI_OSS_PUBLIC_BASE_URL", Label: "公开访问 Base URL"}}}, {ID: "oss", Title: "OSS 资产存储", Description: "私有桶通过后端签名访问。", Fields: []Field{{Key: "ALI_OSS_ENDPOINT", Label: "Endpoint"}, {Key: "ALI_OSS_BUCKET", Label: "Bucket"}, {Key: "ALI_OSS_ACCESS_KEY_ID", Label: "Access Key ID", Secret: true, Type: "password"}, {Key: "ALI_OSS_ACCESS_KEY_SECRET", Label: "Access Key Secret", Secret: true, Type: "password"}, {Key: "ALI_OSS_PUBLIC_BASE_URL", Label: "Bucket Base URL(无需公共读)"}}},
} }
} }
+6 -5
View File
@@ -5,6 +5,7 @@ import { Download, Eye, ImageIcon, Info, Loader2, Music, RefreshCw, Trash2, X }
import { clampPage, pageItems, Pagination } from "@/components/pagination"; import { clampPage, pageItems, Pagination } from "@/components/pagination";
import { modalEnter, modalExit, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; import { modalEnter, modalExit, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
import { formatBillingAmount } from "@/lib/billing"; import { formatBillingAmount } from "@/lib/billing";
import { assetPreviewUrl } from "@/lib/client/asset-urls";
import type { Asset, GenerationJob } from "@/lib/types"; import type { Asset, GenerationJob } from "@/lib/types";
type AssetView = "assets" | "tasks"; type AssetView = "assets" | "tasks";
@@ -386,10 +387,10 @@ function renderAssetPreview(asset: Asset) {
); );
} }
if (asset.kind === "video" || isVideo(asset)) { if (asset.kind === "video" || isVideo(asset)) {
return <video className="asset-thumb" src={asset.url} muted playsInline preload="metadata" />; return <video className="asset-thumb" src={assetPreviewUrl(asset)} muted playsInline preload="metadata" />;
} }
if (asset.kind === "image" || asset.kind === "mask" || asset.kind === "reference" || isImage(asset)) { if (asset.kind === "image" || asset.kind === "mask" || asset.kind === "reference" || isImage(asset)) {
return <img className="asset-thumb" src={asset.url} alt={asset.name} />; return <img className="asset-thumb" src={assetPreviewUrl(asset)} alt={asset.name} />;
} }
return ( return (
<div className="asset-placeholder asset-kind-placeholder"> <div className="asset-placeholder asset-kind-placeholder">
@@ -400,10 +401,10 @@ function renderAssetPreview(asset: Asset) {
} }
function renderAssetPreviewLarge(asset: Asset) { function renderAssetPreviewLarge(asset: Asset) {
if (isAudio(asset)) return <audio src={asset.url} controls />; if (isAudio(asset)) return <audio src={assetPreviewUrl(asset)} controls />;
if (asset.kind === "video" || isVideo(asset)) return <video src={asset.url} controls playsInline />; if (asset.kind === "video" || isVideo(asset)) return <video src={assetPreviewUrl(asset)} controls playsInline />;
if (asset.kind === "image" || asset.kind === "mask" || asset.kind === "reference" || isImage(asset)) { if (asset.kind === "image" || asset.kind === "mask" || asset.kind === "reference" || isImage(asset)) {
return <img src={asset.url} alt={asset.name} />; return <img src={assetPreviewUrl(asset)} alt={asset.name} />;
} }
return ( return (
<div className="asset-placeholder asset-kind-placeholder"> <div className="asset-placeholder asset-kind-placeholder">
+22 -15
View File
@@ -17,6 +17,7 @@ import {
} from "@/lib/prompt/material-draft"; } from "@/lib/prompt/material-draft";
import { extractMaterialPlaceholders } from "@/lib/prompt/material-placeholders"; import { extractMaterialPlaceholders } from "@/lib/prompt/material-placeholders";
import { formatBillingAmount } from "@/lib/billing"; import { formatBillingAmount } from "@/lib/billing";
import { assetPreviewUrl, materialPreviewUrl, storedAssetPreviewUrl } from "@/lib/client/asset-urls";
import type { BillingQuote } from "@/lib/types"; import type { BillingQuote } from "@/lib/types";
type GenerateMode = "image" | "video"; type GenerateMode = "image" | "video";
@@ -217,6 +218,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
() => recentJobs.find((job) => job.id === taskDetailJobId) || null, () => recentJobs.find((job) => job.id === taskDetailJobId) || null,
[recentJobs, taskDetailJobId] [recentJobs, taskDetailJobId]
); );
const previewTemplateImageURL = storedAssetPreviewUrl(previewTemplate?.previewImageUrl, taskAssets);
const hasLiveTasks = useMemo(() => recentJobs.some((job) => !isTerminalStatus(job.status)), [recentJobs]); const hasLiveTasks = useMemo(() => recentJobs.some((job) => !isTerminalStatus(job.status)), [recentJobs]);
useEffect(() => { useEffect(() => {
@@ -476,7 +478,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
const [asset] = await uploadAssetFiles([file]); const [asset] = await uploadAssetFiles([file]);
setTemplateForm((form) => ({ setTemplateForm((form) => ({
...form, ...form,
previewImageUrl: asset.url, previewImageUrl: assetPreviewUrl(asset),
name: form.name || asset.name.replace(/\.[^.]+$/, "") name: form.name || asset.name.replace(/\.[^.]+$/, "")
})); }));
} catch (err) { } catch (err) {
@@ -608,7 +610,10 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
} }
function openTemplateEditor(template: ImageTemplate) { function openTemplateEditor(template: ImageTemplate) {
setTemplateForm(formFromTemplate(template)); setTemplateForm({
...formFromTemplate(template),
previewImageUrl: storedAssetPreviewUrl(template.previewImageUrl, taskAssets)
});
setEditingTemplateId(template.id); setEditingTemplateId(template.id);
setTemplateError(null); setTemplateError(null);
setMaterialDraft(null); setMaterialDraft(null);
@@ -919,7 +924,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
aria-label={`放大预览 ${template.name}`} aria-label={`放大预览 ${template.name}`}
onClick={() => setPreviewTemplate(template)} onClick={() => setPreviewTemplate(template)}
> >
{renderImageTemplatePreview(template)} {renderImageTemplatePreview(template, taskAssets)}
</button> </button>
<div className="image-template-card-footer"> <div className="image-template-card-footer">
<div className="image-template-card-copy"> <div className="image-template-card-copy">
@@ -1363,11 +1368,11 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate
</button> </button>
</div> </div>
<div className="template-preview-dialog-media"> <div className="template-preview-dialog-media">
{previewTemplate.previewImageUrl ? <img src={previewTemplate.previewImageUrl} alt="" /> : <ImagePlus size={40} />} {previewTemplateImageURL ? <img src={previewTemplateImageURL} alt="" /> : <ImagePlus size={40} />}
</div> </div>
<div className="template-preview-dialog-actions"> <div className="template-preview-dialog-actions">
{previewTemplate.previewImageUrl ? ( {previewTemplateImageURL ? (
<a className="button" href={previewTemplate.previewImageUrl} download> <a className="button" href={previewTemplateImageURL} download>
<Download size={16} /> <Download size={16} />
下载图片 下载图片
</a> </a>
@@ -1495,8 +1500,8 @@ function renderTaskThumbnail(job: GenerationJob, assetById: Map<string, Asset>)
} }
const isVideo = asset.kind === "video" || materialTypeForAsset(asset) === "video"; const isVideo = asset.kind === "video" || materialTypeForAsset(asset) === "video";
const media = isVideo const media = isVideo
? <video src={asset.url} muted playsInline preload="metadata" /> ? <video src={assetPreviewUrl(asset)} muted playsInline preload="metadata" />
: <img src={asset.url} alt="" />; : <img src={assetPreviewUrl(asset)} alt="" />;
return <div className="create-task-thumb" aria-hidden="true">{media}</div>; return <div className="create-task-thumb" aria-hidden="true">{media}</div>;
} }
@@ -1698,9 +1703,9 @@ function BillingEstimate({
function renderTaskAssetMedia(asset: Asset, size: "thumb" | "large" = "large") { function renderTaskAssetMedia(asset: Asset, size: "thumb" | "large" = "large") {
const type = materialTypeForAsset(asset); const type = materialTypeForAsset(asset);
if (type === "video") return <video className={`task-detail-media ${size}`} src={asset.url} controls={size === "large"} muted={size === "thumb"} playsInline preload="metadata" />; if (type === "video") return <video className={`task-detail-media ${size}`} src={assetPreviewUrl(asset)} controls={size === "large"} muted={size === "thumb"} playsInline preload="metadata" />;
if (type === "audio") return <audio className="task-detail-audio" src={asset.url} controls />; if (type === "audio") return <audio className="task-detail-audio" src={assetPreviewUrl(asset)} controls />;
return <img className={`task-detail-media ${size}`} src={asset.url} alt={asset.name} />; return <img className={`task-detail-media ${size}`} src={assetPreviewUrl(asset)} alt={asset.name} />;
} }
function renderMaterialUrlPreview(url: string, type: MaterialKind) { function renderMaterialUrlPreview(url: string, type: MaterialKind) {
@@ -1892,10 +1897,11 @@ function formatDuration(ms: number) {
} }
function renderMaterialPreview(material: PromptMaterial, size: "normal" | "tiny" | "large" = "normal") { function renderMaterialPreview(material: PromptMaterial, size: "normal" | "tiny" | "large" = "normal") {
const previewURL = materialPreviewUrl(material);
return ( return (
<span className={clsx("material-preview", size, material.type)} aria-hidden="true"> <span className={clsx("material-preview", size, material.type)} aria-hidden="true">
{material.type === "image" ? <img src={material.url} alt="" /> : null} {material.type === "image" ? <img src={previewURL} alt="" /> : null}
{material.type === "video" ? <video src={material.url} muted playsInline preload="metadata" /> : null} {material.type === "video" ? <video src={previewURL} muted playsInline preload="metadata" /> : null}
{material.type === "audio" ? <Music size={size === "tiny" ? 13 : size === "large" ? 20 : 15} /> : null} {material.type === "audio" ? <Music size={size === "tiny" ? 13 : size === "large" ? 20 : 15} /> : null}
</span> </span>
); );
@@ -1966,10 +1972,11 @@ function sortTemplates(a: ImageTemplate, b: ImageTemplate) {
return (b.updatedAt || "").localeCompare(a.updatedAt || ""); return (b.updatedAt || "").localeCompare(a.updatedAt || "");
} }
function renderImageTemplatePreview(template: ImageTemplate) { function renderImageTemplatePreview(template: ImageTemplate, assets: Asset[]) {
const previewURL = storedAssetPreviewUrl(template.previewImageUrl, assets);
return ( return (
<span className="image-template-preview" aria-hidden="true"> <span className="image-template-preview" aria-hidden="true">
{template.previewImageUrl ? <img src={template.previewImageUrl} alt="" /> : <ImagePlus size={22} />} {previewURL ? <img src={previewURL} alt="" /> : <ImagePlus size={22} />}
</span> </span>
); );
} }
+13
View File
@@ -0,0 +1,13 @@
export function assetPreviewUrl(asset: { id: string }) {
return `/api/assets/${encodeURIComponent(asset.id)}/download?inline=1`;
}
export function materialPreviewUrl(material: { id?: string; url: string }) {
return material.id ? assetPreviewUrl({ id: material.id }) : material.url;
}
export function storedAssetPreviewUrl(url: string | undefined, assets: Array<{ id: string; url: string }>) {
if (!url) return "";
const asset = assets.find((item) => item.url === url);
return asset ? assetPreviewUrl(asset) : url;
}
+24
View File
@@ -0,0 +1,24 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { assetPreviewUrl, materialPreviewUrl, storedAssetPreviewUrl } from "@/lib/client/asset-urls";
describe("private asset browser URLs", () => {
it("uses the authenticated inline endpoint for stored assets", () => {
expect(assetPreviewUrl({ id: "asset/a b" })).toBe("/api/assets/asset%2Fa%20b/download?inline=1");
expect(materialPreviewUrl({ id: "asset-1", url: "https://private.test/raw.png" })).toBe("/api/assets/asset-1/download?inline=1");
expect(materialPreviewUrl({ url: "https://external.test/reference.png" })).toBe("https://external.test/reference.png");
expect(storedAssetPreviewUrl("https://private.test/legacy.png", [{ id: "asset-legacy", url: "https://private.test/legacy.png" }])).toBe("/api/assets/asset-legacy/download?inline=1");
expect(storedAssetPreviewUrl("https://external.test/template.png", [])).toBe("https://external.test/template.png");
});
it("does not render persisted private asset URLs directly", async () => {
const sources = await Promise.all([
readFile(new URL("../components/create-studio.tsx", import.meta.url), "utf8"),
readFile(new URL("../components/asset-manager.tsx", import.meta.url), "utf8")
]);
for (const source of sources) {
expect(source).not.toMatch(/<(?:img|video|audio)[^>]+src=\{asset\.url\}/);
}
});
});