feat(backend): implement file service with upload, CRUD, ACL, and audit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0ae8b81a0b
commit
99508cdbf8
@ -38,6 +38,7 @@ require (
|
|||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
||||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
|||||||
@ -80,6 +80,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
|||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
||||||
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
|||||||
392
backend/internal/service/file_service.go
Normal file
392
backend/internal/service/file_service.go
Normal file
@ -0,0 +1,392 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileService creates a FileService.
|
||||||
|
func NewFileService(
|
||||||
|
files port.FileRepo,
|
||||||
|
mimes port.MimeRepo,
|
||||||
|
storage port.FileStorage,
|
||||||
|
acl *ACLService,
|
||||||
|
audit *AuditService,
|
||||||
|
tx port.Transactor,
|
||||||
|
) *FileService {
|
||||||
|
return &FileService{
|
||||||
|
files: files,
|
||||||
|
mimes: mimes,
|
||||||
|
storage: storage,
|
||||||
|
acl: acl,
|
||||||
|
audit: audit,
|
||||||
|
tx: tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload validates the MIME type, saves the file to storage, creates the DB
|
||||||
|
// record, and applies any initial tags — all within a single transaction.
|
||||||
|
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 := extractEXIF(data)
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
if err := s.files.SetTags(ctx, id, *p.TagIDs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tags, err := s.files.ListTags(ctx, id)
|
||||||
|
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. Restricted to admins and the creator.
|
||||||
|
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.ErrValidation
|
||||||
|
}
|
||||||
|
|
||||||
|
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. The MIME type
|
||||||
|
// may change. Thumbnail/preview caches are not invalidated here — callers
|
||||||
|
// should handle that if needed.
|
||||||
|
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 := extractEXIF(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 {
|
||||||
|
x, err := exif.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b, err := x.MarshalJSON()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.RawMessage(b)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user