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 `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 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 := &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.NewConflictError("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, 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) { 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.NewBadRequestError("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.NewConflictError("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.NewConflictError("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.NewConflictError("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.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 := &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.NewConflictError("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.NewNotFoundError("file not found", model.ErrNotFound) } return nil, model.NewInternalError("find file", err) } if file.UserID != userID { return nil, model.NewForbiddenError("access denied", 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.NewNotFoundError("parent directory not found", model.ErrNotFound) } return model.NewInternalError("find parent", err) } if parent.UserID != userID { return model.NewForbiddenError("access denied", model.ErrForbidden) } 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, } }