Files
agent/backend/internal/logic/documents/handlers.go

558 lines
17 KiB
Go

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 = ?", request.SourceInboxItemID).First(&inbox).Error; err != nil {
writeError(c, gorm.ErrRecordNotFound)
return
}
input.SourceInboxItemID = &inbox.ID
}
var document *models.SaDocument
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, "<!doctype html><meta charset=\"utf-8\"><title>链接无效</title><p>分享链接不存在或已过期。</p>")
return
}
shareHeaders(c)
contentURL := strings.TrimSuffix(requestBaseURL(c), "/") + "/api/v1/document-shares/" + url.PathEscape(token) + "/content"
body := sharedPreviewBody(*loaded, contentURL)
page := `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">` +
`<title>` + html.EscapeString(loaded.Document.Name) + `</title><style>` +
`*{box-sizing:border-box}body{margin:0;background:#f6f7f9;color:#1d2129;font:15px/1.7 system-ui,-apple-system,"Segoe UI",sans-serif}` +
`main{max-width:980px;min-height:100vh;margin:auto;padding:36px 48px;background:#fff}h1{margin:0 0 28px;font-size:22px}` +
`article{overflow-wrap:anywhere}pre{overflow:auto;padding:16px;background:#f7f8fa;border-radius:8px}code{font-family:ui-monospace,monospace}` +
`img,video{display:block;max-width:100%;margin:auto}iframe{width:100%;height:calc(100vh - 130px);border:1px solid #e5e6eb}` +
`audio{width:100%}.meta{color:#86909c}@media(max-width:640px){main{padding:24px 18px}}</style></head><body><main><h1>` +
html.EscapeString(loaded.Document.Name) + `</h1>` + body + `</main></body></html>`
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 `<article>` + bluemonday.UGCPolicy().Sanitize(rendered.String()) + `</article>`
}
return `<pre>` + html.EscapeString(loaded.Content.Markdown) + `</pre>`
}
switch {
case strings.HasPrefix(loaded.Document.MimeType, "image/"):
return `<img src="` + safeURL + `" alt="` + html.EscapeString(loaded.Document.Name) + `">`
case strings.HasPrefix(loaded.Document.MimeType, "audio/"):
return `<audio src="` + safeURL + `" controls></audio>`
case strings.HasPrefix(loaded.Document.MimeType, "video/"):
return `<video src="` + safeURL + `" controls></video>`
case loaded.Document.Extension == ".pdf":
return `<iframe src="` + safeURL + `" title="` + html.EscapeString(loaded.Document.Name) + `"></iframe>`
default:
return `<p class="meta">此文件暂不支持在线预览。</p>`
}
}
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.SaDocument, 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
}