diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index e8498c2..7b2188d 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -7,6 +7,11 @@ import ( "strings" ) +const ( + DefaultLocalGeneratedStorageDir = "data/static/generated" + DefaultLocalUploadedStorageDir = "data/static/uploaded" +) + type Config struct { AppEnv string HTTPAddr string @@ -15,6 +20,9 @@ type Config struct { JWTSecret string ServerMainBaseURL string ServerMainInternalToken string + PublicBaseURL string + LocalGeneratedStorageDir string + LocalUploadedStorageDir string TaskProgressCallbackEnabled bool TaskProgressCallbackURL string TaskProgressCallbackTimeoutMS string @@ -38,6 +46,9 @@ func Load() Config { "/", ), ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""), + PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"), + LocalGeneratedStorageDir: env("AI_GATEWAY_GENERATED_STORAGE_DIR", env("LOCAL_GENERATED_STORAGE_DIR", env("AI_GATEWAY_STATIC_STORAGE_DIR", DefaultLocalGeneratedStorageDir))), + LocalUploadedStorageDir: env("AI_GATEWAY_UPLOADED_STORAGE_DIR", env("LOCAL_UPLOADED_STORAGE_DIR", DefaultLocalUploadedStorageDir)), TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true", TaskProgressCallbackURL: env("TASK_PROGRESS_CALLBACK_URL", strings.TrimRight(env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/")+"/internal/platform/task-progress-callbacks", diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 25b86b5..85256ca 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -41,6 +41,8 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor mux.HandleFunc("GET /healthz", server.health) mux.HandleFunc("GET /readyz", server.ready) mux.HandleFunc("GET /static/simulation/{asset}", serveSimulationAsset) + mux.HandleFunc("GET /static/generated/{asset}", server.serveGeneratedStaticAsset) + mux.HandleFunc("GET /static/uploaded/{asset}", server.serveUploadedStaticAsset) mux.Handle("POST /api/v1/auth/register", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.register))) mux.Handle("POST /api/v1/auth/login", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.login))) diff --git a/apps/api/internal/httpapi/static_assets.go b/apps/api/internal/httpapi/static_assets.go new file mode 100644 index 0000000..72d8c3d --- /dev/null +++ b/apps/api/internal/httpapi/static_assets.go @@ -0,0 +1,37 @@ +package httpapi + +import ( + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" +) + +func (s *Server) serveGeneratedStaticAsset(w http.ResponseWriter, r *http.Request) { + s.serveLocalStaticAsset(w, r, s.cfg.LocalGeneratedStorageDir, config.DefaultLocalGeneratedStorageDir) +} + +func (s *Server) serveUploadedStaticAsset(w http.ResponseWriter, r *http.Request) { + s.serveLocalStaticAsset(w, r, s.cfg.LocalUploadedStorageDir, config.DefaultLocalUploadedStorageDir) +} + +func (s *Server) serveLocalStaticAsset(w http.ResponseWriter, r *http.Request, storageDir string, fallbackStorageDir string) { + fileName := filepath.Base(strings.TrimSpace(r.PathValue("asset"))) + if fileName == "" || fileName == "." || fileName == ".." || fileName == string(filepath.Separator) { + http.NotFound(w, r) + return + } + storageDir = strings.TrimSpace(storageDir) + if storageDir == "" { + storageDir = fallbackStorageDir + } + filePath := filepath.Join(storageDir, fileName) + info, err := os.Stat(filePath) + if err != nil || info.IsDir() { + http.NotFound(w, r) + return + } + http.ServeFile(w, r, filePath) +} diff --git a/apps/api/internal/httpapi/static_assets_test.go b/apps/api/internal/httpapi/static_assets_test.go new file mode 100644 index 0000000..88e3944 --- /dev/null +++ b/apps/api/internal/httpapi/static_assets_test.go @@ -0,0 +1,65 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" +) + +func TestServeGeneratedStaticAsset(t *testing.T) { + storageDir := t.TempDir() + if err := os.WriteFile(filepath.Join(storageDir, "result.png"), []byte("png"), 0o644); err != nil { + t.Fatalf("failed to write generated asset fixture: %v", err) + } + server := &Server{cfg: config.Config{LocalGeneratedStorageDir: storageDir}} + request := httptest.NewRequest(http.MethodGet, "/static/generated/result.png", nil) + request.SetPathValue("asset", "result.png") + response := httptest.NewRecorder() + + server.serveGeneratedStaticAsset(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("expected generated asset to be served, got status %d", response.Code) + } + if response.Body.String() != "png" { + t.Fatalf("unexpected generated asset payload: %q", response.Body.String()) + } +} + +func TestServeUploadedStaticAsset(t *testing.T) { + storageDir := t.TempDir() + if err := os.WriteFile(filepath.Join(storageDir, "upload.pdf"), []byte("pdf"), 0o644); err != nil { + t.Fatalf("failed to write uploaded asset fixture: %v", err) + } + server := &Server{cfg: config.Config{LocalUploadedStorageDir: storageDir}} + request := httptest.NewRequest(http.MethodGet, "/static/uploaded/upload.pdf", nil) + request.SetPathValue("asset", "upload.pdf") + response := httptest.NewRecorder() + + server.serveUploadedStaticAsset(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("expected uploaded asset to be served, got status %d", response.Code) + } + if response.Body.String() != "pdf" { + t.Fatalf("unexpected uploaded asset payload: %q", response.Body.String()) + } +} + +func TestServeLocalStaticAssetRejectsTraversal(t *testing.T) { + storageDir := t.TempDir() + server := &Server{cfg: config.Config{LocalGeneratedStorageDir: storageDir}} + request := httptest.NewRequest(http.MethodGet, "/static/generated/..", nil) + request.SetPathValue("asset", "..") + response := httptest.NewRecorder() + + server.serveGeneratedStaticAsset(response, request) + + if response.Code != http.StatusNotFound { + t.Fatalf("expected traversal-like generated asset name to 404, got status %d", response.Code) + } +} diff --git a/apps/api/internal/runner/upload.go b/apps/api/internal/runner/upload.go index e5d0d6f..1f57f82 100644 --- a/apps/api/internal/runner/upload.go +++ b/apps/api/internal/runner/upload.go @@ -9,20 +9,29 @@ import ( "encoding/json" "fmt" "io" + "mime" "mime/multipart" "net/http" "net/textproto" "net/url" + "os" + "path/filepath" "strings" "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/clients" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) const defaultServerMainOpenAPIUploadURL = "http://127.0.0.1:3001/v1/files/upload" const maxGeneratedAssetFetchBytes = 256 << 20 +const ( + localStaticGeneratedPathPrefix = "/static/generated/" + localStaticUploadedPathPrefix = "/static/uploaded/" +) + type FileUploadPayload struct { ContentType string FileName string @@ -32,8 +41,9 @@ type FileUploadPayload struct { } type generatedAssetUploadPolicy struct { - UploadInlineMedia bool - UploadURLMedia bool + UploadInlineMedia bool + UploadURLMedia bool + StoreInlineMediaLocally bool } type generatedAssetDecision struct { @@ -96,14 +106,11 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task return result, nil } var channels []store.FileStorageChannel - if needsUpload { + if needsUpload && generatedAssetNeedsChannelLookup(policy, decisions) { channels, err = s.activeFileStorageChannels(ctx, store.FileStorageSceneImageResult) if err != nil { return nil, &clients.ClientError{Code: "upload_config_failed", Message: err.Error(), Retryable: true} } - if len(channels) == 0 { - return nil, &clients.ClientError{Code: "upload_no_channel", Message: "no enabled file storage channel for generated media results", Retryable: false} - } } next := map[string]any{} for key, value := range result { @@ -132,13 +139,11 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task var contentType string var err error if decision.Inline != nil { - upload, contentType, kind, err = s.uploadGeneratedAsset(ctx, taskID, decision.Inline, index, channels) + upload, contentType, kind, strategy, err = s.uploadGeneratedAsset(ctx, taskID, decision.Inline, index, channels, policy.StoreInlineMediaLocally) sourceKey = decision.Inline.SourceKey - strategy = "upload_inline_media" } else { - upload, contentType, kind, err = s.uploadGeneratedURLAsset(ctx, taskID, decision.URL, index, channels) + upload, contentType, kind, strategy, err = s.uploadGeneratedURLAsset(ctx, taskID, decision.URL, index, channels) sourceKey = decision.URL.SourceKey - strategy = "upload_url_media" } if err != nil { return nil, err @@ -191,40 +196,174 @@ func generatedAssetUploadPolicyFromName(policyName string) generatedAssetUploadP case store.FileStorageResultUploadPolicyUploadAll: return generatedAssetUploadPolicy{UploadInlineMedia: true, UploadURLMedia: true} case store.FileStorageResultUploadPolicyUploadNone: - return generatedAssetUploadPolicy{UploadInlineMedia: false, UploadURLMedia: false} + return generatedAssetUploadPolicy{UploadInlineMedia: true, UploadURLMedia: false, StoreInlineMediaLocally: true} default: return defaultGeneratedAssetUploadPolicy() } } -func (s *Service) uploadGeneratedAsset(ctx context.Context, taskID string, asset *generatedInlineAsset, index int, channels []store.FileStorageChannel) (map[string]any, string, string, error) { +func generatedAssetNeedsChannelLookup(policy generatedAssetUploadPolicy, decisions []generatedAssetDecision) bool { + for _, decision := range decisions { + if decision.URL != nil { + return true + } + if decision.Inline != nil && !policy.StoreInlineMediaLocally { + return true + } + } + return false +} + +func (s *Service) uploadGeneratedAsset(ctx context.Context, taskID string, asset *generatedInlineAsset, index int, channels []store.FileStorageChannel, forceLocal bool) (map[string]any, string, string, string, error) { contentType := resolvedGeneratedAssetContentType(asset.ContentType, asset.Kind, asset.Bytes) kind := generatedAssetKindFromContentType(asset.Kind, contentType) - upload, err := s.uploadFileWithFailover(ctx, FileUploadPayload{ + payload := FileUploadPayload{ Bytes: asset.Bytes, ContentType: contentType, FileName: generatedAssetFileName(taskID, index, contentType, kind), Scene: store.FileStorageSceneImageResult, Source: "ai-gateway", - }, channels) - return upload, contentType, kind, err + } + if forceLocal || len(channels) == 0 { + upload, err := s.storeFileLocally(payload, s.cfg.LocalGeneratedStorageDir, config.DefaultLocalGeneratedStorageDir, localStaticGeneratedPathPrefix) + return upload, contentType, kind, "local_static_inline_media", err + } + upload, err := s.uploadFileWithFailover(ctx, payload, channels) + return upload, contentType, kind, "upload_inline_media", err } -func (s *Service) uploadGeneratedURLAsset(ctx context.Context, taskID string, asset *generatedURLAsset, index int, channels []store.FileStorageChannel) (map[string]any, string, string, error) { +func (s *Service) uploadGeneratedURLAsset(ctx context.Context, taskID string, asset *generatedURLAsset, index int, channels []store.FileStorageChannel) (map[string]any, string, string, string, error) { payload, contentType, err := s.readGeneratedURLAsset(ctx, asset) if err != nil { - return nil, "", "", err + return nil, "", "", "", err } contentType = resolvedGeneratedAssetContentType(firstNonEmptyString(contentType, asset.ContentType), asset.Kind, payload) kind := generatedAssetKindFromContentType(asset.Kind, contentType) - upload, err := s.uploadFileWithFailover(ctx, FileUploadPayload{ + uploadPayload := FileUploadPayload{ Bytes: payload, ContentType: contentType, FileName: generatedAssetFileName(taskID, index, contentType, kind), Scene: store.FileStorageSceneImageResult, Source: "ai-gateway", - }, channels) - return upload, contentType, kind, err + } + if len(channels) == 0 { + upload, err := s.storeFileLocally(uploadPayload, s.cfg.LocalGeneratedStorageDir, config.DefaultLocalGeneratedStorageDir, localStaticGeneratedPathPrefix) + return upload, contentType, kind, "local_static_url_media", err + } + upload, err := s.uploadFileWithFailover(ctx, uploadPayload, channels) + return upload, contentType, kind, "upload_url_media", err +} + +func (s *Service) storeFileLocally(payload FileUploadPayload, storageDir string, fallbackStorageDir string, pathPrefix string) (map[string]any, error) { + storageDir = strings.TrimSpace(storageDir) + if storageDir == "" { + storageDir = fallbackStorageDir + } + if err := os.MkdirAll(storageDir, 0o755); err != nil { + return nil, &clients.ClientError{Code: "local_static_store_failed", Message: err.Error(), Retryable: true} + } + fileName := filepath.Base(strings.TrimSpace(payload.FileName)) + if fileName == "" || fileName == "." || fileName == ".." || fileName == string(filepath.Separator) { + kind := generatedAssetKindFromContentType("", payload.ContentType) + fileName = generatedAssetFileName("generated", 0, payload.ContentType, kind) + } + targetPath := filepath.Join(storageDir, fileName) + file, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return nil, &clients.ClientError{Code: "local_static_store_failed", Message: err.Error(), Retryable: true} + } + _, writeErr := file.Write(payload.Bytes) + closeErr := file.Close() + if writeErr != nil { + _ = os.Remove(targetPath) + return nil, &clients.ClientError{Code: "local_static_store_failed", Message: writeErr.Error(), Retryable: true} + } + if closeErr != nil { + _ = os.Remove(targetPath) + return nil, &clients.ClientError{Code: "local_static_store_failed", Message: closeErr.Error(), Retryable: true} + } + return map[string]any{ + "url": s.localStaticFileURL(fileName, pathPrefix), + "fileName": fileName, + "contentType": payload.ContentType, + "size": len(payload.Bytes), + "storageChannel": map[string]any{ + "id": "local-static", + "channelKey": "local-static", + "name": "AI Gateway local static storage", + "provider": "local_static", + }, + }, nil +} + +func (s *Service) localStaticFileURL(fileName string, pathPrefix string) string { + if strings.TrimSpace(pathPrefix) == "" { + pathPrefix = localStaticUploadedPathPrefix + } + path := pathPrefix + url.PathEscape(filepath.Base(fileName)) + baseURL := strings.TrimRight(strings.TrimSpace(s.cfg.PublicBaseURL), "/") + if baseURL == "" { + return path + } + return baseURL + path +} + +func localStaticUploadFileName(originalName string, contentType string) string { + baseName := filepath.Base(strings.TrimSpace(originalName)) + originalExt := strings.ToLower(filepath.Ext(baseName)) + namePart := strings.TrimSuffix(baseName, originalExt) + namePart = sanitizeGeneratedAssetNamePart(namePart) + if namePart == "" { + namePart = "gateway-upload" + } + if len(namePart) > 48 { + namePart = namePart[:48] + } + return fmt.Sprintf("%s-%s%s", namePart, randomHexSuffix(6), uploadFileExtension(contentType, originalExt)) +} + +func uploadFileExtension(contentType string, fallbackExt string) string { + normalized := normalizeGeneratedContentType(contentType) + if generatedContentTypeIsMedia(normalized) { + return fileExtensionForContentType(normalized, generatedAssetKindFromContentType("", normalized)) + } + if normalized != "" && normalized != "application/octet-stream" { + if extensions, err := mime.ExtensionsByType(normalized); err == nil && len(extensions) > 0 { + if ext := sanitizeFileExtension(extensions[0]); ext != "" { + return ext + } + } + } + if ext := sanitizeFileExtension(fallbackExt); ext != "" { + return ext + } + if normalized == "application/json" { + return ".json" + } + if strings.HasPrefix(normalized, "text/") { + return ".txt" + } + return ".bin" +} + +func sanitizeFileExtension(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "" + } + if !strings.HasPrefix(value, ".") { + value = "." + value + } + if len(value) > 16 { + return "" + } + for _, item := range value[1:] { + if (item >= 'a' && item <= 'z') || (item >= '0' && item <= '9') { + continue + } + return "" + } + return value } func (s *Service) readGeneratedURLAsset(ctx context.Context, asset *generatedURLAsset) ([]byte, string, error) { @@ -316,12 +455,25 @@ func (s *Service) UploadFile(ctx context.Context, payload FileUploadPayload) (ma return nil, &clients.ClientError{Code: "upload_config_failed", Message: err.Error(), Retryable: true} } if len(channels) == 0 { - return nil, &clients.ClientError{Code: "upload_no_channel", Message: "no enabled file storage channel", Retryable: false} + payload.FileName = localStaticUploadFileName(payload.FileName, payload.ContentType) + upload, err := s.storeFileLocally(payload, s.cfg.LocalUploadedStorageDir, config.DefaultLocalUploadedStorageDir, localStaticUploadedPathPrefix) + if err != nil { + return nil, err + } + upload["assetStorage"] = map[string]any{ + "scene": payload.Scene, + "source": firstNonEmptyString(payload.Source, "ai-gateway-openapi"), + "strategy": "local_static_upload", + } + return upload, nil } return s.uploadFileWithFailover(ctx, payload, channels) } func (s *Service) activeFileStorageChannels(ctx context.Context, scene string) ([]store.FileStorageChannel, error) { + if s.store == nil { + return nil, nil + } channels, err := s.store.ListEnabledFileStorageChannelsForScene(ctx, scene) if err != nil && !store.IsUndefinedDatabaseObject(err) { return nil, err @@ -329,33 +481,7 @@ func (s *Service) activeFileStorageChannels(ctx context.Context, scene string) ( if len(channels) > 0 { return channels, nil } - fallback := s.fallbackFileStorageChannel() - if fallback == nil { - return nil, nil - } - if !fileStorageChannelSupportsScene(*fallback, scene) { - return nil, nil - } - return []store.FileStorageChannel{*fallback}, nil -} - -func (s *Service) fallbackFileStorageChannel() *store.FileStorageChannel { - baseURL := strings.TrimRight(strings.TrimSpace(s.cfg.ServerMainBaseURL), "/") - apiKey := strings.TrimSpace(s.cfg.ServerMainInternalToken) - if baseURL == "" || apiKey == "" { - return nil - } - return &store.FileStorageChannel{ - ChannelKey: "server-main-env-fallback", - Name: "server-main env fallback", - Provider: "server_main_openapi", - UploadURL: baseURL + "/v1/files/upload", - APIKey: apiKey, - Scenes: []string{store.FileStorageSceneUpload, store.FileStorageSceneImageResult}, - RetryPolicy: defaultUploadRetryPolicy(), - Priority: 100, - Status: "enabled", - } + return nil, nil } func (s *Service) uploadFileWithFailover(ctx context.Context, payload FileUploadPayload, channels []store.FileStorageChannel) (map[string]any, error) { @@ -363,11 +489,15 @@ func (s *Service) uploadFileWithFailover(ctx context.Context, payload FileUpload for _, channel := range channels { upload, err := s.uploadWithChannelRetries(ctx, payload, channel) if err == nil { - _ = s.store.MarkFileStorageChannelSuccess(context.WithoutCancel(ctx), channel.ID) + if s.store != nil { + _ = s.store.MarkFileStorageChannelSuccess(context.WithoutCancel(ctx), channel.ID) + } return upload, nil } lastErr = err - _ = s.store.MarkFileStorageChannelFailure(context.WithoutCancel(ctx), channel.ID, err.Error()) + if s.store != nil { + _ = s.store.MarkFileStorageChannelFailure(context.WithoutCancel(ctx), channel.ID, err.Error()) + } } if lastErr != nil { return nil, lastErr @@ -375,23 +505,6 @@ func (s *Service) uploadFileWithFailover(ctx context.Context, payload FileUpload return nil, &clients.ClientError{Code: "upload_no_channel", Message: "no enabled file storage channel", Retryable: false} } -func fileStorageChannelSupportsScene(channel store.FileStorageChannel, scene string) bool { - scene = strings.TrimSpace(scene) - if scene == "" { - return true - } - scenes := channel.Scenes - if len(scenes) == 0 { - scenes = []string{store.FileStorageSceneUpload, store.FileStorageSceneImageResult} - } - for _, item := range scenes { - if strings.TrimSpace(item) == scene { - return true - } - } - return false -} - func (s *Service) uploadWithChannelRetries(ctx context.Context, payload FileUploadPayload, channel store.FileStorageChannel) (map[string]any, error) { maxRetries, delays := uploadRetrySchedule(channel.RetryPolicy) var lastErr error diff --git a/apps/api/internal/runner/upload_test.go b/apps/api/internal/runner/upload_test.go index d183c29..1354cf5 100644 --- a/apps/api/internal/runner/upload_test.go +++ b/apps/api/internal/runner/upload_test.go @@ -1,10 +1,15 @@ package runner import ( + "bytes" + "context" "encoding/base64" + "os" + "path/filepath" "strings" "testing" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) @@ -109,17 +114,20 @@ func TestGeneratedAssetDecisionUploadsURLWhenPolicyUploadAll(t *testing.T) { } } -func TestGeneratedAssetDecisionSkipsAllWhenPolicyUploadNone(t *testing.T) { +func TestGeneratedAssetDecisionStoresInlineLocallyWhenPolicyUploadNone(t *testing.T) { item := map[string]any{ "b64_json": base64.StdEncoding.EncodeToString([]byte("inline image")), } - decision, err := generatedAssetDecisionForItem("images.generations", item, generatedAssetUploadPolicy{UploadInlineMedia: false, UploadURLMedia: false}) + decision, err := generatedAssetDecisionForItem("images.generations", item, generatedAssetUploadPolicyFromName(store.FileStorageResultUploadPolicyUploadNone)) if err != nil { t.Fatalf("unexpected error: %v", err) } - if decision.Inline != nil || decision.URL != nil || len(decision.StripKeys) != 0 { - t.Fatalf("upload_none should keep the result unchanged: %+v", decision) + if decision.Inline == nil || decision.URL != nil { + t.Fatalf("upload_none should still turn inline payloads into static URLs: %+v", decision) + } + if !containsString(decision.StripKeys, "b64_json") { + t.Fatalf("inline payload should be stripped before persistence: %+v", decision.StripKeys) } } @@ -132,17 +140,17 @@ func TestGeneratedAssetUploadPolicyFromName(t *testing.T) { { name: "default", policyName: store.FileStorageResultUploadPolicyDefault, - want: generatedAssetUploadPolicy{UploadInlineMedia: true, UploadURLMedia: false}, + want: generatedAssetUploadPolicy{UploadInlineMedia: true, UploadURLMedia: false, StoreInlineMediaLocally: false}, }, { name: "upload all", policyName: store.FileStorageResultUploadPolicyUploadAll, - want: generatedAssetUploadPolicy{UploadInlineMedia: true, UploadURLMedia: true}, + want: generatedAssetUploadPolicy{UploadInlineMedia: true, UploadURLMedia: true, StoreInlineMediaLocally: false}, }, { name: "upload none", policyName: store.FileStorageResultUploadPolicyUploadNone, - want: generatedAssetUploadPolicy{UploadInlineMedia: false, UploadURLMedia: false}, + want: generatedAssetUploadPolicy{UploadInlineMedia: true, UploadURLMedia: false, StoreInlineMediaLocally: true}, }, } @@ -185,3 +193,87 @@ func TestGeneratedAssetFileNameIsUniqueAndTyped(t *testing.T) { t.Fatalf("unexpected generated file name: %s", first) } } + +func TestUploadGeneratedAssetStoresLocalWhenNoChannels(t *testing.T) { + storageDir := t.TempDir() + service := &Service{cfg: config.Config{LocalGeneratedStorageDir: storageDir}} + payload := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0} + asset := &generatedInlineAsset{ + Bytes: payload, + ContentType: "image/jpeg", + Kind: "image", + SourceKey: "b64_json", + } + + upload, contentType, kind, strategy, err := service.uploadGeneratedAsset(context.Background(), "task-123", asset, 0, nil, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if contentType != "image/png" || kind != "image" || strategy != "local_static_inline_media" { + t.Fatalf("unexpected local upload metadata: contentType=%s kind=%s strategy=%s", contentType, kind, strategy) + } + urlValue := stringFromAny(upload["url"]) + if !strings.HasPrefix(urlValue, "/static/generated/gateway-result-task-123-01-") || !strings.HasSuffix(urlValue, ".png") { + t.Fatalf("unexpected local static URL: %s", urlValue) + } + entries, err := os.ReadDir(storageDir) + if err != nil { + t.Fatalf("failed to read local static dir: %v", err) + } + if len(entries) != 1 || !strings.HasSuffix(entries[0].Name(), ".png") { + t.Fatalf("expected one PNG file in local static dir, got %+v", entries) + } + stored, err := os.ReadFile(filepath.Join(storageDir, entries[0].Name())) + if err != nil { + t.Fatalf("failed to read local static file: %v", err) + } + if !bytes.Equal(stored, payload) { + t.Fatalf("stored payload does not match source payload") + } +} + +func TestUploadFileStoresLocalWhenNoChannels(t *testing.T) { + storageDir := t.TempDir() + service := &Service{cfg: config.Config{ + LocalUploadedStorageDir: storageDir, + ServerMainBaseURL: "http://127.0.0.1:1", + ServerMainInternalToken: "change-me", + }} + payload := []byte("%PDF-1.4") + + upload, err := service.UploadFile(context.Background(), FileUploadPayload{ + Bytes: payload, + ContentType: "application/pdf", + FileName: "用户文件.png", + Source: "playground", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + urlValue := stringFromAny(upload["url"]) + if !strings.HasPrefix(urlValue, "/static/uploaded/") || !strings.HasSuffix(urlValue, ".pdf") { + t.Fatalf("unexpected uploaded local static URL: %s", urlValue) + } + storageChannel, _ := upload["storageChannel"].(map[string]any) + if stringFromAny(storageChannel["provider"]) != "local_static" { + t.Fatalf("expected local static provider metadata, got %+v", upload["storageChannel"]) + } + assetStorage, _ := upload["assetStorage"].(map[string]any) + if stringFromAny(assetStorage["strategy"]) != "local_static_upload" || stringFromAny(assetStorage["scene"]) != store.FileStorageSceneUpload { + t.Fatalf("unexpected upload asset storage metadata: %+v", assetStorage) + } + entries, err := os.ReadDir(storageDir) + if err != nil { + t.Fatalf("failed to read uploaded static dir: %v", err) + } + if len(entries) != 1 || !strings.HasSuffix(entries[0].Name(), ".pdf") { + t.Fatalf("expected one PDF file in uploaded static dir, got %+v", entries) + } + stored, err := os.ReadFile(filepath.Join(storageDir, entries[0].Name())) + if err != nil { + t.Fatalf("failed to read uploaded static file: %v", err) + } + if !bytes.Equal(stored, payload) { + t.Fatalf("stored uploaded payload does not match source payload") + } +} diff --git a/apps/web/src/pages/admin/SystemSettingsPanel.tsx b/apps/web/src/pages/admin/SystemSettingsPanel.tsx index 241e5b1..bbbdc38 100644 --- a/apps/web/src/pages/admin/SystemSettingsPanel.tsx +++ b/apps/web/src/pages/admin/SystemSettingsPanel.tsx @@ -43,7 +43,7 @@ const sceneOptions = [ const resultUploadPolicyOptions = [ { value: 'default', label: '默认:仅非链接资源转存', description: 'URL 结果直接保存;base64 / buffer 等结果转存后保存 URL' }, { value: 'upload_all', label: '全部转存', description: 'URL、base64、buffer 等返图结果都会转存到当前文件渠道' }, - { value: 'upload_none', label: '全部不转存', description: '返图结果原样保存,不触发文件存储上传' }, + { value: 'upload_none', label: '全部不转存', description: '链接结果直接保存;base64 / buffer 结果写入网关本地静态托管后保存 URL' }, ]; export function SystemSettingsPanel(props: {