feat(backend): implement file handler and wire all /files endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,8 +6,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rwcarlsen/goexif/exif"
|
||||
|
||||
@@ -23,13 +26,14 @@ 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
|
||||
IsPublic bool
|
||||
TagIDs []uuid.UUID
|
||||
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.
|
||||
@@ -42,14 +46,35 @@ type UpdateParams struct {
|
||||
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
|
||||
tx port.Transactor
|
||||
files port.FileRepo
|
||||
mimes port.MimeRepo
|
||||
storage port.FileStorage
|
||||
acl *ACLService
|
||||
audit *AuditService
|
||||
tx port.Transactor
|
||||
importPath string // default server-side import directory
|
||||
}
|
||||
|
||||
// NewFileService creates a FileService.
|
||||
@@ -60,19 +85,26 @@ func NewFileService(
|
||||
acl *ACLService,
|
||||
audit *AuditService,
|
||||
tx port.Transactor,
|
||||
importPath string,
|
||||
) *FileService {
|
||||
return &FileService{
|
||||
files: files,
|
||||
mimes: mimes,
|
||||
storage: storage,
|
||||
acl: acl,
|
||||
audit: audit,
|
||||
tx: tx,
|
||||
files: files,
|
||||
mimes: mimes,
|
||||
storage: storage,
|
||||
acl: acl,
|
||||
audit: audit,
|
||||
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)
|
||||
|
||||
@@ -90,7 +122,15 @@ func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File,
|
||||
data := buf.Bytes()
|
||||
|
||||
// Extract EXIF metadata (best-effort; non-image files will error silently).
|
||||
exifData := extractEXIF(data)
|
||||
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()
|
||||
@@ -107,15 +147,16 @@ func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File,
|
||||
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,
|
||||
Notes: p.Notes,
|
||||
Metadata: p.Metadata,
|
||||
EXIF: exifData,
|
||||
CreatorID: userID,
|
||||
IsPublic: p.IsPublic,
|
||||
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
|
||||
@@ -128,7 +169,6 @@ func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File,
|
||||
if err := s.files.SetTags(ctx, created.ID, p.TagIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
// Re-fetch to populate Tags on the returned value.
|
||||
tags, err := s.files.ListTags(ctx, created.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -283,7 +323,7 @@ func (s *FileService) Restore(ctx context.Context, id uuid.UUID) (*domain.File,
|
||||
}
|
||||
|
||||
// PermanentDelete removes the file record and its stored bytes. Only allowed
|
||||
// when the file is already in trash. Restricted to admins and the creator.
|
||||
// when the file is already in trash.
|
||||
func (s *FileService) PermanentDelete(ctx context.Context, id uuid.UUID) error {
|
||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||
|
||||
@@ -292,7 +332,7 @@ func (s *FileService) PermanentDelete(ctx context.Context, id uuid.UUID) error {
|
||||
return err
|
||||
}
|
||||
if !f.IsDeleted {
|
||||
return domain.ErrValidation
|
||||
return domain.ErrConflict
|
||||
}
|
||||
|
||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
||||
@@ -313,9 +353,7 @@ func (s *FileService) PermanentDelete(ctx context.Context, id uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Replace swaps the stored bytes for a file with new content. The MIME type
|
||||
// may change. Thumbnail/preview caches are not invalidated here — callers
|
||||
// should handle that if needed.
|
||||
// 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)
|
||||
|
||||
@@ -342,9 +380,8 @@ func (s *FileService) Replace(ctx context.Context, id uuid.UUID, p UploadParams)
|
||||
return nil, fmt.Errorf("FileService.Replace: read body: %w", err)
|
||||
}
|
||||
data := buf.Bytes()
|
||||
exifData := extractEXIF(data)
|
||||
exifData, _ := extractEXIFWithDatetime(data)
|
||||
|
||||
// Save new bytes, overwriting the existing stored file.
|
||||
if _, err := s.storage.Save(ctx, id, bytes.NewReader(data)); err != nil {
|
||||
return nil, fmt.Errorf("FileService.Replace: save to storage: %w", err)
|
||||
}
|
||||
@@ -373,20 +410,350 @@ func (s *FileService) List(ctx context.Context, params domain.FileListParams) (*
|
||||
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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tag operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ListFileTags returns the tags on a file, enforcing view ACL.
|
||||
func (s *FileService) ListFileTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error) {
|
||||
if _, err := s.Get(ctx, fileID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.files.ListTags(ctx, fileID)
|
||||
}
|
||||
|
||||
// SetFileTags replaces all tags on a file (full replace semantics), enforcing edit ACL.
|
||||
func (s *FileService) SetFileTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) ([]domain.Tag, error) {
|
||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||
|
||||
f, err := s.files.GetByID(ctx, fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, domain.ErrForbidden
|
||||
}
|
||||
|
||||
if err := s.files.SetTags(ctx, fileID, tagIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
objType := fileObjectType
|
||||
_ = s.audit.Log(ctx, "file_tag_add", &objType, &fileID, nil)
|
||||
return s.files.ListTags(ctx, fileID)
|
||||
}
|
||||
|
||||
// AddTag adds a single tag to a file, enforcing edit ACL.
|
||||
func (s *FileService) AddTag(ctx context.Context, fileID, tagID uuid.UUID) ([]domain.Tag, error) {
|
||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||
|
||||
f, err := s.files.GetByID(ctx, fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, domain.ErrForbidden
|
||||
}
|
||||
|
||||
current, err := s.files.ListTags(ctx, fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Only add if not already present.
|
||||
for _, t := range current {
|
||||
if t.ID == tagID {
|
||||
return current, nil
|
||||
}
|
||||
}
|
||||
ids := make([]uuid.UUID, 0, len(current)+1)
|
||||
for _, t := range current {
|
||||
ids = append(ids, t.ID)
|
||||
}
|
||||
ids = append(ids, tagID)
|
||||
|
||||
if err := s.files.SetTags(ctx, fileID, ids); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
objType := fileObjectType
|
||||
_ = s.audit.Log(ctx, "file_tag_add", &objType, &fileID, map[string]any{"tag_id": tagID})
|
||||
return s.files.ListTags(ctx, fileID)
|
||||
}
|
||||
|
||||
// RemoveTag removes a single tag from a file, enforcing edit ACL.
|
||||
func (s *FileService) RemoveTag(ctx context.Context, fileID, tagID uuid.UUID) error {
|
||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||
|
||||
f, err := s.files.GetByID(ctx, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
|
||||
current, err := s.files.ListTags(ctx, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := make([]uuid.UUID, 0, len(current))
|
||||
for _, t := range current {
|
||||
if t.ID != tagID {
|
||||
ids = append(ids, t.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.files.SetTags(ctx, fileID, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
objType := fileObjectType
|
||||
_ = s.audit.Log(ctx, "file_tag_remove", &objType, &fileID, map[string]any{"tag_id": tagID})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 {
|
||||
// Skip files not found or forbidden; surface real errors.
|
||||
if err == domain.ErrNotFound || err == domain.ErrForbidden {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BulkSetTags adds or removes the given tags on multiple files.
|
||||
// For "add": tags are appended to each file's existing set.
|
||||
// For "remove": tags are removed from each file's existing set.
|
||||
// Returns the tag IDs that were applied (the input tagIDs, for add).
|
||||
func (s *FileService) BulkSetTags(ctx context.Context, fileIDs []uuid.UUID, action string, tagIDs []uuid.UUID) ([]uuid.UUID, error) {
|
||||
for _, fileID := range fileIDs {
|
||||
switch action {
|
||||
case "add":
|
||||
for _, tagID := range tagIDs {
|
||||
if _, err := s.AddTag(ctx, fileID, tagID); err != nil {
|
||||
if err == domain.ErrNotFound || err == domain.ErrForbidden {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
case "remove":
|
||||
for _, tagID := range tagIDs {
|
||||
if err := s.RemoveTag(ctx, fileID, tagID); err != nil {
|
||||
if err == domain.ErrNotFound || err == domain.ErrForbidden {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, domain.ErrValidation
|
||||
}
|
||||
}
|
||||
if action == "add" {
|
||||
return tagIDs, nil
|
||||
}
|
||||
return []uuid.UUID{}, nil
|
||||
}
|
||||
|
||||
// CommonTags loads the tag sets for all given files and splits them into:
|
||||
// - common: tag IDs present on every file
|
||||
// - partial: tag IDs present on some but not all files
|
||||
func (s *FileService) CommonTags(ctx context.Context, fileIDs []uuid.UUID) (common, partial []uuid.UUID, err error) {
|
||||
if len(fileIDs) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// Count how many files each tag appears on.
|
||||
counts := map[uuid.UUID]int{}
|
||||
for _, fid := range fileIDs {
|
||||
tags, err := s.files.ListTags(ctx, fid)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, t := range tags {
|
||||
counts[t.ID]++
|
||||
}
|
||||
}
|
||||
|
||||
n := len(fileIDs)
|
||||
for id, cnt := range counts {
|
||||
if cnt == n {
|
||||
common = append(common, id)
|
||||
} else {
|
||||
partial = append(partial, id)
|
||||
}
|
||||
}
|
||||
if common == nil {
|
||||
common = []uuid.UUID{}
|
||||
}
|
||||
if partial == nil {
|
||||
partial = []uuid.UUID{}
|
||||
}
|
||||
return common, partial, 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// extractEXIF attempts to parse EXIF data from raw bytes and marshal it to
|
||||
// JSON. Returns nil on any error (non-image files, no EXIF header, etc.).
|
||||
func extractEXIF(data []byte) json.RawMessage {
|
||||
// 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
|
||||
return nil, nil
|
||||
}
|
||||
b, err := x.MarshalJSON()
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
return json.RawMessage(b)
|
||||
var dt *time.Time
|
||||
if t, err := x.DateTime(); err == nil {
|
||||
dt = &t
|
||||
}
|
||||
return json.RawMessage(b), dt
|
||||
}
|
||||
Reference in New Issue
Block a user