945df7ef8a
Two broken-access-control holes: - PUT/DELETE /files/:id/tags(/:tag_id) and GET /files/:id/tags went straight to TagService with no ACL check, letting any authenticated user read or rewrite tags on anyone's private files. The handlers now require view (list) or edit (mutate) on the target file via new FileService.AuthorizeView/AuthorizeEdit helpers. - POST /files/import accepted an arbitrary host path from any user, turning it into an arbitrary server-side file read. It is now admin-only and the supplied path is confined to IMPORT_PATH. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
625 lines
16 KiB
Go
625 lines
16 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"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)
|
|
}
|
|
|
|
// AuthorizeView ensures the caller may view the file. Returns ErrNotFound if the
|
|
// file does not exist or ErrForbidden if the caller lacks view access.
|
|
func (s *FileService) AuthorizeView(ctx context.Context, id uuid.UUID) error {
|
|
_, err := s.Get(ctx, id)
|
|
return err
|
|
}
|
|
|
|
// AuthorizeEdit ensures the caller may edit the file. Returns ErrNotFound if the
|
|
// file does not exist or ErrForbidden if the caller lacks edit access.
|
|
func (s *FileService) AuthorizeEdit(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
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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) {
|
|
if s.importPath == "" {
|
|
return nil, domain.ErrValidation
|
|
}
|
|
|
|
dir := s.importPath
|
|
if path != "" {
|
|
// Confine caller-supplied paths to the configured import directory so a
|
|
// directory-traversal value cannot read arbitrary host files.
|
|
confined, err := confineToBase(s.importPath, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dir = confined
|
|
}
|
|
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// confineToBase resolves target and verifies it does not escape base (after
|
|
// cleaning and resolving "..") so a caller cannot read files outside the
|
|
// configured import directory. Returns the cleaned absolute path on success.
|
|
func confineToBase(base, target string) (string, error) {
|
|
absBase, err := filepath.Abs(base)
|
|
if err != nil {
|
|
return "", domain.ErrValidation
|
|
}
|
|
absTarget, err := filepath.Abs(target)
|
|
if err != nil {
|
|
return "", domain.ErrValidation
|
|
}
|
|
rel, err := filepath.Rel(absBase, absTarget)
|
|
if err != nil {
|
|
return "", domain.ErrValidation
|
|
}
|
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
|
return "", domain.ErrForbidden
|
|
}
|
|
return absTarget, nil
|
|
}
|
|
|
|
// 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
|
|
} |