feat(storage): 完善对象存储配置与过期策略
补齐 OSS/S3 的 Endpoint、Region、Bucket、CDN、对象前缀和签名有效期配置,并为生成结果与请求素材自动维护分级生命周期规则。普通上传继续保持永久,私有资源按配置生成限时签名 URL,管理端连接测试覆盖生命周期、上传、读取和删除。\n\n新增可重复的真实 OSS 验收脚本,凭据仅从本地环境读取,接口响应继续保持脱敏。\n\n验证:Go 全量测试、迁移安全检查、pnpm lint、pnpm test、pnpm build、本地阿里云 OSS 真实上传下载删除验收。
This commit is contained in:
@@ -3361,7 +3361,7 @@
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "对指定 OSS 或 S3 通道执行隔离的 Put、Head、Delete 探针,不返回凭据或对象键。",
|
||||
"description": "对指定 OSS 或 S3 通道检查并初始化临时文件生命周期规则,然后执行隔离的 Put、Head、Delete 探针;不返回凭据或对象键。",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -11340,9 +11340,15 @@
|
||||
"durationMs": {
|
||||
"type": "integer"
|
||||
},
|
||||
"expirationPolicy": {
|
||||
"type": "string"
|
||||
},
|
||||
"headSucceeded": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"lifecycleReady": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -530,8 +530,12 @@ definitions:
|
||||
type: boolean
|
||||
durationMs:
|
||||
type: integer
|
||||
expirationPolicy:
|
||||
type: string
|
||||
headSucceeded:
|
||||
type: boolean
|
||||
lifecycleReady:
|
||||
type: boolean
|
||||
provider:
|
||||
type: string
|
||||
putSucceeded:
|
||||
@@ -6558,7 +6562,7 @@ paths:
|
||||
- system
|
||||
/api/admin/system/file-storage/channels/{channelID}/test:
|
||||
post:
|
||||
description: 对指定 OSS 或 S3 通道执行隔离的 Put、Head、Delete 探针,不返回凭据或对象键。
|
||||
description: 对指定 OSS 或 S3 通道检查并初始化临时文件生命周期规则,然后执行隔离的 Put、Head、Delete 探针;不返回凭据或对象键。
|
||||
parameters:
|
||||
- description: 文件存储通道 ID
|
||||
in: path
|
||||
|
||||
@@ -238,11 +238,13 @@ type FileStorageChannelListResponse struct {
|
||||
}
|
||||
|
||||
type FileStorageChannelTestResponse struct {
|
||||
Provider string `json:"provider"`
|
||||
PutSucceeded bool `json:"putSucceeded"`
|
||||
HeadSucceeded bool `json:"headSucceeded"`
|
||||
DeleteSucceeded bool `json:"deleteSucceeded"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
Provider string `json:"provider"`
|
||||
PutSucceeded bool `json:"putSucceeded"`
|
||||
HeadSucceeded bool `json:"headSucceeded"`
|
||||
DeleteSucceeded bool `json:"deleteSucceeded"`
|
||||
LifecycleReady bool `json:"lifecycleReady"`
|
||||
ExpirationPolicy string `json:"expirationPolicy"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
}
|
||||
|
||||
type FileUploadResponse struct {
|
||||
|
||||
@@ -417,6 +417,8 @@ func (s *Server) ensureRequestAsset(ctx context.Context, decoded decodedRequestA
|
||||
expiry := now.Add(time.Duration(s.localTempAssetTTLHours()) * time.Hour)
|
||||
expiresAt = &expiry
|
||||
localPath = requestAssetLocalPath(s.cfg.LocalUploadedStorageDir, stringFromRequestAny(upload["fileName"]))
|
||||
} else {
|
||||
expiresAt = requestAssetUploadExpiresAt(upload)
|
||||
}
|
||||
asset, err := s.store.UpsertRequestAsset(ctx, store.RequestAssetInput{
|
||||
SHA256: sha,
|
||||
@@ -576,7 +578,7 @@ func requestAssetStillUsable(asset store.RequestAsset, now time.Time) bool {
|
||||
if asset.ExpiredAt != nil {
|
||||
return false
|
||||
}
|
||||
if asset.ExpiresAt != nil && !asset.ExpiresAt.After(now) {
|
||||
if asset.ExpiresAt != nil && !asset.ExpiresAt.After(now.Add(30*time.Second)) {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(asset.StorageProvider), "local_static") {
|
||||
@@ -592,6 +594,25 @@ func requestAssetStillUsable(asset store.RequestAsset, now time.Time) bool {
|
||||
return strings.TrimSpace(asset.URL) != ""
|
||||
}
|
||||
|
||||
func requestAssetUploadExpiresAt(upload map[string]any) *time.Time {
|
||||
var earliest *time.Time
|
||||
for _, key := range []string{"urlExpiresAt", "objectExpiresAt"} {
|
||||
value := strings.TrimSpace(stringFromRequestAny(upload[key]))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if earliest == nil || parsed.Before(*earliest) {
|
||||
candidate := parsed
|
||||
earliest = &candidate
|
||||
}
|
||||
}
|
||||
return earliest
|
||||
}
|
||||
|
||||
func requestAssetStorageProvider(upload map[string]any) string {
|
||||
if channel, ok := upload["storageChannel"].(map[string]any); ok {
|
||||
if provider := stringFromRequestAny(channel["provider"]); provider != "" {
|
||||
|
||||
@@ -176,6 +176,23 @@ func TestRequestAssetStillUsableRequiresExistingLocalFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestAssetUsesEarliestObjectStorageExpiry(t *testing.T) {
|
||||
expiresAt := requestAssetUploadExpiresAt(map[string]any{
|
||||
"urlExpiresAt": "2026-08-04T01:00:00Z",
|
||||
"objectExpiresAt": "2026-09-03T00:00:00Z",
|
||||
})
|
||||
if expiresAt == nil || expiresAt.Format(time.RFC3339) != "2026-08-04T01:00:00Z" {
|
||||
t.Fatalf("unexpected request asset expiry: %v", expiresAt)
|
||||
}
|
||||
asset := store.RequestAsset{URL: "https://signed.example/object", StorageProvider: "s3", ExpiresAt: expiresAt}
|
||||
if !requestAssetStillUsable(asset, time.Date(2026, time.August, 4, 0, 58, 0, 0, time.UTC)) {
|
||||
t.Fatal("request asset was refreshed before the safety window")
|
||||
}
|
||||
if requestAssetStillUsable(asset, time.Date(2026, time.August, 4, 0, 59, 45, 0, time.UTC)) {
|
||||
t.Fatal("request asset inside the signed URL safety window was reused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalConversationMessageHashUsesTextAndAssetRefs(t *testing.T) {
|
||||
message := map[string]any{
|
||||
"role": "user",
|
||||
|
||||
@@ -280,7 +280,7 @@ func (s *Server) deleteFileStorageChannel(w http.ResponseWriter, r *http.Request
|
||||
|
||||
// testFileStorageChannel godoc
|
||||
// @Summary 测试对象存储通道
|
||||
// @Description 对指定 OSS 或 S3 通道执行隔离的 Put、Head、Delete 探针,不返回凭据或对象键。
|
||||
// @Description 对指定 OSS 或 S3 通道检查并初始化临时文件生命周期规则,然后执行隔离的 Put、Head、Delete 探针;不返回凭据或对象键。
|
||||
// @Tags system
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
@@ -358,9 +358,26 @@ func validateFileStorageChannelInput(input store.FileStorageChannelInput, existi
|
||||
if !validFileStorageBaseURL(endpoint) {
|
||||
return "object storage config.endpoint must be an http or https URL without embedded credentials"
|
||||
}
|
||||
if publicBaseURL := firstNonEmpty(fileStorageConfigString(input.Config, "publicBaseUrl"), fileStorageConfigString(input.Config, "publicBaseURL")); publicBaseURL != "" && !validFileStorageBaseURL(publicBaseURL) {
|
||||
if publicBaseURL := firstNonEmpty(
|
||||
fileStorageConfigString(input.Config, "publicBaseUrl"),
|
||||
fileStorageConfigString(input.Config, "publicBaseURL"),
|
||||
fileStorageConfigString(input.Config, "cdnDomain"),
|
||||
fileStorageConfigString(input.Config, "publicDomain"),
|
||||
); publicBaseURL != "" && !validFileStorageBaseURL(publicBaseURL) {
|
||||
return "object storage config.publicBaseUrl must be an http or https URL without embedded credentials"
|
||||
}
|
||||
if policy := firstNonEmpty(
|
||||
fileStorageConfigString(input.Config, "temporaryFileExpirePolicy"),
|
||||
fileStorageConfigString(input.Config, "apiReturnExpirePolicy"),
|
||||
); policy != "" && !validFileStorageExpirationPolicy(policy) {
|
||||
return "object storage config.temporaryFileExpirePolicy must be never, 1d, 1m, 3m or 6m"
|
||||
}
|
||||
if value, exists := input.Config["signedUrlExpiresSeconds"]; exists {
|
||||
seconds, ok := fileStorageConfigInteger(value)
|
||||
if !ok || seconds < 60 || seconds > 7*24*60*60 {
|
||||
return "object storage config.signedUrlExpiresSeconds must be an integer between 60 and 604800"
|
||||
}
|
||||
}
|
||||
accessKeyID := input.AccessKeyID
|
||||
if accessKeyID == nil {
|
||||
accessKeyID = input.AccessKey
|
||||
@@ -378,6 +395,34 @@ func validateFileStorageChannelInput(input store.FileStorageChannelInput, existi
|
||||
return ""
|
||||
}
|
||||
|
||||
func validFileStorageExpirationPolicy(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "never", "1d", "1m", "3m", "6m":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func fileStorageConfigInteger(value any) (int64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return int64(typed), true
|
||||
case int32:
|
||||
return int64(typed), true
|
||||
case int64:
|
||||
return typed, true
|
||||
case float64:
|
||||
converted := int64(typed)
|
||||
return converted, float64(converted) == typed
|
||||
case float32:
|
||||
converted := int64(typed)
|
||||
return converted, float32(converted) == typed
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func validFileStorageBaseURL(value string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||
return err == nil && parsed.User == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https")
|
||||
|
||||
@@ -53,3 +53,34 @@ func TestValidateObjectStorageChannelRejectsCredentialedEndpoint(t *testing.T) {
|
||||
t.Fatalf("credentialed endpoint was accepted: %q", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateObjectStorageChannelAcceptsExpirationAndCDNAliases(t *testing.T) {
|
||||
input := store.FileStorageChannelInput{
|
||||
ChannelKey: "oss-expiring", Name: "OSS expiring", Provider: "aliyun_oss", Status: "disabled",
|
||||
Config: map[string]any{
|
||||
"endpoint": "https://oss-cn-shanghai.aliyuncs.com", "region": "cn-shanghai", "bucket": "media",
|
||||
"cdnDomain": "https://cdn.example.com", "temporaryFileExpirePolicy": "3m", "signedUrlExpiresSeconds": float64(3600),
|
||||
},
|
||||
}
|
||||
if message := validateFileStorageChannelInput(input, nil); message != "" {
|
||||
t.Fatalf("valid object storage expiration config rejected: %s", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateObjectStorageChannelRejectsInvalidExpiration(t *testing.T) {
|
||||
base := store.FileStorageChannelInput{
|
||||
ChannelKey: "oss-expiring", Name: "OSS expiring", Provider: "aliyun_oss", Status: "disabled",
|
||||
Config: map[string]any{
|
||||
"endpoint": "https://oss-cn-shanghai.aliyuncs.com", "region": "cn-shanghai", "bucket": "media",
|
||||
"temporaryFileExpirePolicy": "7d", "signedUrlExpiresSeconds": float64(3600),
|
||||
},
|
||||
}
|
||||
if message := validateFileStorageChannelInput(base, nil); !strings.Contains(message, "temporaryFileExpirePolicy") {
|
||||
t.Fatalf("invalid expiration policy was accepted: %q", message)
|
||||
}
|
||||
base.Config["temporaryFileExpirePolicy"] = "1d"
|
||||
base.Config["signedUrlExpiresSeconds"] = float64(30)
|
||||
if message := validateFileStorageChannelInput(base, nil); !strings.Contains(message, "signedUrlExpiresSeconds") {
|
||||
t.Fatalf("invalid signed URL TTL was accepted: %q", message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,11 +47,13 @@ type objectStorageAdapter struct {
|
||||
}
|
||||
|
||||
type FileStorageChannelTestResult struct {
|
||||
Provider string `json:"provider"`
|
||||
PutSucceeded bool `json:"putSucceeded"`
|
||||
HeadSucceeded bool `json:"headSucceeded"`
|
||||
DeleteSucceeded bool `json:"deleteSucceeded"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
Provider string `json:"provider"`
|
||||
PutSucceeded bool `json:"putSucceeded"`
|
||||
HeadSucceeded bool `json:"headSucceeded"`
|
||||
DeleteSucceeded bool `json:"deleteSucceeded"`
|
||||
LifecycleReady bool `json:"lifecycleReady"`
|
||||
ExpirationPolicy string `json:"expirationPolicy"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
}
|
||||
|
||||
// TestFileStorageChannel performs an isolated write, metadata read and cleanup
|
||||
@@ -59,16 +61,29 @@ type FileStorageChannelTestResult struct {
|
||||
// content-addressed business object and no object key or credential is exposed.
|
||||
func (s *Service) TestFileStorageChannel(ctx context.Context, channel store.FileStorageChannel) (FileStorageChannelTestResult, error) {
|
||||
startedAt := time.Now()
|
||||
result := FileStorageChannelTestResult{Provider: strings.ToLower(strings.TrimSpace(channel.Provider))}
|
||||
result := FileStorageChannelTestResult{
|
||||
Provider: strings.ToLower(strings.TrimSpace(channel.Provider)),
|
||||
ExpirationPolicy: objectStorageExpirationPolicy(channel.Config),
|
||||
}
|
||||
adapter, err := newObjectStorageAdapter(channel)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if result.ExpirationPolicy != objectStorageExpirationNever {
|
||||
if err := adapter.ensureLifecycle(ctx); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.LifecycleReady = true
|
||||
}
|
||||
probeScene := store.FileStorageSceneUpload
|
||||
if result.LifecycleReady {
|
||||
probeScene = store.FileStorageSceneImageResult
|
||||
}
|
||||
payload := FileUploadPayload{
|
||||
Bytes: []byte("easyai-storage-probe:" + uuid.NewString()),
|
||||
ContentType: "application/octet-stream",
|
||||
FileName: "probe.bin",
|
||||
Scene: store.FileStorageSceneUpload,
|
||||
Scene: probeScene,
|
||||
Source: "admin-connection-test",
|
||||
}
|
||||
upload, err := adapter.put(ctx, payload)
|
||||
@@ -112,6 +127,12 @@ func newObjectStorageAdapter(channel store.FileStorageChannel) (*objectStorageAd
|
||||
}
|
||||
|
||||
func (a *objectStorageAdapter) put(ctx context.Context, payload FileUploadPayload) (map[string]any, error) {
|
||||
retention := objectStorageRetentionFor(a.channel.Config, payload.Scene, a.now().UTC())
|
||||
if retention.Enabled {
|
||||
if err := a.ensureLifecycle(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
objectKey := a.objectKey(payload)
|
||||
requestURL, err := a.objectURL(objectKey)
|
||||
if err != nil {
|
||||
@@ -126,6 +147,14 @@ func (a *objectStorageAdapter) put(ctx context.Context, payload FileUploadPayloa
|
||||
return nil, storageClientError("storage_config_invalid", err.Error(), 0, false)
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
if retention.Enabled {
|
||||
tagging := retention.taggingHeader()
|
||||
if strings.EqualFold(a.channel.Provider, "s3") {
|
||||
req.Header.Set("x-amz-tagging", tagging)
|
||||
} else {
|
||||
req.Header.Set("x-oss-tagging", tagging)
|
||||
}
|
||||
}
|
||||
if err := a.sign(req, sha256Hex(payload.Bytes), a.now().UTC()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -148,11 +177,12 @@ func (a *objectStorageAdapter) put(ctx context.Context, payload FileUploadPayloa
|
||||
}
|
||||
urlExpiresAt := ""
|
||||
if publicURL == "" {
|
||||
publicURL, err = a.presignGet(objectKey, objectStorageSignedURLTTL)
|
||||
signedURLTTL := objectStorageSignedURLTTLForConfig(a.channel.Config)
|
||||
publicURL, err = a.presignGet(objectKey, signedURLTTL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
urlExpiresAt = a.now().UTC().Add(objectStorageSignedURLTTL).Format(time.RFC3339)
|
||||
urlExpiresAt = a.now().UTC().Add(signedURLTTL).Format(time.RFC3339)
|
||||
}
|
||||
digest := sha256.Sum256(payload.Bytes)
|
||||
result := map[string]any{
|
||||
@@ -173,6 +203,10 @@ func (a *objectStorageAdapter) put(ctx context.Context, payload FileUploadPayloa
|
||||
if urlExpiresAt != "" {
|
||||
result["urlExpiresAt"] = urlExpiresAt
|
||||
}
|
||||
if retention.Enabled {
|
||||
result["objectExpiresAt"] = retention.ExpiresAt.Format(time.RFC3339)
|
||||
result["expirationPolicy"] = retention.Policy
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -301,10 +335,7 @@ func (a *objectStorageAdapter) objectURL(objectKey string) (string, error) {
|
||||
}
|
||||
|
||||
func (a *objectStorageAdapter) publicURL(objectKey string) string {
|
||||
baseURL := strings.TrimRight(objectStorageConfigString(a.channel.Config, "publicBaseUrl"), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = strings.TrimRight(objectStorageConfigString(a.channel.Config, "publicBaseURL"), "/")
|
||||
}
|
||||
baseURL := strings.TrimRight(objectStoragePublicBaseURL(a.channel.Config), "/")
|
||||
if baseURL == "" {
|
||||
return ""
|
||||
}
|
||||
@@ -322,19 +353,45 @@ func (a *objectStorageAdapter) sign(req *http.Request, payloadHash string, now t
|
||||
|
||||
func (a *objectStorageAdapter) signOSSRequest(req *http.Request, now time.Time) {
|
||||
date := now.UTC().Format(http.TimeFormat)
|
||||
contentMD5 := req.Header.Get("Content-MD5")
|
||||
contentType := req.Header.Get("Content-Type")
|
||||
canonicalHeaders := ""
|
||||
if token := strings.TrimSpace(a.channel.SessionToken); token != "" {
|
||||
req.Header.Set("x-oss-security-token", token)
|
||||
canonicalHeaders = "x-oss-security-token:" + token + "\n"
|
||||
}
|
||||
canonicalHeaders := canonicalOSSHeaders(req)
|
||||
canonicalResource := "/" + objectStorageConfigString(a.channel.Config, "bucket") + req.URL.EscapedPath()
|
||||
stringToSign := req.Method + "\n\n" + contentType + "\n" + date + "\n" + canonicalHeaders + canonicalResource
|
||||
if _, ok := req.URL.Query()["lifecycle"]; ok {
|
||||
canonicalResource += "?lifecycle"
|
||||
}
|
||||
stringToSign := req.Method + "\n" + contentMD5 + "\n" + contentType + "\n" + date + "\n" + canonicalHeaders + canonicalResource
|
||||
signature := hmacSHA1Base64([]byte(a.channel.AccessKeySecret), stringToSign)
|
||||
req.Header.Set("Authorization", "OSS "+a.channel.AccessKeyID+":"+signature)
|
||||
req.Header.Set("Date", date)
|
||||
}
|
||||
|
||||
func canonicalOSSHeaders(req *http.Request) string {
|
||||
headers := map[string]string{}
|
||||
for key, values := range req.Header {
|
||||
lower := strings.ToLower(strings.TrimSpace(key))
|
||||
if strings.HasPrefix(lower, "x-oss-") {
|
||||
headers[lower] = strings.Join(values, ",")
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(headers))
|
||||
for key := range headers {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var canonical strings.Builder
|
||||
for _, key := range keys {
|
||||
canonical.WriteString(key)
|
||||
canonical.WriteByte(':')
|
||||
canonical.WriteString(strings.Join(strings.Fields(headers[key]), " "))
|
||||
canonical.WriteByte('\n')
|
||||
}
|
||||
return canonical.String()
|
||||
}
|
||||
|
||||
func (a *objectStorageAdapter) presignOSSGet(requestURL string, objectKey string, ttl time.Duration, now time.Time) (string, error) {
|
||||
parsed, err := url.Parse(requestURL)
|
||||
if err != nil {
|
||||
@@ -487,18 +544,27 @@ func objectStorageConfigBool(config map[string]any, key string) bool {
|
||||
}
|
||||
|
||||
func objectStorageAccessScope(channel store.FileStorageChannel, scene string) string {
|
||||
if scope := strings.ToLower(objectStorageConfigString(channel.Config, "accessScope")); scope == "public" || scope == "private" {
|
||||
return scope
|
||||
}
|
||||
if strings.TrimSpace(scene) == store.FileStorageSceneRequestAsset {
|
||||
return "private"
|
||||
}
|
||||
if strings.TrimSpace(objectStorageConfigString(channel.Config, "publicBaseUrl")) != "" || strings.TrimSpace(objectStorageConfigString(channel.Config, "publicBaseURL")) != "" {
|
||||
if scope := strings.ToLower(objectStorageConfigString(channel.Config, "accessScope")); scope == "public" || scope == "private" {
|
||||
return scope
|
||||
}
|
||||
if objectStoragePublicBaseURL(channel.Config) != "" {
|
||||
return "public"
|
||||
}
|
||||
return "private"
|
||||
}
|
||||
|
||||
func objectStoragePublicBaseURL(config map[string]any) string {
|
||||
return firstNonEmptyString(
|
||||
objectStorageConfigString(config, "publicBaseUrl"),
|
||||
objectStorageConfigString(config, "publicBaseURL"),
|
||||
objectStorageConfigString(config, "cdnDomain"),
|
||||
objectStorageConfigString(config, "publicDomain"),
|
||||
)
|
||||
}
|
||||
|
||||
func escapeObjectKey(objectKey string) string {
|
||||
parts := strings.Split(strings.TrimLeft(objectKey, "/"), "/")
|
||||
for index, part := range parts {
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
objectStorageExpirationNever = "never"
|
||||
objectStorageExpiration1Day = "1d"
|
||||
objectStorageExpiration1Month = "1m"
|
||||
objectStorageExpiration3Months = "3m"
|
||||
objectStorageExpiration6Months = "6m"
|
||||
|
||||
objectStorageSignedURLTTLMin = time.Minute
|
||||
objectStorageSignedURLTTLMax = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type objectStorageLifecycleDefinition struct {
|
||||
ID string
|
||||
TagValue string
|
||||
Days int
|
||||
}
|
||||
|
||||
var objectStorageLifecycleDefinitions = []objectStorageLifecycleDefinition{
|
||||
{ID: "DeleteTempFiles-1d", TagValue: objectStorageExpiration1Day, Days: 1},
|
||||
{ID: "DeleteTempFiles-1m", TagValue: objectStorageExpiration1Month, Days: 30},
|
||||
{ID: "DeleteTempFiles-3m", TagValue: objectStorageExpiration3Months, Days: 90},
|
||||
{ID: "DeleteTempFiles-6m", TagValue: objectStorageExpiration6Months, Days: 180},
|
||||
}
|
||||
|
||||
type objectStorageRetention struct {
|
||||
Enabled bool
|
||||
Policy string
|
||||
TagValue string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func (r objectStorageRetention) taggingHeader() string {
|
||||
values := url.Values{}
|
||||
values.Set("TempFile", "true")
|
||||
values.Set("ExpiresAt", strconv.FormatInt(r.ExpiresAt.Unix(), 10))
|
||||
values.Set("ExpiresIn", r.TagValue)
|
||||
return values.Encode()
|
||||
}
|
||||
|
||||
func objectStorageExpirationPolicy(config map[string]any) string {
|
||||
policy := strings.ToLower(firstNonEmptyString(
|
||||
objectStorageConfigString(config, "temporaryFileExpirePolicy"),
|
||||
objectStorageConfigString(config, "apiReturnExpirePolicy"),
|
||||
))
|
||||
switch policy {
|
||||
case objectStorageExpiration1Day,
|
||||
objectStorageExpiration1Month,
|
||||
objectStorageExpiration3Months,
|
||||
objectStorageExpiration6Months:
|
||||
return policy
|
||||
default:
|
||||
return objectStorageExpirationNever
|
||||
}
|
||||
}
|
||||
|
||||
func objectStorageRetentionFor(config map[string]any, scene string, now time.Time) objectStorageRetention {
|
||||
if strings.TrimSpace(scene) != "image_result" && strings.TrimSpace(scene) != "request_asset" {
|
||||
return objectStorageRetention{Policy: objectStorageExpirationNever}
|
||||
}
|
||||
policy := objectStorageExpirationPolicy(config)
|
||||
duration := objectStorageExpirationDuration(policy)
|
||||
if duration <= 0 {
|
||||
return objectStorageRetention{Policy: objectStorageExpirationNever}
|
||||
}
|
||||
return objectStorageRetention{
|
||||
Enabled: true,
|
||||
Policy: policy,
|
||||
TagValue: policy,
|
||||
ExpiresAt: now.Add(duration),
|
||||
}
|
||||
}
|
||||
|
||||
func objectStorageExpirationDuration(policy string) time.Duration {
|
||||
switch policy {
|
||||
case objectStorageExpiration1Day:
|
||||
return 24 * time.Hour
|
||||
case objectStorageExpiration1Month:
|
||||
return 30 * 24 * time.Hour
|
||||
case objectStorageExpiration3Months:
|
||||
return 90 * 24 * time.Hour
|
||||
case objectStorageExpiration6Months:
|
||||
return 180 * 24 * time.Hour
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func objectStorageSignedURLTTLForConfig(config map[string]any) time.Duration {
|
||||
seconds := int64FromAny(config["signedUrlExpiresSeconds"])
|
||||
if seconds <= 0 {
|
||||
return objectStorageSignedURLTTL
|
||||
}
|
||||
ttl := time.Duration(seconds) * time.Second
|
||||
if ttl < objectStorageSignedURLTTLMin {
|
||||
return objectStorageSignedURLTTLMin
|
||||
}
|
||||
if ttl > objectStorageSignedURLTTLMax {
|
||||
return objectStorageSignedURLTTLMax
|
||||
}
|
||||
return ttl
|
||||
}
|
||||
|
||||
func int64FromAny(value any) int64 {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return int64(typed)
|
||||
case int32:
|
||||
return int64(typed)
|
||||
case int64:
|
||||
return typed
|
||||
case float32:
|
||||
return int64(typed)
|
||||
case float64:
|
||||
return int64(typed)
|
||||
case string:
|
||||
parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
return parsed
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
objectStorageLifecycleReady sync.Map
|
||||
objectStorageLifecycleLocks sync.Map
|
||||
)
|
||||
|
||||
func (a *objectStorageAdapter) ensureLifecycle(ctx context.Context) error {
|
||||
cacheKey := strings.Join([]string{
|
||||
strings.ToLower(strings.TrimSpace(a.channel.Provider)),
|
||||
objectStorageConfigString(a.channel.Config, "endpoint"),
|
||||
objectStorageConfigString(a.channel.Config, "bucket"),
|
||||
}, "\x00")
|
||||
if _, ok := objectStorageLifecycleReady.Load(cacheKey); ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
lockValue, _ := objectStorageLifecycleLocks.LoadOrStore(cacheKey, &sync.Mutex{})
|
||||
lifecycleLock := lockValue.(*sync.Mutex)
|
||||
lifecycleLock.Lock()
|
||||
defer lifecycleLock.Unlock()
|
||||
if _, ok := objectStorageLifecycleReady.Load(cacheKey); ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
current, err := a.readLifecycle(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next, changed, err := mergeObjectStorageLifecycle(a.channel.Provider, current)
|
||||
if err != nil {
|
||||
return storageClientError("storage_config_invalid", "object storage lifecycle configuration is invalid", 0, false)
|
||||
}
|
||||
if changed {
|
||||
if err := a.writeLifecycle(ctx, next); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
objectStorageLifecycleReady.Store(cacheKey, struct{}{})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *objectStorageAdapter) readLifecycle(ctx context.Context) ([]byte, error) {
|
||||
requestURL, err := a.bucketLifecycleURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return nil, storageClientError("storage_config_invalid", err.Error(), 0, false)
|
||||
}
|
||||
if err := a.sign(req, sha256Hex(nil), a.now().UTC()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, storageClientError("storage_write_failed", "object storage lifecycle check failed", 0, true)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
if readErr != nil {
|
||||
return nil, storageClientError("storage_write_failed", "object storage lifecycle response could not be read", resp.StatusCode, storageStatusRetryable(resp.StatusCode))
|
||||
}
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, objectStorageHTTPError("storage_config_invalid", resp.StatusCode, body)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (a *objectStorageAdapter) writeLifecycle(ctx context.Context, payload []byte) error {
|
||||
requestURL, err := a.bucketLifecycleURL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, requestURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return storageClientError("storage_config_invalid", err.Error(), 0, false)
|
||||
}
|
||||
digest := md5.Sum(payload)
|
||||
req.Header.Set("Content-MD5", base64.StdEncoding.EncodeToString(digest[:]))
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
if err := a.sign(req, sha256Hex(payload), a.now().UTC()); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return storageClientError("storage_write_failed", "object storage lifecycle configuration failed", 0, true)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if readErr != nil {
|
||||
return storageClientError("storage_write_failed", "object storage lifecycle response could not be read", resp.StatusCode, storageStatusRetryable(resp.StatusCode))
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return objectStorageHTTPError("storage_config_invalid", resp.StatusCode, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *objectStorageAdapter) bucketLifecycleURL() (string, error) {
|
||||
endpoint, err := url.Parse(strings.TrimRight(objectStorageConfigString(a.channel.Config, "endpoint"), "/"))
|
||||
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" || endpoint.User != nil {
|
||||
return "", storageClientError("storage_config_invalid", "invalid object storage endpoint", 0, false)
|
||||
}
|
||||
bucket := objectStorageConfigString(a.channel.Config, "bucket")
|
||||
hostname := endpoint.Hostname()
|
||||
forcePathStyle := objectStorageConfigBool(a.channel.Config, "forcePathStyle")
|
||||
if strings.EqualFold(a.channel.Provider, "s3") {
|
||||
if forcePathStyle {
|
||||
endpoint.Path = path.Join(endpoint.Path, bucket)
|
||||
} else if !strings.HasPrefix(strings.ToLower(hostname), strings.ToLower(bucket)+".") {
|
||||
if port := endpoint.Port(); port != "" {
|
||||
endpoint.Host = bucket + "." + hostname + ":" + port
|
||||
} else {
|
||||
endpoint.Host = bucket + "." + endpoint.Host
|
||||
}
|
||||
}
|
||||
} else if !forcePathStyle && net.ParseIP(hostname) == nil && hostname != "localhost" && !strings.HasPrefix(strings.ToLower(hostname), strings.ToLower(bucket)+".") {
|
||||
if port := endpoint.Port(); port != "" {
|
||||
endpoint.Host = bucket + "." + hostname + ":" + port
|
||||
} else {
|
||||
endpoint.Host = bucket + "." + endpoint.Host
|
||||
}
|
||||
}
|
||||
if endpoint.Path == "" {
|
||||
endpoint.Path = "/"
|
||||
}
|
||||
endpoint.RawQuery = "lifecycle"
|
||||
endpoint.Fragment = ""
|
||||
return endpoint.String(), nil
|
||||
}
|
||||
|
||||
type rawLifecycleConfiguration struct {
|
||||
Rules []rawLifecycleRule `xml:"Rule"`
|
||||
}
|
||||
|
||||
type rawLifecycleRule struct {
|
||||
ID string `xml:"ID"`
|
||||
Status string `xml:"Status"`
|
||||
Tag rawLifecycleTag `xml:"Tag"`
|
||||
Filter rawLifecycleFilter `xml:"Filter"`
|
||||
Expiration rawLifecycleExpiration `xml:"Expiration"`
|
||||
InnerXML string `xml:",innerxml"`
|
||||
}
|
||||
|
||||
type rawLifecycleFilter struct {
|
||||
Tag rawLifecycleTag `xml:"Tag"`
|
||||
}
|
||||
|
||||
type rawLifecycleTag struct {
|
||||
Key string `xml:"Key"`
|
||||
Value string `xml:"Value"`
|
||||
}
|
||||
|
||||
type rawLifecycleExpiration struct {
|
||||
Days int `xml:"Days"`
|
||||
}
|
||||
|
||||
func mergeObjectStorageLifecycle(provider string, current []byte) ([]byte, bool, error) {
|
||||
configuration := rawLifecycleConfiguration{}
|
||||
if len(bytes.TrimSpace(current)) > 0 {
|
||||
if err := xml.Unmarshal(current, &configuration); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
managed := map[string]objectStorageLifecycleDefinition{}
|
||||
for _, definition := range objectStorageLifecycleDefinitions {
|
||||
managed[definition.ID] = definition
|
||||
}
|
||||
allManagedReady := true
|
||||
for _, definition := range objectStorageLifecycleDefinitions {
|
||||
matched := false
|
||||
for _, rule := range configuration.Rules {
|
||||
if rule.ID == definition.ID {
|
||||
matched = lifecycleRuleMatches(rule, definition)
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
allManagedReady = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allManagedReady {
|
||||
return current, false, nil
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
output.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
|
||||
if strings.EqualFold(strings.TrimSpace(provider), "s3") {
|
||||
output.WriteString(`<LifecycleConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">`)
|
||||
} else {
|
||||
output.WriteString(`<LifecycleConfiguration>`)
|
||||
}
|
||||
for _, rule := range configuration.Rules {
|
||||
if _, isManaged := managed[rule.ID]; isManaged {
|
||||
continue
|
||||
}
|
||||
output.WriteString("<Rule>")
|
||||
output.WriteString(rule.InnerXML)
|
||||
output.WriteString("</Rule>")
|
||||
}
|
||||
for _, definition := range objectStorageLifecycleDefinitions {
|
||||
output.WriteString(lifecycleRuleXML(provider, definition))
|
||||
}
|
||||
output.WriteString(`</LifecycleConfiguration>`)
|
||||
return []byte(output.String()), true, nil
|
||||
}
|
||||
|
||||
func lifecycleRuleMatches(rule rawLifecycleRule, definition objectStorageLifecycleDefinition) bool {
|
||||
tag := rule.Tag
|
||||
if tag.Key == "" {
|
||||
tag = rule.Filter.Tag
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(rule.Status), "Enabled") &&
|
||||
tag.Key == "ExpiresIn" && tag.Value == definition.TagValue &&
|
||||
rule.Expiration.Days == definition.Days
|
||||
}
|
||||
|
||||
func lifecycleRuleXML(provider string, definition objectStorageLifecycleDefinition) string {
|
||||
if strings.EqualFold(strings.TrimSpace(provider), "s3") {
|
||||
return fmt.Sprintf(
|
||||
"<Rule><ID>%s</ID><Filter><Tag><Key>ExpiresIn</Key><Value>%s</Value></Tag></Filter><Status>Enabled</Status><Expiration><Days>%d</Days></Expiration></Rule>",
|
||||
definition.ID,
|
||||
definition.TagValue,
|
||||
definition.Days,
|
||||
)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"<Rule><ID>%s</ID><Prefix></Prefix><Tag><Key>ExpiresIn</Key><Value>%s</Value></Tag><Status>Enabled</Status><Expiration><Days>%d</Days></Expiration></Rule>",
|
||||
definition.ID,
|
||||
definition.TagValue,
|
||||
definition.Days,
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -80,6 +81,7 @@ func TestRequestAssetUsesPrivateSignedURLEvenWhenPublicBaseURLExists(t *testing.
|
||||
defer server.Close()
|
||||
channel := testObjectStorageChannel("s3", server.URL, "s3-private")
|
||||
channel.Config["publicBaseUrl"] = "https://cdn.example"
|
||||
channel.Config["accessScope"] = "public"
|
||||
adapter, err := newObjectStorageAdapter(channel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -101,6 +103,112 @@ func TestRequestAssetUsesPrivateSignedURLEvenWhenPublicBaseURLExists(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateSignedURLUsesConfiguredTTL(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
channel := testObjectStorageChannel("s3", server.URL, "s3-private-ttl")
|
||||
channel.Config["signedUrlExpiresSeconds"] = 3600
|
||||
adapter, err := newObjectStorageAdapter(channel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adapter.now = func() time.Time { return time.Date(2026, time.August, 4, 0, 0, 0, 0, time.UTC) }
|
||||
|
||||
result, err := adapter.put(t.Context(), FileUploadPayload{
|
||||
Bytes: []byte("private input"), ContentType: "image/png", FileName: "input.png", Scene: store.FileStorageSceneRequestAsset,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := url.Parse(stringFromAny(result["url"]))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := parsed.Query().Get("X-Amz-Expires"); got != "3600" {
|
||||
t.Fatalf("X-Amz-Expires=%q, want 3600", got)
|
||||
}
|
||||
if got := stringFromAny(result["urlExpiresAt"]); got != "2026-08-04T01:00:00Z" {
|
||||
t.Fatalf("urlExpiresAt=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemporaryObjectEnsuresS3LifecycleAndAddsTagging(t *testing.T) {
|
||||
var methods []string
|
||||
var lifecyclePayload string
|
||||
var tagging string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, lifecycle := r.URL.Query()["lifecycle"]; lifecycle {
|
||||
methods = append(methods, r.Method+" lifecycle")
|
||||
if r.Method == http.MethodGet {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
lifecyclePayload = string(body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
methods = append(methods, r.Method+" object")
|
||||
tagging = r.Header.Get("x-amz-tagging")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
channel := testObjectStorageChannel("s3", server.URL, "s3-expiring")
|
||||
channel.Config["temporaryFileExpirePolicy"] = "1m"
|
||||
adapter, err := newObjectStorageAdapter(channel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adapter.now = func() time.Time { return time.Date(2026, time.August, 4, 0, 0, 0, 0, time.UTC) }
|
||||
result, err := adapter.put(t.Context(), FileUploadPayload{
|
||||
Bytes: []byte("temporary image"), ContentType: "image/png", Scene: store.FileStorageSceneImageResult,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(methods, ","); got != "GET lifecycle,PUT lifecycle,PUT object" {
|
||||
t.Fatalf("methods=%s", got)
|
||||
}
|
||||
for _, fragment := range []string{"DeleteTempFiles-1d", "DeleteTempFiles-1m", "DeleteTempFiles-3m", "DeleteTempFiles-6m", "<Value>1m</Value>"} {
|
||||
if !strings.Contains(lifecyclePayload, fragment) {
|
||||
t.Fatalf("lifecycle payload missing %q: %s", fragment, lifecyclePayload)
|
||||
}
|
||||
}
|
||||
values, err := url.ParseQuery(tagging)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if values.Get("TempFile") != "true" || values.Get("ExpiresIn") != "1m" || values.Get("ExpiresAt") != "1788393600" {
|
||||
t.Fatalf("unexpected tagging: %q", tagging)
|
||||
}
|
||||
if got := stringFromAny(result["objectExpiresAt"]); got != "2026-09-03T00:00:00Z" {
|
||||
t.Fatalf("objectExpiresAt=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeObjectStorageLifecyclePreservesUnrelatedRules(t *testing.T) {
|
||||
current := []byte(`<?xml version="1.0"?><LifecycleConfiguration><Rule><ID>KeepArchive</ID><Prefix>archive/</Prefix><Status>Enabled</Status><Expiration><Days>365</Days></Expiration></Rule></LifecycleConfiguration>`)
|
||||
merged, changed, err := mergeObjectStorageLifecycle("aliyun_oss", current)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatal("lifecycle merge unexpectedly reported no change")
|
||||
}
|
||||
value := string(merged)
|
||||
if !strings.Contains(value, "<ID>KeepArchive</ID>") || !strings.Contains(value, "<Prefix>archive/</Prefix>") {
|
||||
t.Fatalf("unrelated lifecycle rule was not preserved: %s", value)
|
||||
}
|
||||
for _, definition := range objectStorageLifecycleDefinitions {
|
||||
if !strings.Contains(value, "<ID>"+definition.ID+"</ID>") {
|
||||
t.Fatalf("managed lifecycle rule %s missing", definition.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliyunOSSUsesBucketVirtualHostForRegionalEndpoint(t *testing.T) {
|
||||
channel := testObjectStorageChannel("aliyun_oss", "https://oss-cn-hangzhou.aliyuncs.com", "oss-primary")
|
||||
channel.Config["forcePathStyle"] = false
|
||||
@@ -138,6 +246,37 @@ func TestAliyunOSSRequestUsesSignatureV1(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliyunOSSSignatureIncludesLifecycleTaggingHeader(t *testing.T) {
|
||||
channel := testObjectStorageChannel("aliyun_oss", "https://oss-cn-hangzhou.aliyuncs.com", "oss-tagged")
|
||||
adapter, err := newObjectStorageAdapter(channel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodPut, "https://bucket.oss-cn-hangzhou.aliyuncs.com/media/image_result/object.png", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "image/png")
|
||||
request.Header.Set("x-oss-tagging", "ExpiresAt=1788393600&ExpiresIn=1m&TempFile=true")
|
||||
now := time.Date(2026, time.August, 4, 1, 2, 3, 0, time.UTC)
|
||||
adapter.signOSSRequest(request, now)
|
||||
stringToSign := "PUT\n\nimage/png\nTue, 04 Aug 2026 01:02:03 GMT\n" +
|
||||
"x-oss-tagging:ExpiresAt=1788393600&ExpiresIn=1m&TempFile=true\n" +
|
||||
"/bucket/media/image_result/object.png"
|
||||
want := "OSS access-id:" + hmacSHA1Base64([]byte("access-secret"), stringToSign)
|
||||
if got := request.Header.Get("Authorization"); got != want {
|
||||
t.Fatalf("authorization=%q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemporaryRetentionDoesNotApplyToPersistentUploadScene(t *testing.T) {
|
||||
config := map[string]any{"temporaryFileExpirePolicy": "1d"}
|
||||
retention := objectStorageRetentionFor(config, store.FileStorageSceneUpload, time.Now())
|
||||
if retention.Enabled || retention.Policy != objectStorageExpirationNever {
|
||||
t.Fatalf("persistent upload unexpectedly expires: %+v", retention)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObjectStorageRetriesCurrentChannelThenFailsOver(t *testing.T) {
|
||||
var primaryCalls atomic.Int64
|
||||
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user