oss请求处理
This commit is contained in:
1 parent
18cb51670c
commit
a9a4cdf125
18 files changed
+450
-29
No files matched your search
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(无需公共读)"}}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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\}/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user