add load applications

This commit is contained in:
2026-08-30 21:17:32 +07:00
parent 3b15fb3a2b
commit c0b5ceb65f
28 changed files with 1536 additions and 139 deletions
+109 -6
View File
@@ -2,34 +2,137 @@ 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,
file *file_storage.File,
) (string, error) {
file.Name = s.stringTools.Transliterate(file.Name)
if err := s.fileStorage.Put(ctx, file); err != nil {
return "", err
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 ""
}
return file.Name, nil
}
func (s *FileService) DownloadFile(
@@ -0,0 +1,164 @@
package file_service
import (
"context"
"errors"
"strings"
"testing"
"evening_detective_server/internal/modules/file_storage"
"evening_detective_server/internal/modules/string_tools"
)
// fakeStorage — in-memory реализация IFileStorage для тестов.
type fakeStorage struct {
files map[string]*file_storage.File
}
func newFakeStorage() *fakeStorage {
return &fakeStorage{files: map[string]*file_storage.File{}}
}
func (s *fakeStorage) Put(_ context.Context, f *file_storage.File) error {
cp := *f
cp.Data = append([]byte(nil), f.Data...)
s.files[f.Name] = &cp
return nil
}
func (s *fakeStorage) Get(_ context.Context, name string) (*file_storage.File, error) {
f, ok := s.files[name]
if !ok {
return nil, file_storage.ErrFileNotFound
}
return f, nil
}
func (s *fakeStorage) Delete(_ context.Context, name string) error {
delete(s.files, name)
return nil
}
func (s *fakeStorage) MimeType(filename string) string {
return file_storage.FileType(filename)
}
func newService(maxFileSize int) *FileService {
return NewFileService(newFakeStorage(), string_tools.NewStringTools(), maxFileSize)
}
func mustUpload(t *testing.T, svc *FileService, filename string, data []byte) (string, string) {
t.Helper()
name, fileType, err := svc.UploadFile(context.Background(), filename, data)
if err != nil {
t.Fatalf("UploadFile(%q): %v", filename, err)
}
return name, fileType
}
func TestUploadFileValid(t *testing.T) {
svc := newService(1 << 20)
cases := []struct {
filename string
data []byte
wantType string
wantExt string
wantMime string
}{
{"улика.pdf", []byte("%PDF-1.7 content"), "pdf", ".pdf", "application/pdf"},
{"photo.png", []byte("\x89PNG\r\n\x1a\ncontent"), "image", ".png", "image/png"},
{"photo.PNG", []byte("\x89PNG\r\n\x1a\ncontent"), "image", ".png", "image/png"},
{"photo.jpg", []byte("\xff\xd8\xff\xe0jpeg-content"), "image", ".jpg", "image/jpeg"},
{"audio.mp3", []byte("ID3\x04\x00\x00\x00\x00\x00\x00audio"), "audio", ".mp3", "audio/mpeg"},
{"audio.ogg", []byte("OggS\x00\x02audio"), "audio", ".ogg", "application/ogg"},
{"audio.wav", []byte("RIFF\x24\x00\x00\x00WAVEfmt "), "audio", ".wav", "audio/wave"},
}
for _, c := range cases {
name, fileType := mustUpload(t, svc, c.filename, c.data)
if fileType != c.wantType {
t.Errorf("%s: fileType = %q, want %q", c.filename, fileType, c.wantType)
}
if !strings.HasSuffix(name, c.wantExt) {
t.Errorf("%s: имя %q не оканчивается на %q", c.filename, name, c.wantExt)
}
if !strings.Contains(name, "_") {
t.Errorf("%s: имя %q не содержит уникальный hex-суффикс", c.filename, name)
}
// Сохранённый MIME соответствует содержимому (sniffed-тип).
stored, err := svc.fileStorage.Get(context.Background(), name)
if err != nil {
t.Fatalf("Get(%q): %v", name, err)
}
if stored.Mime != c.wantMime {
t.Errorf("%s: MIME = %q, want %q", c.filename, stored.Mime, c.wantMime)
}
}
}
func TestUploadFileUniqueNames(t *testing.T) {
svc := newService(1 << 20)
data := []byte("%PDF-1.7 same content")
first, _ := mustUpload(t, svc, "clue.pdf", data)
second, _ := mustUpload(t, svc, "clue.pdf", data)
if first == second {
t.Errorf("одинаковые имена %q — коллизия в бакете", first)
}
}
func TestUploadFileValidation(t *testing.T) {
svc := newService(1 << 20)
pdf := []byte("%PDF-1.7 content")
cases := []struct {
name string
filename string
data []byte
wantErr error
}{
{"пустое имя", "", pdf, ErrEmptyName},
{"пустые данные", "clue.pdf", nil, ErrEmptyData},
{"нет расширения", "clue", pdf, ErrInvalidExtension},
{"недопустимое расширение", "clue.exe", []byte("MZ\x90\x00"), ErrInvalidExtension},
{"пустой base (.pdf)", ".pdf", pdf, ErrEmptyName},
{"base из не-латиницы (!!!.pdf)", "!!!.pdf", pdf, ErrEmptyName},
{"html под видом png", "photo.png", []byte("<html>hi</html>"), ErrInvalidContentType},
{"octet-stream", "clue.pdf", []byte{0x00, 0x01, 0x02, 0x03}, ErrInvalidContentType},
{"mismatch pdf+png", "clue.pdf", []byte("\x89PNG\r\n\x1a\ncontent"), ErrInvalidContentType},
}
for _, c := range cases {
_, _, err := svc.UploadFile(context.Background(), c.filename, c.data)
if !errors.Is(err, c.wantErr) {
t.Errorf("%s: err = %v, want %v", c.name, err, c.wantErr)
}
}
}
func TestUploadFileTooLarge(t *testing.T) {
svc := newService(1024)
// ровно лимит — проходит
if _, _, err := svc.UploadFile(context.Background(), "clue.pdf", bytes1024()); err != nil {
t.Errorf("ровно лимит: err = %v, want nil", err)
}
// больше лимита — ErrFileTooLarge
_, _, err := svc.UploadFile(context.Background(), "clue.pdf", bytes1025())
if !errors.Is(err, ErrFileTooLarge) {
t.Errorf("больше лимита: err = %v, want ErrFileTooLarge", err)
}
}
func bytes1024() []byte {
b := make([]byte, 1024)
copy(b, "%PDF-1.7 ")
for i := 8; i < len(b); i++ {
b[i] = 'x'
}
return b
}
func bytes1025() []byte {
return append(bytes1024(), 'y')
}