package documents import ( "archive/zip" "bytes" "crypto/rand" "crypto/sha256" _ "embed" "encoding/hex" "errors" "fmt" "html" "io" "mime" "net/http" "os" "path/filepath" "regexp" "strings" "time" "unicode" "github.com/eidrisov/xlsReader/xls" "github.com/signintech/gopdf" "gorm.io/gorm" "gorm.io/gorm/clause" "senlinai-agent/backend/internal/models" ) var ( ErrInvalidDocument = errors.New("invalid document") ErrInvalidParent = errors.New("invalid document parent") ErrNameConflict = errors.New("document name already exists") ErrRevisionConflict = errors.New("document revision conflict") ErrUnsupportedExport = errors.New("unsupported document export") ErrShareNotFound = errors.New("document share not found") ErrBlobNotFound = errors.New("document blob not found") ) type Service struct { db *gorm.DB root string now func() time.Time } type CreateInput struct { Name string ParentIdentity string Markdown string SourceInboxItemID *uint } type UpdateInput struct { Name *string ParentIdentity *string Markdown *string Revision *uint64 } type StoredBlob struct { StorageKey string RelativePath string AbsolutePath string OriginalName string MimeType string Extension string Size int64 Checksum string } type SharedDocument struct { Document models.SaDocumentTree Content *models.SaDocumentContent Blob *models.SaDocumentBlob } type SheetPreview struct { SheetName string `json:"sheetName"` Rows [][]string `json:"rows"` Truncated bool `json:"truncated"` } //go:embed assets/NotoSansSC.ttf var documentPDFFont []byte func NewService(database *gorm.DB, root string) *Service { return &Service{db: database, root: root, now: time.Now} } func (s *Service) database() *gorm.DB { if s.db != nil { return s.db } return models.DBService } func (s *Service) List(userID uint, projectIdentity string) ([]models.SaDocumentTree, error) { project, err := s.ownedProject(userID, projectIdentity) if err != nil { return nil, err } var documents []models.SaDocumentTree err = s.database().Where("project_id = ?", project.ID). Order("kind asc, normalized_name asc, id asc").Find(&documents).Error return documents, err } func (s *Service) CreateFolder(userID uint, projectIdentity string, input CreateInput) (*models.SaDocumentTree, error) { return s.createNode(userID, projectIdentity, input, models.DocumentKindFolder, "", "", 0) } func (s *Service) CreateMarkdown(userID uint, projectIdentity string, input CreateInput) (*models.SaDocumentTree, error) { if strings.TrimSpace(filepath.Ext(input.Name)) == "" { input.Name += ".md" } var created *models.SaDocumentTree err := s.database().Transaction(func(tx *gorm.DB) error { service := NewService(tx, s.root) document, err := service.createNode(userID, projectIdentity, input, models.DocumentKindFile, ".md", "text/markdown; charset=utf-8", int64(len([]byte(input.Markdown)))) if err != nil { return err } if err := tx.Create(&models.SaDocumentContent{DocumentID: document.ID, Markdown: input.Markdown}).Error; err != nil { return err } created = document return nil }) return created, err } func (s *Service) createNode(userID uint, projectIdentity string, input CreateInput, kind, extension, mimeType string, size int64) (*models.SaDocumentTree, error) { project, err := s.ownedProject(userID, projectIdentity) if err != nil { return nil, err } parentID, parentIdentity, err := s.resolveParent(project.ID, input.ParentIdentity) if err != nil { return nil, err } name := cleanDisplayName(input.Name) if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) { return nil, ErrInvalidDocument } name, err = s.availableName(project.ID, parentID, name) if err != nil { return nil, err } if kind == models.DocumentKindFile { extension = strings.ToLower(filepath.Ext(name)) } document := &models.SaDocumentTree{ ProjectID: project.ID, ProjectIdentity: project.Identity, ParentID: parentID, ParentIdentity: parentIdentity, ParentScope: identityValue(parentIdentity), CreatedBy: userID, SourceInboxItemID: input.SourceInboxItemID, Kind: kind, Name: name, NormalizedName: normalizeName(name), Extension: extension, MimeType: mimeType, Size: size, Revision: 1, ScanStatus: "not_scanned", } if err := s.database().Create(document).Error; err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return nil, ErrNameConflict } return nil, err } return document, nil } func (s *Service) CreateUploaded(userID uint, projectIdentity, parentIdentity string, stored StoredBlob) (*models.SaDocumentTree, error) { var created *models.SaDocumentTree err := s.database().Transaction(func(tx *gorm.DB) error { service := NewService(tx, s.root) document, err := service.createNode(userID, projectIdentity, CreateInput{ Name: stored.OriginalName, ParentIdentity: parentIdentity, }, models.DocumentKindFile, stored.Extension, stored.MimeType, stored.Size) if err != nil { return err } if err := tx.Create(&models.SaDocumentBlob{ DocumentID: document.ID, StorageKey: stored.StorageKey, RelativePath: stored.RelativePath, OriginalName: stored.OriginalName, Checksum: stored.Checksum, }).Error; err != nil { return err } created = document return nil }) if err != nil { _ = os.Remove(stored.AbsolutePath) } return created, err } func (s *Service) Get(userID uint, projectIdentity, documentIdentity string) (*SharedDocument, error) { project, err := s.ownedProject(userID, projectIdentity) if err != nil { return nil, err } var document models.SaDocumentTree if err := s.database().Where("identity = ? AND project_id = ?", strings.TrimSpace(documentIdentity), project.ID).First(&document).Error; err != nil { return nil, err } return s.loadDocument(document) } func (s *Service) Update(userID uint, projectIdentity, documentIdentity string, input UpdateInput) (*SharedDocument, error) { var updated *SharedDocument err := s.database().Transaction(func(tx *gorm.DB) error { service := NewService(tx, s.root) project, err := service.ownedProject(userID, projectIdentity) if err != nil { return err } var document models.SaDocumentTree if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("identity = ? AND project_id = ?", strings.TrimSpace(documentIdentity), project.ID). First(&document).Error; err != nil { return err } updates := map[string]any{} if input.Name != nil { name := cleanDisplayName(*input.Name) if name == "" || strings.ContainsAny(name, `/\`) { return ErrInvalidDocument } if normalizeName(name) != document.NormalizedName { if service.nameExists(project.ID, document.ParentID, normalizeName(name), document.ID) { return ErrNameConflict } updates["name"] = name updates["normalized_name"] = normalizeName(name) if document.Kind == models.DocumentKindFile { updates["extension"] = strings.ToLower(filepath.Ext(name)) } } } if input.ParentIdentity != nil { parentID, parentIdentity, err := service.resolveParent(project.ID, *input.ParentIdentity) if err != nil { return err } if parentID != nil && *parentID == document.ID { return ErrInvalidParent } if document.Kind == models.DocumentKindFolder && parentID != nil { descendant, err := service.isDescendant(document.ID, *parentID) if err != nil { return err } if descendant { return ErrInvalidParent } } nextName := document.NormalizedName if value, ok := updates["normalized_name"].(string); ok { nextName = value } if service.nameExists(project.ID, parentID, nextName, document.ID) { return ErrNameConflict } updates["parent_id"] = parentID updates["parent_identity"] = parentIdentity updates["parent_scope"] = identityValue(parentIdentity) } if input.Markdown != nil { if document.Kind != models.DocumentKindFile || document.Extension != ".md" || input.Revision == nil { return ErrInvalidDocument } if document.Revision != *input.Revision { return ErrRevisionConflict } var content models.SaDocumentContent err := tx.Where("document_id = ?", document.ID).First(&content).Error if errors.Is(err, gorm.ErrRecordNotFound) { content = models.SaDocumentContent{DocumentID: document.ID, Markdown: *input.Markdown} err = tx.Create(&content).Error } else if err == nil { err = tx.Model(&content).Update("markdown", *input.Markdown).Error } if err != nil { return err } updates["size"] = int64(len([]byte(*input.Markdown))) updates["revision"] = document.Revision + 1 } if len(updates) > 0 { if err := tx.Model(&document).Updates(updates).Error; err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return ErrNameConflict } return err } } if err := tx.Where("id = ?", document.ID).First(&document).Error; err != nil { return err } updated, err = service.loadDocument(document) return err }) return updated, err } func (s *Service) Delete(userID uint, projectIdentity, documentIdentity string) error { project, err := s.ownedProject(userID, projectIdentity) if err != nil { return err } var document models.SaDocumentTree if err := s.database().Where("identity = ? AND project_id = ?", documentIdentity, project.ID).First(&document).Error; err != nil { return err } ids, err := s.subtreeIDs(project.ID, document.ID) if err != nil { return err } return s.database().Where("id IN ?", ids).Delete(&models.SaDocumentTree{}).Error } func (s *Service) Store(projectIdentity, originalName string, source io.Reader) (StoredBlob, error) { projectSegment := filepath.Base(strings.TrimSpace(projectIdentity)) if projectSegment == "." || projectSegment == "" || projectSegment != strings.TrimSpace(projectIdentity) { return StoredBlob{}, ErrInvalidDocument } name := cleanDisplayName(originalName) if name == "" { name = "upload.bin" } directoryRelative := filepath.ToSlash(filepath.Join("projects", projectSegment, "documents")) directoryAbsolute, err := s.absolutePath(directoryRelative) if err != nil { return StoredBlob{}, err } if err := os.MkdirAll(directoryAbsolute, 0o755); err != nil { return StoredBlob{}, err } temp, err := os.CreateTemp(directoryAbsolute, ".upload-*") if err != nil { return StoredBlob{}, err } tempName := temp.Name() defer func() { _ = os.Remove(tempName) }() hasher := sha256.New() var sniff bytes.Buffer writer := io.MultiWriter(temp, hasher, &limitedWriter{writer: &sniff, remaining: 512}) size, copyErr := io.Copy(writer, source) closeErr := temp.Close() if copyErr != nil { return StoredBlob{}, copyErr } if closeErr != nil { return StoredBlob{}, closeErr } keyBytes := make([]byte, 16) if _, err := rand.Read(keyBytes); err != nil { return StoredBlob{}, err } key := hex.EncodeToString(keyBytes) relative := filepath.ToSlash(filepath.Join(directoryRelative, key)) absolute, err := s.absolutePath(relative) if err != nil { return StoredBlob{}, err } if err := os.Rename(tempName, absolute); err != nil { return StoredBlob{}, err } detected := http.DetectContentType(sniff.Bytes()) extension := strings.ToLower(filepath.Ext(name)) if extensionType := mime.TypeByExtension(extension); extensionType != "" && detected == "application/octet-stream" { detected = extensionType } return StoredBlob{ StorageKey: key, RelativePath: relative, AbsolutePath: absolute, OriginalName: name, MimeType: detected, Extension: extension, Size: size, Checksum: hex.EncodeToString(hasher.Sum(nil)), }, nil } func (s *Service) OpenBlob(blob models.SaDocumentBlob) (*os.File, error) { absolute, err := s.absolutePath(blob.RelativePath) if err != nil { return nil, err } return os.Open(absolute) } func (s *Service) PreviewLegacySheet(loaded SharedDocument, maxRows, maxColumns int) (SheetPreview, error) { if loaded.Document.Extension != ".xls" || loaded.Blob == nil { return SheetPreview{}, ErrInvalidDocument } file, err := s.OpenBlob(*loaded.Blob) if err != nil { return SheetPreview{}, err } defer file.Close() workbook, err := xls.OpenReader(file) if err != nil { return SheetPreview{}, ErrInvalidDocument } sheet, err := workbook.GetSheet(0) if err != nil { return SheetPreview{}, ErrInvalidDocument } sourceRows := sheet.GetRows() preview := SheetPreview{SheetName: sheet.GetName(), Truncated: len(sourceRows) > maxRows} if len(sourceRows) > maxRows { sourceRows = sourceRows[:maxRows] } preview.Rows = make([][]string, 0, len(sourceRows)) for _, sourceRow := range sourceRows { columns := sourceRow.GetCols() if len(columns) > maxColumns { columns = columns[:maxColumns] preview.Truncated = true } row := make([]string, 0, len(columns)) for _, column := range columns { row = append(row, column.GetString()) } preview.Rows = append(preview.Rows, row) } return preview, nil } func (s *Service) CreateShare(userID uint, projectIdentity, documentIdentity string, duration time.Duration) (*models.SaDocumentShare, string, error) { loaded, err := s.Get(userID, projectIdentity, documentIdentity) if err != nil { return nil, "", err } if loaded.Document.Kind != models.DocumentKindFile { return nil, "", ErrInvalidDocument } if duration <= 0 || duration > 30*24*time.Hour { return nil, "", ErrInvalidDocument } tokenBytes := make([]byte, 32) if _, err := rand.Read(tokenBytes); err != nil { return nil, "", err } token := hex.EncodeToString(tokenBytes) hash := sha256.Sum256([]byte(token)) share := &models.SaDocumentShare{ DocumentID: loaded.Document.ID, CreatedBy: userID, TokenHash: hex.EncodeToString(hash[:]), ExpiresAt: s.now().UTC().Add(duration), } return share, token, s.database().Create(share).Error } func (s *Service) ListShares(userID uint, projectIdentity, documentIdentity string) ([]models.SaDocumentShare, error) { loaded, err := s.Get(userID, projectIdentity, documentIdentity) if err != nil { return nil, err } var shares []models.SaDocumentShare err = s.database().Where("document_id = ?", loaded.Document.ID).Order("created_at desc").Find(&shares).Error return shares, err } func (s *Service) RevokeShare(userID uint, projectIdentity, documentIdentity, shareIdentity string) error { loaded, err := s.Get(userID, projectIdentity, documentIdentity) if err != nil { return err } now := s.now().UTC() result := s.database().Model(&models.SaDocumentShare{}). Where("identity = ? AND document_id = ?", shareIdentity, loaded.Document.ID). Update("revoked_at", &now) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } func (s *Service) ResolveShare(token string) (*SharedDocument, error) { hash := sha256.Sum256([]byte(strings.TrimSpace(token))) var share models.SaDocumentShare if err := s.database().Where( "token_hash = ? AND revoked_at IS NULL AND expires_at > ?", hex.EncodeToString(hash[:]), s.now().UTC(), ).First(&share).Error; err != nil { return nil, ErrShareNotFound } var document models.SaDocumentTree if err := s.database().Where("id = ?", share.DocumentID).First(&document).Error; err != nil { return nil, ErrShareNotFound } return s.loadDocument(document) } func (s *Service) Export(document SharedDocument, format string) ([]byte, string, string, error) { if document.Content == nil || document.Document.Extension != ".md" { return nil, "", "", ErrUnsupportedExport } base := strings.TrimSuffix(document.Document.Name, filepath.Ext(document.Document.Name)) switch strings.ToLower(strings.TrimSpace(format)) { case "md", "markdown": return []byte(document.Content.Markdown), base + ".md", "text/markdown; charset=utf-8", nil case "docx": data, err := markdownDOCX(document.Content.Markdown) return data, base + ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", err case "pdf": data, err := markdownPDF(document.Content.Markdown) return data, base + ".pdf", "application/pdf", err default: return nil, "", "", ErrUnsupportedExport } } func (s *Service) loadDocument(document models.SaDocumentTree) (*SharedDocument, error) { result := &SharedDocument{Document: document} var content models.SaDocumentContent if err := s.database().Where("document_id = ?", document.ID).First(&content).Error; err == nil { result.Content = &content } else if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } var blob models.SaDocumentBlob if err := s.database().Where("document_id = ?", document.ID).First(&blob).Error; err == nil { result.Blob = &blob } else if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } return result, nil } func (s *Service) ownedProject(userID uint, identity string) (*models.SaProject, error) { var project models.SaProject err := s.database().Where("identity = ? AND owner_id = ?", strings.TrimSpace(identity), userID).First(&project).Error return &project, err } func (s *Service) resolveParent(projectID uint, identity string) (*uint, *string, error) { identity = strings.TrimSpace(identity) if identity == "" { return nil, nil, nil } var parent models.SaDocumentTree if err := s.database().Where("identity = ? AND project_id = ? AND kind = ?", identity, projectID, models.DocumentKindFolder).First(&parent).Error; err != nil { return nil, nil, ErrInvalidParent } return &parent.ID, &parent.Identity, nil } func (s *Service) availableName(projectID uint, parentID *uint, name string) (string, error) { extension := filepath.Ext(name) base := strings.TrimSuffix(name, extension) for attempt := 0; attempt < 1000; attempt++ { candidate := name if attempt > 0 { candidate = fmt.Sprintf("%s (%d)%s", base, attempt, extension) } if !s.nameExists(projectID, parentID, normalizeName(candidate), 0) { return candidate, nil } } return "", ErrNameConflict } func (s *Service) nameExists(projectID uint, parentID *uint, normalized string, exceptID uint) bool { query := s.database().Model(&models.SaDocumentTree{}).Where("project_id = ? AND normalized_name = ?", projectID, normalized) if parentID == nil { query = query.Where("parent_id IS NULL") } else { query = query.Where("parent_id = ?", *parentID) } if exceptID > 0 { query = query.Where("id <> ?", exceptID) } var count int64 return query.Count(&count).Error == nil && count > 0 } func (s *Service) isDescendant(ancestorID, candidateID uint) (bool, error) { current := candidateID for current != 0 { if current == ancestorID { return true, nil } var document models.SaDocumentTree if err := s.database().Unscoped().Select("parent_id").Where("id = ?", current).First(&document).Error; err != nil { return false, err } if document.ParentID == nil { return false, nil } current = *document.ParentID } return false, nil } func (s *Service) subtreeIDs(projectID, rootID uint) ([]uint, error) { result := []uint{rootID} frontier := []uint{rootID} for len(frontier) > 0 { var children []models.SaDocumentTree if err := s.database().Select("id").Where("project_id = ? AND parent_id IN ?", projectID, frontier).Find(&children).Error; err != nil { return nil, err } frontier = frontier[:0] for _, child := range children { result = append(result, child.ID) frontier = append(frontier, child.ID) } } return result, nil } func (s *Service) absolutePath(relative string) (string, error) { cleaned := filepath.Clean(filepath.FromSlash(strings.TrimSpace(relative))) if cleaned == "." || filepath.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { return "", ErrInvalidDocument } root, err := filepath.Abs(s.root) if err != nil { return "", err } absolute, err := filepath.Abs(filepath.Join(root, cleaned)) if err != nil { return "", err } relativeToRoot, err := filepath.Rel(root, absolute) if err != nil || relativeToRoot == ".." || strings.HasPrefix(relativeToRoot, ".."+string(filepath.Separator)) { return "", ErrInvalidDocument } return absolute, nil } func cleanDisplayName(value string) string { return strings.TrimSpace(filepath.Base(strings.ReplaceAll(value, "\\", "/"))) } func normalizeName(value string) string { return strings.Map(func(r rune) rune { if unicode.IsSpace(r) { return ' ' } return unicode.ToLower(r) }, strings.TrimSpace(value)) } func identityValue(value *string) string { if value == nil { return "" } return *value } type limitedWriter struct { writer io.Writer remaining int } func (w *limitedWriter) Write(p []byte) (int, error) { if w.remaining <= 0 { return len(p), nil } size := len(p) if size > w.remaining { size = w.remaining } _, _ = w.writer.Write(p[:size]) w.remaining -= size return len(p), nil } var markdownPrefix = regexp.MustCompile(`(?m)^#{1,6}\s+|^\s*[-*+]\s+|^\s*\d+\.\s+|^\s*>\s?`) func plainMarkdown(value string) string { value = markdownPrefix.ReplaceAllString(value, "") value = strings.NewReplacer("**", "", "__", "", "`", "", "\r", "").Replace(value) return value } func markdownDOCX(markdown string) ([]byte, error) { var buffer bytes.Buffer archive := zip.NewWriter(&buffer) files := map[string]string{ "[Content_Types].xml": ``, "_rels/.rels": ``, "word/document.xml": docxDocumentXML(markdown), } for name, body := range files { writer, err := archive.Create(name) if err != nil { return nil, err } if _, err := io.WriteString(writer, body); err != nil { return nil, err } } if err := archive.Close(); err != nil { return nil, err } return buffer.Bytes(), nil } func docxDocumentXML(markdown string) string { var paragraphs strings.Builder for _, line := range strings.Split(plainMarkdown(markdown), "\n") { paragraphs.WriteString(``) paragraphs.WriteString(html.EscapeString(line)) paragraphs.WriteString(``) } return `` + paragraphs.String() + `` } func markdownPDF(markdown string) ([]byte, error) { const ( pageWidth = 595.28 pageHeight = 841.89 marginX = 52.0 top = 56.0 bottom = 60.0 ) pdf := &gopdf.GoPdf{} pdf.Start(gopdf.Config{PageSize: gopdf.Rect{W: pageWidth, H: pageHeight}}) if err := pdf.AddTTFFontData("NotoSansSC", documentPDFFont); err != nil { return nil, fmt.Errorf("load document PDF font: %w", err) } pageNumber := 0 y := top addPage := func() error { if pageNumber > 0 { if err := writePDFFooter(pdf, pageNumber, pageWidth, pageHeight); err != nil { return err } } pdf.AddPage() pageNumber++ y = top return nil } if err := addPage(); err != nil { return nil, err } for _, rawLine := range strings.Split(strings.ReplaceAll(markdown, "\r", ""), "\n") { line := plainMarkdown(rawLine) fontSize := 11.0 lineHeight := 18.0 switch { case strings.HasPrefix(rawLine, "# "): fontSize, lineHeight = 22, 32 case strings.HasPrefix(rawLine, "## "): fontSize, lineHeight = 18, 27 case strings.HasPrefix(rawLine, "### "): fontSize, lineHeight = 15, 23 case strings.TrimSpace(rawLine) == "": y += 9 continue } if err := pdf.SetFont("NotoSansSC", "", fontSize); err != nil { return nil, err } lines, err := pdf.SplitTextWithWordWrap(line, pageWidth-2*marginX) if err != nil { return nil, err } if len(lines) == 0 { lines = []string{""} } for _, wrapped := range lines { if y+lineHeight > pageHeight-bottom { if err := addPage(); err != nil { return nil, err } } pdf.SetX(marginX) pdf.SetY(y) if err := pdf.Cell(&gopdf.Rect{W: pageWidth - 2*marginX, H: lineHeight}, wrapped); err != nil { return nil, err } y += lineHeight } y += 3 } if err := writePDFFooter(pdf, pageNumber, pageWidth, pageHeight); err != nil { return nil, err } var buffer bytes.Buffer if err := pdf.Write(&buffer); err != nil { return nil, err } return buffer.Bytes(), nil } func writePDFFooter(pdf *gopdf.GoPdf, pageNumber int, pageWidth, pageHeight float64) error { if err := pdf.SetFont("NotoSansSC", "", 9); err != nil { return err } pdf.SetTextColor(120, 120, 120) pdf.SetX(0) pdf.SetY(pageHeight - 34) err := pdf.CellWithOption(&gopdf.Rect{W: pageWidth, H: 14}, fmt.Sprintf("第 %d 页", pageNumber), gopdf.CellOption{Align: gopdf.Center}) pdf.SetTextColor(30, 30, 30) return err }