增加复制粘贴交互
This commit is contained in:
1 parent
4195525dc7
commit
deb9cc5bcf
14 files changed
+1144
-31
No files matched your search
@@ -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