package service import ( "bytes" "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "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"` } // FileService handles file management business logic. type FileService struct { fileRepo repository.FileRepository storage storage.StorageBackend maxUploadSize int64 } // NewFileService creates a FileService. func NewFileService( fileRepo repository.FileRepository, storage storage.StorageBackend, maxUploadSize int64, ) *FileService { return &FileService{ fileRepo: fileRepo, storage: storage, maxUploadSize: 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, fmt.Errorf("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) } // Limit the reader if a max upload size is configured. if s.maxUploadSize > 0 { reader = io.LimitReader(reader, s.maxUploadSize+1) } // Detect MIME type from file content. 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) } head = head[:n] mimeType := mimetype.Detect(head).String() reader = io.MultiReader(bytes.NewReader(head), reader) fileID := uuid.NewString() storagePath := fmt.Sprintf("%s/%s", userID, fileID) // Compute SHA-256 hash while writing to storage. hasher := sha256.New() teeReader := io.TeeReader(reader, hasher) written, err := s.storage.Save(ctx, storagePath, teeReader) if err != nil { return nil, fmt.Errorf("save file: %w", 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) } file := &model.File{ ID: fileID, UserID: userID, ParentID: parentID, Name: fileName, Size: written, MimeType: mimeType, StoragePath: storagePath, Hash: hex.EncodeToString(hasher.Sum(nil)), IsDir: false, } if err := s.fileRepo.Create(ctx, file); err != nil { // 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, fmt.Errorf("create file record: %w", err) } return modelToFileInfo(file), nil } // 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, fmt.Errorf("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 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, fmt.Errorf("list files: %w", 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, fmt.Errorf("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, fmt.Errorf("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) } } 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, fmt.Errorf("update file: %w", err) } return modelToFileInfo(file), nil } // Delete removes a file or (empty) directory. func (s *FileService) Delete(ctx context.Context, userID, fileID string) error { file, err := s.getOwnedFile(ctx, userID, fileID) if err != nil { 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 fmt.Errorf("check directory contents: %w", err) } if total > 0 { return fmt.Errorf("directory is not empty") } } if err := s.fileRepo.Delete(ctx, fileID); err != nil { return fmt.Errorf("delete file record: %w", 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 } } 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, fmt.Errorf("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) } dir := &model.File{ ID: uuid.NewString(), UserID: userID, ParentID: parentID, 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, fmt.Errorf("create directory record: %w", 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.ErrNotFound } return nil, fmt.Errorf("find file: %w", err) } if file.UserID != userID { return nil, 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 fmt.Errorf("parent directory not found") } return fmt.Errorf("find parent: %w", err) } if parent.UserID != userID { return model.ErrForbidden } if !parent.IsDir { return fmt.Errorf("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 fmt.Errorf("file name must not be empty") } if strings.ContainsAny(name, "/\\\x00") { return fmt.Errorf("file name contains invalid characters") } if name == "." || name == ".." { return fmt.Errorf("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, } }