package runner import ( "bytes" "context" "crypto/hmac" "crypto/sha1" "crypto/sha256" "encoding/base64" "encoding/hex" "fmt" "io" "net" "net/http" "net/url" "path" "sort" "strconv" "strings" "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/clients" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" "github.com/google/uuid" ) const ( objectStorageRequestTimeout = 120 * time.Second objectStorageReadLimit = 256 << 20 objectStorageSignedURLTTL = 15 * time.Minute ) var sharedObjectStorageHTTPClient = &http.Client{ Timeout: objectStorageRequestTimeout, Transport: &http.Transport{ MaxIdleConns: 256, MaxIdleConnsPerHost: 256, MaxConnsPerHost: 256, IdleConnTimeout: 90 * time.Second, }, } type objectStorageAdapter struct { channel store.FileStorageChannel client *http.Client now func() time.Time } type FileStorageChannelTestResult struct { 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 // against one object-storage channel. The random probe avoids overwriting a // 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)), 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: probeScene, Source: "admin-connection-test", } upload, err := adapter.put(ctx, payload) if err != nil { return result, err } result.PutSucceeded = true objectKey := stringFromAny(upload["objectKey"]) if objectKey == "" { return result, storageClientError("storage_write_failed", "object storage probe did not return an object reference", 0, false) } if err := adapter.head(ctx, objectKey); err != nil { _ = adapter.delete(context.WithoutCancel(ctx), objectKey) return result, err } result.HeadSucceeded = true if err := adapter.delete(ctx, objectKey); err != nil { return result, err } result.DeleteSucceeded = true result.DurationMS = time.Since(startedAt).Milliseconds() return result, nil } func newObjectStorageAdapter(channel store.FileStorageChannel) (*objectStorageAdapter, error) { provider := strings.ToLower(strings.TrimSpace(channel.Provider)) if provider != "aliyun_oss" && provider != "s3" { return nil, &clients.ClientError{Code: "storage_config_invalid", Message: "unsupported object storage provider", Retryable: false} } if objectStorageConfigString(channel.Config, "endpoint") == "" || objectStorageConfigString(channel.Config, "bucket") == "" { return nil, &clients.ClientError{Code: "storage_config_invalid", Message: "object storage endpoint and bucket are required", Retryable: false} } if strings.TrimSpace(channel.AccessKeyID) == "" || strings.TrimSpace(channel.AccessKeySecret) == "" { return nil, &clients.ClientError{Code: "storage_auth_failed", Message: "object storage credentials are not configured", Retryable: false} } return &objectStorageAdapter{ channel: channel, client: sharedObjectStorageHTTPClient, now: time.Now, }, nil } 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 { return nil, err } contentType := strings.TrimSpace(payload.ContentType) if contentType == "" { contentType = "application/octet-stream" } req, err := http.NewRequestWithContext(ctx, http.MethodPut, requestURL, bytes.NewReader(payload.Bytes)) if err != nil { 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 } resp, err := a.client.Do(req) if err != nil { return nil, storageClientError("storage_write_failed", err.Error(), 0, true) } defer resp.Body.Close() responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) if readErr != nil { return nil, storageClientError("storage_write_failed", readErr.Error(), resp.StatusCode, storageStatusRetryable(resp.StatusCode)) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, objectStorageHTTPError("storage_write_failed", resp.StatusCode, responseBody) } accessScope := objectStorageAccessScope(a.channel, payload.Scene) publicURL := "" if accessScope == "public" { publicURL = a.publicURL(objectKey) } urlExpiresAt := "" if publicURL == "" { signedURLTTL := objectStorageSignedURLTTLForConfig(a.channel.Config) publicURL, err = a.presignGet(objectKey, signedURLTTL) if err != nil { return nil, err } urlExpiresAt = a.now().UTC().Add(signedURLTTL).Format(time.RFC3339) } digest := sha256.Sum256(payload.Bytes) result := map[string]any{ "url": publicURL, "fileName": path.Base(objectKey), "objectKey": objectKey, "contentType": contentType, "size": len(payload.Bytes), "sha256": hex.EncodeToString(digest[:]), "accessScope": accessScope, "storageChannel": map[string]any{ "id": a.channel.ID, "channelKey": a.channel.ChannelKey, "name": a.channel.Name, "provider": a.channel.Provider, }, } if urlExpiresAt != "" { result["urlExpiresAt"] = urlExpiresAt } if retention.Enabled { result["objectExpiresAt"] = retention.ExpiresAt.Format(time.RFC3339) result["expirationPolicy"] = retention.Policy } return result, nil } func (a *objectStorageAdapter) get(ctx context.Context, objectKey string) ([]byte, error) { requestURL, err := a.objectURL(objectKey) 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_read_failed", err.Error(), 0, true) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) return nil, objectStorageHTTPError("storage_read_failed", resp.StatusCode, body) } payload, err := io.ReadAll(io.LimitReader(resp.Body, objectStorageReadLimit+1)) if err != nil { return nil, storageClientError("storage_read_failed", err.Error(), resp.StatusCode, true) } if len(payload) > objectStorageReadLimit { return nil, storageClientError("storage_read_failed", "stored object exceeds the read limit", resp.StatusCode, false) } return payload, nil } func (a *objectStorageAdapter) head(ctx context.Context, objectKey string) error { return a.emptyObjectRequest(ctx, http.MethodHead, objectKey) } func (a *objectStorageAdapter) delete(ctx context.Context, objectKey string) error { return a.emptyObjectRequest(ctx, http.MethodDelete, objectKey) } func (a *objectStorageAdapter) emptyObjectRequest(ctx context.Context, method string, objectKey string) error { requestURL, err := a.objectURL(objectKey) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, method, requestURL, nil) if err != nil { return storageClientError("storage_config_invalid", err.Error(), 0, false) } if err := a.sign(req, sha256Hex(nil), a.now().UTC()); err != nil { return err } resp, err := a.client.Do(req) if err != nil { return storageClientError("storage_read_failed", err.Error(), 0, true) } defer resp.Body.Close() _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) if resp.StatusCode < 200 || resp.StatusCode >= 300 { return objectStorageHTTPError("storage_read_failed", resp.StatusCode, nil) } return nil } func (a *objectStorageAdapter) presignGet(objectKey string, ttl time.Duration) (string, error) { requestURL, err := a.objectURL(objectKey) if err != nil { return "", err } if ttl <= 0 { ttl = objectStorageSignedURLTTL } if strings.EqualFold(a.channel.Provider, "s3") { return a.presignS3Get(requestURL, ttl, a.now().UTC()) } return a.presignOSSGet(requestURL, objectKey, ttl, a.now().UTC()) } func (a *objectStorageAdapter) objectKey(payload FileUploadPayload) string { now := a.now().UTC() digest := sha256.Sum256(payload.Bytes) extension := uploadFileExtension(payload.ContentType, path.Ext(payload.FileName)) prefix := strings.Trim(firstNonEmptyString( objectStorageConfigString(a.channel.Config, "objectPrefix"), objectStorageConfigString(a.channel.Config, "objectKeyPrefix"), ), "/") parts := make([]string, 0, 7) if prefix != "" { parts = append(parts, prefix) } parts = append(parts, firstNonEmptyString(strings.TrimSpace(payload.Scene), store.FileStorageSceneUpload), fmt.Sprintf("%04d", now.Year()), fmt.Sprintf("%02d", now.Month()), fmt.Sprintf("%02d", now.Day()), hex.EncodeToString(digest[:])+extension, ) return strings.Join(parts, "/") } func (a *objectStorageAdapter) objectURL(objectKey string) (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") if strings.EqualFold(a.channel.Provider, "s3") { if objectStorageConfigBool(a.channel.Config, "forcePathStyle") { endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/" + bucket + "/" + strings.TrimLeft(objectKey, "/") } else { endpoint.Host = bucket + "." + endpoint.Host endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/" + strings.TrimLeft(objectKey, "/") } } else { hostname := endpoint.Hostname() forcePathStyle := objectStorageConfigBool(a.channel.Config, "forcePathStyle") 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 } } endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/" + strings.TrimLeft(objectKey, "/") } return endpoint.String(), nil } func (a *objectStorageAdapter) publicURL(objectKey string) string { baseURL := strings.TrimRight(objectStoragePublicBaseURL(a.channel.Config), "/") if baseURL == "" { return "" } return baseURL + "/" + escapeObjectKey(objectKey) } func (a *objectStorageAdapter) sign(req *http.Request, payloadHash string, now time.Time) error { if strings.EqualFold(a.channel.Provider, "s3") { a.signS3Request(req, payloadHash, now) return nil } a.signOSSRequest(req, now) return nil } 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") if token := strings.TrimSpace(a.channel.SessionToken); token != "" { req.Header.Set("x-oss-security-token", token) } canonicalHeaders := canonicalOSSHeaders(req) canonicalResource := "/" + objectStorageConfigString(a.channel.Config, "bucket") + req.URL.EscapedPath() 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 { return "", storageClientError("storage_config_invalid", err.Error(), 0, false) } expires := strconv.FormatInt(now.Add(ttl).Unix(), 10) canonicalResource := "/" + objectStorageConfigString(a.channel.Config, "bucket") + "/" + strings.TrimLeft(objectKey, "/") signature := hmacSHA1Base64([]byte(a.channel.AccessKeySecret), "GET\n\n\n"+expires+"\n"+canonicalResource) query := parsed.Query() query.Set("OSSAccessKeyId", a.channel.AccessKeyID) query.Set("Expires", expires) query.Set("Signature", signature) if token := strings.TrimSpace(a.channel.SessionToken); token != "" { query.Set("security-token", token) } parsed.RawQuery = query.Encode() return parsed.String(), nil } func (a *objectStorageAdapter) signS3Request(req *http.Request, payloadHash string, now time.Time) { region := firstNonEmptyString(objectStorageConfigString(a.channel.Config, "region"), "us-east-1") amzDate := now.Format("20060102T150405Z") date := now.Format("20060102") req.Header.Set("x-amz-date", amzDate) req.Header.Set("x-amz-content-sha256", payloadHash) if token := strings.TrimSpace(a.channel.SessionToken); token != "" { req.Header.Set("x-amz-security-token", token) } canonicalHeaders, signedHeaders := s3CanonicalHeaders(req) canonicalRequest := strings.Join([]string{ req.Method, s3CanonicalURI(req.URL), s3CanonicalQuery(req.URL.Query()), canonicalHeaders, signedHeaders, payloadHash, }, "\n") scope := date + "/" + region + "/s3/aws4_request" stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + sha256Hex([]byte(canonicalRequest)) signature := hex.EncodeToString(s3SigningHMAC(a.channel.AccessKeySecret, date, region, stringToSign)) req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+a.channel.AccessKeyID+"/"+scope+", SignedHeaders="+signedHeaders+", Signature="+signature) } func (a *objectStorageAdapter) presignS3Get(requestURL string, ttl time.Duration, now time.Time) (string, error) { parsed, err := url.Parse(requestURL) if err != nil { return "", storageClientError("storage_config_invalid", err.Error(), 0, false) } region := firstNonEmptyString(objectStorageConfigString(a.channel.Config, "region"), "us-east-1") amzDate := now.Format("20060102T150405Z") date := now.Format("20060102") scope := date + "/" + region + "/s3/aws4_request" seconds := int64(ttl / time.Second) if seconds < 1 { seconds = 1 } if seconds > 7*24*60*60 { seconds = 7 * 24 * 60 * 60 } query := parsed.Query() query.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256") query.Set("X-Amz-Credential", a.channel.AccessKeyID+"/"+scope) query.Set("X-Amz-Date", amzDate) query.Set("X-Amz-Expires", strconv.FormatInt(seconds, 10)) query.Set("X-Amz-SignedHeaders", "host") if token := strings.TrimSpace(a.channel.SessionToken); token != "" { query.Set("X-Amz-Security-Token", token) } canonicalRequest := "GET\n" + s3CanonicalURI(parsed) + "\n" + s3CanonicalQuery(query) + "\nhost:" + strings.ToLower(parsed.Host) + "\n\nhost\nUNSIGNED-PAYLOAD" stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + sha256Hex([]byte(canonicalRequest)) query.Set("X-Amz-Signature", hex.EncodeToString(s3SigningHMAC(a.channel.AccessKeySecret, date, region, stringToSign))) parsed.RawQuery = query.Encode() return parsed.String(), nil } func s3CanonicalHeaders(req *http.Request) (string, string) { headers := map[string]string{"host": strings.ToLower(req.URL.Host)} for key, values := range req.Header { lower := strings.ToLower(strings.TrimSpace(key)) if lower == "content-type" || strings.HasPrefix(lower, "x-amz-") { 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(), strings.Join(keys, ";") } func s3CanonicalURI(value *url.URL) string { uri := value.EscapedPath() if uri == "" { return "/" } return uri } func s3CanonicalQuery(values url.Values) string { return strings.ReplaceAll(values.Encode(), "+", "%20") } func s3SigningHMAC(secret string, date string, region string, stringToSign string) []byte { dateKey := hmacSHA256([]byte("AWS4"+secret), date) regionKey := hmacSHA256(dateKey, region) serviceKey := hmacSHA256(regionKey, "s3") signingKey := hmacSHA256(serviceKey, "aws4_request") return hmacSHA256(signingKey, stringToSign) } func hmacSHA256(key []byte, value string) []byte { mac := hmac.New(sha256.New, key) _, _ = mac.Write([]byte(value)) return mac.Sum(nil) } func hmacSHA1Base64(key []byte, value string) string { mac := hmac.New(sha1.New, key) _, _ = mac.Write([]byte(value)) return base64.StdEncoding.EncodeToString(mac.Sum(nil)) } func sha256Hex(value []byte) string { digest := sha256.Sum256(value) return hex.EncodeToString(digest[:]) } func objectStorageConfigString(config map[string]any, key string) string { if config == nil { return "" } value, _ := config[key].(string) return strings.TrimSpace(value) } func objectStorageConfigBool(config map[string]any, key string) bool { if config == nil { return false } value, _ := config[key].(bool) return value } func objectStorageAccessScope(channel store.FileStorageChannel, scene string) string { if strings.TrimSpace(scene) == store.FileStorageSceneRequestAsset { return "private" } 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 { parts[index] = url.PathEscape(part) } return strings.Join(parts, "/") } func storageStatusRetryable(status int) bool { return status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || status >= 500 } func objectStorageHTTPError(code string, status int, body []byte) error { if status == http.StatusUnauthorized || status == http.StatusForbidden { code = "storage_auth_failed" } message := http.StatusText(status) if message == "" { message = "object storage request failed" } // The provider response may contain object names, endpoints or credential // diagnostics. Keep it out of the public error chain; channel health stores // only the stable status description as well. return storageClientError(code, message, status, storageStatusRetryable(status)) } func storageClientError(code string, message string, status int, retryable bool) error { return &clients.ClientError{Code: code, Message: message, StatusCode: status, Retryable: retryable} }