Files
mygo/internal/service/file.go
T
ld a18f96912d feat(repository): refactor query/mutation ports with capability-safe
interfaces

- refactor: split UserRepository into AuthUserRepository and
  AdminUserRepository capabilities
- refactor: split SessionRepository into AuthSessionRepository and
  repository-owned CLI/admin methods
- refactor: replace generic Repository Update/Delete with
  operation-specific params and ownership predicates
- refactor: replace CredentialRepository generic CRUD with
  passkey-specific methods
- refactor: replace FileRepository generic Create/Update/Delete with
  UploadedFileParams, DirectoryParams, and owned soft-delete
- refactor: remove repository fields from WebApp struct; repositories
  are now composition-time wiring only
- feat: add domain error kinds ErrParentNotFound, ErrParentNotDir,
  ErrDirectoryNotEmpty, ErrInvalidMove
- feat: add CredentialTypeAppPasskey constant
- feat: add testutil.SetUserAdmin for test fixture setup that bypasses
  production service ports
- test: add architecture test banning GORM Save in repository package
- test: add capability interface contract tests ensuring each service
  receives the minimal interface
- test: add blockingStorage helper for concurrent promotion tests
- test: add preserved DSN parameter test for sqliteImmediateDSN
- docs: update architecture decisions with repository write rules and
  capability separation
- docs: update roadmap to clarify atomic single-use refresh sessions
- docs: add -race test target to development docs
2026-07-16 12:24:36 +08:00

428 lines
12 KiB
Go

package service
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"strings"
"time"
"github.com/gabriel-vasile/mimetype"
"github.com/google/uuid"
"github.com/dhao2001/mygo/internal/model"
"github.com/dhao2001/mygo/internal/repository"
"github.com/dhao2001/mygo/internal/storage"
)
// FileInfo is the public representation of a file or directory.
type FileInfo struct {
ID string
UserID string
ParentID *string
Name string
Size int64
MimeType string
IsDir bool
Hash string
CreatedAt time.Time
UpdatedAt time.Time
}
// FileList is a paginated list of files.
type FileList struct {
Files []FileInfo
Total int64
}
// FileService handles file management business logic.
type FileService struct {
fileRepo repository.FileRepository
storage storage.StorageBackend
maxUploadSize int64
logger *slog.Logger
}
// NewFileService creates a FileService.
func NewFileService(
fileRepo repository.FileRepository,
storage storage.StorageBackend,
maxUploadSize int64,
logger *slog.Logger,
) *FileService {
if logger == nil {
logger = slog.Default()
}
return &FileService{
fileRepo: fileRepo,
storage: storage,
maxUploadSize: maxUploadSize,
logger: logger,
}
}
// MaxUploadSize returns the configured maximum upload size in bytes.
// A value of 0 means uploads are not size-limited by MyGO.
func (s *FileService) MaxUploadSize() int64 {
return s.maxUploadSize
}
// Upload stores a file's content and creates its metadata record.
// The MIME type is always detected server-side from the file content.
func (s *FileService) Upload(ctx context.Context, userID string, parentID *string, fileName string, reader io.Reader) (*FileInfo, error) {
if err := validateFileName(fileName); err != nil {
return nil, err
}
if parentID != nil {
if err := s.verifyParent(ctx, userID, *parentID); err != nil {
return nil, err
}
}
// Check for name conflicts in the target directory.
if _, err := s.fileRepo.FindByNameAndParent(ctx, userID, parentID, fileName); err == nil {
return nil, model.NewConflictError("a file with this name already exists in this directory")
} else if !errors.Is(err, model.ErrNotFound) {
return nil, model.NewInternalError("check name conflict", err)
}
if s.maxUploadSize > 0 {
reader = &uploadLimitReader{reader: reader, remaining: s.maxUploadSize}
}
// Detect MIME type from file content.
head := make([]byte, 512)
n, readErr := io.ReadFull(reader, head)
if isUploadTooLargeError(readErr) {
return nil, uploadTooLargeError(s.maxUploadSize)
}
if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF {
return nil, model.NewInternalError("read for mime detection", readErr)
}
head = head[:n]
mimeType := mimetype.Detect(head).String()
reader = io.MultiReader(bytes.NewReader(head), reader)
fileID := uuid.NewString()
stagedPath := fmt.Sprintf("staging/%s/%s", userID, uuid.NewString())
storagePath := fmt.Sprintf("data/%s/%s", userID, fileID)
// Compute SHA-256 hash while writing to staging storage.
hasher := sha256.New()
teeReader := io.TeeReader(reader, hasher)
written, err := s.storage.SaveStaged(ctx, stagedPath, teeReader)
if err != nil {
_ = s.storage.DeleteStaged(ctx, stagedPath)
if isUploadTooLargeError(err) {
return nil, uploadTooLargeError(s.maxUploadSize)
}
return nil, model.NewInternalError("save staged file", err)
}
if s.maxUploadSize > 0 && written > s.maxUploadSize {
_ = s.storage.DeleteStaged(ctx, stagedPath)
return nil, uploadTooLargeError(s.maxUploadSize)
}
if err := s.storage.PromoteStaged(ctx, stagedPath, storagePath); err != nil {
_ = s.storage.DeleteStaged(ctx, stagedPath)
return nil, model.NewInternalError("promote staged file", err)
}
file, err := s.fileRepo.CreateUploadedFile(ctx, repository.UploadedFileParams{
ID: fileID,
UserID: userID,
ParentID: parentID,
Name: fileName,
Size: written,
MimeType: mimeType,
StoragePath: storagePath,
Hash: hex.EncodeToString(hasher.Sum(nil)),
})
if err != nil {
// Compensate: remove the promoted file.
_ = s.storage.Delete(ctx, storagePath)
if errors.Is(err, model.ErrDuplicate) {
return nil, model.NewConflictError("a file with this name already exists in this directory")
}
if errors.Is(err, model.ErrParentNotFound) {
return nil, model.NewNotFoundError("parent directory not found", model.ErrParentNotFound)
}
if errors.Is(err, model.ErrParentNotDir) {
return nil, model.NewBadRequestError("parent is not a directory")
}
return nil, model.NewInternalError("create file record", err)
}
return modelToFileInfo(file), nil
}
type uploadLimitReader struct {
reader io.Reader
remaining int64
}
func (r *uploadLimitReader) Read(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
if r.remaining == 0 {
var one [1]byte
n, err := r.reader.Read(one[:])
if n > 0 {
p[0] = one[0]
return 1, model.ErrUploadTooLarge
}
return 0, err
}
if int64(len(p)) > r.remaining {
p = p[:r.remaining]
}
n, err := r.reader.Read(p)
r.remaining -= int64(n)
return n, err
}
func isUploadTooLargeError(err error) bool {
if err == nil {
return false
}
return errors.Is(err, model.ErrUploadTooLarge)
}
func uploadTooLargeError(maxUploadSize int64) *model.AppError {
if maxUploadSize > 0 {
return model.NewPayloadTooLargeError(fmt.Sprintf("file size exceeds the maximum allowed size of %d bytes", maxUploadSize), model.ErrUploadTooLarge)
}
return model.NewPayloadTooLargeError("request body is too large", model.ErrUploadTooLarge)
}
// Download returns a reader for the file's content and its metadata.
// The caller must close the returned ReadCloser.
func (s *FileService) Download(ctx context.Context, userID, fileID string) (io.ReadCloser, *FileInfo, error) {
file, err := s.getOwnedFile(ctx, userID, fileID)
if err != nil {
return nil, nil, err
}
if file.IsDir {
return nil, nil, model.NewBadRequestError("cannot download a directory")
}
reader, err := s.storage.Open(ctx, file.StoragePath)
if err != nil {
return nil, nil, model.NewInternalError("open file", err)
}
return reader, modelToFileInfo(file), nil
}
// Get returns metadata for a single file or directory.
func (s *FileService) Get(ctx context.Context, userID, fileID string) (*FileInfo, error) {
file, err := s.getOwnedFile(ctx, userID, fileID)
if err != nil {
return nil, err
}
return modelToFileInfo(file), nil
}
// List returns the contents of a directory with pagination.
func (s *FileService) List(ctx context.Context, userID string, parentID *string, offset, limit int) (*FileList, error) {
if parentID != nil {
if err := s.verifyParent(ctx, userID, *parentID); err != nil {
return nil, err
}
}
files, total, err := s.fileRepo.FindByParentIDPaginated(ctx, userID, parentID, offset, limit)
if err != nil {
return nil, model.NewInternalError("list files", err)
}
infos := make([]FileInfo, 0, len(files))
for i := range files {
infos = append(infos, *modelToFileInfo(&files[i]))
}
return &FileList{Files: infos, Total: total}, nil
}
// Update renames or moves a file or directory.
func (s *FileService) Update(ctx context.Context, userID, fileID string, newName string, newParentID *string) (*FileInfo, error) {
var newNamePtr *string
if newName != "" {
if err := validateFileName(newName); err != nil {
return nil, err
}
newNamePtr = &newName
}
if newParentID != nil {
if *newParentID == fileID {
return nil, model.NewBadRequestError("cannot move a directory into itself")
}
}
file, err := s.fileRepo.UpdateOwnedMetadata(ctx, repository.FileMetadataUpdate{
FileID: fileID,
UserID: userID,
NewName: newNamePtr,
NewParentID: newParentID,
})
if err != nil {
if errors.Is(err, model.ErrDuplicate) {
return nil, model.NewConflictError("a file with this name already exists in the target directory")
}
if errors.Is(err, model.ErrNotFound) {
return nil, model.NewNotFoundError("file not found", model.ErrNotFound)
}
if errors.Is(err, model.ErrParentNotFound) {
return nil, model.NewNotFoundError("parent directory not found", model.ErrParentNotFound)
}
if errors.Is(err, model.ErrParentNotDir) {
return nil, model.NewBadRequestError("parent is not a directory")
}
if errors.Is(err, model.ErrInvalidMove) {
return nil, model.NewBadRequestError("cannot move a directory into itself")
}
return nil, model.NewInternalError("update file", err)
}
return modelToFileInfo(file), nil
}
// Delete soft-deletes a file or (empty) directory. Storage content is preserved.
func (s *FileService) Delete(ctx context.Context, userID, fileID string) error {
if err := s.fileRepo.SoftDeleteOwned(ctx, userID, fileID); err != nil {
if errors.Is(err, model.ErrNotFound) {
return model.NewNotFoundError("file not found", model.ErrNotFound)
}
if errors.Is(err, model.ErrDirectoryNotEmpty) {
return model.NewConflictError("directory is not empty")
}
return model.NewInternalError("delete file record", err)
}
return nil
}
// CreateDir creates a new directory.
func (s *FileService) CreateDir(ctx context.Context, userID string, parentID *string, dirName string) (*FileInfo, error) {
if err := validateFileName(dirName); err != nil {
return nil, err
}
if parentID != nil {
if err := s.verifyParent(ctx, userID, *parentID); err != nil {
return nil, err
}
}
// Check for name conflicts in the target directory.
if _, err := s.fileRepo.FindByNameAndParent(ctx, userID, parentID, dirName); err == nil {
return nil, model.NewConflictError("a file with this name already exists in this directory")
} else if !errors.Is(err, model.ErrNotFound) {
return nil, model.NewInternalError("check name conflict", err)
}
dir, err := s.fileRepo.CreateDirectory(ctx, repository.DirectoryParams{
ID: uuid.NewString(),
UserID: userID,
ParentID: parentID,
Name: dirName,
})
if err != nil {
if errors.Is(err, model.ErrDuplicate) {
return nil, model.NewConflictError("a file with this name already exists in this directory")
}
if errors.Is(err, model.ErrParentNotFound) {
return nil, model.NewNotFoundError("parent directory not found", model.ErrParentNotFound)
}
if errors.Is(err, model.ErrParentNotDir) {
return nil, model.NewBadRequestError("parent is not a directory")
}
return nil, model.NewInternalError("create directory record", err)
}
return modelToFileInfo(dir), nil
}
// getOwnedFile fetches a file and verifies ownership.
func (s *FileService) getOwnedFile(ctx context.Context, userID, fileID string) (*model.File, error) {
file, err := s.fileRepo.FindByID(ctx, fileID)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return nil, model.NewNotFoundError("file not found", model.ErrNotFound)
}
return nil, model.NewInternalError("find file", err)
}
if file.UserID != userID {
return nil, model.NewNotFoundError("file not found", model.ErrNotFound)
}
return file, nil
}
// verifyParent checks that a parent directory exists, belongs to the user, and is a directory.
func (s *FileService) verifyParent(ctx context.Context, userID, parentID string) error {
parent, err := s.fileRepo.FindByID(ctx, parentID)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return model.NewNotFoundError("parent directory not found", model.ErrNotFound)
}
return model.NewInternalError("find parent", err)
}
if parent.UserID != userID {
return model.NewNotFoundError("parent directory not found", model.ErrNotFound)
}
if !parent.IsDir {
return model.NewBadRequestError("parent is not a directory")
}
return nil
}
// validateFileName rejects names that are empty, contain path separators,
// null bytes, or are reserved names "." and "..".
func validateFileName(name string) error {
if name == "" {
return model.NewBadRequestError("file name must not be empty")
}
if strings.ContainsAny(name, "/\\\x00") {
return model.NewBadRequestError("file name contains invalid characters")
}
if name == "." || name == ".." {
return model.NewBadRequestError(fmt.Sprintf("file name is reserved: %s", name))
}
return nil
}
// modelToFileInfo converts a model.File to a FileInfo DTO.
func modelToFileInfo(f *model.File) *FileInfo {
return &FileInfo{
ID: f.ID,
UserID: f.UserID,
ParentID: f.ParentID,
Name: f.Name,
Size: f.Size,
MimeType: f.MimeType,
IsDir: f.IsDir,
Hash: f.Hash,
CreatedAt: f.CreatedAt,
UpdatedAt: f.UpdatedAt,
}
}