558 lines
17 KiB
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
|
|
}
|