From deb9cc5bcf3ee2832fce8a68ba77486d4155daf4 Mon Sep 17 00:00:00 2001 From: andy Date: Wed, 30 Sep 2026 13:22:53 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=A4=8D=E5=88=B6=E7=B2=98?= =?UTF-8?q?=E8=B4=B4=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/assets/import_image.go | 225 +++++++++++++++ backend/internal/assets/import_image_test.go | 261 ++++++++++++++++++ backend/internal/httpapi/assets.go | 47 +++- backend/internal/httpapi/assets_test.go | 98 +++++++ .../internal/httpapi/method_compat_test.go | 4 +- backend/internal/httpapi/route_surface.go | 2 +- components/create-studio.tsx | 204 ++++++++++++-- components/seedream-workspace.tsx | 27 +- contracts/assets/http-v1.json | 18 ++ contracts/http/route-surface.v1.json | 1 + lib/client/clipboard-images.ts | 92 ++++++ tests/assets-http-contract.test.ts | 9 +- tests/clipboard-images.test.ts | 126 +++++++++ tests/create-studio-paste.test.ts | 61 ++++ 14 files changed, 1144 insertions(+), 31 deletions(-) create mode 100644 backend/internal/assets/import_image.go create mode 100644 backend/internal/assets/import_image_test.go create mode 100644 lib/client/clipboard-images.ts create mode 100644 tests/clipboard-images.test.ts create mode 100644 tests/create-studio-paste.test.ts diff --git a/backend/internal/assets/import_image.go b/backend/internal/assets/import_image.go new file mode 100644 index 0000000..c0087b5 --- /dev/null +++ b/backend/internal/assets/import_image.go @@ -0,0 +1,225 @@ +package assets + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "net/url" + "strings" +) + +var ( + ErrImportImageURL = errors.New("invalid image URL") + ErrImportImageUnavailable = errors.New("image URL is unavailable") + ErrImportImageUnsupported = errors.New("unsupported image format") +) + +const ( + maxImportImageURLBytes = 8192 + maxImportImageBytes = 20 << 20 + maxImportImagePixels = 25_000_000 +) + +// ImportImage fetches an untrusted image into the configured blob store using +// the same write ordering and catalog compensation as a regular upload. +func (s *Service) ImportImage(ctx context.Context, scope Scope, rawURL string) (Asset, error) { + if err := validScope(scope); err != nil { + return Asset{}, err + } + if scope.kind != platformScope { + return Asset{}, ErrImportImageURL + } + if len(rawURL) == 0 || len(rawURL) > maxImportImageURLBytes || strings.TrimSpace(rawURL) != rawURL { + return Asset{}, ErrImportImageURL + } + u, err := url.Parse(rawURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil { + return Asset{}, ErrImportImageURL + } + if s.remote == nil { + return Asset{}, ErrImportImageUnavailable + } + blob, err := s.remote.Fetch(ctx, rawURL) + if err != nil { + if errors.Is(err, ErrRemoteTooLarge) { + return Asset{}, ErrRemoteTooLarge + } + return Asset{}, ErrImportImageUnavailable + } + if blob.Body == nil { + return Asset{}, ErrImportImageUnavailable + } + defer blob.Body.Close() + if blob.Size > maxImportImageBytes { + return Asset{}, ErrRemoteTooLarge + } + content, err := io.ReadAll(io.LimitReader(blob.Body, maxImportImageBytes+1)) + if err != nil { + return Asset{}, ErrImportImageUnavailable + } + if len(content) > maxImportImageBytes { + return Asset{}, ErrRemoteTooLarge + } + extension, mime, ok := inspectImportedImage(content) + if !ok { + return Asset{}, ErrImportImageUnsupported + } + return s.Upload(ctx, scope, UploadCommand{Bytes: content, FileName: "导入图片" + extension, ContentType: mime, Kind: KindImage}) +} + +func inspectImportedImage(content []byte) (string, string, bool) { + if len(content) < 12 { + return "", "", false + } + if bytes.Equal(content[:4], []byte("RIFF")) && bytes.Equal(content[8:12], []byte("WEBP")) { + if validWebP(content) { + return ".webp", "image/webp", true + } + return "", "", false + } + if content[0] == 'B' && content[1] == 'M' { + if validBMP(content) { + return ".bmp", "image/bmp", true + } + return "", "", false + } + config, format, err := image.DecodeConfig(bytes.NewReader(content)) + if err != nil || !validImageDimensions(config.Width, config.Height) { + return "", "", false + } + // Decode the first frame so that a forged or truncated header cannot be + // stored as a supported image. DecodeConfig alone checks only the header. + if _, _, err = image.Decode(bytes.NewReader(content)); err != nil { + return "", "", false + } + switch format { + case "png": + return ".png", "image/png", true + case "jpeg": + return ".jpg", "image/jpeg", true + case "gif": + return ".gif", "image/gif", true + default: + return "", "", false + } +} + +func validImageDimensions(width, height int) bool { + return width > 0 && height > 0 && int64(width)*int64(height) <= maxImportImagePixels +} + +// The standard library does not decode WebP or BMP. Validate their containers, +// image headers and dimensions before accepting the original raster bytes. +func validWebP(b []byte) bool { + if len(b) < 30 || uint64(binary.LittleEndian.Uint32(b[4:8]))+8 != uint64(len(b)) { + return false + } + first, payload, next, ok := webPChunk(b, 12) + if !ok { + return false + } + if first == "VP8 " || first == "VP8L" { + _, _, valid := webPImageDimensions(first, payload) + return valid && next == len(b) + } + if first != "VP8X" || len(payload) != 10 || payload[0]&^byte(0x3e) != 0 || payload[1] != 0 || payload[2] != 0 || payload[3] != 0 { + return false + } + // Animation requires ANIM/ANMF frame handling; reject it in this version. + if payload[0]&0x02 != 0 { + return false + } + canvasWidth := 1 + int(payload[4]) + (int(payload[5]) << 8) + (int(payload[6]) << 16) + canvasHeight := 1 + int(payload[7]) + (int(payload[8]) << 8) + (int(payload[9]) << 16) + if !validImageDimensions(canvasWidth, canvasHeight) { + return false + } + images := 0 + for next < len(b) { + chunk, data, after, ok := webPChunk(b, next) + if !ok { + return false + } + switch chunk { + case "VP8 ", "VP8L": + width, height, valid := webPImageDimensions(chunk, data) + if !valid || width != canvasWidth || height != canvasHeight || images != 0 { + return false + } + images++ + case "ALPH", "ICCP", "EXIF", "XMP ": + if len(data) == 0 { + return false + } + default: + return false + } + next = after + } + return images == 1 +} + +func webPChunk(b []byte, offset int) (string, []byte, int, bool) { + if offset > len(b)-8 { + return "", nil, 0, false + } + size := uint64(binary.LittleEndian.Uint32(b[offset+4 : offset+8])) + end := uint64(offset) + 8 + size + (size & 1) + if size == 0 || end > uint64(len(b)) { + return "", nil, 0, false + } + return string(b[offset : offset+4]), b[offset+8 : offset+8+int(size)], int(end), true +} + +func webPImageDimensions(chunk string, payload []byte) (int, int, bool) { + switch chunk { + case "VP8 ": + if len(payload) < 10 || payload[0]&1 != 0 || !bytes.Equal(payload[3:6], []byte{0x9d, 0x01, 0x2a}) { + return 0, 0, false + } + partitionBytes := int(payload[0]) | int(payload[1])<<8 | int(payload[2])<<16 + if partitionBytes>>5 > len(payload)-10 { + return 0, 0, false + } + width := int(binary.LittleEndian.Uint16(payload[6:8]) & 0x3fff) + height := int(binary.LittleEndian.Uint16(payload[8:10]) & 0x3fff) + return width, height, validImageDimensions(width, height) + case "VP8L": + if len(payload) <= 5 || payload[0] != 0x2f || payload[4]&0xe0 != 0 { + return 0, 0, false + } + width := 1 + int(payload[1]) + (int(payload[2]&0x3f) << 8) + height := 1 + int(payload[2]>>6) + (int(payload[3]) << 2) + (int(payload[4]&0x0f) << 10) + return width, height, validImageDimensions(width, height) + } + return 0, 0, false +} + +func validBMP(b []byte) bool { + if len(b) < 54 || int(binary.LittleEndian.Uint32(b[2:6])) != len(b) { + return false + } + offset := int(binary.LittleEndian.Uint32(b[10:14])) + header := binary.LittleEndian.Uint32(b[14:18]) + if header < 40 || int(header) > len(b)-14 || offset < 14+int(header) || offset >= len(b) { + return false + } + width := int(int32(binary.LittleEndian.Uint32(b[18:22]))) + height := int(int32(binary.LittleEndian.Uint32(b[22:26]))) + if height < 0 { + height = -height + } + depth := binary.LittleEndian.Uint16(b[28:30]) + compression := binary.LittleEndian.Uint32(b[30:34]) + if !validImageDimensions(width, height) || binary.LittleEndian.Uint16(b[26:28]) != 1 || compression != 0 || (depth != 24 && depth != 32) { + return false + } + stride := (int64(width)*int64(depth) + 31) / 32 * 4 + return stride*int64(height) <= int64(len(b)-offset) +} diff --git a/backend/internal/assets/import_image_test.go b/backend/internal/assets/import_image_test.go new file mode 100644 index 0000000..eafbf56 --- /dev/null +++ b/backend/internal/assets/import_image_test.go @@ -0,0 +1,261 @@ +package assets + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "image" + "image/gif" + "io" + "net" + "strings" + "testing" + "time" +) + +type importFetcher func(context.Context, string) (Blob, error) + +func (f importFetcher) Fetch(ctx context.Context, u string) (Blob, error) { return f(ctx, u) } + +func TestImportImageStoresRecognizedBytesAsUpload(t *testing.T) { + pngData, jpegData, webpData := imageFormatFixtures(t) + var gifBuffer bytes.Buffer + if err := gif.Encode(&gifBuffer, image.NewRGBA(image.Rect(0, 0, 2, 2)), nil); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, ext, mime string + data []byte + }{ + {"png", ".png", "image/png", pngData}, + {"jpeg", ".jpg", "image/jpeg", jpegData}, + {"webp", ".webp", "image/webp", webpData}, + {"gif", ".gif", "image/gif", gifBuffer.Bytes()}, + {"bmp", ".bmp", "image/bmp", importBMP()}, + } { + t.Run(tc.name, func(t *testing.T) { + cat, store := &memoryCatalog{}, &memoryBlobs{} + svc := NewService(cat, store, importFetcher(func(context.Context, string) (Blob, error) { + return Blob{Body: io.NopCloser(bytes.NewReader(tc.data)), ContentType: "text/html", Size: int64(len(tc.data))}, nil + }), nil, func(prefix string) string { return prefix + "-1" }) + asset, err := svc.ImportImage(context.Background(), PlatformScope("owner-a"), "https://images.test/picture.svg?secret=signed") + if err != nil { + t.Fatal(err) + } + if asset.OwnerID != "owner-a" || asset.Source != SourceUpload || asset.Kind != KindImage || asset.Name != "导入图片"+tc.ext || asset.Metadata["contentType"] != tc.mime || store.putContentType != tc.mime || !bytes.Equal(store.putBody, tc.data) || len(cat.assets) != 1 { + t.Fatalf("asset=%#v store=%q", asset, store.putContentType) + } + if bytes.Contains([]byte(asset.URL), []byte("signed")) || asset.Metadata["importedFrom"] != nil { + t.Fatalf("remote URL persisted: %#v", asset) + } + }) + } +} + +func TestImportImageRejectsInvalidInputWithoutCreatingRecord(t *testing.T) { + pngData, _, _ := imageFormatFixtures(t) + for _, tc := range []struct { + name, url string + data []byte + want error + }{ + {"svg", "https://images.test/a.svg", []byte(``), ErrImportImageUnsupported}, + {"html", "https://images.test/a.png", []byte("login"), ErrImportImageUnsupported}, + {"fake png", "https://images.test/a.png", []byte("not png"), ErrImportImageUnsupported}, + {"truncated png", "https://images.test/a.png", pngData[:32], ErrImportImageUnsupported}, + {"userinfo", "https://user:password@images.test/a.png", pngData, ErrImportImageURL}, + {"protocol", "file:///etc/passwd", pngData, ErrImportImageURL}, + } { + t.Run(tc.name, func(t *testing.T) { + cat, store := &memoryCatalog{}, &memoryBlobs{} + svc := NewService(cat, store, importFetcher(func(context.Context, string) (Blob, error) { + return Blob{Body: io.NopCloser(bytes.NewReader(tc.data)), Size: int64(len(tc.data))}, nil + }), nil, nil) + if _, err := svc.ImportImage(context.Background(), PlatformScope("owner"), tc.url); !errors.Is(err, tc.want) { + t.Fatalf("err=%v want=%v", err, tc.want) + } + if len(cat.assets) != 0 || store.putKey != "" { + t.Fatalf("persisted on rejection: %#v %q", cat.assets, store.putKey) + } + }) + } +} + +func TestImportImageBoundsAndCompensation(t *testing.T) { + pngData, _, _ := imageFormatFixtures(t) + cat, store := &memoryCatalog{createErr: errors.New("db down")}, &memoryBlobs{} + svc := NewService(cat, store, importFetcher(func(context.Context, string) (Blob, error) { + return Blob{Body: io.NopCloser(bytes.NewReader(pngData)), Size: int64(len(pngData))}, nil + }), nil, nil) + if _, err := svc.ImportImage(context.Background(), PlatformScope("owner"), "https://images.test/a.png"); !errors.Is(err, cat.createErr) || len(store.deleted) != 1 { + t.Fatalf("err=%v deleted=%v", err, store.deleted) + } + cat2, store2 := &memoryCatalog{}, &memoryBlobs{} + svc2 := NewService(cat2, store2, importFetcher(func(context.Context, string) (Blob, error) { + return Blob{Body: io.NopCloser(bytes.NewReader(pngData)), Size: maxImportImageBytes + 1}, nil + }), nil, nil) + if _, err := svc2.ImportImage(context.Background(), PlatformScope("owner"), "https://images.test/a.png"); !errors.Is(err, ErrRemoteTooLarge) || len(cat2.assets) != 0 { + t.Fatalf("err=%v records=%d", err, len(cat2.assets)) + } + cat3, store3 := &memoryCatalog{}, &memoryBlobs{} + svc3 := NewService(cat3, store3, importFetcher(func(context.Context, string) (Blob, error) { + return Blob{Body: io.NopCloser(io.LimitReader(&repeatingReader{value: 'x'}, maxImportImageBytes+1)), Size: -1}, nil + }), nil, nil) + if _, err := svc3.ImportImage(context.Background(), PlatformScope("owner"), "https://images.test/a.png"); !errors.Is(err, ErrRemoteTooLarge) || len(cat3.assets) != 0 || store3.putKey != "" { + t.Fatalf("unknown length error=%v records=%d key=%q", err, len(cat3.assets), store3.putKey) + } + cat4, store4 := &memoryCatalog{}, &failingImportBlobStore{memoryBlobs: &memoryBlobs{}} + svc4 := NewService(cat4, store4, importFetcher(func(context.Context, string) (Blob, error) { + return Blob{Body: io.NopCloser(bytes.NewReader(pngData)), Size: int64(len(pngData))}, nil + }), nil, nil) + if _, err := svc4.ImportImage(context.Background(), PlatformScope("owner"), "https://images.test/a.png"); !errors.Is(err, store4.failure) || len(cat4.assets) != 0 { + t.Fatalf("put failure error=%v records=%d", err, len(cat4.assets)) + } +} + +type repeatingReader struct{ value byte } + +func (r *repeatingReader) Read(p []byte) (int, error) { + for i := range p { + p[i] = r.value + } + return len(p), nil +} + +type failingImportBlobStore struct { + *memoryBlobs + failure error +} + +func (s *failingImportBlobStore) Put(context.Context, string, io.Reader, int64, string) (StoredObject, error) { + if s.failure == nil { + s.failure = errors.New("object store failed") + } + return StoredObject{}, s.failure +} + +func TestImportImageURLLengthAndSafeFetcherError(t *testing.T) { + pngData, _, _ := imageFormatFixtures(t) + remoteCalled := false + remote := importFetcher(func(_ context.Context, _ string) (Blob, error) { + remoteCalled = true + return Blob{Body: io.NopCloser(bytes.NewReader(pngData)), Size: int64(len(pngData))}, nil + }) + svc := NewService(&memoryCatalog{}, &memoryBlobs{}, remote, nil, nil) + base := "https://images.test/a.png?signature=" + allowed := base + strings.Repeat("x", maxImportImageURLBytes-len(base)) + if _, err := svc.ImportImage(context.Background(), PlatformScope("owner"), allowed); err != nil || !remoteCalled { + t.Fatalf("8192-byte URL err=%v called=%v", err, remoteCalled) + } + remoteCalled = false + if _, err := svc.ImportImage(context.Background(), PlatformScope("owner"), allowed+"x"); !errors.Is(err, ErrImportImageURL) || remoteCalled { + t.Fatalf("overlong URL err=%v called=%v", err, remoteCalled) + } + secret := "https://images.test/a.png?signature=secret-value" + svc = NewService(&memoryCatalog{}, &memoryBlobs{}, importFetcher(func(context.Context, string) (Blob, error) { return Blob{}, errors.New("GET " + secret + ": timeout") }), nil, nil) + if _, err := svc.ImportImage(context.Background(), PlatformScope("owner"), secret); !errors.Is(err, ErrImportImageUnavailable) || strings.Contains(err.Error(), "secret-value") { + t.Fatalf("unsafe fetch error=%v", err) + } +} + +func TestImportImageRejectsPrivateDestinationsAndPrivateRedirectWithProductionPolicy(t *testing.T) { + privateDialer := &importRedirectDialer{} + policy := NewPublicDestinationPolicy(nil, privateDialer) + fetcher, err := NewPublicHTTPRemoteFetcher(time.Second, maxImportImageBytes, policy) + if err != nil { + t.Fatal(err) + } + for _, target := range []string{"http://127.0.0.1/a.png", "http://169.254.169.254/latest/meta-data", "http://[::1]/a.png"} { + cat := &memoryCatalog{} + svc := NewService(cat, &memoryBlobs{}, fetcher, nil, nil) + if _, err := svc.ImportImage(context.Background(), PlatformScope("owner"), target); !errors.Is(err, ErrImportImageUnavailable) || len(cat.assets) != 0 { + t.Fatalf("private target %q err=%v assets=%d", target, err, len(cat.assets)) + } + } + if privateDialer.calls != 0 { + t.Fatalf("private targets reached dialer %d times", privateDialer.calls) + } + dialer := &importRedirectDialer{} + policy = NewPublicDestinationPolicy(nil, dialer) + fetcher, err = NewPublicHTTPRemoteFetcher(time.Second, maxImportImageBytes, policy) + if err != nil { + t.Fatal(err) + } + cat := &memoryCatalog{} + svc := NewService(cat, &memoryBlobs{}, fetcher, nil, nil) + if _, err := svc.ImportImage(context.Background(), PlatformScope("owner"), "http://8.8.8.8/a.png?signature=secret"); !errors.Is(err, ErrImportImageUnavailable) || len(cat.assets) != 0 || dialer.calls != 1 { + t.Fatalf("private redirect err=%v assets=%d dial calls=%d", err, len(cat.assets), dialer.calls) + } +} + +type importRedirectDialer struct{ calls int } + +func (d *importRedirectDialer) DialContext(context.Context, string, string) (net.Conn, error) { + d.calls++ + client, server := net.Pipe() + go func() { + _, _ = io.WriteString(server, "HTTP/1.1 302 Found\r\nLocation: http://169.254.169.254/private\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + _ = server.Close() + }() + return client, nil +} + +func TestImportImageRejectsFakeExtendedWebP(t *testing.T) { + // Valid canvas metadata with an unrelated trailing chunk is not an image. + webp := make([]byte, 12) + copy(webp, "RIFF") + copy(webp[8:], "WEBP") + webp = appendWebPChunk(webp, "VP8X", []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0}) + webp = appendWebPChunk(webp, "EXIF", []byte("fake")) + binary.LittleEndian.PutUint32(webp[4:8], uint32(len(webp)-8)) + if validWebP(webp) { + t.Fatal("VP8X without image payload accepted") + } + webp = appendWebPChunk(webp, "ANMF", []byte("fake")) + binary.LittleEndian.PutUint32(webp[4:8], uint32(len(webp)-8)) + if validWebP(webp) { + t.Fatal("animation payload accepted") + } + _, _, simple := imageFormatFixtures(t) + valid := make([]byte, 12) + copy(valid, "RIFF") + copy(valid[8:], "WEBP") + valid = appendWebPChunk(valid, "VP8X", []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0}) + valid = append(valid, simple[12:]...) + binary.LittleEndian.PutUint32(valid[4:8], uint32(len(valid)-8)) + if !validWebP(valid) { + t.Fatal("valid extended still WebP was rejected") + } +} + +func TestImportImagePixelLimit(t *testing.T) { + if !validImageDimensions(5000, 5000) || validImageDimensions(5001, 5000) || validImageDimensions(0, 1) { + t.Fatal("pixel limit does not enforce 25 million positive pixels") + } +} + +func appendWebPChunk(b []byte, name string, data []byte) []byte { + b = append(b, []byte(name)...) + var size [4]byte + binary.LittleEndian.PutUint32(size[:], uint32(len(data))) + b = append(b, size[:]...) + b = append(b, data...) + if len(data)&1 != 0 { + b = append(b, 0) + } + return b +} + +func importBMP() []byte { + b := make([]byte, 70) + copy(b, "BM") + binary.LittleEndian.PutUint32(b[2:6], uint32(len(b))) + binary.LittleEndian.PutUint32(b[10:14], 54) + binary.LittleEndian.PutUint32(b[14:18], 40) + binary.LittleEndian.PutUint32(b[18:22], 2) + binary.LittleEndian.PutUint32(b[22:26], 2) + binary.LittleEndian.PutUint16(b[26:28], 1) + binary.LittleEndian.PutUint16(b[28:30], 24) + return b +} diff --git a/backend/internal/httpapi/assets.go b/backend/internal/httpapi/assets.go index 3d5dd62..3c0d77c 100644 --- a/backend/internal/httpapi/assets.go +++ b/backend/internal/httpapi/assets.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "mime" "net/http" "net/url" "path" @@ -76,6 +77,8 @@ func (h *assetsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.platformCollection(w, r, method) case "platform-upload": h.upload(w, r, false) + case "platform-import-image": + h.importImage(w, r) case "platform-item": h.delete(w, r, values[0]) case "platform-download": @@ -156,6 +159,45 @@ func (h *assetsHandler) publicCollection(w http.ResponseWriter, r *http.Request, writeJSON(w, http.StatusCreated, map[string]any{"asset": created}) } +func (h *assetsHandler) importImage(w http.ResponseWriter, r *http.Request) { + scope, ok := h.platformScope(w, r) + if !ok { + return + } + mediaType, _, mediaErr := mime.ParseMediaType(r.Header.Get("Content-Type")) + if mediaErr != nil || mediaType != "application/json" { + writeAssetJSONError(w, http.StatusUnsupportedMediaType, "请使用 JSON 请求") + return + } + if r.Header.Get("Sec-Fetch-Site") == "cross-site" { + writeAssetJSONError(w, http.StatusForbidden, "跨站请求不被允许") + return + } + var input struct { + URL string `json:"url"` + } + if !decodeAssetJSON(w, r, h.config.MaxJSONBytes, &input) { + return + } + created, err := h.service.ImportImage(r.Context(), scope, input.URL) + if err != nil { + switch { + case errors.Is(err, assets.ErrImportImageURL): + writeAssetJSONError(w, http.StatusBadRequest, "图片地址无效,请使用 http 或 https 链接") + case errors.Is(err, assets.ErrRemoteTooLarge): + writeAssetJSONError(w, http.StatusRequestEntityTooLarge, "图片过大") + case errors.Is(err, assets.ErrImportImageUnsupported): + writeAssetJSONError(w, http.StatusUnsupportedMediaType, "不支持的图片格式") + case errors.Is(err, assets.ErrImportImageUnavailable): + writeAssetJSONError(w, http.StatusBadGateway, "图片地址不可访问") + default: + writeAssetJSONError(w, http.StatusInternalServerError, "Internal server error.") + } + return + } + writeJSON(w, http.StatusCreated, map[string]any{"asset": created}) +} + type createAssetInput struct { URL string `json:"url"` Name string `json:"name"` @@ -456,6 +498,9 @@ func matchAssetRoute(value string) (string, []string) { if value == "/api/assets/upload" { return "platform-upload", nil } + if value == "/api/assets/import-image" { + return "platform-import-image", nil + } if value == "/api/v1/assets" { return "public-collection", nil } @@ -491,7 +536,7 @@ func assetRouteAllow(route string) string { switch route { case "platform-collection", "public-collection": return "GET, HEAD, POST, OPTIONS" - case "platform-upload": + case "platform-upload", "platform-import-image": return "POST, OPTIONS" case "platform-item": return "DELETE, OPTIONS" diff --git a/backend/internal/httpapi/assets_test.go b/backend/internal/httpapi/assets_test.go index 386634d..6c34cb6 100644 --- a/backend/internal/httpapi/assets_test.go +++ b/backend/internal/httpapi/assets_test.go @@ -4,6 +4,9 @@ import ( "bytes" "context" "encoding/json" + "errors" + "image" + "image/png" "io" "mime/multipart" "net/http" @@ -14,6 +17,7 @@ import ( "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/assets" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/httpapi" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/publicapi" ) @@ -204,6 +208,100 @@ func TestAssetsLimitsAndInfrastructureErrorsDoNotLeak(t *testing.T) { } } +type imageImportRemote struct { + data []byte + err error + called bool +} + +func (f *imageImportRemote) Fetch(context.Context, string) (assets.Blob, error) { + f.called = true + if f.err != nil { + return assets.Blob{}, f.err + } + return assets.Blob{Body: io.NopCloser(bytes.NewReader(f.data)), ContentType: "text/html", Size: int64(len(f.data))}, nil +} + +type imageImportResolver struct{} + +func (imageImportResolver) Resolve(context.Context, string) (identity.Session, error) { + return identity.Session{User: identity.User{ID: "owner-a", Role: "user"}}, nil +} + +func TestImportImageHTTPAuthMethodAndStoredResult(t *testing.T) { + var encoded bytes.Buffer + if err := png.Encode(&encoded, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil { + t.Fatal(err) + } + remote := &imageImportRemote{data: encoded.Bytes()} + cat, blobs := &assetCatalog{}, &assetBlobs{values: map[string][]byte{}} + svc := assets.NewService(cat, blobs, remote, time.Now, func(prefix string) string { return prefix + "-1" }) + platform, _ := httpapi.NewPlatformAuthorizer(httpapi.AuthState{Required: true, Configured: true}, imageImportResolver{}) + h, _ := httpapi.NewAssetsHandler(svc, platform, publicapi.NewAuthenticator(publicapi.Config{APIKeys: "agent:secret"}), httpapi.AssetsConfig{}) + path := "/api/assets/import-image" + unauth := request(t, h, http.MethodPost, path, strings.NewReader(`{"url":"https://image.test/a.png"}`), map[string]string{"Content-Type": "application/json", "Authorization": "Bearer secret"}) + if unauth.Code != 401 || remote.called { + t.Fatalf("unauth=%d called=%v", unauth.Code, remote.called) + } + wrongMethod := request(t, h, http.MethodGet, path, nil, nil) + if wrongMethod.Code != 405 || wrongMethod.Header().Get("Allow") != "POST, OPTIONS" { + t.Fatalf("method=%d allow=%q", wrongMethod.Code, wrongMethod.Header().Get("Allow")) + } + options := request(t, h, http.MethodOptions, path, nil, nil) + if options.Code != 204 || options.Header().Get("Allow") != "POST, OPTIONS" { + t.Fatalf("options=%d allow=%q", options.Code, options.Header().Get("Allow")) + } + for _, headers := range []map[string]string{ + {"Cookie": identity.SessionCookieName + "=valid", "Content-Type": "text/plain"}, + {"Cookie": identity.SessionCookieName + "=valid", "Content-Type": "application/json", "Sec-Fetch-Site": "cross-site"}, + } { + bad := request(t, h, http.MethodPost, path, strings.NewReader(`{"url":"https://image.test/a.png"}`), headers) + if bad.Code != 415 && bad.Code != 403 { + t.Fatalf("unsafe request=%d %s", bad.Code, bad.Body.String()) + } + } + good := request(t, h, http.MethodPost, path, strings.NewReader(`{"url":"https://image.test/a.svg?Signature=sensitive"}`), map[string]string{"Cookie": identity.SessionCookieName + "=valid", "Content-Type": "application/json", "Sec-Fetch-Site": "same-origin"}) + if good.Code != 201 { + t.Fatalf("import=%d %s", good.Code, good.Body.String()) + } + var response struct { + Asset assets.Asset `json:"asset"` + } + if err := json.Unmarshal(good.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Asset.OwnerID != "owner-a" || response.Asset.Source != assets.SourceUpload || response.Asset.Kind != assets.KindImage || response.Asset.Metadata["importedFrom"] != nil || !bytes.Equal(blobs.values[response.Asset.StoragePath], encoded.Bytes()) || strings.Contains(good.Body.String(), "sensitive") { + t.Fatalf("response=%s", good.Body.String()) + } +} + +func TestImportImageHTTPErrorsNeverLeakRemoteURLOrCreateRecord(t *testing.T) { + secretURL := "https://image.test/private.png?Signature=secret-token" + for _, tc := range []struct { + name string + data []byte + err error + status int + message string + }{ + {"invalid format", []byte("access denied"), nil, 415, "不支持的图片格式"}, + {"unavailable", nil, errors.New("GET " + secretURL + ": timeout"), 502, "图片地址不可访问"}, + {"too large", nil, assets.ErrRemoteTooLarge, 413, "图片过大"}, + } { + t.Run(tc.name, func(t *testing.T) { + cat, blobs := &assetCatalog{}, &assetBlobs{values: map[string][]byte{}} + remote := &imageImportRemote{data: tc.data, err: tc.err} + svc := assets.NewService(cat, blobs, remote, time.Now, nil) + platform, _ := httpapi.NewPlatformAuthorizer(httpapi.AuthState{}, nil) + h, _ := httpapi.NewAssetsHandler(svc, platform, publicapi.NewAuthenticator(publicapi.Config{}), httpapi.AssetsConfig{}) + resp := request(t, h, http.MethodPost, "/api/assets/import-image", strings.NewReader(`{"url":"`+secretURL+`"}`), map[string]string{"Content-Type": "application/json"}) + if resp.Code != tc.status || !strings.Contains(resp.Body.String(), tc.message) || strings.Contains(resp.Body.String(), "secret-token") || len(cat.values) != 0 || len(blobs.values) != 0 { + t.Fatalf("status=%d body=%s records=%d blobs=%d", resp.Code, resp.Body.String(), len(cat.values), len(blobs.values)) + } + }) + } +} + func request(t *testing.T, h http.Handler, method, path string, body io.Reader, headers map[string]string) *httptest.ResponseRecorder { t.Helper() r := httptest.NewRequest(method, path, body) diff --git a/backend/internal/httpapi/method_compat_test.go b/backend/internal/httpapi/method_compat_test.go index 05aa0b4..7077440 100644 --- a/backend/internal/httpapi/method_compat_test.go +++ b/backend/internal/httpapi/method_compat_test.go @@ -12,8 +12,8 @@ import ( func TestRouteMethodCompatibilityDerivesEverySurfacePath(t *testing.T) { patterns := routeMethodPatterns(GoRouteSurface()) - if len(patterns) != 48 { - t.Fatalf("route patterns=%d want 48", len(patterns)) + if len(patterns) != 49 { + t.Fatalf("route patterns=%d want 49", len(patterns)) } for _, pattern := range patterns { if _, ok := pattern.methods[http.MethodOptions]; !ok { diff --git a/backend/internal/httpapi/route_surface.go b/backend/internal/httpapi/route_surface.go index f89d91e..b5e5961 100644 --- a/backend/internal/httpapi/route_surface.go +++ b/backend/internal/httpapi/route_surface.go @@ -14,7 +14,7 @@ type RouteSurface struct { var goRouteSurface = []RouteSurface{ {"DELETE", "/api/admin/accounts"}, {"GET", "/api/admin/accounts"}, {"PATCH", "/api/admin/accounts"}, {"POST", "/api/admin/accounts"}, {"PUT", "/api/admin/accounts"}, {"POST", "/api/admin/accounts/groups"}, {"POST", "/api/admin/accounts/password"}, {"GET", "/api/admin/billing"}, {"PATCH", "/api/admin/billing/account"}, {"POST", "/api/admin/billing/adjustments"}, {"GET", "/api/admin/billing/prices"}, {"PATCH", "/api/admin/billing/prices"}, {"PATCH", "/api/admin/billing/prices/{id}"}, {"DELETE", "/api/admin/organizations"}, {"GET", "/api/admin/organizations"}, {"PATCH", "/api/admin/organizations"}, {"POST", "/api/admin/organizations"}, {"GET", "/api/admin/usage"}, - {"GET", "/api/assets"}, {"POST", "/api/assets"}, {"POST", "/api/assets/upload"}, {"DELETE", "/api/assets/{id}"}, {"GET", "/api/assets/{id}/download"}, + {"GET", "/api/assets"}, {"POST", "/api/assets"}, {"POST", "/api/assets/upload"}, {"POST", "/api/assets/import-image"}, {"DELETE", "/api/assets/{id}"}, {"GET", "/api/assets/{id}/download"}, {"GET", "/api/auth/callback"}, {"GET", "/api/auth/captcha"}, {"GET", "/api/auth/login"}, {"GET", "/api/auth/logout"}, {"POST", "/api/auth/logout"}, {"GET", "/api/auth/me"}, {"POST", "/api/auth/password"}, {"POST", "/api/auth/password/change"}, {"GET", "/api/billing"}, {"POST", "/api/billing/quote"}, {"GET", "/api/generations/image"}, {"POST", "/api/generations/image"}, {"DELETE", "/api/generations/image/{id}"}, {"GET", "/api/generations/image/{id}"}, {"POST", "/api/generations/image/{id}/retry"}, {"GET", "/api/generations/video"}, {"POST", "/api/generations/video"}, {"DELETE", "/api/generations/video/{id}"}, {"GET", "/api/generations/video/{id}"}, {"GET", "/api/health"}, {"GET", "/api/image-templates"}, {"POST", "/api/image-templates"}, {"DELETE", "/api/image-templates/{id}"}, {"PATCH", "/api/image-templates/{id}"}, {"POST", "/api/internal/worker/tick"}, {"GET", "/api/layer-compositions/{id}"}, {"PUT", "/api/layer-compositions/{id}"}, {"DELETE", "/api/logs"}, {"GET", "/api/logs"}, {"POST", "/api/prompt/assemble"}, {"GET", "/api/ready"}, {"GET", "/api/settings"}, {"POST", "/api/settings"}, {"GET", "/api/usage"}, diff --git a/components/create-studio.tsx b/components/create-studio.tsx index 929b9dc..b227d1b 100644 --- a/components/create-studio.tsx +++ b/components/create-studio.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react"; +import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type KeyboardEvent, type ReactNode, type RefObject } from "react"; import { Check, CircleDollarSign, Download, Film, ImageIcon, ImagePlus, Info, Layers3, Loader2, Music, Pencil, Plus, RefreshCw, Save, ScanLine, Send, Upload, WandSparkles, X } from "lucide-react"; import clsx from "clsx"; import { clampPage, pageItems, Pagination } from "@/components/pagination"; @@ -32,6 +32,7 @@ import { extractMaterialPlaceholders } from "@/lib/prompt/material-placeholders" import { formatBillingAmount } from "@/lib/billing"; import { assetContentUrl, assetPreviewUrl, materialPreviewUrl, storedAssetPreviewUrl } from "@/lib/client/asset-urls"; import { createSeedreamMarkedFile } from "@/lib/client/seedream-markup"; +import { clipboardImageFiles, clipboardImageURL, insertClipboardImageReferences } from "@/lib/client/clipboard-images"; import { buildSeedreamInteractivePrompt, buildSeedreamLayerPrompt, @@ -50,6 +51,21 @@ type MaterialKind = PromptMaterial["type"]; type ImageGenerateEngine = "jimeng" | "evolink" | "bailian" | "seedream"; type VideoGenerateEngine = "seedance" | "bailian" | "minimax"; +type MaterialUploadOptions = { + targetLabel?: string; + expectedType?: MaterialKind; + seedreamSourceMode?: "single" | "append"; + maxFiles?: number; + replaceAll?: boolean; + importedURL?: boolean; + paste?: { + mode: GenerateMode; + contextKey: string; + insertReferences: boolean; + selection?: { text: string; start: number; end: number }; + }; +}; + type MentionState = { start: number; query: string; @@ -180,7 +196,9 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate image: "", video: "" }); - const [materials, setMaterials] = useState([]); + const [materials, setMaterialState] = useState([]); + const materialsRef = useRef([]); + const uploadingRef = useRef(false); const [imageTemplates, setImageTemplates] = useState([]); const [taskAssets, setTaskAssets] = useState([]); const [recentJobs, setRecentJobs] = useState([]); @@ -242,6 +260,9 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate const evolinkModelChosenRef = useRef(false); const generateMode: GenerateMode = mode === "video" ? "video" : "image"; + const pasteContextKey = [generateMode, imageCreationMode, imageEngine, evolinkModel, videoEngine, videoModel, activeTemplateId, templateEditorOpen, previewTemplate?.id, taskDetailJobId].join("|"); + const pasteContextRef = useRef(pasteContextKey); + pasteContextRef.current = pasteContextKey; const prompt = promptByMode[generateMode]; const selectedJimengInfluence = jimengInfluenceOptions.find((option) => option.id === jimengInfluence) || jimengInfluenceOptions[1]; const selectedEvolinkQuality = evolinkQualityOptions.find((option) => option.id === evolinkQuality) || evolinkQualityOptions[1]; @@ -316,8 +337,9 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate && missingMaterialPlaceholders.length === 0 && missingAnnotationPlaceholders.length === 0 && minimaxMaterialsValid; - const submitDisabled = busy || !generationInputReady; - const submitTitle = !minimaxMaterialsValid + const submitDisabled = busy || uploading || !generationInputReady; + const submitTitle = uploading ? "素材上传或导入中,请稍候" + : !minimaxMaterialsValid ? "MiniMax H3 第一版仅支持 0 或 1 张图片素材" : unsupportedSpecializedPlaceholders.length ? `交互编辑和图层拆分不支持 ${unsupportedSpecializedPlaceholders.map((placeholder) => placeholder.token).join("、")}` @@ -576,6 +598,14 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate setPromptByMode((items) => ({ ...items, [generateMode]: value })); } + // Keep the latest list available to asynchronous uploads without putting + // side effects inside a React state updater (which can run more than once). + function setMaterials(value: PromptMaterial[] | ((items: PromptMaterial[]) => PromptMaterial[])) { + const next = typeof value === "function" ? value(materialsRef.current) : value; + materialsRef.current = next; + setMaterialState(next); + } + function selectImageCreationMode(nextMode: SeedreamCreationMode) { setImageCreationMode(nextMode); setMentionState(null); @@ -651,10 +681,87 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate }); const payload = await response.json(); if (!response.ok) throw new Error(payload.error || "上传失败"); + if (!Array.isArray(payload.assets) || payload.assets.length !== files.length) { + throw new Error("上传结果不完整,请刷新素材后重试。"); + } return payload.assets as Asset[]; } - async function uploadFiles(files: FileList | null, options: { targetLabel?: string; expectedType?: MaterialKind; seedreamSourceMode?: "single" | "append"; maxFiles?: number; replaceAll?: boolean } = {}) { + function handleImagePaste(event: ClipboardEvent) { + if (templateEditorOpen || previewTemplate || taskDetailJobId) return; + const files = clipboardImageFiles(event.clipboardData); + const imageURL = files.length ? null : clipboardImageURL(event.clipboardData); + if (!files.length && !imageURL) return; // Ordinary text and mixed text/URLs keep native paste behavior. + // A URL is only a candidate: do not fetch it or intercept text paste without consent. + // Show only the hostname, never a signed query string or credentials. + if (imageURL && !window.confirm(`是否将来自 ${new URL(imageURL).hostname} 的链接导入为图片素材?\n确定后由服务器下载并保存图片;取消则按普通文字粘贴。`)) return; + event.preventDefault(); + event.stopPropagation(); + if (uploadingRef.current || busy) { + setError(uploadingRef.current ? "素材正在上传或导入,请稍后再粘贴。" : "任务正在提交,请稍后再粘贴。"); + return; + } + const options: MaterialUploadOptions = { expectedType: "image" }; + if (specializedImageMode) { + options.seedreamSourceMode = imageCreationMode === "interactive" ? "append" : "single"; + if (options.seedreamSourceMode === "append" && seedreamInteractiveSourceKeys.length >= 10) { + setError("Seedream 5.0 Pro 交互编辑最多支持 10 张输入图片。"); + return; + } + if (imageCreationMode === "layers") { + if (files[0] && !isSeedreamLayerInputFile(files[0])) { + setError("Seedream 5.0 Pro 图层拆分只支持 PNG 或 JPEG 输入图片。"); + return; + } + if (selectedSeedreamSource && !window.confirm("替换当前待拆分图片吗?当前标注会清空,原图片仍保留在素材中。")) return; + } + } else if (minimaxVideoMode) { + options.maxFiles = 1; + options.replaceAll = true; + if (materials.length && !window.confirm("MiniMax H3 只支持一张首帧图,是否替换当前素材?")) return; + } else if ((generateMode === "image" && imageEngine === "seedream") || (generateMode === "video" && videoEngine === "bailian")) { + const limit = generateMode === "image" ? 10 : 2; + const remaining = Math.max(0, limit - materials.filter((material) => material.type === "image").length); + if (!remaining) { + setError(generateMode === "image" ? "Seedream 5.0 Pro 最多支持 10 张参考图,请先移除图片。" : "百炼图生视频最多支持 2 张图片,请先移除图片。"); + return; + } + options.maxFiles = remaining; + } + const textarea = event.target === promptRef.current ? promptRef.current : null; + options.paste = { + mode: generateMode, + contextKey: pasteContextKey, + // Workspace inputs are selected directly and have their own sequential + // image numbering; don't turn an empty automatic-split prompt into text. + insertReferences: !specializedImageMode, + selection: textarea ? { text: prompt, start: textarea.selectionStart, end: textarea.selectionEnd } : undefined + }; + if (imageURL) { + options.importedURL = true; + void receiveMaterialAssets(() => importImageURL(imageURL), options, 1); + } else { + void uploadFiles(files, options); + } + } + + async function importImageURL(url: string): Promise { + const response = await fetch("/api/assets/import-image", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }) + }); + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(payload?.error || "图片链接导入失败,请检查链接是否有效或稍后重试。"); + const asset = payload?.asset; + if (!asset || asset.kind !== "image" || !asset.id || !asset.url || !asset.name) { + throw new Error("图片导入结果不完整,请刷新素材后重试。"); + } + return [asset as Asset]; + } + + async function uploadFiles(files: FileList | File[] | null, options: MaterialUploadOptions = {}) { + if (uploadingRef.current || busy) return; const allFiles = Array.from(files || []); const remainingInteractiveSlots = Math.max(0, 10 - seedreamInteractiveSourceKeys.length); const selectedFiles = options.seedreamSourceMode === "single" @@ -677,28 +784,69 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate setError("Seedream 5.0 Pro 图层拆分只支持 PNG 或 JPEG 输入图片。"); return; } + await receiveMaterialAssets(() => uploadAssetFiles(selectedFiles), options, allFiles.length); + } + + // File paste and URL imports must share the same lock, context checks, source + // selection and reference insertion; a remote URL never goes straight to a model. + async function receiveMaterialAssets(loadAssets: () => Promise, options: MaterialUploadOptions, requestedCount: number) { + if (uploadingRef.current || busy) return; + uploadingRef.current = true; setUploading(true); setError(null); setNotice(null); try { - const uploaded = await uploadAssetFiles(selectedFiles); - if (expectedType && uploaded.some((asset) => materialTypeForAsset(asset) !== expectedType)) { - throw new Error(`请上传${shortTypeName(expectedType)}素材。`); + const uploaded = await loadAssets(); + if (options.paste && options.paste.contextKey !== pasteContextRef.current) { + setNotice("创作模式或编辑目标已切换,本次粘贴未加入当前任务,请重新粘贴。"); + return; } - setMaterials((items) => { - const next = options.replaceAll ? [] : [...items]; - uploaded.forEach((asset, index) => { - const targetLabel = index === 0 ? normalizeMaterialLabel(options.targetLabel) : undefined; - const material = materialFromAsset(asset, next, targetLabel); - const existingIndex = targetLabel ? next.findIndex((item) => normalizeMaterialLabel(item.label) === targetLabel) : -1; - if (existingIndex >= 0) { - next[existingIndex] = material; - return; - } - next.push(material); - }); - return next; + if (options.expectedType && uploaded.some((asset) => materialTypeForAsset(asset) !== options.expectedType)) { + throw new Error(`请上传${shortTypeName(options.expectedType)}素材。`); + } + if (options.importedURL && options.seedreamSourceMode === "single" + && uploaded.some((asset) => !["image/png", "image/jpeg"].includes(String(asset.metadata?.contentType)))) { + throw new Error("此图片已导入素材,但图层拆分只支持 PNG 或 JPEG,请使用对应格式的图片。"); + } + const next = options.replaceAll ? [] : [...materialsRef.current]; + const addedMaterials: PromptMaterial[] = []; + uploaded.forEach((asset, index) => { + const targetLabel = index === 0 ? normalizeMaterialLabel(options.targetLabel) : undefined; + const material = materialFromAsset(asset, next, targetLabel); + addedMaterials.push(material); + const existingIndex = targetLabel ? next.findIndex((item) => normalizeMaterialLabel(item.label) === targetLabel) : -1; + if (existingIndex >= 0) { + next[existingIndex] = material; + return; + } + next.push(material); }); + setMaterials(next); + if (options.paste?.insertReferences) { + const pasted = options.paste; + const labels = addedMaterials.map((material) => material.label || ""); + const textarea = promptRef.current; + const selection = pasted.selection; + const restoreCaret = textarea && selection && document.activeElement === textarea + && textarea.value === selection.text + && textarea.selectionStart === selection.start && textarea.selectionEnd === selection.end; + const expectedPrompt = restoreCaret ? insertClipboardImageReferences(selection.text, labels, selection) : null; + setPromptByMode((items) => ({ + ...items, + [pasted.mode]: insertClipboardImageReferences(items[pasted.mode], labels, selection) + })); + // Do not steal focus or move a cursor the user changed while uploading. + if (restoreCaret && expectedPrompt !== null && expectedPrompt !== selection.text) { + const cursor = expectedPrompt.length - (selection.text.length - selection.end); + window.requestAnimationFrame(() => { + if (document.activeElement === textarea && textarea.value === expectedPrompt) { + textarea.setSelectionRange(cursor, cursor); + } + }); + } + setMentionState(null); + setMaterialDraft(null); + } if (options.seedreamSourceMode === "single" && uploaded[0]) { setSeedreamSourceKey(uploaded[0].id || uploaded[0].url); setSeedreamLayerAnnotations([]); @@ -707,11 +855,17 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate const uploadedKeys = uploaded.map((asset) => asset.id || asset.url); setSeedreamInteractiveSourceKeys((keys) => [...new Set([...keys, ...uploadedKeys])].slice(0, 10)); setSeedreamSourceKey(uploadedKeys[0]); - if (allFiles.length > selectedFiles.length) setNotice(`已添加 ${selectedFiles.length} 张图片,交互编辑最多支持 10 张输入图。`); + if (requestedCount > uploaded.length) setNotice(`已添加 ${uploaded.length} 张图片,交互编辑最多支持 10 张输入图。`); + } + if (options.paste) { + setNotice(requestedCount > uploaded.length + ? `当前模式仅接收本次前 ${uploaded.length} 张图片,其余图片未上传。` + : `已${options.importedURL ? "导入" : "粘贴"} ${uploaded.length} 张图片${options.paste.insertReferences ? "并加入提示词引用" : "并选为输入图"}。`); } } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { + uploadingRef.current = false; setUploading(false); } } @@ -1141,6 +1295,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate } async function submit() { + if (uploadingRef.current || busy) return; if (unsupportedSpecializedPlaceholders.length) { setError(`${imageCreationMode === "layers" ? "图层拆分仅支持 1 张输入图片" : "交互编辑仅支持最多 10 张输入图片,且不接受视频或音频"},请删除 ${unsupportedSpecializedPlaceholders.map((placeholder) => placeholder.token).join("、")}。`); return; @@ -1351,6 +1506,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate expectedType: "image", seedreamSourceMode: imageCreationMode === "interactive" ? "append" : "single" })} + onPasteImage={handleImagePaste} onInsertAnnotation={insertAtCursor} /> ) : null} @@ -1364,6 +1520,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate type="file" multiple={minimaxVideoMode ? false : !specializedImageMode || imageCreationMode === "interactive"} accept={minimaxVideoMode ? "image/*" : specializedImageMode ? imageCreationMode === "layers" ? "image/png,image/jpeg" : "image/*" : materialUploadAccept} + disabled={uploading || busy} onChange={(event) => { void uploadFiles(event.target.files, minimaxVideoMode ? { expectedType: "image", maxFiles: 1, replaceAll: true } @@ -1386,6 +1543,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate id="createPrompt" ref={promptRef} value={prompt} + onPaste={handleImagePaste} onChange={(event) => { handlePromptInput(event.target.value, event.target.selectionStart); }} @@ -1426,6 +1584,8 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: Generate ) : null} + {uploading ? "素材上传或导入中,请稍候…" : "支持 Ctrl+V / ⌘+V 粘贴截图、图片或图片下载地址;链接确认后导入,文字可照常粘贴。"} + {unsupportedSpecializedPlaceholders.length ? (
{imageCreationMode === "layers" ? "图层拆分仅接受 1 张输入图片" : "交互编辑仅接受最多 10 张输入图片,不接受视频或音频"},请从提示词中删除 diff --git a/components/seedream-workspace.tsx b/components/seedream-workspace.tsx index a24189f..466ac5e 100644 --- a/components/seedream-workspace.tsx +++ b/components/seedream-workspace.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import { useEffect, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type PointerEvent as ReactPointerEvent, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { BoxSelect, Brush, Check, Hand, ImageIcon, Loader2, MapPin, Maximize2, Minimize2, Redo2, ScanLine, Trash2, Undo2, Upload, X, ZoomIn, ZoomOut } from "lucide-react"; import clsx from "clsx"; import { materialPreviewUrl } from "@/lib/client/asset-urls"; @@ -37,6 +37,7 @@ export function SeedreamWorkspace({ onSelectSource, onToggleSource, onUpload, + onPasteImage, onInsertAnnotation }: { mode: Exclude; @@ -49,6 +50,7 @@ export function SeedreamWorkspace({ onSelectSource: (material: PromptMaterial) => void; onToggleSource: (material: PromptMaterial) => void; onUpload: (files: FileList | null) => void; + onPasteImage?: (event: ReactClipboardEvent) => void; onInsertAnnotation: (token: string) => void; }) { const [tool, setTool] = useState("box"); @@ -324,7 +326,15 @@ export function SeedreamWorkspace({ const currentImageIndex = sourceIndexByKey.get(sourceKey) || 1; return ( -
+
{ + if (event.target instanceof HTMLElement && event.target.closest("input, textarea, [contenteditable]")) return; + onPasteImage?.(event); + }} + >
{mode === "interactive" ? "精准编辑画布" : "指定拆分区域"} @@ -334,10 +344,19 @@ export function SeedreamWorkspace({
{!source ? ( -
+
{ + if (event.target instanceof Element && event.target.closest("label, button, input")) return; + event.currentTarget.focus({ preventScroll: true }); + }} + > {sources.length ? "选择参与生成的图片" : "先上传一张图片"} {mode === "interactive" ? "可以选择多张图片进行跨图定位编辑" : "模板效果预览图不会自动参与拆分"} + 支持 Ctrl+V / ⌘+V 粘贴图片或图片下载地址,链接确认后导入
- 滚轮缩放 · 空格 / 中键拖动 · 放大后可继续标注 · 适应画布恢复全图 + 滚轮缩放 · 空格 / 中键拖动 · 放大后可继续标注 · 适应画布恢复全图 · Ctrl+V / ⌘+V 粘贴图片或图片地址 {mode === "interactive" ? "蓝色涂鸦会在提交时自动合成到对应输入图;框选和点选会转换成官方坐标标签。" : "蓝色涂鸦会合成到待拆图片;框选会转换为 坐标,不会修改原始素材。"} diff --git a/contracts/assets/http-v1.json b/contracts/assets/http-v1.json index 5cad396..30d97bf 100644 --- a/contracts/assets/http-v1.json +++ b/contracts/assets/http-v1.json @@ -8,6 +8,10 @@ "publicNotDownloadable": { "status": 404, "body": { "error": "Asset file is not downloadable." } }, "noFiles": { "status": 400, "body": { "error": "No files uploaded." } }, "tooLarge": { "status": 413, "body": { "error": "Request body is too large." } }, + "importImageInvalidURL": { "status": 400, "body": { "error": "图片地址无效,请使用 http 或 https 链接" } }, + "importImageTooLarge": { "status": 413, "body": { "error": "图片过大" } }, + "importImageUnsupported": { "status": 415, "body": { "error": "不支持的图片格式" } }, + "importImageUnavailable": { "status": 502, "body": { "error": "图片地址不可访问" } }, "internal": { "status": 500, "body": { "error": "Internal server error." } } }, "methods": { @@ -20,9 +24,23 @@ "downloadCacheControl": "private, no-store", "servedCacheControl": "public, max-age=31536000, immutable" }, + "importImage": { + "method": "POST", + "path": "/api/assets/import-image", + "auth": "platform-session", + "contentType": "application/json", + "request": { "url": "string" }, + "success": { "status": 201, "body": { "asset": "Asset" } }, + "source": "upload", + "formats": ["png", "jpeg", "webp", "gif", "bmp"], + "maxURLBytes": 8192, + "maxImageBytes": 20971520, + "maxImagePixels": 25000000 + }, "routes": [ { "path": "/api/assets", "allow": "GET, HEAD, POST, OPTIONS" }, { "path": "/api/assets/upload", "allow": "POST, OPTIONS" }, + { "path": "/api/assets/import-image", "allow": "POST, OPTIONS" }, { "path": "/api/assets/{id}", "allow": "DELETE, OPTIONS" }, { "path": "/api/assets/{id}/download", "allow": "GET, HEAD, OPTIONS" }, { "path": "/api/v1/assets", "allow": "GET, HEAD, POST, OPTIONS" }, diff --git a/contracts/http/route-surface.v1.json b/contracts/http/route-surface.v1.json index 5f4c85a..92c6696 100644 --- a/contracts/http/route-surface.v1.json +++ b/contracts/http/route-surface.v1.json @@ -22,6 +22,7 @@ { "method": "GET", "path": "/api/assets" }, { "method": "POST", "path": "/api/assets" }, { "method": "POST", "path": "/api/assets/upload" }, + { "method": "POST", "path": "/api/assets/import-image" }, { "method": "DELETE", "path": "/api/assets/{id}" }, { "method": "GET", "path": "/api/assets/{id}/download" }, { "method": "GET", "path": "/api/auth/callback" }, diff --git a/lib/client/clipboard-images.ts b/lib/client/clipboard-images.ts new file mode 100644 index 0000000..f3aef89 --- /dev/null +++ b/lib/client/clipboard-images.ts @@ -0,0 +1,92 @@ +type ClipboardImageSource = Pick; + +const imageExtensions: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/webp": "webp", + "image/gif": "gif", + "image/bmp": "bmp", + "image/avif": "avif", + "image/tiff": "tiff", + "image/svg+xml": "svg" +}; + +/** Extract actual clipboard image files without interpreting text, HTML, or URLs. */ +export function clipboardImageFiles(clipboardData: ClipboardImageSource | null | undefined): File[] { + if (!clipboardData) return []; + + const fromItems: File[] = []; + for (const item of Array.from(clipboardData.items || [])) { + if (item.kind !== "file") continue; + const file = item.getAsFile(); + if (!file) continue; + const mime = file.type || item.type; + if (mime.startsWith("image/")) fromItems.push(withUsableImageName(file, mime)); + } + if (fromItems.length > 0) return fromItems; + + return Array.from(clipboardData.files || []) + .filter((file) => file.type.startsWith("image/")) + .map((file) => withUsableImageName(file, file.type)); +} + +/** Recognize a single pasted HTTP(S) URL; the server must verify it is a safe image. */ +export function clipboardImageURL(clipboardData: Pick | null | undefined): string | null { + const value = clipboardData?.getData("text/plain").trim() || ""; + if (!value || value.length > 8192 || /\s|[\u0000-\u001f\u007f]/.test(value)) return null; + if (!/^https?:\/\//i.test(value)) return null; + + try { + const parsed = new URL(value); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + const authority = value.match(/^https?:\/\/([^/?#]*)/i)?.[1] || ""; + if (!parsed.hostname || authority.includes("@") || parsed.username || parsed.password) return null; + return value; + } catch { + return null; + } +} + +function withUsableImageName(file: File, mime: string): File { + const extension = imageExtensions[mime.toLowerCase()]; + const safeName = file.name.replace(/[\\/\u0000-\u001f\u007f]/g, "_").trim(); + const genericName = !safeName || /^(?:image|blob|unknown)(?:\.image)?$/i.test(safeName); + const hasExtension = /\.[a-z\d]{1,10}$/i.test(safeName) && !/\.image$/i.test(safeName); + if (safeName === file.name && hasExtension && file.type) return file; + + const stem = genericName ? "pasted-image" : safeName.replace(/\.image$/i, ""); + const name = extension && !hasExtension ? `${stem}.${extension}` : stem || "pasted-image"; + if (name === file.name && file.type) return file; + return new File([file], name, { type: file.type || mime, lastModified: file.lastModified }); +} + +/** Insert newly uploaded image references without replacing edits made during upload. */ +export function insertClipboardImageReferences( + currentPrompt: string, + labels: string[], + selection?: { text: string; start: number; end: number } +): string { + const replaceSelection = selection?.text === currentPrompt + && Number.isInteger(selection.start) + && Number.isInteger(selection.end) + && selection.start >= 0 + && selection.end >= selection.start + && selection.end <= currentPrompt.length; + const before = replaceSelection ? currentPrompt.slice(0, selection.start) : currentPrompt; + const after = replaceSelection ? currentPrompt.slice(selection.end) : ""; + const retainedPrompt = before + after; + const references: string[] = []; + + for (const label of labels) { + const normalized = label.startsWith("@") ? label : `@${label}`; + if (!/^@图片\d+$/.test(normalized) || references.includes(normalized)) continue; + const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + if (!new RegExp(`${escaped}(?!\\d)`).test(retainedPrompt)) references.push(normalized); + } + if (references.length === 0) return currentPrompt; + + const inserted = references.join(" "); + const leadingSpace = before && !/\s$/.test(before) ? " " : ""; + const trailingSpace = after && !/^\s/.test(after) ? " " : ""; + return before + leadingSpace + inserted + trailingSpace + after; +} diff --git a/tests/assets-http-contract.test.ts b/tests/assets-http-contract.test.ts index 398f2d1..1acca7c 100644 --- a/tests/assets-http-contract.test.ts +++ b/tests/assets-http-contract.test.ts @@ -7,11 +7,18 @@ describe("assets HTTP v1 compatibility contract", () => { expect(contract.version).toBe(1); expect(contract.limits).toEqual({ jsonBytes: 1_048_576, uploadBytes: 20_971_520, remoteBytes: 20_971_520 }); expect(contract.routes.map((route: { path: string }) => route.path)).toEqual([ - "/api/assets", "/api/assets/upload", "/api/assets/{id}", "/api/assets/{id}/download", + "/api/assets", "/api/assets/upload", "/api/assets/import-image", "/api/assets/{id}", "/api/assets/{id}/download", "/api/v1/assets", "/api/v1/assets/{id}", "/api/v1/assets/{id}/download", "/uploads/{path...}", "/generated-results/{path...}" ]); expect(contract.errors.internal.body).toEqual({ error: "Internal server error." }); + expect(contract.errors.importImageUnsupported).toEqual({ status: 415, body: { error: "不支持的图片格式" } }); + expect(contract.importImage).toEqual({ + method: "POST", path: "/api/assets/import-image", auth: "platform-session", contentType: "application/json", + request: { url: "string" }, success: { status: 201, body: { asset: "Asset" } }, source: "upload", + formats: ["png", "jpeg", "webp", "gif", "bmp"], maxURLBytes: 8192, maxImageBytes: 20_971_520, + maxImagePixels: 25_000_000 + }); expect(contract.methods).toEqual({ headExecutesGet: true, optionsStatus: 204, unsupportedStatus: 405, unsupportedBody: "empty" }); expect(contract.binary).toEqual({ downloadCacheControl: "private, no-store", diff --git a/tests/clipboard-images.test.ts b/tests/clipboard-images.test.ts new file mode 100644 index 0000000..0a9c1ee --- /dev/null +++ b/tests/clipboard-images.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; + +import { clipboardImageFiles, clipboardImageURL, insertClipboardImageReferences } from "@/lib/client/clipboard-images"; + +function clipboard(items: Array<{ kind: string; type: string; getAsFile: () => File | null }>, files: File[] = []) { + return { items, files } as unknown as Pick; +} + +function item(file: File | null, type = file?.type || "image/png") { + return { kind: "file", type, getAsFile: () => file }; +} + +describe("clipboardImageFiles", () => { + it("accepts image items and avoids their duplicate files representation", () => { + const png = new File(["png bytes"], "image", { type: "image/png" }); + const jpeg = new File(["jpeg bytes"], "photo.jpeg", { type: "image/jpeg" }); + const result = clipboardImageFiles(clipboard([item(png), item(jpeg)], [png, jpeg])); + + expect(result).toHaveLength(2); + expect(result.map((file) => file.name)).toEqual(["pasted-image.png", "photo.jpeg"]); + expect(result[1]).toBe(jpeg); + expect(result[0].type).toBe("image/png"); + }); + + it("falls back to files when clipboard items do not expose an image", () => { + const webp = new File(["webp bytes"], "screenshot", { type: "image/webp" }); + const text = new File(["https://example.test/image.png"], "link.txt", { type: "text/plain" }); + const result = clipboardImageFiles(clipboard([item(null), { kind: "string", type: "text/plain", getAsFile: () => null }], [text, webp])); + + expect(result.map((file) => file.name)).toEqual(["screenshot.webp"]); + }); + + it("ignores text, HTML, URLs, null files and non-image files", () => { + const text = new File(["image URL"], "photo.png", { type: "text/plain" }); + expect(clipboardImageFiles(clipboard([ + { kind: "string", type: "text/html", getAsFile: () => null }, + { kind: "string", type: "text/uri-list", getAsFile: () => null }, + item(null), + item(text) + ], [text]))).toEqual([]); + expect(clipboardImageFiles(null)).toEqual([]); + }); + + it("normalizes generic screenshot names but keeps usable names and image bytes", async () => { + const original = new File([Uint8Array.from([1, 2, 3, 4])], "capture.image", { type: "image/gif" }); + const good = new File(["jpeg"], "holiday.jpg", { type: "image/jpeg" }); + const [renamed, preserved] = clipboardImageFiles(clipboard([item(original), item(good)])); + + expect(renamed.name).toBe("capture.gif"); + expect(new Uint8Array(await renamed.arrayBuffer())).toEqual(Uint8Array.from([1, 2, 3, 4])); + expect(preserved).toBe(good); + }); + + it("fills a missing File MIME from the image clipboard item even when its filename is usable", async () => { + const original = new File([Uint8Array.from([7, 8, 9])], "screenshot.png"); + const [file] = clipboardImageFiles(clipboard([item(original, "image/png")])); + + expect(file.name).toBe("screenshot.png"); + expect(file.type).toBe("image/png"); + expect(new Uint8Array(await file.arrayBuffer())).toEqual(Uint8Array.from([7, 8, 9])); + }); +}); + +describe("insertClipboardImageReferences", () => { + it("replaces a selected range when the prompt has not changed", () => { + expect(insertClipboardImageReferences("请修改这里的衣服", ["@图片1"], { + text: "请修改这里的衣服", start: 3, end: 5 + })).toBe("请修改 @图片1 的衣服"); + expect(insertClipboardImageReferences("请修改 衣服", ["图片2"], { + text: "请修改 衣服", start: 4, end: 5 + })).toBe("请修改 @图片2 衣服"); + }); + + it("appends to the latest prompt when the user edited it during upload", () => { + expect(insertClipboardImageReferences("原提示词又加了内容", ["@图片3"], { + text: "原提示词", start: 0, end: 4 + })).toBe("原提示词又加了内容 @图片3"); + }); + + it("deduplicates exact references without confusing image 1 and image 10", () => { + expect(insertClipboardImageReferences("看 @图片10", ["@图片1", "@图片1", "@图片10"])) + .toBe("看 @图片10 @图片1"); + expect(insertClipboardImageReferences("看 @图片1", ["@图片1"])) + .toBe("看 @图片1"); + }); + + it("does not erase selection text if all requested references already exist", () => { + expect(insertClipboardImageReferences("替换这里 @图片1", ["@图片1"], { + text: "替换这里 @图片1", start: 2, end: 4 + })).toBe("替换这里 @图片1"); + }); +}); + +describe("clipboardImageURL", () => { + const asClipboard = (plain: string, html = "") => ({ + getData: (type: string) => type === "text/plain" ? plain : html + }) as Pick; + + it("preserves a single signed URL exactly after trimming, regardless of suffix", () => { + const signed = "https://images.example.test/asset.image?Expires=123&Signature=A%2FB%3D&key=abc"; + expect(clipboardImageURL(asClipboard(` ${signed} `))).toBe(signed); + expect(clipboardImageURL(asClipboard("http://images.example.test/opaque?id=1"))) + .toBe("http://images.example.test/opaque?id=1"); + }); + + it("does not extract URLs from prose or accept more than one URL", () => { + expect(clipboardImageURL(asClipboard("请看 https://images.example.test/a.png"))).toBeNull(); + expect(clipboardImageURL(asClipboard("https://images.example.test/a.png\nhttps://images.example.test/b.png"))).toBeNull(); + expect(clipboardImageURL(asClipboard("https://images.example.test/a.png more"))).toBeNull(); + }); + + it("ignores HTML and rejects non-HTTP schemes and userinfo", () => { + expect(clipboardImageURL(asClipboard("", ''))).toBeNull(); + for (const value of [ + "data:image/png;base64,AAAA", "file:///tmp/a.png", "javascript:alert(1)", + "https://user:pass@images.example.test/a.png", "https://@images.example.test/a.png", + "images.example.test/a.png" + ]) expect(clipboardImageURL(asClipboard(value))).toBeNull(); + expect(clipboardImageURL(null)).toBeNull(); + }); + + it("rejects oversized URLs and embedded whitespace", () => { + expect(clipboardImageURL(asClipboard(`https://images.example.test/${"a".repeat(8192)}`))).toBeNull(); + expect(clipboardImageURL(asClipboard("https://images.example.test/a\tb"))).toBeNull(); + }); +}); diff --git a/tests/create-studio-paste.test.ts b/tests/create-studio-paste.test.ts new file mode 100644 index 0000000..a2b3764 --- /dev/null +++ b/tests/create-studio-paste.test.ts @@ -0,0 +1,61 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +const createStudioUrl = new URL("../components/create-studio.tsx", import.meta.url); + +async function pasteFlowSource() { + const source = await readFile(createStudioUrl, "utf8"); + const start = source.indexOf("function handleImagePaste("); + const end = source.indexOf("async function uploadTemplatePreview(", start); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + return { source, flow: source.slice(start, end) }; +} + +describe("create studio image paste wiring", () => { + it("handles paste in both the prompt and workspace, leaving text paste to the browser", async () => { + const { source, flow } = await pasteFlowSource(); + expect(source).toContain("onPaste={handleImagePaste}"); + expect(source).toContain("onPasteImage={handleImagePaste}"); + expect(flow.indexOf("if (!files.length && !imageURL) return;")).toBeLessThan(flow.indexOf("event.preventDefault()")); + expect(flow).toContain("clipboardImageFiles(event.clipboardData)"); + }); + + it("requires confirmation before downloading an entire URL and keeps canceled links as native text paste", async () => { + const { flow } = await pasteFlowSource(); + expect(flow).toContain("files.length ? null : clipboardImageURL(event.clipboardData)"); + const confirmation = flow.indexOf("if (imageURL && !window.confirm("); + expect(confirmation).toBeGreaterThanOrEqual(0); + expect(confirmation).toBeLessThan(flow.indexOf("event.preventDefault()")); + expect(flow).toContain('fetch("/api/assets/import-image"'); + expect(flow).toContain('headers: { "Content-Type": "application/json" }'); + expect(flow).toContain("receiveMaterialAssets(() => importImageURL(imageURL), options, 1)"); + expect(flow).toContain("receiveMaterialAssets(() => uploadAssetFiles(selectedFiles), options, allFiles.length)"); + expect(flow).toContain("new URL(imageURL).hostname"); + expect(flow).toContain('asset.kind !== "image"'); + expect(flow).toContain('options.importedURL && options.seedreamSourceMode === "single"'); + }); + + it("blocks concurrent uploads and discards a stale result after a mode or target switch", async () => { + const { flow } = await pasteFlowSource(); + expect(flow).toContain("if (uploadingRef.current || busy)"); + expect(flow).toContain("uploadingRef.current = true"); + expect(flow).toContain("uploadingRef.current = false"); + expect(flow).toContain("options.paste.contextKey !== pasteContextRef.current"); + expect(flow.indexOf("options.paste.contextKey !== pasteContextRef.current")) + .toBeLessThan(flow.indexOf("setMaterials(next)")); + }); + + it("routes special modes through existing source limits and adds references only for basic image or video", async () => { + const { flow } = await pasteFlowSource(); + expect(flow).toContain('imageCreationMode === "interactive" ? "append" : "single"'); + expect(flow).toContain("isSeedreamLayerInputFile(files[0])"); + expect(flow).toContain("if (selectedSeedreamSource && !window.confirm("); + expect(flow).toContain("options.maxFiles = 1"); + expect(flow).toContain("options.replaceAll = true"); + expect(flow).toContain("options.maxFiles = remaining"); + expect(flow).toContain("insertReferences: !specializedImageMode"); + expect(flow).toContain('const labels = addedMaterials.map((material) => material.label || "");'); + expect(flow).toContain("insertClipboardImageReferences(items[pasted.mode], labels, selection)"); + }); +});