569 lines
14 KiB
Go
569 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/gabriel-vasile/mimetype"
|
|
"github.com/google/uuid"
|
|
"github.com/rwcarlsen/goexif/exif"
|
|
|
|
"tanabata/backend/internal/domain"
|
|
"tanabata/backend/internal/port"
|
|
)
|
|
|
|
const fileObjectType = "file"
|
|
|
|
// fileObjectTypeID is the primary key of the "file" row in core.object_types.
|
|
// It matches the first value inserted in 007_seed_data.sql.
|
|
const fileObjectTypeID int16 = 1
|
|
|
|
// UploadParams holds the parameters for uploading a new file.
|
|
type UploadParams struct {
|
|
Reader io.Reader
|
|
MIMEType string
|
|
OriginalName *string
|
|
Notes *string
|
|
Metadata json.RawMessage
|
|
ContentDatetime *time.Time
|
|
IsPublic bool
|
|
TagIDs []uuid.UUID
|
|
}
|
|
|
|
// UpdateParams holds the parameters for updating file metadata.
|
|
type UpdateParams struct {
|
|
OriginalName *string
|
|
Notes *string
|
|
Metadata json.RawMessage
|
|
ContentDatetime *time.Time
|
|
IsPublic *bool
|
|
TagIDs *[]uuid.UUID // nil means don't change tags
|
|
}
|
|
|
|
// ContentResult holds the open reader and metadata for a file download.
|
|
type ContentResult struct {
|
|
Body io.ReadCloser
|
|
MIMEType string
|
|
OriginalName *string
|
|
}
|
|
|
|
// ImportFileError records a failed file during an import operation.
|
|
type ImportFileError struct {
|
|
Filename string `json:"filename"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// ImportResult summarises a directory import.
|
|
type ImportResult struct {
|
|
Imported int `json:"imported"`
|
|
Skipped int `json:"skipped"`
|
|
Errors []ImportFileError `json:"errors"`
|
|
}
|
|
|
|
// FileService handles business logic for file records.
|
|
type FileService struct {
|
|
files port.FileRepo
|
|
mimes port.MimeRepo
|
|
storage port.FileStorage
|
|
acl *ACLService
|
|
audit *AuditService
|
|
tags *TagService
|
|
tx port.Transactor
|
|
importPath string // default server-side import directory
|
|
}
|
|
|
|
// NewFileService creates a FileService.
|
|
func NewFileService(
|
|
files port.FileRepo,
|
|
mimes port.MimeRepo,
|
|
storage port.FileStorage,
|
|
acl *ACLService,
|
|
audit *AuditService,
|
|
tags *TagService,
|
|
tx port.Transactor,
|
|
importPath string,
|
|
) *FileService {
|
|
return &FileService{
|
|
files: files,
|
|
mimes: mimes,
|
|
storage: storage,
|
|
acl: acl,
|
|
audit: audit,
|
|
tags: tags,
|
|
tx: tx,
|
|
importPath: importPath,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Core CRUD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Upload validates the MIME type, saves the file to storage, creates the DB
|
|
// record, and applies any initial tags — all within a single transaction.
|
|
// If ContentDatetime is nil and EXIF DateTimeOriginal is present, it is used.
|
|
func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File, error) {
|
|
userID, _, _ := domain.UserFromContext(ctx)
|
|
|
|
// Validate MIME type against the whitelist.
|
|
mime, err := s.mimes.GetByName(ctx, p.MIMEType)
|
|
if err != nil {
|
|
return nil, err // ErrUnsupportedMIME or DB error
|
|
}
|
|
|
|
// Buffer the upload so we can extract EXIF without re-reading storage.
|
|
var buf bytes.Buffer
|
|
if _, err := io.Copy(&buf, p.Reader); err != nil {
|
|
return nil, fmt.Errorf("FileService.Upload: read body: %w", err)
|
|
}
|
|
data := buf.Bytes()
|
|
|
|
// Extract EXIF metadata (best-effort; non-image files will error silently).
|
|
exifData, exifDatetime := extractEXIFWithDatetime(data)
|
|
|
|
// Resolve content datetime: explicit > EXIF > zero value.
|
|
var contentDatetime time.Time
|
|
if p.ContentDatetime != nil {
|
|
contentDatetime = *p.ContentDatetime
|
|
} else if exifDatetime != nil {
|
|
contentDatetime = *exifDatetime
|
|
}
|
|
|
|
// Assign UUID v7 so CreatedAt can be derived from it later.
|
|
fileID, err := uuid.NewV7()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("FileService.Upload: generate UUID: %w", err)
|
|
}
|
|
|
|
// Save file bytes to disk before opening the transaction so that a disk
|
|
// failure does not abort an otherwise healthy DB transaction.
|
|
if _, err := s.storage.Save(ctx, fileID, bytes.NewReader(data)); err != nil {
|
|
return nil, fmt.Errorf("FileService.Upload: save to storage: %w", err)
|
|
}
|
|
|
|
var created *domain.File
|
|
txErr := s.tx.WithTx(ctx, func(ctx context.Context) error {
|
|
f := &domain.File{
|
|
ID: fileID,
|
|
OriginalName: p.OriginalName,
|
|
MIMEType: mime.Name,
|
|
MIMEExtension: mime.Extension,
|
|
ContentDatetime: contentDatetime,
|
|
Notes: p.Notes,
|
|
Metadata: p.Metadata,
|
|
EXIF: exifData,
|
|
CreatorID: userID,
|
|
IsPublic: p.IsPublic,
|
|
}
|
|
|
|
var createErr error
|
|
created, createErr = s.files.Create(ctx, f)
|
|
if createErr != nil {
|
|
return createErr
|
|
}
|
|
|
|
if len(p.TagIDs) > 0 {
|
|
tags, err := s.tags.SetFileTags(ctx, created.ID, p.TagIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
created.Tags = tags
|
|
}
|
|
return nil
|
|
})
|
|
if txErr != nil {
|
|
// Attempt to clean up the orphaned file; ignore cleanup errors.
|
|
_ = s.storage.Delete(ctx, fileID)
|
|
return nil, txErr
|
|
}
|
|
|
|
objType := fileObjectType
|
|
_ = s.audit.Log(ctx, "file_create", &objType, &created.ID, nil)
|
|
return created, nil
|
|
}
|
|
|
|
// Get returns a file by ID, enforcing view ACL.
|
|
func (s *FileService) Get(ctx context.Context, id uuid.UUID) (*domain.File, error) {
|
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
|
|
f, err := s.files.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ok, err := s.acl.CanView(ctx, userID, isAdmin, f.CreatorID, f.IsPublic, fileObjectTypeID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
return nil, domain.ErrForbidden
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// Update applies metadata changes to a file, enforcing edit ACL.
|
|
func (s *FileService) Update(ctx context.Context, id uuid.UUID, p UpdateParams) (*domain.File, error) {
|
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
|
|
f, err := s.files.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
return nil, domain.ErrForbidden
|
|
}
|
|
|
|
patch := &domain.File{}
|
|
if p.OriginalName != nil {
|
|
patch.OriginalName = p.OriginalName
|
|
}
|
|
if p.Notes != nil {
|
|
patch.Notes = p.Notes
|
|
}
|
|
if p.Metadata != nil {
|
|
patch.Metadata = p.Metadata
|
|
}
|
|
if p.ContentDatetime != nil {
|
|
patch.ContentDatetime = *p.ContentDatetime
|
|
}
|
|
if p.IsPublic != nil {
|
|
patch.IsPublic = *p.IsPublic
|
|
}
|
|
|
|
var updated *domain.File
|
|
txErr := s.tx.WithTx(ctx, func(ctx context.Context) error {
|
|
var updateErr error
|
|
updated, updateErr = s.files.Update(ctx, id, patch)
|
|
if updateErr != nil {
|
|
return updateErr
|
|
}
|
|
if p.TagIDs != nil {
|
|
tags, err := s.tags.SetFileTags(ctx, id, *p.TagIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
updated.Tags = tags
|
|
}
|
|
return nil
|
|
})
|
|
if txErr != nil {
|
|
return nil, txErr
|
|
}
|
|
|
|
objType := fileObjectType
|
|
_ = s.audit.Log(ctx, "file_edit", &objType, &id, nil)
|
|
return updated, nil
|
|
}
|
|
|
|
// Delete soft-deletes a file (moves to trash), enforcing edit ACL.
|
|
func (s *FileService) Delete(ctx context.Context, id uuid.UUID) error {
|
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
|
|
f, err := s.files.GetByID(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return domain.ErrForbidden
|
|
}
|
|
|
|
if err := s.files.SoftDelete(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
|
|
objType := fileObjectType
|
|
_ = s.audit.Log(ctx, "file_delete", &objType, &id, nil)
|
|
return nil
|
|
}
|
|
|
|
// Restore moves a soft-deleted file out of trash, enforcing edit ACL.
|
|
func (s *FileService) Restore(ctx context.Context, id uuid.UUID) (*domain.File, error) {
|
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
|
|
f, err := s.files.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
return nil, domain.ErrForbidden
|
|
}
|
|
|
|
restored, err := s.files.Restore(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
objType := fileObjectType
|
|
_ = s.audit.Log(ctx, "file_restore", &objType, &id, nil)
|
|
return restored, nil
|
|
}
|
|
|
|
// PermanentDelete removes the file record and its stored bytes. Only allowed
|
|
// when the file is already in trash.
|
|
func (s *FileService) PermanentDelete(ctx context.Context, id uuid.UUID) error {
|
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
|
|
f, err := s.files.GetByID(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !f.IsDeleted {
|
|
return domain.ErrConflict
|
|
}
|
|
|
|
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return domain.ErrForbidden
|
|
}
|
|
|
|
if err := s.files.DeletePermanent(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
_ = s.storage.Delete(ctx, id)
|
|
|
|
objType := fileObjectType
|
|
_ = s.audit.Log(ctx, "file_permanent_delete", &objType, &id, nil)
|
|
return nil
|
|
}
|
|
|
|
// Replace swaps the stored bytes for a file with new content.
|
|
func (s *FileService) Replace(ctx context.Context, id uuid.UUID, p UploadParams) (*domain.File, error) {
|
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
|
|
f, err := s.files.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
return nil, domain.ErrForbidden
|
|
}
|
|
|
|
mime, err := s.mimes.GetByName(ctx, p.MIMEType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if _, err := io.Copy(&buf, p.Reader); err != nil {
|
|
return nil, fmt.Errorf("FileService.Replace: read body: %w", err)
|
|
}
|
|
data := buf.Bytes()
|
|
exifData, _ := extractEXIFWithDatetime(data)
|
|
|
|
if _, err := s.storage.Save(ctx, id, bytes.NewReader(data)); err != nil {
|
|
return nil, fmt.Errorf("FileService.Replace: save to storage: %w", err)
|
|
}
|
|
|
|
patch := &domain.File{
|
|
MIMEType: mime.Name,
|
|
MIMEExtension: mime.Extension,
|
|
EXIF: exifData,
|
|
}
|
|
if p.OriginalName != nil {
|
|
patch.OriginalName = p.OriginalName
|
|
}
|
|
|
|
updated, err := s.files.Update(ctx, id, patch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
objType := fileObjectType
|
|
_ = s.audit.Log(ctx, "file_replace", &objType, &id, nil)
|
|
return updated, nil
|
|
}
|
|
|
|
// List delegates to FileRepo with the given params.
|
|
func (s *FileService) List(ctx context.Context, params domain.FileListParams) (*domain.FilePage, error) {
|
|
return s.files.List(ctx, params)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Content / thumbnail / preview streaming
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// GetContent opens the raw file for download, enforcing view ACL.
|
|
func (s *FileService) GetContent(ctx context.Context, id uuid.UUID) (*ContentResult, error) {
|
|
f, err := s.Get(ctx, id) // ACL checked inside Get
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rc, err := s.storage.Read(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &ContentResult{
|
|
Body: rc,
|
|
MIMEType: f.MIMEType,
|
|
OriginalName: f.OriginalName,
|
|
}, nil
|
|
}
|
|
|
|
// GetThumbnail returns the thumbnail JPEG, enforcing view ACL.
|
|
func (s *FileService) GetThumbnail(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) {
|
|
if _, err := s.Get(ctx, id); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.storage.Thumbnail(ctx, id)
|
|
}
|
|
|
|
// GetPreview returns the preview JPEG, enforcing view ACL.
|
|
func (s *FileService) GetPreview(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) {
|
|
if _, err := s.Get(ctx, id); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.storage.Preview(ctx, id)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bulk operations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// BulkDelete soft-deletes multiple files. Files the caller cannot edit are silently skipped.
|
|
func (s *FileService) BulkDelete(ctx context.Context, fileIDs []uuid.UUID) error {
|
|
for _, id := range fileIDs {
|
|
if err := s.Delete(ctx, id); err != nil {
|
|
if err == domain.ErrNotFound || err == domain.ErrForbidden {
|
|
continue
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Import
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Import scans a server-side directory and uploads all supported files.
|
|
// If path is empty, the configured default import path is used.
|
|
func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, error) {
|
|
dir := path
|
|
if dir == "" {
|
|
dir = s.importPath
|
|
}
|
|
if dir == "" {
|
|
return nil, domain.ErrValidation
|
|
}
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("FileService.Import: read dir %q: %w", dir, err)
|
|
}
|
|
|
|
result := &ImportResult{Errors: []ImportFileError{}}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
result.Skipped++
|
|
continue
|
|
}
|
|
|
|
fullPath := filepath.Join(dir, entry.Name())
|
|
|
|
mt, err := mimetype.DetectFile(fullPath)
|
|
if err != nil {
|
|
result.Errors = append(result.Errors, ImportFileError{
|
|
Filename: entry.Name(),
|
|
Reason: fmt.Sprintf("MIME detection failed: %s", err),
|
|
})
|
|
continue
|
|
}
|
|
|
|
mimeStr := mt.String()
|
|
// Strip parameters (e.g. "text/plain; charset=utf-8" → "text/plain").
|
|
if idx := len(mimeStr); idx > 0 {
|
|
for i, c := range mimeStr {
|
|
if c == ';' {
|
|
mimeStr = mimeStr[:i]
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if _, err := s.mimes.GetByName(ctx, mimeStr); err != nil {
|
|
result.Skipped++
|
|
continue
|
|
}
|
|
|
|
f, err := os.Open(fullPath)
|
|
if err != nil {
|
|
result.Errors = append(result.Errors, ImportFileError{
|
|
Filename: entry.Name(),
|
|
Reason: fmt.Sprintf("open failed: %s", err),
|
|
})
|
|
continue
|
|
}
|
|
|
|
name := entry.Name()
|
|
_, uploadErr := s.Upload(ctx, UploadParams{
|
|
Reader: f,
|
|
MIMEType: mimeStr,
|
|
OriginalName: &name,
|
|
})
|
|
f.Close()
|
|
|
|
if uploadErr != nil {
|
|
result.Errors = append(result.Errors, ImportFileError{
|
|
Filename: entry.Name(),
|
|
Reason: uploadErr.Error(),
|
|
})
|
|
continue
|
|
}
|
|
result.Imported++
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// extractEXIFWithDatetime parses EXIF from raw bytes, returning both the JSON
|
|
// representation and the DateTimeOriginal (if present). Both may be nil.
|
|
func extractEXIFWithDatetime(data []byte) (json.RawMessage, *time.Time) {
|
|
x, err := exif.Decode(bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
b, err := x.MarshalJSON()
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
var dt *time.Time
|
|
if t, err := x.DateTime(); err == nil {
|
|
dt = &t
|
|
}
|
|
return json.RawMessage(b), dt
|
|
} |