Files
shaw ab71e12ec4 fix(images): 异步生图转存的图片下载与同步回填策略对齐
异步生图任务把上游返回的图片 url 下载后转存对象存储,此前沿用默认 http.Client,
与同步 Images 端点 url 回填(c227863d)的下载策略不一致。

- 下载目的地为 localhost、回环、私网、链路本地、CGNAT 等非公网地址时不下载,
  重定向的每一跳同样校验;直连时在 socket 层按实际连接 IP 判断
- 继续支持环境变量代理:经代理时先校验目标主机的解析结果
- 响应声明类型与字节嗅探都不是图片时任务失败,不再按 png 落盘
- 下载客户端进程内共享,复用连接池;超时与大小上限不变
2026-10-19 17:51:18 +08:00

498 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// pngBytes is a minimal payload whose signature makes http.DetectContentType
// report image/png.
var pngBytes = []byte("\x89PNG\r\n\x1a\nfake-png-payload")
type savedImage struct {
key string
contentType string
data []byte
}
type fakeImageStorage struct {
saved []savedImage
url string
err error
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func (f *fakeImageStorage) Save(_ context.Context, key, contentType string, data []byte) (string, error) {
if f.err != nil {
return "", f.err
}
f.saved = append(f.saved, savedImage{key: key, contentType: contentType, data: append([]byte(nil), data...)})
if f.url != "" {
return f.url, nil
}
return "https://cdn.test/" + key, nil
}
func TestImageResultUploaderRewritesB64JSON(t *testing.T) {
storage := &fakeImageStorage{}
uploader := NewImageResultUploader(storage, "images/", 0, nil)
b64 := base64.StdEncoding.EncodeToString(pngBytes)
result := json.RawMessage(`{"created":1,"data":[{"b64_json":"` + b64 + `","revised_prompt":"a cat"}]}`)
out, err := uploader.Rewrite(context.Background(), "imgtask_abc", result)
require.NoError(t, err)
require.Len(t, storage.saved, 1)
require.Equal(t, "images/imgtask_abc-0.png", storage.saved[0].key)
require.Equal(t, "image/png", storage.saved[0].contentType)
require.Equal(t, pngBytes, storage.saved[0].data)
var parsed struct {
Data []map[string]json.RawMessage `json:"data"`
}
require.NoError(t, json.Unmarshal(out, &parsed))
require.Len(t, parsed.Data, 1)
require.JSONEq(t, `"https://cdn.test/images/imgtask_abc-0.png"`, string(parsed.Data[0]["url"]))
_, hasB64 := parsed.Data[0]["b64_json"]
require.False(t, hasB64, "b64_json must be stripped after offload")
require.JSONEq(t, `"a cat"`, string(parsed.Data[0]["revised_prompt"]), "unrelated fields preserved")
}
func TestImageResultUploaderRewritesURL(t *testing.T) {
var requested string
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requested = req.URL.String()
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"image/png"}},
Body: io.NopCloser(bytes.NewReader(pngBytes)),
Request: req,
}, nil
})}
storage := &fakeImageStorage{}
uploader := NewImageResultUploader(storage, "images/", 0, client)
result := json.RawMessage(`{"created":1,"data":[{"url":"https://images.example.test/pic.png"}]}`)
out, err := uploader.Rewrite(context.Background(), "imgtask_xyz", result)
require.NoError(t, err)
require.Equal(t, "https://images.example.test/pic.png", requested)
require.Len(t, storage.saved, 1)
require.Equal(t, pngBytes, storage.saved[0].data)
require.Equal(t, "image/png", storage.saved[0].contentType)
var parsed struct {
Data []map[string]json.RawMessage `json:"data"`
}
require.NoError(t, json.Unmarshal(out, &parsed))
require.JSONEq(t, `"https://cdn.test/images/imgtask_xyz-0.png"`, string(parsed.Data[0]["url"]))
}
func TestImageResultUploaderRewritesImageDataURLWithoutHTTP(t *testing.T) {
httpCalls := 0
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
httpCalls++
return nil, errors.New("HTTP must not be called for data URLs")
})}
storage := &fakeImageStorage{}
uploader := NewImageResultUploader(storage, "images/", 0, client)
b64 := base64.StdEncoding.EncodeToString(pngBytes)
result := json.RawMessage(`{"data":[{"url":"DATA:image/jpeg;name=photo.jpg;BaSe64,` + b64 + `","revised_prompt":"kept"}]}`)
out, err := uploader.Rewrite(context.Background(), "imgtask_data", result)
require.NoError(t, err)
require.Zero(t, httpCalls)
require.Len(t, storage.saved, 1)
require.Equal(t, pngBytes, storage.saved[0].data)
require.Equal(t, "image/png", storage.saved[0].contentType, "detected bytes take precedence over a conflicting declaration")
require.Equal(t, "images/imgtask_data-0.png", storage.saved[0].key)
var parsed struct {
Data []map[string]json.RawMessage `json:"data"`
}
require.NoError(t, json.Unmarshal(out, &parsed))
require.JSONEq(t, `"https://cdn.test/images/imgtask_data-0.png"`, string(parsed.Data[0]["url"]))
require.JSONEq(t, `"kept"`, string(parsed.Data[0]["revised_prompt"]))
}
func TestImageResultUploaderDataURLValidation(t *testing.T) {
tests := []struct {
name string
url string
wantErr string
}{
{name: "missing comma", url: "data:image/png;base64", wantErr: "missing comma"},
{name: "non image", url: "data:text/plain;base64,aGVsbG8=", wantErr: "is not an image"},
{name: "non base64", url: "data:image/png,raw", wantErr: "not base64"},
{name: "invalid base64", url: "data:image/png;base64,%%%", wantErr: "base64 payload"},
{name: "invalid media type", url: "data:image/png;bad parameter;base64,aGVsbG8=", wantErr: "invalid media type"},
{name: "parameter after base64", url: "data:image/png;base64;name=photo.png,aGVsbG8=", wantErr: "base64 marker must be the final header token"},
{name: "duplicate base64 marker", url: "data:image/png;base64;base64,aGVsbG8=", wantErr: "duplicate base64 marker"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
httpCalls := 0
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
httpCalls++
return nil, errors.New("HTTP must not be called for data URLs")
})}
uploader := NewImageResultUploader(&fakeImageStorage{}, "images/", 0, client)
result, err := json.Marshal(map[string]any{"data": []map[string]string{{"url": tt.url}}})
require.NoError(t, err)
_, err = uploader.Rewrite(context.Background(), "imgtask_bad", result)
require.ErrorContains(t, err, tt.wantErr)
require.Zero(t, httpCalls)
})
}
}
func TestImageResultUploaderRejectsOversizedImageDataURL(t *testing.T) {
storage := &fakeImageStorage{}
uploader := NewImageResultUploader(storage, "images/", 3, nil)
payload := base64.StdEncoding.EncodeToString([]byte("four"))
result := json.RawMessage(`{"data":[{"url":"data:image/png;base64,` + payload + `"}]}`)
_, err := uploader.Rewrite(context.Background(), "imgtask_large", result)
require.ErrorContains(t, err, "decoded image data URL exceeds 3 bytes")
require.Empty(t, storage.saved)
}
func TestImageResultUploaderB64JSONTakesPrecedenceOverDataURL(t *testing.T) {
storage := &fakeImageStorage{}
uploader := NewImageResultUploader(storage, "images/", 0, nil)
b64 := base64.StdEncoding.EncodeToString(pngBytes)
result := json.RawMessage(`{"data":[{"b64_json":"` + b64 + `","url":"data:text/plain,not-an-image"}]}`)
_, err := uploader.Rewrite(context.Background(), "imgtask_precedence", result)
require.NoError(t, err)
require.Len(t, storage.saved, 1)
require.Equal(t, pngBytes, storage.saved[0].data)
}
func TestImageResultUploaderPropagatesStorageError(t *testing.T) {
storage := &fakeImageStorage{err: errors.New("bucket unreachable")}
uploader := NewImageResultUploader(storage, "images/", 0, nil)
b64 := base64.StdEncoding.EncodeToString(pngBytes)
result := json.RawMessage(`{"data":[{"b64_json":"` + b64 + `"}]}`)
_, err := uploader.Rewrite(context.Background(), "imgtask_err", result)
require.Error(t, err)
require.Contains(t, err.Error(), "bucket unreachable")
}
func TestImageResultUploaderNilStoragePassthrough(t *testing.T) {
var uploader *ImageResultUploader
result := json.RawMessage(`{"data":[{"url":"https://example.test/x.png"}]}`)
out, err := uploader.Rewrite(context.Background(), "imgtask_nil", result)
require.NoError(t, err)
require.JSONEq(t, string(result), string(out))
}
func TestImageTaskServiceCompleteOffloadsToStorage(t *testing.T) {
store := &imageTaskMemoryStore{}
storage := &fakeImageStorage{}
uploader := NewImageResultUploader(storage, "images/", 0, nil)
svc := NewImageTaskServiceWithUploader(store, uploader, time.Hour, time.Minute)
require.True(t, svc.Enabled())
owner := ImageTaskOwner{UserID: 1, APIKeyID: 2}
created, err := svc.Create(context.Background(), owner)
require.NoError(t, err)
b64 := base64.StdEncoding.EncodeToString(pngBytes)
result := json.RawMessage(`{"created":1,"data":[{"b64_json":"` + b64 + `"}]}`)
require.NoError(t, svc.Complete(context.Background(), created.ID, http.StatusOK, result))
got, err := svc.Get(context.Background(), owner, created.ID)
require.NoError(t, err)
require.Equal(t, ImageTaskStatusCompleted, got.Status)
require.Equal(t, "https://cdn.test/images/"+created.ID+"-0.png", got.ImageURL)
require.NotContains(t, string(got.Result), "b64_json", "large base64 must not be persisted to Redis")
require.Len(t, storage.saved, 1)
}
func TestImageTaskServiceCompleteOffloadFailureMarksFailed(t *testing.T) {
store := &imageTaskMemoryStore{}
storage := &fakeImageStorage{err: errors.New("bucket unreachable")}
uploader := NewImageResultUploader(storage, "images/", 0, nil)
svc := NewImageTaskServiceWithUploader(store, uploader, time.Hour, time.Minute)
owner := ImageTaskOwner{UserID: 1, APIKeyID: 2}
created, err := svc.Create(context.Background(), owner)
require.NoError(t, err)
b64 := base64.StdEncoding.EncodeToString(pngBytes)
result := json.RawMessage(`{"data":[{"b64_json":"` + b64 + `"}]}`)
require.NoError(t, svc.Complete(context.Background(), created.ID, http.StatusOK, result))
got, err := svc.Get(context.Background(), owner, created.ID)
require.NoError(t, err)
require.Equal(t, ImageTaskStatusFailed, got.Status)
require.Equal(t, http.StatusBadGateway, got.HTTPStatus)
require.Contains(t, string(got.Error), "object storage")
require.NotContains(t, string(got.Result), "b64_json", "failed offload must not persist base64 to Redis")
}
func TestImageResultUploaderDefaultClientRejectsPrivateURL(t *testing.T) {
var hits atomic.Int32
internal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
_, _ = w.Write([]byte("GIF89a-payload"))
}))
defer internal.Close()
storage := &fakeImageStorage{}
uploader := NewImageResultUploader(storage, "images/", 0, nil)
result := json.RawMessage(`{"data":[{"url":"` + internal.URL + `/a.gif"}]}`)
_, err := uploader.Rewrite(context.Background(), "imgtask_private", result)
require.ErrorContains(t, err, "image url host is not allowed")
require.Zero(t, hits.Load(), "internal service must not be contacted")
require.Empty(t, storage.saved)
}
func TestImageTaskServiceCompletePrivateURLMarksFailed(t *testing.T) {
var hits atomic.Int32
internal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
_, _ = w.Write(pngBytes)
}))
defer internal.Close()
store := &imageTaskMemoryStore{}
storage := &fakeImageStorage{}
svc := NewImageTaskServiceWithUploader(store, NewImageResultUploader(storage, "images/", 0, nil), time.Hour, time.Minute)
owner := ImageTaskOwner{UserID: 1, APIKeyID: 2}
created, err := svc.Create(context.Background(), owner)
require.NoError(t, err)
result := json.RawMessage(`{"data":[{"url":"` + internal.URL + `/a.gif"}]}`)
require.NoError(t, svc.Complete(context.Background(), created.ID, http.StatusOK, result))
got, err := svc.Get(context.Background(), owner, created.ID)
require.NoError(t, err)
require.Equal(t, ImageTaskStatusFailed, got.Status)
require.Empty(t, got.ImageURL)
require.NotContains(t, string(got.Result), internal.URL)
require.Zero(t, hits.Load())
require.Empty(t, storage.saved)
}
func newCountingImageDownloadTransport(proxyURL *url.URL) (*imageDownloadTransport, *atomic.Int32, *atomic.Int32) {
var directCalls, proxiedCalls atomic.Int32
respond := func(counter *atomic.Int32) http.RoundTripper {
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
counter.Add(1)
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(pngBytes)), Request: req}, nil
})
}
return &imageDownloadTransport{
direct: respond(&directCalls),
proxied: respond(&proxiedCalls),
proxy: func(*http.Request) (*url.URL, error) { return proxyURL, nil },
resolvesPrivate: func(ctx context.Context, host string) (bool, error) {
if host == "intranet.example.test" {
return true, nil
}
if host == "unresolvable.example.test" {
return false, errors.New("no such host")
}
return isPrivateOrLoopbackHost(ctx, host)
},
}, &directCalls, &proxiedCalls
}
func TestImageDownloadTransportRejectsNonPublicHosts(t *testing.T) {
hosts := []string{
"http://127.0.0.1:18082/a.gif",
"http://localhost/a.png",
"http://LOCALHOST./a.png",
"http://foo.localhost/a.png",
"http://10.0.0.8/a.png",
"http://172.16.3.4/a.png",
"http://192.168.1.1/a.png",
"http://100.64.0.1/a.png",
"http://169.254.169.254/latest/meta-data/",
"http://metadata.google.internal/computeMetadata/v1/",
"http://0.0.0.0/a.png",
"http://[::1]/a.png",
"http://[::ffff:127.0.0.1]/a.png",
"http://[fd00::1]/a.png",
"http://[fe80::1%25eth0]/a.png",
"http:///a.png",
}
for _, proxied := range []bool{false, true} {
for _, rawURL := range hosts {
var proxyURL *url.URL
if proxied {
proxyURL = &url.URL{Scheme: "http", Host: "127.0.0.1:7890"}
}
transport, directCalls, proxiedCalls := newCountingImageDownloadTransport(proxyURL)
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
require.NoError(t, err)
_, err = transport.RoundTrip(req)
require.Error(t, err, "proxied=%v url=%s", proxied, rawURL)
require.Zero(t, directCalls.Load(), rawURL)
require.Zero(t, proxiedCalls.Load(), rawURL)
}
}
}
func TestImageDownloadTransportRoutesPublicHosts(t *testing.T) {
transport, directCalls, proxiedCalls := newCountingImageDownloadTransport(nil)
req, err := http.NewRequest(http.MethodGet, "https://203.0.113.10/a.png", nil)
require.NoError(t, err)
resp, err := transport.RoundTrip(req)
require.NoError(t, err)
_ = resp.Body.Close()
require.EqualValues(t, 1, directCalls.Load())
require.Zero(t, proxiedCalls.Load())
transport, directCalls, proxiedCalls = newCountingImageDownloadTransport(&url.URL{Scheme: "http", Host: "127.0.0.1:7890"})
req, err = http.NewRequest(http.MethodGet, "https://203.0.113.10/a.png", nil)
require.NoError(t, err)
resp, err = transport.RoundTrip(req)
require.NoError(t, err)
_ = resp.Body.Close()
require.Zero(t, directCalls.Load())
require.EqualValues(t, 1, proxiedCalls.Load(), "a loopback env proxy must still be usable for public targets")
}
func TestImageDownloadClientRejectsRedirectToPrivateHost(t *testing.T) {
var calls atomic.Int32
transport := &imageDownloadTransport{
direct: roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls.Add(1)
return &http.Response{
StatusCode: http.StatusFound,
Header: http.Header{"Location": []string{"http://169.254.169.254/latest/meta-data/"}},
Body: io.NopCloser(bytes.NewReader(nil)),
Request: req,
}, nil
}),
}
uploader := NewImageResultUploader(&fakeImageStorage{}, "images/", 0, &http.Client{Transport: transport})
_, err := uploader.Rewrite(context.Background(), "imgtask_redirect", json.RawMessage(`{"data":[{"url":"https://images.example.test/a.png"}]}`))
require.ErrorContains(t, err, "image url host is not allowed: 169.254.169.254")
require.EqualValues(t, 1, calls.Load(), "the private redirect hop must not be sent")
}
func TestImageDownloadClientDialsOnlyPublicAddresses(t *testing.T) {
var hits atomic.Int32
internal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
_, _ = w.Write(pngBytes)
}))
defer internal.Close()
client := newImageDownloadHTTPClient(nil)
direct, ok := client.Transport.(*imageDownloadTransport).direct.(*http.Transport)
require.True(t, ok)
require.Nil(t, direct.Proxy, "the direct transport must not pick up an environment proxy")
// 绕过 RoundTrip 预检直接 dial:IP 字面量与经 DNS 解析到回环的域名都必须在 socket 层被拒。
conn, err := direct.DialContext(context.Background(), "tcp", internal.Listener.Addr().String())
if conn != nil {
_ = conn.Close()
}
require.ErrorContains(t, err, "address not allowed for image download")
_, port, err := net.SplitHostPort(internal.Listener.Addr().String())
require.NoError(t, err)
conn, err = direct.DialContext(context.Background(), "tcp", net.JoinHostPort("localhost", port))
if conn != nil {
_ = conn.Close()
}
require.ErrorContains(t, err, "address not allowed for image download")
require.Zero(t, hits.Load())
}
func newStaticImageDownloadClient(contentType string, body []byte) *http.Client {
return &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
header := http.Header{}
if contentType != "" {
header.Set("Content-Type", contentType)
}
return &http.Response{StatusCode: http.StatusOK, Header: header, Body: io.NopCloser(bytes.NewReader(body)), Request: req}, nil
})}
}
func TestImageResultUploaderDownloadContentTypes(t *testing.T) {
avif := []byte("\x00\x00\x00\x1cftypavif-not-sniffable")
tests := []struct {
name string
contentType string
body []byte
wantType string
wantErr string
}{
{name: "declared png", contentType: "image/png", body: pngBytes, wantType: "image/png"},
{name: "octet-stream sniffed png", contentType: "application/octet-stream", body: pngBytes, wantType: "image/png"},
{name: "missing header sniffed png", body: pngBytes, wantType: "image/png"},
{name: "declared avif not sniffable", contentType: "Image/AVIF; charset=binary", body: avif, wantType: "image/avif"},
{name: "json body", contentType: "application/json", body: []byte(`{"ok":true}`), wantErr: "content is not an image"},
{name: "html body", contentType: "text/html", body: []byte("<html>admin</html>"), wantErr: "content is not an image"},
{name: "empty body", body: nil, wantErr: "content is not an image"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
storage := &fakeImageStorage{}
uploader := NewImageResultUploader(storage, "images/", 0, newStaticImageDownloadClient(tt.contentType, tt.body))
_, err := uploader.Rewrite(context.Background(), "imgtask_ct", json.RawMessage(`{"data":[{"url":"https://images.example.test/a"}]}`))
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
require.Empty(t, storage.saved)
return
}
require.NoError(t, err)
require.Len(t, storage.saved, 1)
require.Equal(t, tt.wantType, storage.saved[0].contentType)
})
}
}
func TestImageDownloadDialControl(t *testing.T) {
for _, address := range []string{"127.0.0.1:443", "10.1.2.3:80", "100.100.100.200:80", "169.254.169.254:80", "[::1]:443", "[fe80::1%eth0]:443", "[::ffff:192.168.0.1]:443", "bad"} {
require.Error(t, imageDownloadDialControl("tcp", address, nil), address)
}
for _, address := range []string{"203.0.113.10:443", "[2001:db8::1]:443"} {
require.NoError(t, imageDownloadDialControl("tcp", address, nil), address)
}
}
func TestImageDownloadTransportProxiedChecksResolvedHost(t *testing.T) {
for _, host := range []string{"intranet.example.test", "unresolvable.example.test"} {
transport, directCalls, proxiedCalls := newCountingImageDownloadTransport(&url.URL{Scheme: "http", Host: "127.0.0.1:7890"})
req, err := http.NewRequest(http.MethodGet, "https://"+host+"/a.png", nil)
require.NoError(t, err)
_, err = transport.RoundTrip(req)
require.Error(t, err, host)
require.Zero(t, directCalls.Load(), host)
require.Zero(t, proxiedCalls.Load(), "a proxied request must fail closed when the target is private or unresolvable: %s", host)
}
}