diff --git a/internal/services/gateway/rest.go b/internal/services/gateway/rest.go index 41d1fd4..39d57a2 100644 --- a/internal/services/gateway/rest.go +++ b/internal/services/gateway/rest.go @@ -103,6 +103,8 @@ type loginResponse struct { // 经 WebSocket 通道触发流式并立即返回。Image 是 // 为视觉路径附带的可选 base64 data: URI 或 http(s) URL; // loop 把它经视觉/OCR 工具或主模型路由(design/06 §0)。 +// Image 受 maxChatImageChars 限制(与 POST /api/v1/upload 的 5 MiB 同口径), +// 超限一律 400 image_too_large,不被静默吃下。 // DeviceID 是客户端产出的发送浏览器/设备标识 // (design/16 §4.5),例如 "web:chrome:a1b2c3";它可选且按原样持久化。 // Mode 是客户端发送该回合时的行为模式(design/01 §3、design/16 @@ -124,6 +126,52 @@ type chatResponse struct { StopReason string `json:"stopReason"` } +// maxChatImageBytes 是 chat.image 允许的**解码后图像字节**上限,与 +// POST /api/v1/upload 的 maxUploadBytes(5 MiB,见 upload.go)同口径: +// 图片有上传与内联两条入口,两边必须给出同一个「一张图能有多大」的答案, +// 否则上传的限制可被 base64 内联绕开 —— 客户端发来多大就落库多大, +// 每页历史(GET /sessions/{id}/messages)与每次 WS history 回放都要 +// 把整段 base64 再传一遍(协同文档 G3)。 +const maxChatImageBytes = 5 << 20 + +// chatImagePrefixAllowance 是 data: URI 前缀(如 "data:image/png;base64,") +// 所占的字符余量。按常见图片 MIME 的最长前缀取整,不含 base64 载荷本身。 +const chatImagePrefixAllowance = 64 + +// maxChatImageChars 是同一口径换算到 base64 字符串长度的上限。base64 每 +// 3 字节编码为 4 字符(膨胀 4/3),因此正好 5 MiB 的整幅图编码后是 +// ceil(maxChatImageBytes/3)*4 = 6990508 字符(这就是 (n+2)/3*4 的由来: +// 余 1、2 字节时末组仍需补满 4 字符),再加 data: 前缀余量 —— 恰好 5 MiB +// 的合法图片绝不会被误拒。 +// +// 校验按**字符串长度**做,是因为入口层只有这一步是零分配的:vision 层要先 +// base64 解码整个字符串才拿到真实字节数(vision.decodeBase64 → +// base64.StdEncoding.DecodeString),也就是先分配再报错。在入口层按长度拦下, +// 超大字符串根本走不到解码那一步。 +const maxChatImageChars = ((maxChatImageBytes+2)/3)*4 + chatImagePrefixAllowance + +// chatImageTooLargeCode 是 image 超限时返回给客户端的机器可读错误码, +// 与其它 400 文案区分开:客户端只看 code 就能把「图太大,请改走上传」 +// 与「请求体格式错」分开处理。 +const chatImageTooLargeCode = "image_too_large" + +// checkChatImageSize 校验 image 字符串长度是否在 limit 之内,超限时返回 +// 400 + image_too_large 的显式错误(带上限与本次长度,并指明改用 +// POST /api/v1/upload 取 URL 再发)。limit 作为参数传入,使该判定能被 +// 用小上限直接单测(边界与比较运算符因此可被钉住),REST 入口传的是 +// maxChatImageChars。 +// +// 文案里的体积由 limit 反推(base64 膨胀 4/3 → 解码后约 limit*3/4 字节), +// 因此换上限时文案自动跟随,不会出现「写着 5 MiB、实际是别的数」。 +func checkChatImageSize(image string, limit int) error { + if len(image) <= limit { + return nil + } + return apperr.Wrap(apperr.ErrBadRequest, chatImageTooLargeCode, + "图片过大:image 上限约 %.1f MiB(base64 字符串上限 %d 字符,本次 %d 字符);请先 POST /api/v1/upload 上传,再用返回的 url 作为 image 发送", + float64(limit)*3/4/(1<<20), limit, len(image)) +} + // handleHealth 报告存活。它不认证,因为它是探针。 func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) @@ -174,6 +222,15 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) return } + // image 上限在**任何**解码/落库之前检查,且对同步与 stream 两条分支 + // 都生效(stream 只是把该轮转发给 ws.chat,转发前必须先拒), + // 因此超限的图片既不进模型、也不落库、也不会被历史回放重传。 + // 日志只带长度与上限,绝不带图片内容。 + if err := checkChatImageSize(req.Image, maxChatImageChars); err != nil { + s.log.Warn("chat image rejected", "session", req.SessionID, "chars", len(req.Image), "limit", maxChatImageChars) + writeError(w, err) + return + } if strings.TrimSpace(req.Text) == "" && strings.TrimSpace(req.Image) == "" { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "text or image is required")) return diff --git a/internal/services/gateway/rest_chat_image_test.go b/internal/services/gateway/rest_chat_image_test.go new file mode 100644 index 0000000..1f9fa3c --- /dev/null +++ b/internal/services/gateway/rest_chat_image_test.go @@ -0,0 +1,210 @@ +package gateway + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// 本文件锁定协同文档 G3 的入口层口径:POST /api/v1/chat 的 image 有显式 +// 上限(与 POST /api/v1/upload 的 5 MiB 同口径),超限回 400 + +// image_too_large 的明确错误,而不是把几 MB 的 base64 原样吃下、落库、 +// 再被每页历史重传一遍。 + +// chatImageRecordingRunner 记录每次 Run 看到的完整消息(不只是 sessionID), +// 因此既能断言「超限的轮次从未被驱动」,也能逐字节比对正常小图的图片载荷。 +type chatImageRecordingRunner struct { + calls [][]model.Message + res *loop.Result +} + +func (r *chatImageRecordingRunner) Run(_ context.Context, _ string, msgs []model.Message) (*loop.Result, error) { + r.calls = append(r.calls, msgs) + if r.res != nil { + return r.res, nil + } + return &loop.Result{Text: "收到", Steps: 1, StopReason: "stop"}, nil +} + +// images 返回每次 Run 中首个图片内容的 ImageURL。 +func (r *chatImageRecordingRunner) images() []string { + out := make([]string, 0, len(r.calls)) + for _, msgs := range r.calls { + for _, m := range msgs { + for _, c := range m.Content { + if c.Type == model.ContentTypeImage { + out = append(out, c.ImageURL) + } + } + } + } + return out +} + +// oversizeImage 返回一个长度恰为 maxChatImageChars+1 的 image 串(内容本身 +// 是占位符,测试只关心长度不变量;真实图片内容绝不进日志或断言)。 +func oversizeImage() string { + return strings.Repeat("A", maxChatImageChars+1) +} + +// TestChatImageLimitMatchesUploadBudget 钉住上限的推导口径:它必须与 +// POST /api/v1/upload 的 maxUploadBytes 同值(两条入口同一个答案),且换算成 +// base64 字符串长度后既**装得下**一张满额的 5 MiB 图,也不留下荒谬的余量。 +// 这条不变量让「有人改了上限或改歪了膨胀系数」在测试里立刻暴露(反证)。 +func TestChatImageLimitMatchesUploadBudget(t *testing.T) { + t.Parallel() + if maxChatImageBytes != maxUploadBytes { + t.Fatalf("maxChatImageBytes = %d, want %d (与 POST /upload 同口径)", maxChatImageBytes, maxUploadBytes) + } + // 上限必须装得下一张满额图:base64 长度对应的解码字节数 ≥ 预算。 + if got := maxChatImageChars / 4 * 3; got < maxChatImageBytes { + t.Fatalf("maxChatImageChars 只装得下 %d 字节,少于预算 %d", got, maxChatImageBytes) + } + // 余量只能是 data: 前缀的余量,不能顺带塞进一整张图。 + if maxChatImageChars > ((maxChatImageBytes+2)/3)*4+4096 { + t.Fatalf("maxChatImageChars = %d 的余量过大(前缀余量应 ≤4096)", maxChatImageChars) + } +} + +// TestChatImageSizeBoundary 用极小上限直接钉住比较运算符与错误载荷:恰好 +// 等于上限通过,多一个字符就 400 + image_too_large。若判定被改成 >= / < +// (即有人「顺手」把边界挪一位),这里立刻红。 +func TestChatImageSizeBoundary(t *testing.T) { + t.Parallel() + const limit = 8 + if err := checkChatImageSize(strings.Repeat("A", limit), limit); err != nil { + t.Fatalf("恰好等于上限必须通过,得到 %v", err) + } + err := checkChatImageSize(strings.Repeat("A", limit+1), limit) + if err == nil { + t.Fatal("超过上限一个字符必须报错") + } + if !errors.Is(err, apperr.ErrBadRequest) { + t.Fatalf("错误必须 wrap ErrBadRequest(400),得到 %v", err) + } + var target *apperr.AppError + if !errors.As(err, &target) || target.Code != chatImageTooLargeCode { + t.Fatalf("错误码必须为 %q,得到 %v", chatImageTooLargeCode, err) + } + if got := apperr.HTTPStatus(err); got != http.StatusBadRequest { + t.Fatalf("HTTP 状态 = %d, want 400", got) + } +} + +// TestChatImageTooLargeRejected 是主用例:超限的 image 必须 400 + +// image_too_large,且**从未驱动过任何回合** —— 也就不会进模型、不会落库、 +// 不会被历史回放重传。变异反证:把入口的校验删掉,这条测试会因为 turn 被 +// 驱动而红。 +func TestChatImageTooLargeRejected(t *testing.T) { + t.Parallel() + runner := &chatImageRecordingRunner{} + env := newEnv(t, runner, false) + tok := env.token(t, auth.RoleOwner) + resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat", + map[string]any{"sessionId": "web", "image": oversizeImage()}, tok) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) + } + code, msg := chatImageErrorOf(t, body) + if code != chatImageTooLargeCode { + t.Fatalf("error.code = %q, want %q", code, chatImageTooLargeCode) + } + if !strings.Contains(msg, "upload") || !strings.Contains(msg, "/api/v1/upload") { + t.Fatalf("错误文案必须指路 POST /api/v1/upload,得到 %q", msg) + } + if strings.Contains(msg, "AAAA") { + t.Fatalf("错误文案不得回显图片内容:%q", msg) + } + if len(runner.calls) != 0 { + t.Fatalf("超限请求驱动了 %d 个回合,want 0", len(runner.calls)) + } +} + +// TestChatImageTooLargeRejectedOnStreamPath 覆盖 REST 的 stream 分支:它会把 +// 该轮转发给 ws.chat,转发前必须同样被拒(否则流式成为绕开上限的后门)。 +func TestChatImageTooLargeRejectedOnStreamPath(t *testing.T) { + t.Parallel() + runner := &chatImageRecordingRunner{} + env := newEnv(t, runner, false) + tok := env.token(t, auth.RoleOwner) + resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat", + map[string]any{"sessionId": "web", "image": oversizeImage(), "stream": true}, tok) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) + } + if code, _ := chatImageErrorOf(t, body); code != chatImageTooLargeCode { + t.Fatalf("error.code = %q, want %q", code, chatImageTooLargeCode) + } + if len(runner.calls) != 0 { + t.Fatalf("超限的 stream 请求驱动了 %d 个回合,want 0", len(runner.calls)) + } +} + +// TestChatImageAtLimitAccepted 是边界:长度恰好等于上限的 image 照常通过 +// (合法客户端在 5 MiB 整幅图上绝不能吃到误拒)。 +func TestChatImageAtLimitAccepted(t *testing.T) { + t.Parallel() + runner := &chatImageRecordingRunner{} + env := newEnv(t, runner, false) + tok := env.token(t, auth.RoleOwner) + image := "data:image/png;base64," + strings.Repeat("A", maxChatImageChars-len("data:image/png;base64,")) + if len(image) != maxChatImageChars { + t.Fatalf("构造长度 = %d, want %d", len(image), maxChatImageChars) + } + resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat", + map[string]any{"sessionId": "web", "image": image}, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + if got := runner.images(); len(got) != 1 || got[0] != image { + t.Fatalf("到达 runner 的 image 与请求逐字节不一致:len=%d", len(got)) + } +} + +// TestChatSmallImageUnchanged 锁定「正常小图行为与今天逐字节一致」:小 +// data: URI 原样进入图片内容部分,文本与图片两部分都在。 +func TestChatSmallImageUnchanged(t *testing.T) { + t.Parallel() + runner := &chatImageRecordingRunner{} + env := newEnv(t, runner, false) + tok := env.token(t, auth.RoleOwner) + image := "data:image/png;base64,iVBORw0KGgo=" + resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat", + map[string]any{"sessionId": "web", "text": "看看这张", "image": image}, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + if len(runner.calls) != 1 { + t.Fatalf("turn 数 = %d, want 1", len(runner.calls)) + } + msgs := runner.calls[0] + if len(msgs) != 1 { + t.Fatalf("消息数 = %d, want 1", len(msgs)) + } + if len(msgs[0].Content) != 2 || msgs[0].Content[0].Text != "看看这张" || + msgs[0].Content[1].Type != model.ContentTypeImage || msgs[0].Content[1].ImageURL != image { + t.Fatalf("图片回合内容变了:%+v", msgs[0].Content) + } +} + +// chatImageErrorOf 取出 {error:{code,message}} 的 code 与 message。 +func chatImageErrorOf(t *testing.T, body []byte) (code, message string) { + t.Helper() + env := decodeBody(t, body) + var eobj struct { + Code string `json:"code"` + Message string `json:"message"` + } + if err := json.Unmarshal(env["error"], &eobj); err != nil { + t.Fatalf("error envelope: %v (body=%s)", err, body) + } + return eobj.Code, eobj.Message +}