b988b4b15e
- fix: replace multipart form parsing with streaming multipart reads and apply request body limits when max_upload_size is configured. - refactor: route uploads through staging paths before promotion to long-term data paths, keeping incomplete uploads out of durable storage records. - test: cover oversized uploads, unlimited uploads, staged cleanup, and local storage promotion boundaries. - docs: document the staged upload model and multipart parent_id query parameter.
445 lines
13 KiB
Go
445 lines
13 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"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 `json:"id"`
|
|
UserID string `json:"user_id"`
|
|
ParentID *string `json:"parent_id"`
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
MimeType string `json:"mime_type"`
|
|
IsDir bool `json:"is_dir"`
|
|
Hash string `json:"hash,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// FileList is a paginated list of files.
|
|
type FileList struct {
|
|
Files []FileInfo `json:"files"`
|
|
Total int64 `json:"total"`
|
|
}
|
|
|
|
var errUploadTooLarge = errors.New("upload exceeds maximum size")
|
|
|
|
// 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.AppError{Status: http.StatusConflict, Message: "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 := &model.File{
|
|
ID: fileID,
|
|
UserID: userID,
|
|
ParentID: parentID,
|
|
Name: fileName,
|
|
Size: written,
|
|
MimeType: mimeType,
|
|
StoragePath: storagePath,
|
|
Hash: hex.EncodeToString(hasher.Sum(nil)),
|
|
Status: model.StatusActive,
|
|
IsDir: false,
|
|
}
|
|
|
|
if err := s.fileRepo.Create(ctx, file); err != nil {
|
|
// Compensate: remove the promoted file.
|
|
_ = s.storage.Delete(ctx, storagePath)
|
|
if errors.Is(err, model.ErrDuplicate) {
|
|
return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in this 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, 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
|
|
}
|
|
if errors.Is(err, errUploadTooLarge) {
|
|
return true
|
|
}
|
|
var maxBytesErr *http.MaxBytesError
|
|
return errors.As(err, &maxBytesErr)
|
|
}
|
|
|
|
func uploadTooLargeError(maxUploadSize int64) *model.AppError {
|
|
if maxUploadSize > 0 {
|
|
return &model.AppError{Status: http.StatusRequestEntityTooLarge, Message: fmt.Sprintf("file size exceeds the maximum allowed size of %d bytes", maxUploadSize)}
|
|
}
|
|
return &model.AppError{Status: http.StatusRequestEntityTooLarge, Message: "request body is too large"}
|
|
}
|
|
|
|
// 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.AppError{Status: http.StatusBadRequest, Message: "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) {
|
|
file, err := s.getOwnedFile(ctx, userID, fileID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Validate new name if provided.
|
|
if newName != "" {
|
|
if err := validateFileName(newName); err != nil {
|
|
return nil, err
|
|
}
|
|
file.Name = newName
|
|
}
|
|
|
|
// If parent is changing, verify the new parent.
|
|
if newParentID != nil {
|
|
if *newParentID == fileID {
|
|
return nil, &model.AppError{Status: http.StatusBadRequest, Message: "cannot move a directory into itself"}
|
|
}
|
|
if err := s.verifyParent(ctx, userID, *newParentID); err != nil {
|
|
return nil, err
|
|
}
|
|
file.ParentID = newParentID
|
|
}
|
|
|
|
// Check for name conflicts in the target directory.
|
|
if newName != "" || newParentID != nil {
|
|
conflict, err := s.fileRepo.FindByNameAndParent(ctx, userID, file.ParentID, file.Name)
|
|
if err == nil && conflict.ID != fileID {
|
|
return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in the target directory"}
|
|
} else if err != nil && !errors.Is(err, model.ErrNotFound) {
|
|
return nil, model.NewInternalError("check name conflict", err)
|
|
}
|
|
}
|
|
|
|
if err := s.fileRepo.Update(ctx, file); err != nil {
|
|
if errors.Is(err, model.ErrDuplicate) {
|
|
return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in the target directory"}
|
|
}
|
|
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 {
|
|
file, err := s.getOwnedFile(ctx, userID, fileID)
|
|
if err != nil {
|
|
var ae *model.AppError
|
|
if errors.As(err, &ae) && errors.Is(ae.Err, model.ErrNotFound) {
|
|
return nil // idempotent: already deleted
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Directories must be empty before deletion.
|
|
if file.IsDir {
|
|
_, total, err := s.fileRepo.FindByParentIDPaginated(ctx, userID, &fileID, 0, 1)
|
|
if err != nil {
|
|
return model.NewInternalError("check directory contents", err)
|
|
}
|
|
if total > 0 {
|
|
return &model.AppError{Status: http.StatusConflict, Message: "directory is not empty"}
|
|
}
|
|
}
|
|
|
|
if err := s.fileRepo.Delete(ctx, fileID); err != nil {
|
|
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.AppError{Status: http.StatusConflict, Message: "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 := &model.File{
|
|
ID: uuid.NewString(),
|
|
UserID: userID,
|
|
ParentID: parentID,
|
|
Name: dirName,
|
|
Status: model.StatusActive,
|
|
IsDir: true,
|
|
}
|
|
|
|
if err := s.fileRepo.Create(ctx, dir); err != nil {
|
|
if errors.Is(err, model.ErrDuplicate) {
|
|
return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in this 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.AppError{Status: http.StatusNotFound, Message: "file not found", Err: model.ErrNotFound}
|
|
}
|
|
return nil, model.NewInternalError("find file", err)
|
|
}
|
|
|
|
if file.UserID != userID {
|
|
return nil, &model.AppError{Status: http.StatusForbidden, Message: "access denied", Err: model.ErrForbidden}
|
|
}
|
|
|
|
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.AppError{Status: http.StatusNotFound, Message: "parent directory not found"}
|
|
}
|
|
return model.NewInternalError("find parent", err)
|
|
}
|
|
|
|
if parent.UserID != userID {
|
|
return &model.AppError{Status: http.StatusForbidden, Message: "access denied", Err: model.ErrForbidden}
|
|
}
|
|
|
|
if !parent.IsDir {
|
|
return &model.AppError{Status: http.StatusBadRequest, Message: "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.AppError{Status: http.StatusBadRequest, Message: "file name must not be empty"}
|
|
}
|
|
if strings.ContainsAny(name, "/\\\x00") {
|
|
return &model.AppError{Status: http.StatusBadRequest, Message: "file name contains invalid characters"}
|
|
}
|
|
if name == "." || name == ".." {
|
|
return &model.AppError{Status: http.StatusBadRequest, Message: 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,
|
|
}
|
|
}
|