diff --git a/backend/go.mod b/backend/go.mod index a3f7dc7..10ac13a 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -38,6 +38,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 246d912..02c8a8f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 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/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/backend/internal/service/file_service.go b/backend/internal/service/file_service.go new file mode 100644 index 0000000..5332e78 --- /dev/null +++ b/backend/internal/service/file_service.go @@ -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) +} \ No newline at end of file