package file_service import ( "context" "errors" "fmt" "net/http" "path/filepath" "strings" "evening_detective_server/internal/modules/file_storage" "evening_detective_server/internal/modules/string_tools" ) // Ошибки валидации загружаемого файла. Маппятся в gRPC-статусы в app-слое: // ErrFileTooLarge → ResourceExhausted, остальные → InvalidArgument. var ( ErrEmptyName = errors.New("пустое имя файла") ErrEmptyData = errors.New("пустые данные файла") ErrInvalidExtension = errors.New("недопустимое расширение файла (разрешены pdf, jpg, jpeg, png, gif, webp, mp3, ogg, wav)") ErrInvalidContentType = errors.New("содержимое файла не соответствует разрешённым типам (pdf, изображения или аудио)") ErrFileTooLarge = errors.New("файл слишком большой") ) // allowedExtensions — расширения, разрешённые для загрузки: PDF, изображения // и аудио (аудио-введение сценария загружается тем же эндпоинтом). var allowedExtensions = map[string]struct{}{ ".pdf": {}, ".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {}, ".webp": {}, ".mp3": {}, ".ogg": {}, ".wav": {}, } type FileService struct { fileStorage file_storage.IFileStorage stringTools string_tools.IStringTools maxFileSize int } func NewFileService( fileStorage file_storage.IFileStorage, stringTools string_tools.IStringTools, maxFileSize int, ) *FileService { return &FileService{ fileStorage: fileStorage, stringTools: stringTools, maxFileSize: maxFileSize, } } // UploadFile сохраняет файл в хранилище под уникальным именем и возвращает // сохранённое имя и категорию файла (image|pdf|audio). // // Порядок проверок: имя/данные → расширение → base → размер → содержимое. // Проверка размера — defense-in-depth: фактический лимит запроса // устанавливается на HTTP-слое (MaxBytesReader) и gRPC-лимитами. func (s *FileService) UploadFile( ctx context.Context, filename string, data []byte, ) (string, string, error) { if filename == "" { return "", "", ErrEmptyName } if len(data) == 0 { return "", "", ErrEmptyData } ext := strings.ToLower(filepath.Ext(filename)) if _, ok := allowedExtensions[ext]; !ok { return "", "", ErrInvalidExtension } base := strings.TrimSuffix(filename, filepath.Ext(filename)) if base == "" { return "", "", ErrEmptyName } // Транслитерация может обнулить base (например, «!!!.pdf»): хранимое // имя без base недопустимо — проверяем до размера и содержимого. transliterated := s.stringTools.Transliterate(base) if transliterated == "" { return "", "", ErrEmptyName } if len(data) > s.maxFileSize { return "", "", fmt.Errorf("%w (лимит %d МБ)", ErrFileTooLarge, s.maxFileSize>>20) } // Категория по содержимому (сырой sniff): text/* и octet-stream не // проходят вообще, расширение должно соответствовать категории. sniffed := http.DetectContentType(data) fileType := contentTypeCategory(sniffed) if fileType == "" || fileType != file_storage.FileType(filename) { return "", "", ErrInvalidContentType } randomName, err := file_storage.NewName() if err != nil { return "", "", fmt.Errorf("не удалось сгенерировать имя файла: %w", err) } storedName := transliterated + "_" + randomName + ext if err := s.fileStorage.Put(ctx, &file_storage.File{ Name: storedName, Data: data, Mime: sniffed, }); err != nil { return "", "", err } return storedName, fileType, nil } // contentTypeCategory возвращает категорию по sniffed-типу: // application/pdf → pdf; image/* → image; audio/* и application/ogg → audio; // всё остальное (text/*, application/octet-stream и пр.) → "". func contentTypeCategory(sniffed string) string { switch { case sniffed == "application/pdf": return "pdf" case strings.HasPrefix(sniffed, "image/"): return "image" case strings.HasPrefix(sniffed, "audio/"): return "audio" case sniffed == "application/ogg": return "audio" default: return "" } } func (s *FileService) DownloadFile( ctx context.Context, filename string, ) (*file_storage.File, error) { file, err := s.fileStorage.Get(ctx, filename) if err != nil { return nil, err } return file, nil }