Public errors now emit code, safe message, and request_id while keeping the legacy error field. Raw SQL, paths, tokens, and XSS payloads are stripped; the frontend branches on code instead of substring matching. Co-authored-by: Cursor <cursoragent@cursor.com>
453 lines
12 KiB
Go
453 lines
12 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"chat-agent-system/internal/authz"
|
|
"chat-agent-system/internal/freedoc"
|
|
)
|
|
|
|
func (s *Server) requireUserID(w http.ResponseWriter, r *http.Request) (int, bool) {
|
|
user, ok := s.requirePrincipal(w, r)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
return int(user.ID), true
|
|
}
|
|
|
|
func extractDocIDFromPath(r *http.Request, actionSuffix string) string {
|
|
path := r.URL.Path
|
|
if actionSuffix != "" {
|
|
suffix := "/" + actionSuffix
|
|
if strings.HasSuffix(path, suffix) {
|
|
path = strings.TrimSuffix(path, suffix)
|
|
}
|
|
}
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) >= 4 && parts[0] == "api" && parts[1] == "v1" && parts[2] == "freeform-documents" {
|
|
return parts[3]
|
|
}
|
|
return r.PathValue("doc_id")
|
|
}
|
|
|
|
// POST /api/v1/freeform-documents — 直接创建(上传附件注册服务端事实源,仅 my_private)
|
|
func (s *Server) handleCreateFreeformDoc(w http.ResponseWriter, r *http.Request) {
|
|
if s.freeDoc == nil {
|
|
writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
|
|
return
|
|
}
|
|
user, ok := s.requirePrincipal(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req freedoc.CreateFreeformDocRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
|
|
"error_code": "INVALID_BODY",
|
|
"message": "请求体格式错误",
|
|
})
|
|
return
|
|
}
|
|
if req.ProjectID <= 0 {
|
|
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
|
|
"error_code": "INVALID_PARAM",
|
|
"message": "project_id required",
|
|
})
|
|
return
|
|
}
|
|
space := strings.TrimSpace(req.Space)
|
|
if space == "" {
|
|
space = "my_private"
|
|
}
|
|
if space != "my_private" {
|
|
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
|
|
"error_code": "INVALID_PARAM",
|
|
"message": "direct create only allows my_private; use share API for project_shared",
|
|
})
|
|
return
|
|
}
|
|
req.Space = "my_private"
|
|
|
|
if s.authorizer == nil {
|
|
if s.failClosedAuthz() {
|
|
writeAuthzError(w, authz.ErrForbidden)
|
|
return
|
|
}
|
|
} else if err := s.authorizer.RequireProjectAccessOrAdmin(r.Context(), int64(req.ProjectID), user.ID, s.isSystemAdminUser(r.Context(), user)); err != nil {
|
|
writeAuthzError(w, err)
|
|
return
|
|
}
|
|
|
|
detail, err := s.freeDoc.CreateDocument(r.Context(), int(user.ID), req)
|
|
if err != nil {
|
|
if errors.Is(err, freedoc.ErrInvalidParam) {
|
|
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
|
|
"error_code": "INVALID_PARAM",
|
|
"message": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
|
|
"error_code": "CREATE_FAILED",
|
|
"message": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
writeJSONResponse(w, http.StatusCreated, map[string]any{
|
|
"code": 0,
|
|
"message": "已注册自由文档",
|
|
"data": detail,
|
|
})
|
|
}
|
|
|
|
// POST /api/v1/freeform-documents/from-message
|
|
func (s *Server) handleCreateFreeformDocFromMessage(w http.ResponseWriter, r *http.Request) {
|
|
if s.freeDoc == nil {
|
|
writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
|
|
return
|
|
}
|
|
|
|
userID, ok := s.requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req freedoc.CreateFromMessageRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
|
|
"error_code": "INVALID_BODY",
|
|
"message": "请求体格式错误",
|
|
})
|
|
return
|
|
}
|
|
|
|
detail, isExisting, err := s.freeDoc.CreateFromMessage(r.Context(), userID, req)
|
|
if err != nil {
|
|
if errors.Is(err, freedoc.ErrMessageNotExportable) {
|
|
writeJSONResponse(w, http.StatusForbidden, map[string]any{
|
|
"error_code": "MESSAGE_NOT_EXPORTABLE",
|
|
"message": "该消息不具备导出或保存资格(纯卡片或未完成)",
|
|
})
|
|
return
|
|
}
|
|
if errors.Is(err, freedoc.ErrNotFound) {
|
|
writeJSONResponse(w, http.StatusNotFound, map[string]any{
|
|
"error_code": "MESSAGE_NOT_FOUND",
|
|
"message": "指定源消息不存在",
|
|
})
|
|
return
|
|
}
|
|
if errors.Is(err, freedoc.ErrConflict) {
|
|
writeJSONResponse(w, http.StatusConflict, map[string]any{
|
|
"error_code": "CONTENT_SHA_MISMATCH",
|
|
"message": "消息内容哈希校验冲突",
|
|
})
|
|
return
|
|
}
|
|
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
|
|
"error_code": "SAVE_FAILED",
|
|
"message": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
status := http.StatusCreated
|
|
if isExisting {
|
|
status = http.StatusOK
|
|
}
|
|
writeJSONResponse(w, status, map[string]any{
|
|
"code": 0,
|
|
"message": "已保存至我的私密",
|
|
"data": detail,
|
|
"is_existing": isExisting,
|
|
})
|
|
}
|
|
|
|
// GET /api/v1/freeform-documents
|
|
func (s *Server) handleListFreeformDocs(w http.ResponseWriter, r *http.Request) {
|
|
if s.freeDoc == nil {
|
|
writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
|
|
return
|
|
}
|
|
|
|
userID, ok := s.requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
space := r.URL.Query().Get("space")
|
|
if space == "" {
|
|
space = "my_private"
|
|
}
|
|
projectID := 0
|
|
if pStr := r.URL.Query().Get("project_id"); pStr != "" {
|
|
projectID, _ = strconv.Atoi(pStr)
|
|
}
|
|
|
|
list, err := s.freeDoc.ListDocuments(r.Context(), userID, projectID, space)
|
|
if err != nil {
|
|
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
|
|
"error_code": "LIST_FAILED",
|
|
"message": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
if list == nil {
|
|
list = []freedoc.FreeformDocSummary{}
|
|
}
|
|
writeJSONResponse(w, http.StatusOK, map[string]any{
|
|
"code": 0,
|
|
"data": list,
|
|
})
|
|
}
|
|
|
|
// GET /api/v1/freeform-documents/{doc_id}
|
|
func (s *Server) handleGetFreeformDoc(w http.ResponseWriter, r *http.Request) {
|
|
if s.freeDoc == nil {
|
|
writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
|
|
return
|
|
}
|
|
|
|
userID, ok := s.requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
docID := extractDocIDFromPath(r, "")
|
|
if docID == "" {
|
|
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
|
|
"error_code": "INVALID_DOC_ID",
|
|
"message": "缺少文档ID",
|
|
})
|
|
return
|
|
}
|
|
|
|
detail, err := s.freeDoc.GetDocument(r.Context(), userID, docID)
|
|
if err != nil {
|
|
if errors.Is(err, freedoc.ErrNotFound) {
|
|
writeJSONResponse(w, http.StatusNotFound, map[string]any{
|
|
"error_code": "DOC_NOT_FOUND",
|
|
"message": "文档不存在或已被删除",
|
|
})
|
|
return
|
|
}
|
|
if errors.Is(err, freedoc.ErrForbidden) {
|
|
writeJSONResponse(w, http.StatusForbidden, map[string]any{
|
|
"error_code": "DOC_ACCESS_FORBIDDEN",
|
|
"message": "该文档为他人私密文档,严格禁止跨用户访问",
|
|
})
|
|
return
|
|
}
|
|
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
|
|
"error_code": "GET_FAILED",
|
|
"message": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
writeJSONResponse(w, http.StatusOK, map[string]any{
|
|
"code": 0,
|
|
"data": detail,
|
|
})
|
|
}
|
|
|
|
// PUT /api/v1/freeform-documents/{doc_id}
|
|
func (s *Server) handleUpdateFreeformDoc(w http.ResponseWriter, r *http.Request) {
|
|
if s.freeDoc == nil {
|
|
writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
|
|
return
|
|
}
|
|
|
|
userID, ok := s.requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
docID := extractDocIDFromPath(r, "")
|
|
if docID == "" {
|
|
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
|
|
"error_code": "INVALID_DOC_ID",
|
|
"message": "缺少文档ID",
|
|
})
|
|
return
|
|
}
|
|
|
|
var req freedoc.UpdateFreeformDocRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
|
|
"error_code": "INVALID_BODY",
|
|
"message": "请求体格式错误",
|
|
})
|
|
return
|
|
}
|
|
|
|
// 优先提取 HTTP If-Match 标头作为乐观并发校验
|
|
if ifMatch := r.Header.Get("If-Match"); ifMatch != "" {
|
|
cleanMatch := strings.Trim(ifMatch, "\"")
|
|
if v, err := strconv.Atoi(cleanMatch); err == nil && v > 0 {
|
|
req.ExpectedVersion = &v
|
|
} else {
|
|
req.ExpectedSHA256 = &cleanMatch
|
|
}
|
|
}
|
|
|
|
detail, err := s.freeDoc.UpdateDocument(r.Context(), userID, docID, req)
|
|
if err != nil {
|
|
if errors.Is(err, freedoc.ErrNotFound) {
|
|
writeJSONResponse(w, http.StatusNotFound, map[string]any{
|
|
"error_code": "DOC_NOT_FOUND",
|
|
"message": "文档不存在",
|
|
})
|
|
return
|
|
}
|
|
if errors.Is(err, freedoc.ErrForbidden) {
|
|
writeJSONResponse(w, http.StatusForbidden, map[string]any{
|
|
"error_code": "DOC_ACCESS_FORBIDDEN",
|
|
"message": "无权修改此文档",
|
|
})
|
|
return
|
|
}
|
|
if errors.Is(err, freedoc.ErrConflict) {
|
|
writeJSONResponse(w, http.StatusConflict, map[string]any{
|
|
"error_code": "CONCURRENCY_CONFLICT",
|
|
"message": "文档已被他人或新版本更新,请刷新重试",
|
|
})
|
|
return
|
|
}
|
|
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
|
|
"error_code": "UPDATE_FAILED",
|
|
"message": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
writeJSONResponse(w, http.StatusOK, map[string]any{
|
|
"code": 0,
|
|
"message": "保存成功",
|
|
"data": detail,
|
|
})
|
|
}
|
|
|
|
// POST /api/v1/freeform-documents/{doc_id}/share
|
|
func (s *Server) handleShareFreeformDoc(w http.ResponseWriter, r *http.Request) {
|
|
if s.freeDoc == nil {
|
|
writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
|
|
return
|
|
}
|
|
|
|
userID, ok := s.requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
docID := extractDocIDFromPath(r, "share")
|
|
if docID == "" {
|
|
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
|
|
"error_code": "INVALID_DOC_ID",
|
|
"message": "缺少文档ID",
|
|
})
|
|
return
|
|
}
|
|
|
|
doc, err := s.freeDoc.ShareDocument(r.Context(), userID, docID)
|
|
if err != nil {
|
|
if errors.Is(err, freedoc.ErrNotFound) {
|
|
writeJSONResponse(w, http.StatusNotFound, map[string]any{"error_code": "DOC_NOT_FOUND", "message": "文档不存在"})
|
|
return
|
|
}
|
|
if errors.Is(err, freedoc.ErrForbidden) {
|
|
writeJSONResponse(w, http.StatusForbidden, map[string]any{"error_code": "FORBIDDEN", "message": "仅文档所有者可共享文档"})
|
|
return
|
|
}
|
|
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{"error_code": "SHARE_FAILED", "message": err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSONResponse(w, http.StatusOK, map[string]any{
|
|
"code": 0,
|
|
"message": "已共享至项目参考库",
|
|
"data": doc,
|
|
})
|
|
}
|
|
|
|
// POST /api/v1/freeform-documents/{doc_id}/unshare
|
|
func (s *Server) handleUnshareFreeformDoc(w http.ResponseWriter, r *http.Request) {
|
|
if s.freeDoc == nil {
|
|
writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
|
|
return
|
|
}
|
|
|
|
userID, ok := s.requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
docID := extractDocIDFromPath(r, "unshare")
|
|
if docID == "" {
|
|
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
|
|
"error_code": "INVALID_DOC_ID",
|
|
"message": "缺少文档ID",
|
|
})
|
|
return
|
|
}
|
|
|
|
doc, err := s.freeDoc.UnshareDocument(r.Context(), userID, docID)
|
|
if err != nil {
|
|
if errors.Is(err, freedoc.ErrNotFound) {
|
|
writeJSONResponse(w, http.StatusNotFound, map[string]any{"error_code": "DOC_NOT_FOUND", "message": "文档不存在"})
|
|
return
|
|
}
|
|
if errors.Is(err, freedoc.ErrForbidden) {
|
|
writeJSONResponse(w, http.StatusForbidden, map[string]any{"error_code": "FORBIDDEN", "message": "仅文档所有者可撤回共享"})
|
|
return
|
|
}
|
|
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{"error_code": "UNSHARE_FAILED", "message": err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSONResponse(w, http.StatusOK, map[string]any{
|
|
"code": 0,
|
|
"message": "已撤回为个人私密",
|
|
"data": doc,
|
|
})
|
|
}
|
|
|
|
// DELETE /api/v1/freeform-documents/{doc_id}
|
|
func (s *Server) handleDeleteFreeformDoc(w http.ResponseWriter, r *http.Request) {
|
|
if s.freeDoc == nil {
|
|
writeSafeError(w, r, http.StatusServiceUnavailable, "freeform doc service not initialized")
|
|
return
|
|
}
|
|
|
|
userID, ok := s.requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
docID := extractDocIDFromPath(r, "")
|
|
if docID == "" {
|
|
writeJSONResponse(w, http.StatusBadRequest, map[string]any{
|
|
"error_code": "INVALID_DOC_ID",
|
|
"message": "缺少文档ID",
|
|
})
|
|
return
|
|
}
|
|
|
|
err := s.freeDoc.DeleteDocument(r.Context(), userID, docID)
|
|
if err != nil {
|
|
if errors.Is(err, freedoc.ErrNotFound) {
|
|
writeJSONResponse(w, http.StatusNotFound, map[string]any{"error_code": "DOC_NOT_FOUND", "message": "文档不存在"})
|
|
return
|
|
}
|
|
if errors.Is(err, freedoc.ErrForbidden) {
|
|
writeJSONResponse(w, http.StatusForbidden, map[string]any{"error_code": "FORBIDDEN", "message": "仅文档所有者可删除"})
|
|
return
|
|
}
|
|
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{"error_code": "DELETE_FAILED", "message": err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSONResponse(w, http.StatusOK, map[string]any{
|
|
"code": 0,
|
|
"message": "已移至回收站",
|
|
})
|
|
}
|