Files
kandev/apps/backend/internal/task/service/attachment_service.go
T
2026-08-13 12:44:08 +01:00

364 lines
13 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"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"
)
const (
// MaxAttachmentBytes is the raw (not base64-encoded) size accepted for a
// single prompt attachment.
MaxAttachmentBytes int64 = models.MaxMessageAttachmentBytes
// MaxAttachmentCount bounds the number of files in one prompt submission.
MaxAttachmentCount = models.MaxMessageAttachmentCount
attachmentDeliveryModePrompt = "prompt"
attachmentDeliveryModePath = "path"
)
var (
ErrAttachmentTooLarge = models.ErrAttachmentTooLarge
ErrAttachmentTotalTooLarge = models.ErrAttachmentTotalTooLarge
ErrTooManyAttachments = models.ErrTooManyAttachments
ErrAttachmentClaimConflict = models.ErrAttachmentClaimConflict
ErrAttachmentNotFound = models.ErrAttachmentNotFound
ErrAttachmentForbidden = models.ErrAttachmentForbidden
ErrAttachmentInvalid = models.ErrAttachmentInvalid
)
const AttachmentStagedTTL = 24 * time.Hour
// AttachmentDescriptor is the safe client-facing representation of an
// attachment. It deliberately contains no storage path or inline bytes.
type AttachmentDescriptor struct {
ID string `json:"attachment_id"`
Name string `json:"name"`
MimeType string `json:"mime_type"`
Kind string `json:"kind"`
DeliveryMode string `json:"delivery_mode"`
SizeBytes int64 `json:"size_bytes"`
State string `json:"state"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
}
// AttachmentService owns private attachment bytes and their durable registry.
type AttachmentService struct {
repo repository.AttachmentRepository
root string
authorizeWorkspace func(context.Context, string) error
authorizeTask func(context.Context, string) error
log *logger.Logger
}
func NewAttachmentService(repo repository.AttachmentRepository, root string, authorizeWorkspace func(context.Context, string) error, log *logger.Logger) (*AttachmentService, error) {
if repo == nil {
return nil, errors.New("attachment repository is required")
}
if strings.TrimSpace(root) == "" {
return nil, errors.New("attachment storage root is required")
}
if log == nil {
return nil, errors.New("attachment logger is required")
}
return &AttachmentService{repo: repo, root: filepath.Join(root, "attachments"), authorizeWorkspace: authorizeWorkspace, log: log.WithFields(zap.String("component", "attachment-service"))}, nil
}
func (s *AttachmentService) ensureRoot() error {
return os.MkdirAll(s.root, 0o700)
}
// SetTaskAuthorizer wires the per-user task visibility check used to serve
// claimed attachments to transcript readers. Staged attachments remain
// owner-only; claimed attachments are authorized through their owning task.
func (s *AttachmentService) SetTaskAuthorizer(authorizer func(context.Context, string) error) {
s.authorizeTask = authorizer
}
// Stage writes one raw file to a temporary file, validates its exact byte
// count, atomically renames it, then commits the descriptor row. Any failure
// removes both the temporary and committed file so callers never observe a
// usable row without bytes.
func (s *AttachmentService) Stage(ctx context.Context, ownerID, workspaceID, name, mimeType, kind, deliveryMode string, src io.Reader) (*models.TaskMessageAttachment, error) {
if workspaceID == "" || ownerID == "" || src == nil {
return nil, ErrAttachmentInvalid
}
if s.authorizeWorkspace != nil {
if err := s.authorizeWorkspace(ctx, workspaceID); err != nil {
return nil, err
}
}
if deliveryMode == "" {
deliveryMode = attachmentDeliveryModePrompt
}
if err := ValidateAttachmentMetadata(name, mimeType, kind, deliveryMode); err != nil {
return nil, err
}
if err := s.ensureRoot(); err != nil {
return nil, fmt.Errorf("create attachment storage: %w", err)
}
id := uuid.NewString()
storageKey := id
dest, n, err := s.writeAttachmentBytes(storageKey, src)
if err != nil {
return nil, err
}
now := time.Now().UTC()
attachment := &models.TaskMessageAttachment{
ID: id, OwnerID: ownerID, WorkspaceID: workspaceID, Name: filepath.Base(name),
MimeType: mimeType, Kind: kind, DeliveryMode: deliveryMode, SizeBytes: n,
StorageKey: storageKey, State: models.AttachmentStateStaged,
ExpiresAt: now.Add(AttachmentStagedTTL), CreatedAt: now, UpdatedAt: now,
}
if err := s.repo.CreateMessageAttachment(ctx, attachment); err != nil {
_ = os.Remove(dest)
return nil, err
}
return attachment, nil
}
func (s *AttachmentService) writeAttachmentBytes(storageKey string, src io.Reader) (string, int64, error) {
tmp, err := os.CreateTemp(s.root, ".upload-*")
if err != nil {
return "", 0, fmt.Errorf("create attachment temp file: %w", err)
}
tmpPath := tmp.Name()
renamed := false
defer func() {
if !renamed {
_ = tmp.Close()
_ = os.Remove(tmpPath)
}
}()
n, copyErr := io.Copy(tmp, io.LimitReader(src, MaxAttachmentBytes+1))
if copyErr != nil {
return "", 0, fmt.Errorf("write attachment: %w", copyErr)
}
if err := tmp.Close(); err != nil {
return "", 0, fmt.Errorf("close attachment: %w", err)
}
if err := ValidateAttachmentSize(n); err != nil {
return "", 0, err
}
dest := filepath.Join(s.root, storageKey)
if err := os.Chmod(tmpPath, 0o600); err != nil {
return "", 0, fmt.Errorf("secure attachment temp file: %w", err)
}
if err := os.Rename(tmpPath, dest); err != nil {
return "", 0, fmt.Errorf("commit attachment bytes: %w", err)
}
renamed = true
return dest, n, nil
}
func ValidateAttachmentMetadata(name, mimeType, kind, deliveryMode string) error {
if strings.TrimSpace(name) == "" || strings.ContainsAny(name, "/\\\x00") {
return fmt.Errorf("%w: invalid name", ErrAttachmentInvalid)
}
if strings.TrimSpace(mimeType) == "" {
return fmt.Errorf("%w: mime type is required", ErrAttachmentInvalid)
}
switch kind {
case "image", "audio", "resource":
default:
return fmt.Errorf("%w: unsupported kind %q", ErrAttachmentInvalid, kind)
}
if deliveryMode == "" {
deliveryMode = attachmentDeliveryModePrompt
}
if deliveryMode != attachmentDeliveryModePrompt && deliveryMode != attachmentDeliveryModePath {
return fmt.Errorf("%w: delivery mode must be prompt or path", ErrAttachmentInvalid)
}
return nil
}
func (s *AttachmentService) Get(ctx context.Context, ownerID, id string) (*models.TaskMessageAttachment, error) {
attachment, err := s.repo.GetMessageAttachment(ctx, id)
if err != nil {
return nil, err
}
if attachment == nil || attachment.OwnerID != ownerID {
return nil, ErrAttachmentForbidden
}
return attachment, nil
}
func (s *AttachmentService) Open(ctx context.Context, ownerID, id string) (*models.TaskMessageAttachment, *os.File, error) {
attachment, err := s.repo.GetMessageAttachment(ctx, id)
if err != nil {
return nil, nil, err
}
if attachment == nil {
return nil, nil, ErrAttachmentForbidden
}
switch attachment.State {
case models.AttachmentStateStaged:
if attachment.OwnerID != ownerID {
return nil, nil, ErrAttachmentForbidden
}
case models.AttachmentStateClaimed:
if attachment.TaskID == "" || s.authorizeTask == nil {
return nil, nil, ErrAttachmentForbidden
}
if err := s.authorizeTask(ctx, attachment.TaskID); err != nil {
return nil, nil, ErrAttachmentForbidden
}
default:
return nil, nil, ErrAttachmentForbidden
}
if filepath.Base(attachment.StorageKey) != attachment.StorageKey || attachment.StorageKey == "" {
return nil, nil, ErrAttachmentInvalid
}
file, err := os.Open(filepath.Join(s.root, attachment.StorageKey))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil, ErrAttachmentNotFound
}
return nil, nil, fmt.Errorf("open attachment: %w", err)
}
return attachment, file, nil
}
// OpenClaimed opens a descriptor for the internal lifecycle delivery path.
// It deliberately authorizes by the claimed task/session binding rather than
// accepting a caller-supplied owner, and never exposes the storage key.
func (s *AttachmentService) OpenClaimed(ctx context.Context, id, taskID, sessionID string) (io.ReadCloser, string, string, int64, error) {
attachment, err := s.repo.GetMessageAttachment(ctx, id)
if err != nil {
return nil, "", "", 0, err
}
if attachment == nil || attachment.State != models.AttachmentStateClaimed ||
attachment.TaskID != taskID || (attachment.SessionID != "" && attachment.SessionID != sessionID) {
return nil, "", "", 0, ErrAttachmentForbidden
}
if filepath.Base(attachment.StorageKey) != attachment.StorageKey || attachment.StorageKey == "" {
return nil, "", "", 0, ErrAttachmentInvalid
}
file, err := os.Open(filepath.Join(s.root, attachment.StorageKey))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, "", "", 0, ErrAttachmentNotFound
}
return nil, "", "", 0, fmt.Errorf("open claimed attachment: %w", err)
}
return file, attachment.Name, attachment.MimeType, attachment.SizeBytes, nil
}
func (s *AttachmentService) Delete(ctx context.Context, ownerID, id string) error {
attachment, err := s.Get(ctx, ownerID, id)
if err != nil {
return err
}
if attachment.State != models.AttachmentStateStaged {
return ErrAttachmentClaimConflict
}
if err := s.repo.DeleteMessageAttachment(ctx, id, ownerID); err != nil {
return err
}
s.removeBytes(attachment)
return nil
}
func (s *AttachmentService) Claim(ctx context.Context, ownerID, workspaceID, taskID, sessionID string, ids []string) error {
if s.authorizeWorkspace != nil {
if err := s.authorizeWorkspace(ctx, workspaceID); err != nil {
return err
}
}
return s.repo.ClaimMessageAttachments(ctx, ids, ownerID, workspaceID, taskID, sessionID)
}
// Release removes claimed descriptors that are no longer referenced by a
// queued message. It is used after an atomic queue replacement succeeds.
func (s *AttachmentService) Release(ctx context.Context, ownerID, taskID, sessionID string, ids []string) error {
attachments, err := s.repo.DeleteClaimedMessageAttachments(ctx, ids, ownerID, taskID, sessionID)
if err != nil {
return err
}
for _, attachment := range attachments {
s.removeBytes(attachment)
}
return nil
}
// DeleteByTask removes all attachment registry rows and private bytes owned by
// a task. Task deletion must clean claimed rows as well as staged rows because
// only staged rows participate in expiry maintenance.
func (s *AttachmentService) DeleteByTask(ctx context.Context, taskID string) error {
attachments, err := s.repo.DeleteMessageAttachmentsByTask(ctx, taskID)
if err != nil {
return err
}
for _, attachment := range attachments {
s.removeBytes(attachment)
}
return nil
}
func (s *AttachmentService) Descriptor(attachment *models.TaskMessageAttachment) AttachmentDescriptor {
return AttachmentDescriptor{
ID: attachment.ID, Name: attachment.Name, MimeType: attachment.MimeType,
Kind: attachment.Kind, DeliveryMode: attachment.DeliveryMode,
SizeBytes: attachment.SizeBytes, State: attachment.State, ExpiresAt: attachment.ExpiresAt,
}
}
// CleanupExpired marks staged descriptors expired and removes their bytes.
func (s *AttachmentService) CleanupExpired(ctx context.Context) (int, error) {
expired, err := s.repo.MarkExpiredMessageAttachments(ctx, time.Now().UTC())
if err != nil {
return 0, err
}
for _, attachment := range expired {
s.removeBytes(attachment)
}
return len(expired), nil
}
func (s *AttachmentService) removeBytes(attachment *models.TaskMessageAttachment) {
if attachment == nil || attachment.StorageKey == "" {
return
}
if err := os.Remove(filepath.Join(s.root, filepath.Base(attachment.StorageKey))); err != nil && !errors.Is(err, os.ErrNotExist) {
s.log.Warn("remove attachment bytes failed", zap.String("attachment_id", attachment.ID), zap.Error(err))
}
}
// ValidateAttachmentSize applies the raw-byte per-file limit. The boundary is
// inclusive so a file exactly MaxAttachmentBytes is valid.
func ValidateAttachmentSize(size int64) error {
if size < 0 || size > MaxAttachmentBytes {
return fmt.Errorf("%w: %d bytes (maximum %d)", ErrAttachmentTooLarge, size, MaxAttachmentBytes)
}
return nil
}
// ValidateAttachmentBatch applies the shared count and aggregate limits.
func ValidateAttachmentBatch(sizes []int64) error {
if len(sizes) > MaxAttachmentCount {
return fmt.Errorf("%w: maximum %d", ErrTooManyAttachments, MaxAttachmentCount)
}
var total int64
for _, size := range sizes {
if err := ValidateAttachmentSize(size); err != nil {
return err
}
total += size
if total > MaxAttachmentBytes {
return fmt.Errorf("%w: maximum %d bytes", ErrAttachmentTotalTooLarge, MaxAttachmentBytes)
}
}
return nil
}