package gateway // 本文件归属 `chat` 请求里的文档引用(design/24 §4.2 路线① / §6.1):请求形态、 // 服务端校验(条数上限、存储名形态、文件真实存在)与该回合用户消息的装配。 // // 它刻意**不改** S1 已落库的 `documents.go`:解析只复用那边的 // `validDocumentName` / `documentReadMIME` / `documentFilePrefix` / // `documentNotFoundError` / `documentsLog`,读侧行为零变动。 // // 两条硬规则: // - 只接受**服务端签发的存储名**(`POST /api/v1/documents` 响应里的 name), // 绝不接受任意 http(s) URL —— 不重开 F3 那种「服务端代取 URL」的 SSRF 面 // (design/24 §5); // - 文档部件只挂在 RoleUser 回合上(design/24 §2.3-③、design/11 §10.2)。 // // 失败一律可见(design/24 §4.4):条数超限、名字形态非法、文件不存在都让整个 // 请求被拒;绝不做「丢掉这条附件但照样起回合」的静默处理。 import ( "os" "path/filepath" "strings" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // maxChatDocuments 是**单回合**附件数上限(design/24 §6.1、§9.4-3 的定稿值 5)。 // 取值取自共享存储核心(切片 M1):Web 上传的单回合上限与 IM 入站附件的单条 // 上限因此同源,不可能一处改宽、另一处照旧。 const maxChatDocuments = documents.MaxPerMessage // tooManyDocumentsCode 是附件数超限的机器可读错误码(design/24 §6.2 表格)。 // 它与 image 侧的 image_too_large 同一风格:客户端只看 code 就能分开处理。 const tooManyDocumentsCode = "too_many_documents" // documentsUnavailableCode 报告本进程没有接文档解析器(未接线的构建 / 单元测试 // 构造的裸 hub)。它是**内部接线**问题,绝不是客户端输入问题:此时带了附件的 // 回合被明确拒绝,而不是把附件静默丢掉。 const documentsUnavailableCode = "chat_documents_unavailable" // chatDocumentRef 是 `chat` 请求里的一条文档引用。它只有 `name` 一个字段: // 客户端能给的就是 `POST /api/v1/documents` 响应里的存储名,MIME / URL / 大小 // 一律由服务端自己解析(design/24 §3.2:字节嗅探是唯一裁决,客户端声明可被 // 否决)。REST 与 WS 上行帧**同形**(design/24 §6.1)。 type chatDocumentRef struct { Name string `json:"name"` } // documentResolver 把一组文档引用解析为服务端文档部件。REST 与 WS 两条入口 // 共用同一个实现(`Server.resolveChatDocuments`),因此两边不可能分叉。 type documentResolver func(refs []chatDocumentRef) ([]model.Document, error) // resolveChatDocuments 校验并解析一个回合携带的全部文档引用。条数上限先于任何 // 文件系统访问被判定(用最小的代价拒最早),随后逐条解析;任一条非法或不存在 // 都让整个请求被拒 —— 静默丢掉一条附件正是 design/24 §4.4 禁止的 // 「传上去了但双方都不知道」。 func (s *Server) resolveChatDocuments(refs []chatDocumentRef) ([]model.Document, error) { if len(refs) > maxChatDocuments { return nil, tooManyDocumentsError(len(refs)) } out := make([]model.Document, 0, len(refs)) for _, ref := range refs { doc, err := s.resolveChatDocument(ref) if err != nil { return nil, err } out = append(out, doc) } return out, nil } // resolveChatDocument 把一条引用解析为文档部件。名字形态必须与 S1 的 // `validDocumentName` 同规则(32 位小写十六进制主名 + 白名单扩展名),且文件 // 必须**确实存在**于 `/uploads/documents/`。形态非法、路径穿越、 // 文件不存在一律收敛到同一个 404 document_not_found —— 与读端点同一口径, // 既不泄露「是否存在」以外的信息,也不可能被当成取任意 URL 的入口。 // // MIME 由读侧的字节嗅探给出(`documentReadMIME`)、大小取自文件本身:落盘内容 // 就是唯一真源,不问数据库、也不信客户端。 func (s *Server) resolveChatDocument(ref chatDocumentRef) (model.Document, error) { if !validDocumentName(ref.Name) { return model.Document{}, documentNotFoundError() } dir, err := s.documentsDir() if err != nil { return model.Document{}, documentNotFoundError() } path := filepath.Join(dir, ref.Name) if filepath.Dir(path) != filepath.Clean(dir) { return model.Document{}, documentNotFoundError() } f, err := os.Open(path) if err != nil { return model.Document{}, documentNotFoundError() } defer func() { _ = f.Close() }() info, err := f.Stat() if err != nil || info.IsDir() { return model.Document{}, documentNotFoundError() } return model.Document{ Name: ref.Name, MIME: documentReadMIME(f, ref.Name), URL: documentFilePrefix + ref.Name, Size: info.Size(), }, nil } // tooManyDocumentsError 构造附件数超限错误:400 + too_many_documents,带上限与 // 本次条数,使客户端不必猜是哪条限额挡下的。 func tooManyDocumentsError(n int) error { return apperr.Wrap(apperr.ErrBadRequest, tooManyDocumentsCode, "单回合附件最多 %d 条,本次 %d 条", maxChatDocuments, n) } // documentsUnavailableError 构造「未接文档解析器」错误:内部接线问题,因此走 // 500 + 显式错误码,绝不假装这回合只有文本。 func documentsUnavailableError() error { return apperr.Wrap(apperr.ErrInternal, documentsUnavailableCode, "文档解析器未接线") } // chatDocumentErrorCode 取出错误里可对客户端暴露的机器可读码(WS 错误帧用)。 // 取不到时回退 bad_request —— 内部细节绝不回给客户端。 func chatDocumentErrorCode(err error) string { if appErr, ok := apperr.As(err); ok && appErr.Code != "" { return appErr.Code } return wsBadRequestCode } // chatTurnMessages 为一个回合构建用户消息:文本、图片与文档部件挂在**同一条** // RoleUser 消息上。文档部件由 model.DocumentMessage 构造(design/24 §2.2), // 且只可能落在 RoleUser 上(§2.3-③)。 // // 纯附件回合(无文本、无图片)同样得到一条消息,且文档部件让它不被判成空回合 // (见 session_tail.go 的 messagesEmpty)。这一分支**不塞空文本部件**:空文本块 // 会被 provider 拒,而文档部件本身就是这条消息的内容。 func chatTurnMessages(text, image string, docs []model.Document) []model.Message { msg := baseUserMessage(text, image) for _, doc := range docs { msg.Content = append(msg.Content, model.DocumentMessage(model.RoleUser, "", doc).Content...) } return []model.Message{msg} } // baseUserMessage 返回回合的基底用户消息:有图片时是可带文本的图片消息,否则是 // 文本消息。它就是引入文档之前 `userChatMessages` 的行为,逐字不变(唯一的 // 差别是文本为空时给出不带文本部件的空基底,供纯附件回合使用)。 func baseUserMessage(text, image string) model.Message { switch { case strings.TrimSpace(image) != "": return model.ImageMessage(model.RoleUser, text, image) case text == "": return model.Message{Role: model.RoleUser} default: return model.TextMessage(model.RoleUser, text) } }