407 lines
12 KiB
Go
407 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
"github.com/kandev/kandev/internal/task/repository"
|
|
)
|
|
|
|
// Document-specific sentinel errors.
|
|
var (
|
|
ErrDocumentNotFound = errors.New("document not found")
|
|
ErrDocumentKeyRequired = errors.New("document key is required")
|
|
ErrDocumentTaskRequired = errors.New("task_id is required")
|
|
ErrInvalidPathComponent = errors.New("invalid path component")
|
|
)
|
|
|
|
// safePathComponent rejects taskID / key values that could traverse the
|
|
// attachments root via "..", embedded separators, or null bytes. The HTTP
|
|
// handler accepts these from clients, so the service layer is the right
|
|
// place to enforce containment before they hit filepath.Join.
|
|
func safePathComponent(s string) error {
|
|
if s == "" || s == "." || s == ".." {
|
|
return ErrInvalidPathComponent
|
|
}
|
|
for _, r := range s {
|
|
if r == '/' || r == '\\' || r == 0 {
|
|
return ErrInvalidPathComponent
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const (
|
|
maxAttachmentBytes = 10 * 1024 * 1024 // 10 MB
|
|
defaultDocCoalesceWindow = 5 * time.Minute
|
|
docTypeAttachment = "attachment"
|
|
)
|
|
|
|
// docRepo is the minimal repository surface DocumentService depends on.
|
|
type docRepo interface {
|
|
repository.DocumentRepository
|
|
}
|
|
|
|
// DocumentService provides task document business logic including CRUD,
|
|
// revision management, revert, and attachment handling.
|
|
type DocumentService struct {
|
|
repo docRepo
|
|
logger *logger.Logger
|
|
coalesceWindow time.Duration
|
|
}
|
|
|
|
// NewDocumentService creates a new DocumentService.
|
|
func NewDocumentService(repo docRepo, log *logger.Logger) *DocumentService {
|
|
return &DocumentService{
|
|
repo: repo,
|
|
logger: log.WithFields(zap.String("component", "document-service")),
|
|
coalesceWindow: defaultDocCoalesceWindow,
|
|
}
|
|
}
|
|
|
|
// CreateOrUpdateDocument upserts the HEAD document and appends or coalesces a revision.
|
|
func (s *DocumentService) CreateOrUpdateDocument(
|
|
ctx context.Context,
|
|
taskID, key, docType, title, content, authorKind, authorName string,
|
|
) (*models.TaskDocument, error) {
|
|
if taskID == "" {
|
|
return nil, ErrDocumentTaskRequired
|
|
}
|
|
if key == "" {
|
|
return nil, ErrDocumentKeyRequired
|
|
}
|
|
docType, title, authorKind, authorName = applyDocDefaults(docType, title, key, authorKind, authorName)
|
|
|
|
existing, err := s.repo.GetDocument(ctx, taskID, key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get document: %w", err)
|
|
}
|
|
head := buildDocHead(taskID, key, docType, title, content, authorKind, authorName, existing)
|
|
|
|
latest, err := s.repo.GetLatestDocumentRevision(ctx, taskID, key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get latest revision: %w", err)
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
rev, coalesceID := s.buildDocRevision(taskID, key, title, content, authorKind, authorName, latest, now)
|
|
|
|
if err := s.repo.WriteDocumentRevision(ctx, head, rev, coalesceID); err != nil {
|
|
s.logger.Error("write document revision", zap.String("task_id", taskID), zap.String("key", key), zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
// Reload HEAD to get DB-set timestamps.
|
|
result, err := s.repo.GetDocument(ctx, taskID, key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reload document: %w", err)
|
|
}
|
|
if result == nil {
|
|
return head, nil
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// applyDocDefaults fills in default values for optional document fields.
|
|
func applyDocDefaults(docType, title, key, authorKind, authorName string) (string, string, string, string) {
|
|
if docType == "" {
|
|
docType = "custom"
|
|
}
|
|
if title == "" {
|
|
title = key
|
|
}
|
|
if authorKind == "" {
|
|
authorKind = createdByAgent
|
|
}
|
|
if authorName == "" {
|
|
if authorKind == createdByAgent {
|
|
authorName = "Agent"
|
|
} else {
|
|
authorName = "User"
|
|
}
|
|
}
|
|
return docType, title, authorKind, authorName
|
|
}
|
|
|
|
// buildDocHead constructs the HEAD document, copying IDs and attachment fields from existing when present.
|
|
func buildDocHead(taskID, key, docType, title, content, authorKind, authorName string, existing *models.TaskDocument) *models.TaskDocument {
|
|
head := &models.TaskDocument{
|
|
TaskID: taskID,
|
|
Key: key,
|
|
Type: docType,
|
|
Title: title,
|
|
Content: content,
|
|
AuthorKind: authorKind,
|
|
AuthorName: authorName,
|
|
}
|
|
if existing != nil {
|
|
head.ID = existing.ID
|
|
head.CreatedAt = existing.CreatedAt
|
|
// Preserve attachment fields if not overriding content.
|
|
if existing.Type == docTypeAttachment && docType == docTypeAttachment {
|
|
head.Filename = existing.Filename
|
|
head.MimeType = existing.MimeType
|
|
head.SizeBytes = existing.SizeBytes
|
|
head.DiskPath = existing.DiskPath
|
|
}
|
|
}
|
|
return head
|
|
}
|
|
|
|
// buildDocRevision constructs the revision and returns a coalesceID when the latest revision
|
|
// was authored by the same author within the coalesce window.
|
|
func (s *DocumentService) buildDocRevision(
|
|
taskID, key, title, content, authorKind, authorName string,
|
|
latest *models.TaskDocumentRevision,
|
|
now time.Time,
|
|
) (*models.TaskDocumentRevision, *string) {
|
|
rev := &models.TaskDocumentRevision{
|
|
TaskID: taskID,
|
|
DocumentKey: key,
|
|
Title: title,
|
|
Content: content,
|
|
AuthorKind: authorKind,
|
|
AuthorName: authorName,
|
|
}
|
|
if !s.canDocCoalesce(latest, authorKind, authorName, now) {
|
|
return rev, nil
|
|
}
|
|
rev.RevisionNumber = latest.RevisionNumber
|
|
rev.AuthorKind = latest.AuthorKind
|
|
rev.AuthorName = latest.AuthorName
|
|
rev.CreatedAt = latest.CreatedAt
|
|
return rev, &latest.ID
|
|
}
|
|
|
|
// GetDocument retrieves a document HEAD by task ID and key.
|
|
func (s *DocumentService) GetDocument(ctx context.Context, taskID, key string) (*models.TaskDocument, error) {
|
|
if taskID == "" {
|
|
return nil, ErrDocumentTaskRequired
|
|
}
|
|
if key == "" {
|
|
return nil, ErrDocumentKeyRequired
|
|
}
|
|
doc, err := s.repo.GetDocument(ctx, taskID, key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if doc == nil {
|
|
return nil, ErrDocumentNotFound
|
|
}
|
|
return doc, nil
|
|
}
|
|
|
|
// DeleteDocument removes a document and all its revisions.
|
|
func (s *DocumentService) DeleteDocument(ctx context.Context, taskID, key string) error {
|
|
if taskID == "" {
|
|
return ErrDocumentTaskRequired
|
|
}
|
|
if key == "" {
|
|
return ErrDocumentKeyRequired
|
|
}
|
|
existing, err := s.repo.GetDocument(ctx, taskID, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if existing == nil {
|
|
return ErrDocumentNotFound
|
|
}
|
|
return s.repo.DeleteDocument(ctx, taskID, key)
|
|
}
|
|
|
|
// ListDocuments returns all documents for a task.
|
|
func (s *DocumentService) ListDocuments(ctx context.Context, taskID string) ([]*models.TaskDocument, error) {
|
|
if taskID == "" {
|
|
return nil, ErrDocumentTaskRequired
|
|
}
|
|
return s.repo.ListDocuments(ctx, taskID)
|
|
}
|
|
|
|
// ListRevisions returns revision history for a document, newest-first.
|
|
func (s *DocumentService) ListRevisions(ctx context.Context, taskID, key string, limit int) ([]*models.TaskDocumentRevision, error) {
|
|
if taskID == "" {
|
|
return nil, ErrDocumentTaskRequired
|
|
}
|
|
if key == "" {
|
|
return nil, ErrDocumentKeyRequired
|
|
}
|
|
return s.repo.ListDocumentRevisions(ctx, taskID, key, limit)
|
|
}
|
|
|
|
// RevertDocument creates a new revision whose content mirrors the target revision.
|
|
func (s *DocumentService) RevertDocument(ctx context.Context, taskID, key, revisionID string) (*models.TaskDocumentRevision, error) {
|
|
if taskID == "" {
|
|
return nil, ErrDocumentTaskRequired
|
|
}
|
|
if key == "" {
|
|
return nil, ErrDocumentKeyRequired
|
|
}
|
|
if revisionID == "" {
|
|
return nil, errors.New("revision_id is required")
|
|
}
|
|
|
|
target, err := s.repo.GetDocumentRevision(ctx, revisionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if target == nil {
|
|
return nil, ErrDocumentNotFound
|
|
}
|
|
if target.TaskID != taskID || target.DocumentKey != key {
|
|
return nil, errors.New("revision does not belong to the given document")
|
|
}
|
|
|
|
existing, err := s.repo.GetDocument(ctx, taskID, key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
head := &models.TaskDocument{
|
|
TaskID: taskID,
|
|
Key: key,
|
|
Title: target.Title,
|
|
Content: target.Content,
|
|
AuthorKind: "user",
|
|
AuthorName: "User",
|
|
}
|
|
if existing != nil {
|
|
head.ID = existing.ID
|
|
head.Type = existing.Type
|
|
head.CreatedAt = existing.CreatedAt
|
|
}
|
|
|
|
targetID := target.ID
|
|
rev := &models.TaskDocumentRevision{
|
|
TaskID: taskID,
|
|
DocumentKey: key,
|
|
Title: target.Title,
|
|
Content: target.Content,
|
|
AuthorKind: "user",
|
|
AuthorName: "User",
|
|
RevertOfRevisionID: &targetID,
|
|
}
|
|
|
|
if err := s.repo.WriteDocumentRevision(ctx, head, rev, nil); err != nil {
|
|
return nil, err
|
|
}
|
|
return rev, nil
|
|
}
|
|
|
|
// UploadAttachment stores a binary attachment on disk and creates/updates the HEAD document.
|
|
// basePath is the root directory for attachment storage (e.g., the workspace data dir).
|
|
func (s *DocumentService) UploadAttachment(
|
|
ctx context.Context,
|
|
taskID, key, filename, mimeType string,
|
|
data []byte,
|
|
basePath string,
|
|
) (*models.TaskDocument, error) {
|
|
if taskID == "" {
|
|
return nil, ErrDocumentTaskRequired
|
|
}
|
|
if key == "" {
|
|
return nil, ErrDocumentKeyRequired
|
|
}
|
|
if err := safePathComponent(taskID); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := safePathComponent(key); err != nil {
|
|
return nil, err
|
|
}
|
|
if int64(len(data)) > maxAttachmentBytes {
|
|
return nil, ErrAttachmentTooLarge
|
|
}
|
|
|
|
ext := filepath.Ext(filename)
|
|
if strings.ContainsAny(ext, "/\\\x00") {
|
|
return nil, ErrInvalidPathComponent
|
|
}
|
|
// filepath.Base strips any directory components from the user-supplied
|
|
// taskID / key before they enter filepath.Join — both as a defense in
|
|
// depth alongside safePathComponent and as the canonical pattern
|
|
// CodeQL recognises as a path-injection sanitiser.
|
|
safeTaskID := filepath.Base(taskID)
|
|
safeKey := filepath.Base(key)
|
|
dir := filepath.Join(basePath, "attachments", safeTaskID)
|
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
|
return nil, fmt.Errorf("create attachment dir: %w", err)
|
|
}
|
|
diskPath := filepath.Join(dir, safeKey+ext)
|
|
if err := os.WriteFile(diskPath, data, 0o640); err != nil {
|
|
return nil, fmt.Errorf("write attachment file: %w", err)
|
|
}
|
|
|
|
existing, err := s.repo.GetDocument(ctx, taskID, key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
head := &models.TaskDocument{
|
|
TaskID: taskID,
|
|
Key: key,
|
|
Type: docTypeAttachment,
|
|
Title: filename,
|
|
Content: "",
|
|
AuthorKind: "user",
|
|
AuthorName: "User",
|
|
Filename: filename,
|
|
MimeType: mimeType,
|
|
SizeBytes: int64(len(data)),
|
|
DiskPath: diskPath,
|
|
}
|
|
if existing != nil {
|
|
head.ID = existing.ID
|
|
head.CreatedAt = existing.CreatedAt
|
|
}
|
|
|
|
// Attachments have no revision history — upsert only.
|
|
if existing == nil {
|
|
if err := s.repo.CreateDocument(ctx, head); err != nil {
|
|
return nil, fmt.Errorf("create attachment document: %w", err)
|
|
}
|
|
} else {
|
|
if err := s.repo.UpdateDocument(ctx, head); err != nil {
|
|
return nil, fmt.Errorf("update attachment document: %w", err)
|
|
}
|
|
}
|
|
return head, nil
|
|
}
|
|
|
|
// DownloadAttachment returns the disk path and document metadata for an attachment.
|
|
func (s *DocumentService) DownloadAttachment(ctx context.Context, taskID, key string) (string, *models.TaskDocument, error) {
|
|
doc, err := s.GetDocument(ctx, taskID, key)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
if doc.Type != docTypeAttachment {
|
|
return "", nil, errors.New("document is not an attachment")
|
|
}
|
|
if doc.DiskPath == "" {
|
|
return "", nil, errors.New("attachment has no disk path")
|
|
}
|
|
return doc.DiskPath, doc, nil
|
|
}
|
|
|
|
func (s *DocumentService) canDocCoalesce(latest *models.TaskDocumentRevision, authorKind, authorName string, now time.Time) bool {
|
|
if latest == nil {
|
|
return false
|
|
}
|
|
if latest.RevertOfRevisionID != nil {
|
|
return false
|
|
}
|
|
if latest.AuthorKind != authorKind || latest.AuthorName != authorName {
|
|
return false
|
|
}
|
|
if s.coalesceWindow <= 0 {
|
|
return false
|
|
}
|
|
return now.Sub(latest.UpdatedAt) < s.coalesceWindow
|
|
}
|