// Package file_service handles file upload and download operations. 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, mp4, webm)") ErrInvalidContentType = errors.New("содержимое файла не соответствует разрешённым типам (pdf, изображения, аудио или видео)") ErrFileTooLarge = errors.New("файл слишком большой") // ErrFileNotFound — файл отсутствует в хранилище. ErrFileNotFound = errors.New("файл не найден") ) // allowedExtensions — расширения, разрешённые для загрузки: PDF, изображения // и аудио/видео (введение и закрывашка загружаются тем же эндпоинтом). var allowedExtensions = map[string]struct{}{ ".pdf": {}, ".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {}, ".webp": {}, ".mp3": {}, ".ogg": {}, ".wav": {}, ".mp4": {}, ".webm": {}, } type FileService struct { fileStorage file_storage.IFileStorage maxFileSize int } func NewFileService( fileStorage file_storage.IFileStorage, maxFileSize int, ) *FileService { return &FileService{ fileStorage: fileStorage, maxFileSize: maxFileSize, } } // UploadFile сохраняет файл в хранилище под уникальным именем и возвращает // сохранённое имя и категорию файла (image|pdf|audio|video). // // Порядок проверок: имя/данные → расширение → 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 := string_tools.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) if ext == ".mp3" && isMP3(data) { sniffed = "audio/mpeg" } 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 } // isMP3 распознаёт MP3 без необязательного ID3-тега по заголовку MPEG-фрейма. func isMP3(data []byte) bool { return len(data) >= 4 && data[0] == 0xff && data[1]&0xe0 == 0xe0 && data[1]&0x18 != 0x08 && data[1]&0x06 != 0 && data[2]&0xf0 != 0 && data[2]&0xf0 != 0xf0 && data[2]&0x0c != 0x0c } // 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 strings.HasPrefix(sniffed, "video/"): return "video" 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 { if errors.Is(err, file_storage.ErrFileNotFound) { return nil, ErrFileNotFound } return nil, err } return file, nil }