增加复制粘贴交互
This commit is contained in:
225
backend/internal/assets/import_image.go
Normal file
225
backend/internal/assets/import_image.go
Normal file
@@ -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)
|
||||
}
|
||||
261
backend/internal/assets/import_image_test.go
Normal file
261
backend/internal/assets/import_image_test.go
Normal file
@@ -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(`<svg xmlns="http://www.w3.org/2000/svg"/>`), ErrImportImageUnsupported},
|
||||
{"html", "https://images.test/a.png", []byte("<html>login</html>"), 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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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("<html>access denied</html>"), 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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user