Add structured logging and centralized error handling
- Initialize slog in the serve command with terminal/file support - Introduce `AppError` with HTTP status for unified service-layer errors - Replace ad-hoc `api.Error` calls with `api.RespondError` - Wrap internal errors with `model.NewInternalError` and add reference IDs - Add `RequestID` middleware and switch router from `gin.Default` to `gin.New`
This commit is contained in:
+46
-39
@@ -8,6 +8,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -44,6 +46,7 @@ type FileService struct {
|
||||
fileRepo repository.FileRepository
|
||||
storage storage.StorageBackend
|
||||
maxUploadSize int64
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewFileService creates a FileService.
|
||||
@@ -51,11 +54,16 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,9 +82,9 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
|
||||
|
||||
// Check for name conflicts in the target directory.
|
||||
if _, err := s.fileRepo.FindByNameAndParent(ctx, userID, parentID, fileName); err == nil {
|
||||
return nil, fmt.Errorf("a file with this name already exists in this directory")
|
||||
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, fmt.Errorf("check name conflict: %w", err)
|
||||
return nil, model.NewInternalError("check name conflict", err)
|
||||
}
|
||||
|
||||
// Limit the reader if a max upload size is configured.
|
||||
@@ -88,7 +96,7 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
|
||||
head := make([]byte, 512)
|
||||
n, readErr := io.ReadFull(reader, head)
|
||||
if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF {
|
||||
return nil, fmt.Errorf("read for mime detection: %w", readErr)
|
||||
return nil, model.NewInternalError("read for mime detection", readErr)
|
||||
}
|
||||
head = head[:n]
|
||||
mimeType := mimetype.Detect(head).String()
|
||||
@@ -103,13 +111,13 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
|
||||
|
||||
written, err := s.storage.Save(ctx, storagePath, teeReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("save file: %w", err)
|
||||
return nil, model.NewInternalError("save file", err)
|
||||
}
|
||||
|
||||
if s.maxUploadSize > 0 && written > s.maxUploadSize {
|
||||
// Clean up the oversized file.
|
||||
_ = s.storage.Delete(ctx, storagePath)
|
||||
return nil, fmt.Errorf("file size exceeds the maximum allowed size of %d bytes", s.maxUploadSize)
|
||||
return nil, &model.AppError{Status: http.StatusBadRequest, Message: fmt.Sprintf("file size exceeds the maximum allowed size of %d bytes", s.maxUploadSize)}
|
||||
}
|
||||
|
||||
file := &model.File{
|
||||
@@ -128,9 +136,9 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
|
||||
// Compensate: remove the stored file.
|
||||
_ = s.storage.Delete(ctx, storagePath)
|
||||
if errors.Is(err, model.ErrDuplicate) {
|
||||
return nil, fmt.Errorf("a file with this name already exists in this directory")
|
||||
return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in this directory"}
|
||||
}
|
||||
return nil, fmt.Errorf("create file record: %w", err)
|
||||
return nil, model.NewInternalError("create file record", err)
|
||||
}
|
||||
|
||||
return modelToFileInfo(file), nil
|
||||
@@ -145,12 +153,12 @@ func (s *FileService) Download(ctx context.Context, userID, fileID string) (io.R
|
||||
}
|
||||
|
||||
if file.IsDir {
|
||||
return nil, nil, fmt.Errorf("cannot download a directory")
|
||||
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, fmt.Errorf("open file: %w", err)
|
||||
return nil, nil, model.NewInternalError("open file", err)
|
||||
}
|
||||
|
||||
return reader, modelToFileInfo(file), nil
|
||||
@@ -175,7 +183,7 @@ func (s *FileService) List(ctx context.Context, userID string, parentID *string,
|
||||
|
||||
files, total, err := s.fileRepo.FindByParentIDPaginated(ctx, userID, parentID, offset, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list files: %w", err)
|
||||
return nil, model.NewInternalError("list files", err)
|
||||
}
|
||||
|
||||
infos := make([]FileInfo, 0, len(files))
|
||||
@@ -204,7 +212,7 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName
|
||||
// If parent is changing, verify the new parent.
|
||||
if newParentID != nil {
|
||||
if *newParentID == fileID {
|
||||
return nil, fmt.Errorf("cannot move a directory into itself")
|
||||
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
|
||||
@@ -216,17 +224,17 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName
|
||||
if newName != "" || newParentID != nil {
|
||||
conflict, err := s.fileRepo.FindByNameAndParent(ctx, userID, file.ParentID, file.Name)
|
||||
if err == nil && conflict.ID != fileID {
|
||||
return nil, fmt.Errorf("a file with this name already exists in the target directory")
|
||||
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, fmt.Errorf("check name conflict: %w", err)
|
||||
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, fmt.Errorf("a file with this name already exists in the target directory")
|
||||
return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in the target directory"}
|
||||
}
|
||||
return nil, fmt.Errorf("update file: %w", err)
|
||||
return nil, model.NewInternalError("update file", err)
|
||||
}
|
||||
|
||||
return modelToFileInfo(file), nil
|
||||
@@ -243,23 +251,22 @@ func (s *FileService) Delete(ctx context.Context, userID, fileID string) error {
|
||||
if file.IsDir {
|
||||
_, total, err := s.fileRepo.FindByParentIDPaginated(ctx, userID, &fileID, 0, 1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check directory contents: %w", err)
|
||||
return model.NewInternalError("check directory contents", err)
|
||||
}
|
||||
if total > 0 {
|
||||
return fmt.Errorf("directory is not empty")
|
||||
return &model.AppError{Status: http.StatusConflict, Message: "directory is not empty"}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.fileRepo.Delete(ctx, fileID); err != nil {
|
||||
return fmt.Errorf("delete file record: %w", err)
|
||||
return model.NewInternalError("delete file record", err)
|
||||
}
|
||||
|
||||
// Remove content from storage (directories have no stored content).
|
||||
if !file.IsDir {
|
||||
if err := s.storage.Delete(ctx, file.StoragePath); err != nil {
|
||||
// Log but don't fail — the DB record is already gone.
|
||||
// A periodic cleanup job can handle orphaned files later.
|
||||
_ = err
|
||||
s.logger.WarnContext(ctx, "failed to delete file from storage",
|
||||
"path", file.StoragePath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,24 +287,24 @@ func (s *FileService) CreateDir(ctx context.Context, userID string, parentID *st
|
||||
|
||||
// Check for name conflicts in the target directory.
|
||||
if _, err := s.fileRepo.FindByNameAndParent(ctx, userID, parentID, dirName); err == nil {
|
||||
return nil, fmt.Errorf("a file with this name already exists in this directory")
|
||||
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, fmt.Errorf("check name conflict: %w", err)
|
||||
return nil, model.NewInternalError("check name conflict", err)
|
||||
}
|
||||
|
||||
dir := &model.File{
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
ParentID: parentID,
|
||||
Name: dirName,
|
||||
IsDir: true,
|
||||
Name: dirName,
|
||||
IsDir: true,
|
||||
}
|
||||
|
||||
if err := s.fileRepo.Create(ctx, dir); err != nil {
|
||||
if errors.Is(err, model.ErrDuplicate) {
|
||||
return nil, fmt.Errorf("a file with this name already exists in this directory")
|
||||
return nil, &model.AppError{Status: http.StatusConflict, Message: "a file with this name already exists in this directory"}
|
||||
}
|
||||
return nil, fmt.Errorf("create directory record: %w", err)
|
||||
return nil, model.NewInternalError("create directory record", err)
|
||||
}
|
||||
|
||||
return modelToFileInfo(dir), nil
|
||||
@@ -308,13 +315,13 @@ func (s *FileService) getOwnedFile(ctx context.Context, userID, fileID string) (
|
||||
file, err := s.fileRepo.FindByID(ctx, fileID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.ErrNotFound
|
||||
return nil, &model.AppError{Status: http.StatusNotFound, Message: "file not found", Err: model.ErrNotFound}
|
||||
}
|
||||
return nil, fmt.Errorf("find file: %w", err)
|
||||
return nil, model.NewInternalError("find file", err)
|
||||
}
|
||||
|
||||
if file.UserID != userID {
|
||||
return nil, model.ErrForbidden
|
||||
return nil, &model.AppError{Status: http.StatusForbidden, Message: "access denied", Err: model.ErrForbidden}
|
||||
}
|
||||
|
||||
return file, nil
|
||||
@@ -325,17 +332,17 @@ func (s *FileService) verifyParent(ctx context.Context, userID, parentID string)
|
||||
parent, err := s.fileRepo.FindByID(ctx, parentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return fmt.Errorf("parent directory not found")
|
||||
return &model.AppError{Status: http.StatusNotFound, Message: "parent directory not found"}
|
||||
}
|
||||
return fmt.Errorf("find parent: %w", err)
|
||||
return model.NewInternalError("find parent", err)
|
||||
}
|
||||
|
||||
if parent.UserID != userID {
|
||||
return model.ErrForbidden
|
||||
return &model.AppError{Status: http.StatusForbidden, Message: "access denied", Err: model.ErrForbidden}
|
||||
}
|
||||
|
||||
if !parent.IsDir {
|
||||
return fmt.Errorf("parent is not a directory")
|
||||
return &model.AppError{Status: http.StatusBadRequest, Message: "parent is not a directory"}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -345,13 +352,13 @@ func (s *FileService) verifyParent(ctx context.Context, userID, parentID string)
|
||||
// null bytes, or are reserved names "." and "..".
|
||||
func validateFileName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("file name must not be empty")
|
||||
return &model.AppError{Status: http.StatusBadRequest, Message: "file name must not be empty"}
|
||||
}
|
||||
if strings.ContainsAny(name, "/\\\x00") {
|
||||
return fmt.Errorf("file name contains invalid characters")
|
||||
return &model.AppError{Status: http.StatusBadRequest, Message: "file name contains invalid characters"}
|
||||
}
|
||||
if name == "." || name == ".." {
|
||||
return fmt.Errorf("file name is reserved: %s", name)
|
||||
return &model.AppError{Status: http.StatusBadRequest, Message: fmt.Sprintf("file name is reserved: %s", name)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user