package documents import ( "bytes" "errors" "fmt" "html" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/microcosm-cc/bluemonday" "github.com/yuin/goldmark" "gorm.io/gorm" "senlinai-agent/backend/internal/httpx" "senlinai-agent/backend/internal/logic/auth" "senlinai-agent/backend/internal/models" ) const DefaultMaxUploadBytes int64 = 50 << 20 type Handler struct { service *Service maxUploadBytes int64 } type DocumentDTO struct { ID string `json:"id"` ProjectID string `json:"projectId"` ParentID *string `json:"parentId"` Kind string `json:"kind"` Name string `json:"name"` Extension string `json:"extension"` MimeType string `json:"mimeType"` Size int64 `json:"size"` Revision uint64 `json:"revision"` Markdown *string `json:"markdown,omitempty"` HasBlob bool `json:"hasBlob"` SourceInboxItemID *string `json:"sourceInboxItemId"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type ShareDTO struct { ID string `json:"id"` URL string `json:"url,omitempty"` ExpiresAt time.Time `json:"expiresAt"` RevokedAt *time.Time `json:"revokedAt"` } type createRequest struct { Name string `json:"name"` ParentID string `json:"parentId"` Markdown string `json:"markdown"` SourceInboxItemID string `json:"sourceInboxItemId"` } type updateRequest struct { Name *string `json:"name"` ParentID *string `json:"parentId"` Markdown *string `json:"markdown"` Revision *uint64 `json:"revision"` } type shareRequest struct { ExpiresInDays int `json:"expiresInDays"` } func NewHandler(service *Service, limits ...int64) *Handler { limit := DefaultMaxUploadBytes if len(limits) > 0 && limits[0] > 0 { limit = limits[0] } return &Handler{service: service, maxUploadBytes: limit} } func (h *Handler) RegisterPublic(router gin.IRouter) { router.GET("/document-shares/:token", h.sharedMetadata) router.GET("/document-shares/:token/view", h.sharedView) router.GET("/document-shares/:token/content", h.sharedContent) } func (h *Handler) Register(router gin.IRouter) { router.GET("/projects/:id/documents", h.list) router.POST("/projects/:id/documents/folders", h.createFolder) router.POST("/projects/:id/documents/markdown", h.createMarkdown) router.POST("/projects/:id/documents/uploads", h.upload) router.GET("/projects/:id/documents/:documentId", h.get) router.PATCH("/projects/:id/documents/:documentId", h.update) router.DELETE("/projects/:id/documents/:documentId", h.delete) router.GET("/projects/:id/documents/:documentId/content", h.content) router.GET("/projects/:id/documents/:documentId/download", h.download) router.GET("/projects/:id/documents/:documentId/sheet-preview", h.sheetPreview) router.GET("/projects/:id/documents/:documentId/export", h.export) router.GET("/projects/:id/documents/:documentId/shares", h.listShares) router.POST("/projects/:id/documents/:documentId/shares", h.createShare) router.DELETE("/projects/:id/documents/:documentId/shares/:shareId", h.revokeShare) } func (h *Handler) list(c *gin.Context) { userID, projectID, ok := requestScope(c) if !ok { return } documents, err := h.service.List(userID, projectID) if err != nil { writeError(c, err) return } result := make([]DocumentDTO, 0, len(documents)) for _, document := range documents { result = append(result, documentDTO(document, nil, false)) } c.JSON(http.StatusOK, result) } func (h *Handler) createFolder(c *gin.Context) { h.create(c, true) } func (h *Handler) createMarkdown(c *gin.Context) { h.create(c, false) } func (h *Handler) create(c *gin.Context, folder bool) { userID, projectID, ok := requestScope(c) if !ok { return } var request createRequest if err := c.ShouldBindJSON(&request); err != nil { httpx.Error(c, http.StatusBadRequest, "invalid_request", "文档参数无效") return } input := CreateInput{Name: request.Name, ParentIdentity: request.ParentID, Markdown: request.Markdown} if request.SourceInboxItemID != "" { var inbox models.SaInboxItem if err := h.service.database().Select("id").Where( "identity = ? AND project_id = ? AND created_by = ?", request.SourceInboxItemID, projectID, userID, ).First(&inbox).Error; err != nil { writeError(c, gorm.ErrRecordNotFound) return } input.SourceInboxItemID = &inbox.ID } var document *models.SaDocumentTree var err error if folder { document, err = h.service.CreateFolder(userID, projectID, input) } else { document, err = h.service.CreateMarkdown(userID, projectID, input) } if err != nil { writeError(c, err) return } markdown := (*string)(nil) if !folder { markdown = &input.Markdown } c.JSON(http.StatusCreated, documentDTO(*document, markdown, false)) } func (h *Handler) upload(c *gin.Context) { userID, projectID, ok := requestScope(c) if !ok { return } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.maxUploadBytes+(1<<20)) header, err := c.FormFile("file") if err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { httpx.Error(c, http.StatusRequestEntityTooLarge, "payload_too_large", "单个文件不能超过 50 MB") return } httpx.Error(c, http.StatusBadRequest, "invalid_request", "请选择要上传的文件") return } if header.Size > h.maxUploadBytes { httpx.Error(c, http.StatusRequestEntityTooLarge, "payload_too_large", "单个文件不能超过 50 MB") return } file, err := header.Open() if err != nil { writeError(c, err) return } defer file.Close() parentID := c.PostForm("parentId") if strings.EqualFold(strings.TrimSpace(filepathExtension(header.Filename)), ".md") { data, err := io.ReadAll(io.LimitReader(file, h.maxUploadBytes+1)) if err != nil { writeError(c, err) return } if int64(len(data)) > h.maxUploadBytes { httpx.Error(c, http.StatusRequestEntityTooLarge, "payload_too_large", "单个文件不能超过 50 MB") return } document, err := h.service.CreateMarkdown(userID, projectID, CreateInput{ Name: header.Filename, ParentIdentity: parentID, Markdown: string(data), }) if err != nil { writeError(c, err) return } markdown := string(data) c.JSON(http.StatusCreated, documentDTO(*document, &markdown, false)) return } stored, err := h.service.Store(projectID, header.Filename, file) if err != nil { writeError(c, err) return } document, err := h.service.CreateUploaded(userID, projectID, parentID, stored) if err != nil { writeError(c, err) return } c.JSON(http.StatusCreated, documentDTO(*document, nil, true)) } func (h *Handler) get(c *gin.Context) { loaded, ok := h.load(c) if !ok { return } var markdown *string if loaded.Content != nil { markdown = &loaded.Content.Markdown } c.JSON(http.StatusOK, documentDTO(loaded.Document, markdown, loaded.Blob != nil)) } func (h *Handler) update(c *gin.Context) { userID, projectID, ok := requestScope(c) if !ok { return } var request updateRequest if err := c.ShouldBindJSON(&request); err != nil { httpx.Error(c, http.StatusBadRequest, "invalid_request", "文档参数无效") return } loaded, err := h.service.Update(userID, projectID, c.Param("documentId"), UpdateInput{ Name: request.Name, ParentIdentity: request.ParentID, Markdown: request.Markdown, Revision: request.Revision, }) if err != nil { writeError(c, err) return } var markdown *string if loaded.Content != nil { markdown = &loaded.Content.Markdown } c.JSON(http.StatusOK, documentDTO(loaded.Document, markdown, loaded.Blob != nil)) } func (h *Handler) delete(c *gin.Context) { userID, projectID, ok := requestScope(c) if !ok { return } if err := h.service.Delete(userID, projectID, c.Param("documentId")); err != nil { writeError(c, err) return } c.Status(http.StatusNoContent) } func (h *Handler) content(c *gin.Context) { loaded, ok := h.load(c) if !ok { return } h.writeContent(c, loaded, false) } func (h *Handler) download(c *gin.Context) { loaded, ok := h.load(c) if !ok { return } h.writeContent(c, loaded, true) } func (h *Handler) sheetPreview(c *gin.Context) { loaded, ok := h.load(c) if !ok { return } preview, err := h.service.PreviewLegacySheet(*loaded, 200, 50) if err != nil { writeError(c, err) return } c.JSON(http.StatusOK, preview) } func (h *Handler) writeContent(c *gin.Context, loaded *SharedDocument, download bool) { if loaded.Content != nil { disposition := "inline" if download { disposition = "attachment" } c.Header("Content-Disposition", contentDisposition(disposition, loaded.Document.Name)) c.Data(http.StatusOK, "text/markdown; charset=utf-8", []byte(loaded.Content.Markdown)) return } if loaded.Blob == nil { writeError(c, ErrBlobNotFound) return } file, err := h.service.OpenBlob(*loaded.Blob) if err != nil { writeError(c, err) return } defer file.Close() disposition := "inline" if download { disposition = "attachment" } c.Header("Content-Disposition", contentDisposition(disposition, loaded.Document.Name)) c.Header("X-Content-Type-Options", "nosniff") c.Header("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; media-src 'self'") c.DataFromReader(http.StatusOK, loaded.Document.Size, loaded.Document.MimeType, file, nil) } func (h *Handler) export(c *gin.Context) { loaded, ok := h.load(c) if !ok { return } data, name, contentType, err := h.service.Export(*loaded, c.Query("format")) if err != nil { writeError(c, err) return } c.Header("Content-Disposition", contentDisposition("attachment", name)) c.Data(http.StatusOK, contentType, data) } func (h *Handler) createShare(c *gin.Context) { userID, projectID, ok := requestScope(c) if !ok { return } var request shareRequest if err := c.ShouldBindJSON(&request); err != nil { httpx.Error(c, http.StatusBadRequest, "invalid_request", "分享参数无效") return } if request.ExpiresInDays == 0 { request.ExpiresInDays = 7 } share, token, err := h.service.CreateShare(userID, projectID, c.Param("documentId"), time.Duration(request.ExpiresInDays)*24*time.Hour) if err != nil { writeError(c, err) return } baseURL := strings.TrimSuffix(requestBaseURL(c), "/") c.JSON(http.StatusCreated, ShareDTO{ ID: share.Identity, URL: baseURL + "/api/v1/document-shares/" + token + "/view", ExpiresAt: share.ExpiresAt.UTC(), }) } func (h *Handler) listShares(c *gin.Context) { userID, projectID, ok := requestScope(c) if !ok { return } shares, err := h.service.ListShares(userID, projectID, c.Param("documentId")) if err != nil { writeError(c, err) return } result := make([]ShareDTO, 0, len(shares)) for _, share := range shares { result = append(result, ShareDTO{ ID: share.Identity, ExpiresAt: share.ExpiresAt.UTC(), RevokedAt: share.RevokedAt, }) } c.JSON(http.StatusOK, result) } func (h *Handler) revokeShare(c *gin.Context) { userID, projectID, ok := requestScope(c) if !ok { return } if err := h.service.RevokeShare(userID, projectID, c.Param("documentId"), c.Param("shareId")); err != nil { writeError(c, err) return } c.Status(http.StatusNoContent) } func (h *Handler) sharedMetadata(c *gin.Context) { loaded, err := h.service.ResolveShare(c.Param("token")) if err != nil { writeError(c, ErrShareNotFound) return } shareHeaders(c) var markdown *string if loaded.Content != nil { markdown = &loaded.Content.Markdown } dto := documentDTO(loaded.Document, markdown, loaded.Blob != nil) dto.ProjectID = "" dto.ParentID = nil dto.SourceInboxItemID = nil c.JSON(http.StatusOK, dto) } func (h *Handler) sharedView(c *gin.Context) { token := c.Param("token") loaded, err := h.service.ResolveShare(token) if err != nil { c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusNotFound, "链接无效

分享链接不存在或已过期。

") return } shareHeaders(c) contentURL := strings.TrimSuffix(requestBaseURL(c), "/") + "/api/v1/document-shares/" + url.PathEscape(token) + "/content" body := sharedPreviewBody(*loaded, contentURL) page := `` + `` + html.EscapeString(loaded.Document.Name) + `

` + html.EscapeString(loaded.Document.Name) + `

` + body + `
` c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(page)) } func sharedPreviewBody(loaded SharedDocument, contentURL string) string { safeURL := html.EscapeString(contentURL) if loaded.Content != nil { var rendered bytes.Buffer if err := goldmark.Convert([]byte(loaded.Content.Markdown), &rendered); err == nil { return `
` + bluemonday.UGCPolicy().Sanitize(rendered.String()) + `
` } return `
` + html.EscapeString(loaded.Content.Markdown) + `
` } switch { case strings.HasPrefix(loaded.Document.MimeType, "image/"): return `` + html.EscapeString(loaded.Document.Name) + `` case strings.HasPrefix(loaded.Document.MimeType, "audio/"): return `` case strings.HasPrefix(loaded.Document.MimeType, "video/"): return `` case loaded.Document.Extension == ".pdf": return `` default: return `

此文件暂不支持在线预览。

` } } func (h *Handler) sharedContent(c *gin.Context) { loaded, err := h.service.ResolveShare(c.Param("token")) if err != nil { writeError(c, ErrShareNotFound) return } shareHeaders(c) h.writeContent(c, loaded, false) } func (h *Handler) load(c *gin.Context) (*SharedDocument, bool) { userID, projectID, ok := requestScope(c) if !ok { return nil, false } loaded, err := h.service.Get(userID, projectID, c.Param("documentId")) if err != nil { writeError(c, err) return nil, false } return loaded, true } func requestScope(c *gin.Context) (uint, string, bool) { userID, ok := auth.CurrentUserID(c) if !ok { httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效") return 0, "", false } projectID, ok := httpx.IdentityParam(c, "id") return userID, projectID, ok } func documentDTO(document models.SaDocumentTree, markdown *string, hasBlob bool) DocumentDTO { return DocumentDTO{ ID: document.Identity, ProjectID: document.ProjectIdentity, ParentID: document.ParentIdentity, Kind: document.Kind, Name: document.Name, Extension: document.Extension, MimeType: document.MimeType, Size: document.Size, Revision: document.Revision, Markdown: markdown, HasBlob: hasBlob, SourceInboxItemID: document.SourceInboxItemIdentity, CreatedAt: document.CreatedAt.UTC(), UpdatedAt: document.UpdatedAt.UTC(), } } func writeError(c *gin.Context, err error) { switch { case errors.Is(err, gorm.ErrRecordNotFound), errors.Is(err, ErrShareNotFound): httpx.Error(c, http.StatusNotFound, "not_found", "文档不存在") case errors.Is(err, ErrNameConflict): httpx.Error(c, http.StatusConflict, "name_conflict", "同一目录已存在同名文档") case errors.Is(err, ErrRevisionConflict): httpx.Error(c, http.StatusConflict, "revision_conflict", "文档已在其他窗口更新") case errors.Is(err, ErrInvalidDocument), errors.Is(err, ErrInvalidParent), errors.Is(err, ErrUnsupportedExport): httpx.Error(c, http.StatusBadRequest, "invalid_request", "文档参数无效") default: httpx.Error(c, http.StatusInternalServerError, "internal_error", "文档操作失败") } } func contentDisposition(kind, name string) string { return fmt.Sprintf(`%s; filename*=UTF-8''%s`, kind, url.PathEscape(name)) } func requestBaseURL(c *gin.Context) string { scheme := "http" if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") { scheme = "https" } return scheme + "://" + c.Request.Host } func shareHeaders(c *gin.Context) { c.Header("X-Robots-Tag", "noindex, nofollow, noarchive") c.Header("X-Content-Type-Options", "nosniff") c.Header("Referrer-Policy", "no-referrer") c.Header("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; media-src 'self'; frame-src 'self'; style-src 'unsafe-inline'; frame-ancestors 'none'") } func filepathExtension(name string) string { index := strings.LastIndex(name, ".") if index < 0 { return "" } return name[index:] } func parseInt(value string, fallback int) int { parsed, err := strconv.Atoi(value) if err != nil { return fallback } return parsed }