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
+191
View File
@@ -0,0 +1,191 @@
package app
import (
"context"
"errors"
"testing"
"evening_detective_server/internal/modules/file_storage"
"evening_detective_server/internal/modules/processor_jwt"
"evening_detective_server/internal/modules/string_tools"
"evening_detective_server/internal/services/file_service"
proto "evening_detective_server/proto"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
// fakeStorage — in-memory реализация IFileStorage для app-слоя.
type fakeStorage struct {
files map[string]*file_storage.File
putErr error
getErr error
}
func newFakeStorage() *fakeStorage {
return &fakeStorage{files: map[string]*file_storage.File{}}
}
func (s *fakeStorage) Put(_ context.Context, f *file_storage.File) error {
if s.putErr != nil {
return s.putErr
}
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) {
if s.getErr != nil {
return nil, s.getErr
}
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 newTestFileServer(storage *fakeStorage, maxSize int) *server {
if storage == nil {
storage = newFakeStorage()
}
return &server{
fileService: file_service.NewFileService(storage, string_tools.NewStringTools(), maxSize),
}
}
func ctxWithClaims(roles ...string) context.Context {
return context.WithValue(context.Background(), "claims", &processor_jwt.JWTClaims{UserID: 1, Roles: roles})
}
func pdfData(n int) []byte {
b := make([]byte, n)
copy(b, "%PDF-1.7 ")
for i := 8; i < n; i++ {
b[i] = 'x'
}
return b
}
func TestUploadFilePermissionDenied(t *testing.T) {
s := newTestFileServer(nil, 1<<20)
_, err := s.UploadFile(ctxWithClaims(), &proto.UploadFileReq{Filename: "clue.pdf", Data: pdfData(16)})
if status.Code(err) != codes.PermissionDenied {
t.Errorf("code = %v, want PermissionDenied", status.Code(err))
}
}
func TestUploadFileValidationInvalidArgument(t *testing.T) {
s := newTestFileServer(nil, 1<<20)
// Имя без расширения.
_, err := s.UploadFile(ctxWithClaims("author"), &proto.UploadFileReq{Filename: "clue", Data: pdfData(16)})
if status.Code(err) != codes.InvalidArgument {
t.Errorf("code = %v, want InvalidArgument", status.Code(err))
}
}
func TestUploadFileTooLargeResourceExhausted(t *testing.T) {
s := newTestFileServer(nil, 1024)
_, err := s.UploadFile(ctxWithClaims("author"), &proto.UploadFileReq{Filename: "clue.pdf", Data: pdfData(1025)})
if status.Code(err) != codes.ResourceExhausted {
t.Errorf("code = %v, want ResourceExhausted", status.Code(err))
}
}
func TestUploadFilePutErrorInternal(t *testing.T) {
storage := newFakeStorage()
storage.putErr = errors.New("s3 недоступен")
s := newTestFileServer(storage, 1<<20)
_, err := s.UploadFile(ctxWithClaims("author"), &proto.UploadFileReq{Filename: "clue.pdf", Data: pdfData(16)})
if status.Code(err) != codes.Internal {
t.Errorf("code = %v, want Internal", status.Code(err))
}
}
func TestUploadFileSuccess(t *testing.T) {
s := newTestFileServer(nil, 1<<20)
rsp, err := s.UploadFile(ctxWithClaims("author"), &proto.UploadFileReq{Filename: "clue.pdf", Data: pdfData(16)})
if err != nil {
t.Fatalf("UploadFile: %v", err)
}
if rsp.Filename == "" {
t.Errorf("Filename пустой")
}
if rsp.FileType != "pdf" {
t.Errorf("FileType = %q, want pdf", rsp.FileType)
}
if rsp.Error != "" {
t.Errorf("Error = %q, want пусто", rsp.Error)
}
}
// mockServerTransportStream — заглушка для grpc.SetHeader в юнит-тестах.
type mockServerTransportStream struct {
grpc.ServerTransportStream
header metadata.MD
}
func (m *mockServerTransportStream) SetHeader(md metadata.MD) error {
m.header = md
return nil
}
func TestDownloadFileNotFound(t *testing.T) {
s := newTestFileServer(nil, 1<<20)
_, err := s.DownloadFile(context.Background(), &proto.DownloadFileReq{Filename: "missing.pdf"})
if status.Code(err) != codes.NotFound {
t.Errorf("code = %v, want NotFound", status.Code(err))
}
}
func TestDownloadFileInternal(t *testing.T) {
storage := newFakeStorage()
storage.getErr = errors.New("s3 недоступен")
s := newTestFileServer(storage, 1<<20)
_, err := s.DownloadFile(context.Background(), &proto.DownloadFileReq{Filename: "clue.pdf"})
if status.Code(err) != codes.Internal {
t.Errorf("code = %v, want Internal", status.Code(err))
}
}
func TestDownloadFileSuccess(t *testing.T) {
storage := newFakeStorage()
storage.files["clue.pdf"] = &file_storage.File{
Name: "clue.pdf",
Data: []byte("%PDF-1.7 content"),
Mime: "application/pdf",
}
s := newTestFileServer(storage, 1<<20)
stream := &mockServerTransportStream{}
ctx := grpc.NewContextWithServerTransportStream(context.Background(), stream)
body, err := s.DownloadFile(ctx, &proto.DownloadFileReq{Filename: "clue.pdf"})
if err != nil {
t.Fatalf("DownloadFile: %v", err)
}
if string(body.Data) != "%PDF-1.7 content" {
t.Errorf("Data = %q", body.Data)
}
if body.ContentType != "application/pdf" {
t.Errorf("ContentType = %q, want application/pdf", body.ContentType)
}
if got := stream.header.Get("X-Content-Type-Options"); len(got) != 1 || got[0] != "nosniff" {
t.Errorf("X-Content-Type-Options = %v, want nosniff", got)
}
}
+30 -11
View File
@@ -312,27 +312,46 @@ func (s *server) GetPermissions(ctx context.Context, req *proto.GetPermissionsRe
}
func (s *server) UploadFile(ctx context.Context, req *proto.UploadFileReq) (*proto.UploadFileRsp, error) {
filename, err := s.fileService.UploadFile(
ctx,
&file_storage.File{
Name: req.Filename,
Data: req.Data,
},
)
// Файлы (в т.ч. улики) загружают авторы сценариев в редакторе.
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
if !roles.HasRole(claims, roles.Author) {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
filename, fileType, err := s.fileService.UploadFile(ctx, req.Filename, req.Data)
if err != nil {
return &proto.UploadFileRsp{
Error: err.Error(),
}, nil
switch {
case errors.Is(err, file_service.ErrFileTooLarge):
return nil, status.Errorf(codes.ResourceExhausted, "%v", err)
case errors.Is(err, file_service.ErrEmptyName),
errors.Is(err, file_service.ErrEmptyData),
errors.Is(err, file_service.ErrInvalidExtension),
errors.Is(err, file_service.ErrInvalidContentType):
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
default:
return nil, status.Errorf(codes.Internal, "%v", err)
}
}
return &proto.UploadFileRsp{
Filename: filename,
FileType: fileType,
}, nil
}
func (s *server) DownloadFile(ctx context.Context, req *proto.DownloadFileReq) (*httpbody.HttpBody, error) {
file, err := s.fileService.DownloadFile(ctx, req.Filename)
if err != nil {
return &httpbody.HttpBody{}, nil
switch {
case errors.Is(err, file_storage.ErrFileNotFound):
return nil, status.Errorf(codes.NotFound, "%v", err)
default:
return nil, status.Errorf(codes.Internal, "%v", err)
}
}
// nosniff — при публичном скачивании браузер не должен угадывать
// Content-Type по содержимому (защита от stored-XSS при расхождении).
if err := grpc.SetHeader(ctx, metadata.Pairs("X-Content-Type-Options", "nosniff")); err != nil {
return nil, status.Errorf(codes.Internal, "failed to set response header: %v", err)
}
return &httpbody.HttpBody{
Data: file.Data,
+6 -4
View File
@@ -109,8 +109,9 @@ func mapKeys(o []*storytelling.Key) []*proto.Key {
func mapApplication(o *storytelling.Application) *proto.Application {
return &proto.Application{
Name: o.Name,
Image: o.Image,
Name: o.Name,
Image: o.Image,
FileType: o.FileType,
}
}
@@ -167,8 +168,9 @@ func convertKeys(o []*proto.Key) []*storytelling.Key {
func convertApplication(o *proto.Application) *storytelling.Application {
return &storytelling.Application{
Name: textFormatter.FormatString(o.Name),
Image: o.Image,
Name: textFormatter.FormatString(o.Name),
Image: o.Image,
FileType: o.FileType,
}
}
+40 -1
View File
@@ -1,6 +1,16 @@
package file_storage
import "context"
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"strings"
)
// ErrFileNotFound — файл не найден в хранилище. Возвращается Get для
// отсутствующих объектов (маппинг smithy-ошибок NoSuchKey/NotFound).
var ErrFileNotFound = errors.New("файл не найден")
type File struct {
Name string
@@ -14,3 +24,32 @@ type IFileStorage interface {
Delete(ctx context.Context, filename string) error
MimeType(filename string) string
}
// FileType возвращает категорию файла по расширению имени: pdf → "pdf",
// jpg/jpeg/png/gif/webp → "image", mp3/ogg/wav → "audio", иначе "".
// Регистр расширения и query-параметры игнорируются.
// Используется для деривации file_type улик (см. storytelling.Application).
func FileType(filename string) string {
ext := strings.ToLower(extension(filename))
switch ext {
case ".pdf":
return "pdf"
case ".jpg", ".jpeg", ".png", ".gif", ".webp":
return "image"
case ".mp3", ".ogg", ".wav":
return "audio"
default:
return ""
}
}
// NewName возвращает случайное имя файла в хранилище (hex-токен 16 байт
// без расширения): исключает коллизии в общем бакете. Расширение добавляет
// вызывающий код.
func NewName() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}
+48 -3
View File
@@ -2,10 +2,14 @@ package file_storage
import (
"context"
"errors"
"mime"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/aws/smithy-go"
"github.com/kzzan/s3kit"
)
@@ -47,19 +51,27 @@ func NewRustFSStorage(
}
func (s *storage) Put(ctx context.Context, file *File) error {
mime := s.MimeType(file.Name)
return s.client.PutObjectBytes(ctx, s.bucket, file.Name, file.Data, mime)
return s.client.PutObjectBytes(ctx, s.bucket, file.Name, file.Data, mimeFor(file))
}
func (s *storage) Get(ctx context.Context, filename string) (*File, error) {
data, err := s.client.GetObjectBytes(ctx, s.bucket, filename)
if err != nil {
// s3kit отдаёт сырые smithy-ошибки: маппим отсутствие объекта
// (NoSuchKey/NotFound) в sentinel для прикладного слоя.
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
switch apiErr.ErrorCode() {
case "NoSuchKey", "NotFound":
return nil, ErrFileNotFound
}
}
return nil, err
}
return &File{
Name: filename,
Data: data,
Mime: s.MimeType(filename),
Mime: mimeFromData(data, filename),
}, nil
}
@@ -73,3 +85,36 @@ func (s *storage) MimeType(filename string) string {
}
return "application/octet-stream"
}
// mimeFor — Content-Type для Put: явный Mime файла приоритетнее вывода из
// расширения, чтобы объект в S3 соответствовал фактическому содержимому.
func mimeFor(f *File) string {
if f.Mime != "" {
return f.Mime
}
return mimeFromData(f.Data, f.Name)
}
// mimeFromData — Content-Type по содержимому (первые 512 байт) с fallback
// на расширение. text/* (например, HTML) трактуется как octet-stream и
// уходит в fallback: при публичном скачивании не должно отдаваться как
// html с доверенного домена.
func mimeFromData(data []byte, filename string) string {
detected := http.DetectContentType(data)
if strings.HasPrefix(detected, "text/") || detected == "application/octet-stream" {
if mimeType := mime.TypeByExtension(filepath.Ext(filename)); mimeType != "" {
return mimeType
}
return "application/octet-stream"
}
return detected
}
// extension — расширение имени файла в нижнем регистре, без query/fragment
// (например, "photo.PNG?v=2" → ".png").
func extension(filename string) string {
if i := strings.IndexAny(filename, "?#"); i >= 0 {
filename = filename[:i]
}
return filepath.Ext(strings.ToLower(filename))
}
@@ -0,0 +1,101 @@
package file_storage
import (
"strings"
"testing"
)
func TestFileType(t *testing.T) {
cases := []struct {
filename string
want string
}{
{"clue.pdf", "pdf"},
{"clue.PDF", "pdf"},
{"photo.jpg", "image"},
{"photo.jpeg", "image"},
{"photo.PNG", "image"},
{"photo.gif", "image"},
{"photo.webp", "image"},
{"audio.mp3", "audio"},
{"audio.ogg", "audio"},
{"audio.wav", "audio"},
{"audio.m4a", ""},
{"photo.PNG?v=2", "image"},
{"http://domain/api/files/photo.pdf", "pdf"},
{"noextension", ""},
{"", ""},
}
for _, c := range cases {
if got := FileType(c.filename); got != c.want {
t.Errorf("FileType(%q) = %q, want %q", c.filename, got, c.want)
}
}
}
func TestNewName(t *testing.T) {
a, err := NewName()
if err != nil {
t.Fatalf("NewName: %v", err)
}
b, err := NewName()
if err != nil {
t.Fatalf("NewName: %v", err)
}
if a == "" || b == "" {
t.Fatalf("NewName вернул пустое имя")
}
if a == b {
t.Errorf("NewName вернул одинаковые имена %q и %q", a, b)
}
// hex-токен 16 байт = 32 символа
if len(a) != 32 {
t.Errorf("длина имени = %d, want 32", len(a))
}
}
func TestMimeFor(t *testing.T) {
png := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("x", 16))
t.Run("explicit Mime wins", func(t *testing.T) {
f := &File{Name: "clue.pdf", Data: png, Mime: "application/pdf"}
if got := mimeFor(f); got != "application/pdf" {
t.Errorf("mimeFor = %q, want application/pdf", got)
}
})
t.Run("empty Mime falls back to content sniff", func(t *testing.T) {
f := &File{Name: "clue.bin", Data: png}
if got := mimeFor(f); got != "image/png" {
t.Errorf("mimeFor = %q, want image/png", got)
}
})
t.Run("empty Mime and unknown content falls back to extension", func(t *testing.T) {
f := &File{Name: "clue.pdf", Data: []byte("not really a pdf")}
if got := mimeFor(f); got != "application/pdf" {
t.Errorf("mimeFor = %q, want application/pdf по расширению", got)
}
})
}
func TestMimeFromData(t *testing.T) {
cases := []struct {
name string
data []byte
filename string
want string
}{
{"pdf", []byte("%PDF-1.7 fake"), "clue.pdf", "application/pdf"},
{"png", []byte("\x89PNG\r\n\x1a\nxxx"), "photo.png", "image/png"},
{"html falls back to extension", []byte("<html>hi</html>"), "photo.png", "image/png"},
{"html unknown extension", []byte("<html>hi</html>"), "clue.xyz", "application/octet-stream"},
{"unknown falls back to extension", []byte{0x00, 0x01, 0x02, 0x03}, "clue.pdf", "application/pdf"},
{"empty data falls back", nil, "clue.pdf", "application/pdf"},
}
for _, c := range cases {
if got := mimeFromData(c.data, c.filename); got != c.want {
t.Errorf("mimeFromData(%q, %q) = %q, want %q", c.name, c.filename, got, c.want)
}
}
}
@@ -64,6 +64,9 @@ type Application struct {
// Картинка
Image string `json:"image"`
// Тип файла улики: image | pdf | audio.
FileType string `json:"file_type,omitempty"`
}
// Дверь - действие или диалог
+3 -2
View File
@@ -144,8 +144,9 @@ func (s *story) mapPlace(
applications = append(
applications,
&Application{
Name: s.cleaner.ClearText(application.Name),
Image: application.Image,
Name: s.cleaner.ClearText(application.Name),
Image: application.Image,
FileType: application.FileType,
},
)
}
+6 -4
View File
@@ -229,8 +229,9 @@ func Test_story_GetStory(t *testing.T) {
Text: "Текст точки.",
Applications: []*Application{
{
Name: "Название улики",
Image: "image.png",
Name: "Название улики",
Image: "image.png",
FileType: "image",
},
},
},
@@ -245,8 +246,9 @@ func Test_story_GetStory(t *testing.T) {
Text: "Текст точки.",
Applications: []*Application{
{
Name: "Название улики",
Image: "image.png",
Name: "Название улики",
Image: "image.png",
FileType: "image",
},
},
},
+4 -3
View File
@@ -1,7 +1,8 @@
package repos
type Application struct {
Name string
Image string
TeamId int
Name string
Image string
TeamId int
FileType string
}
+6 -2
View File
@@ -27,15 +27,17 @@ func (r *ApplicationsRepo) AddApplication(
teamId int,
name string,
image string,
fileType string,
) (int, error) {
id := 0
err := r.pool.QueryRow(
ctx,
`INSERT INTO applications (name, image, team_id)
VALUES ($1, $2, $3)
`INSERT INTO applications (name, image, file_type, team_id)
VALUES ($1, $2, $3, $4)
RETURNING id`,
name,
image,
fileType,
teamId,
).Scan(&id)
@@ -55,6 +57,7 @@ func (r *ApplicationsRepo) GetApplicationsByTeamIDsAndState(
`SELECT
name,
image,
file_type,
team_id
FROM applications
WHERE team_id = ANY($1) and status = $2`,
@@ -72,6 +75,7 @@ func (r *ApplicationsRepo) GetApplicationsByTeamIDsAndState(
err := rows.Scan(
&application.Name,
&application.Image,
&application.FileType,
&application.TeamId,
)
if err != nil {
+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')
}
@@ -1,6 +0,0 @@
package game_service
type Application struct {
Name string
Image string
}
@@ -1,21 +1,64 @@
package game_service
import (
"strings"
"evening_detective_server/internal/modules/file_storage"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/repos"
)
func mapApplications(o []*repos.Application) []*storytelling.Application {
// mapApplications — улики команды для ответа организатору (GetFullGame):
// имена файлов префиксуются доменом идемпотентно, file_type деривируется
// из расширения при пустом значении (легаси-строки таблицы).
func mapApplications(o []*repos.Application, domain string) []*storytelling.Application {
res := make([]*storytelling.Application, 0, len(o))
for _, item := range o {
res = append(res, mapApplication(item))
res = append(res, mapApplication(item, domain))
}
return res
}
func mapApplication(o *repos.Application) *storytelling.Application {
return &storytelling.Application{
func mapApplication(o *repos.Application, domain string) *storytelling.Application {
image := prefixDomain(o.Image, domain)
app := &storytelling.Application{
Name: o.Name,
Image: o.Image,
Image: image,
}
app.FileType = o.FileType
if app.FileType == "" {
app.FileType = file_storage.FileType(image)
}
return app
}
// newApplication — улика для сохранения команде (AddTeamAction): имена
// приходят из истории уже с доменным префиксом (mapStory), в БД хранятся
// относительные пути — префикс снимается идемпотентно; file_type
// деривируется при пустом значении. Name не меняется (очистка — в
// вызывающем коде, cleaner'ом).
func newApplication(app *storytelling.Application, domain string) *repos.Application {
image := strings.TrimPrefix(app.Image, domain)
fileType := app.FileType
if fileType == "" {
fileType = file_storage.FileType(image)
}
return &repos.Application{
Name: app.Name,
Image: image,
FileType: fileType,
}
}
// prefixDomain — идемпотентная префиксация ссылки доменом хранилища
// (пустые ссылки и внешние http/https URL не трогаются).
func prefixDomain(ref, domain string) string {
if ref == "" || strings.HasPrefix(ref, domain) {
return ref
}
low := strings.ToLower(ref)
if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") {
return ref
}
return domain + ref
}
@@ -0,0 +1,117 @@
package game_service
import (
"testing"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/repos"
)
const testDomain = "http://storage.test/api/files/"
func TestNewApplication(t *testing.T) {
cases := []struct {
name string
app *storytelling.Application
image string // ожидаемое хранимое имя (без домена)
ftype string // ожидаемый file_type
}{
{
name: "доменный префикс снимается, file_type сохраняется",
app: &storytelling.Application{
Name: "Улика",
Image: testDomain + "clue.pdf",
FileType: "pdf",
},
image: "clue.pdf",
ftype: "pdf",
},
{
name: "относительное имя не трогается",
app: &storytelling.Application{
Name: "Улика",
Image: "clue.png",
FileType: "image",
},
image: "clue.png",
ftype: "image",
},
{
name: "пустой file_type деривируется из расширения",
app: &storytelling.Application{
Name: "Улика",
Image: testDomain + "clue.ogg",
},
image: "clue.ogg",
ftype: "audio",
},
{
name: "внешний URL снимается как префикс только при совпадении домена",
app: &storytelling.Application{
Name: "Улика",
Image: "https://example.com/clue.pdf",
},
image: "https://example.com/clue.pdf",
ftype: "pdf",
},
}
for _, c := range cases {
got := newApplication(c.app, testDomain)
if got.Name != c.app.Name {
t.Errorf("%s: Name = %q, want %q", c.name, got.Name, c.app.Name)
}
if got.Image != c.image {
t.Errorf("%s: Image = %q, want %q", c.name, got.Image, c.image)
}
if got.FileType != c.ftype {
t.Errorf("%s: FileType = %q, want %q", c.name, got.FileType, c.ftype)
}
}
}
func TestMapApplications(t *testing.T) {
o := []*repos.Application{
{Name: "Улика", Image: "clue.pdf", FileType: ""},
{Name: "Фото", Image: "https://external.example.com/pic.jpg", FileType: "image"},
}
got := mapApplications(o, testDomain)
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
// Относительное имя префиксуется доменом, file_type деривируется.
if got[0].Image != testDomain+"clue.pdf" {
t.Errorf("Image[0] = %q, want %q", got[0].Image, testDomain+"clue.pdf")
}
if got[0].FileType != "pdf" {
t.Errorf("FileType[0] = %q, want pdf", got[0].FileType)
}
// Внешний URL не префиксуется повторно.
if got[1].Image != "https://external.example.com/pic.jpg" {
t.Errorf("Image[1] = %q, want внешний URL без изменений", got[1].Image)
}
if got[1].FileType != "image" {
t.Errorf("FileType[1] = %q, want image", got[1].FileType)
}
}
func TestPrefixDomain(t *testing.T) {
cases := []struct {
ref string
domain string
want string
}{
{"clue.pdf", testDomain, testDomain + "clue.pdf"},
{testDomain + "clue.pdf", testDomain, testDomain + "clue.pdf"},
{"https://example.com/clue.pdf", testDomain, "https://example.com/clue.pdf"},
{"http://example.com/clue.pdf", testDomain, "http://example.com/clue.pdf"},
{"", testDomain, ""},
}
for _, c := range cases {
if got := prefixDomain(c.ref, c.domain); got != c.want {
t.Errorf("prefixDomain(%q) = %q, want %q", c.ref, got, c.want)
}
}
}
+4 -3
View File
@@ -114,7 +114,7 @@ func (s *GameService) GetFullGame(
for _, team := range res.Teams {
team.ActionsCount = actionsCountByTeamIDs[team.ID]
team.Applications = mapApplications(needApplicationsByTeamIDs[team.ID])
team.Applications = mapApplications(needApplicationsByTeamIDs[team.ID], s.domain)
}
return res, nil
@@ -263,8 +263,9 @@ func (s *GameService) AddTeamAction(ctx context.Context, teamId int, password st
lastPlace := teamStory.Places[len(teamStory.Places)-1]
for _, application := range lastPlace.Applications {
_, err := s.applicationsRepo.AddApplication(ctx, teamId, s.cleaner.ClearText(application.Name), application.Image)
if err != nil {
app := newApplication(application, s.domain)
app.Name = s.cleaner.ClearText(app.Name)
if _, err := s.applicationsRepo.AddApplication(ctx, teamId, app.Name, app.Image, app.FileType); err != nil {
return err
}
}
@@ -2,6 +2,9 @@ package scenarios_service
import (
"encoding/json"
"strings"
"evening_detective_server/internal/modules/file_storage"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/repos"
)
@@ -73,16 +76,34 @@ func mapStory(o string, domain string) (*storytelling.Story, error) {
return nil, err
}
for _, place := range res.Places {
if place.Image != "" {
place.Image = domain + place.Image
place.Image = prefixDomain(place.Image, domain)
for _, application := range place.Applications {
application.Image = prefixDomain(application.Image, domain)
if application.FileType == "" {
application.FileType = file_storage.FileType(application.Image)
}
}
}
if res.Introduction != nil && res.Introduction.Audio != "" {
res.Introduction.Audio = domain + res.Introduction.Audio
if res.Introduction != nil {
res.Introduction.Audio = prefixDomain(res.Introduction.Audio, domain)
}
return res, nil
}
// prefixDomain — идемпотентная префиксация ссылки доменом хранилища:
// пустые ссылки и внешние http/https URL не трогаются, повторный префикс
// исключён. Единое правило для картинок точек, аудио введения и улик.
func prefixDomain(ref, domain string) string {
if ref == "" || strings.HasPrefix(ref, domain) {
return ref
}
low := strings.ToLower(ref)
if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") {
return ref
}
return domain + ref
}
func convertStory(o *storytelling.Story) (string, error) {
b, err := json.Marshal(o)
if err != nil {
@@ -0,0 +1,164 @@
package scenarios_service
import (
"context"
"testing"
"evening_detective_server/internal/modules/storytelling"
)
// TestMapStoryPrefixesEvidence — улики получают полный URL (домен + имя),
// как картинки точек и аудио введения; префиксация идемпотентна, внешние
// URL не трогаются; file_type деривируется при пустом значении.
func TestMapStoryPrefixesEvidence(t *testing.T) {
storyJSON := `{
"introduction": {"text": "Вступление", "audio": "intro.mp3"},
"places": [{
"code": "p1",
"name": "Место",
"text": "Текст",
"image": "place.png",
"applications": [
{"name": "Договор", "image": "contract.pdf"},
{"name": "Фото", "image": "https://external.example.com/pic.jpg", "file_type": "image"}
]
}]
}`
story, err := mapStory(storyJSON, testDomain)
if err != nil {
t.Fatalf("mapStory: %v", err)
}
if story.Introduction.Audio != testDomain+"intro.mp3" {
t.Errorf("Audio = %q, want %q", story.Introduction.Audio, testDomain+"intro.mp3")
}
place := story.Places[0]
if place.Image != testDomain+"place.png" {
t.Errorf("place.Image = %q, want %q", place.Image, testDomain+"place.png")
}
// Улика из хранилища: полный URL + деривированный file_type.
app := place.Applications[0]
if app.Image != testDomain+"contract.pdf" {
t.Errorf("application.Image = %q, want %q", app.Image, testDomain+"contract.pdf")
}
if app.FileType != "pdf" {
t.Errorf("application.FileType = %q, want pdf", app.FileType)
}
// Внешний URL не префиксуется, file_type сохраняется.
external := place.Applications[1]
if external.Image != "https://external.example.com/pic.jpg" {
t.Errorf("external.Image = %q, want без изменений", external.Image)
}
if external.FileType != "image" {
t.Errorf("external.FileType = %q, want image", external.FileType)
}
// Идемпотентность: повторный прогон не даёт двойного префикса.
jsonOut, err := convertStory(story)
if err != nil {
t.Fatalf("convertStory: %v", err)
}
again, err := mapStory(jsonOut, testDomain)
if err != nil {
t.Fatalf("mapStory повторно: %v", err)
}
if again.Places[0].Applications[0].Image != testDomain+"contract.pdf" {
t.Errorf("повторный mapStory: Image = %q, want %q", again.Places[0].Applications[0].Image, testDomain+"contract.pdf")
}
}
// TestNormalizeFileType — невалидный file_type заменяется деривацией из
// расширения файла, известные значения сохраняются.
func TestNormalizeFileType(t *testing.T) {
cases := []struct {
fileType string
image string
want string
}{
{"banana", "http://domain/api/files/clue.pdf", "pdf"},
{"banana", "clue", ""},
{"", "clue.png", "image"},
{"pdf", "clue.pdf", "pdf"},
{"image", "clue.gif", "image"},
{"audio", "clue.ogg", "audio"},
}
for _, c := range cases {
if got := normalizeFileType(c.fileType, c.image); got != c.want {
t.Errorf("normalizeFileType(%q, %q) = %q, want %q", c.fileType, c.image, got, c.want)
}
}
}
// TestNormalizeStoryBanana — файл-улика с невалидным file_type проходит
// normalizeStory и получает деривированный тип.
func TestNormalizeStoryBanana(t *testing.T) {
story := &storytelling.Story{
Places: []*storytelling.Place{
{
Code: "p1",
Name: "Место",
Applications: []*storytelling.Application{
{Name: "Договор", Image: "clue.pdf", FileType: "banana"},
},
},
},
}
jsonOut, err := normalizeStory(story)
if err != nil {
t.Fatalf("normalizeStory: %v", err)
}
parsed, err := mapStory(jsonOut, "")
if err != nil {
t.Fatalf("mapStory: %v", err)
}
if got := parsed.Places[0].Applications[0].FileType; got != "pdf" {
t.Errorf("FileType = %q, want pdf", got)
}
}
// TestUpdateStoryTrimsEvidenceDomain — сохранение истории снимает доменный
// префикс с улик (в БД хранятся относительные имена).
func TestUpdateStoryTrimsEvidenceDomain(t *testing.T) {
repo := newFakeScenariosRepo()
storage := newFakeStorage()
svc := newTestService(repo, storage)
// Сценарий с историей, где улика уже с доменным префиксом.
scenario := seedScenario(t, repo, storage)
scenario.Scenario = `{"places":[{"code":"p1","name":"Место","text":"","image":"place.png","applications":[{"name":"Договор","image":"http://storage.test/api/files/contract.pdf","file_type":"pdf"}]}]}`
story := &storytelling.Story{
Places: []*storytelling.Place{
{
Code: "p1",
Name: "Место",
Image: testDomain + "place.png",
Text: "Текст",
Applications: []*storytelling.Application{
{Name: "Договор", Image: testDomain + "contract.pdf", FileType: "pdf"},
},
},
},
}
if err := svc.updateStory(context.Background(), scenario.ID, story); err != nil {
t.Fatalf("updateStory: %v", err)
}
parsed, err := mapStory(scenario.Scenario, testDomain)
if err != nil {
t.Fatalf("mapStory: %v", err)
}
app := parsed.Places[0].Applications[0]
if app.Image != testDomain+"contract.pdf" {
t.Errorf("после trim+prefix Image = %q, want %q (без двойного префикса)", app.Image, testDomain+"contract.pdf")
}
if app.FileType != "pdf" {
t.Errorf("FileType = %q, want pdf", app.FileType)
}
}
+27 -15
View File
@@ -2,8 +2,6 @@ package scenarios_service
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"evening_detective_server/internal/modules/cleaner"
"evening_detective_server/internal/modules/file_storage"
@@ -373,10 +371,11 @@ func (s *ScenarioService) UploadArchive(
if !exists {
return "", fmt.Errorf("изображение %q не найдено в архиве", archiveRef)
}
name, err := newStorageName(archiveRef)
name, err := file_storage.NewName()
if err != nil {
return "", err
}
name += strings.ToLower(filepath.Ext(archiveRef))
if err := s.fileStorage.Put(ctx, &file_storage.File{
Name: name,
Data: content,
@@ -433,20 +432,13 @@ func (s *ScenarioService) UploadArchive(
return id, nil
}
// newStorageName — случайное имя в хранилище (hex + расширение): исключает
// коллизии с файлами других сценариев в общем бакете.
func newStorageName(archivePath string) (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("не удалось сгенерировать имя файла: %w", err)
}
ext := strings.ToLower(filepath.Ext(archivePath))
return hex.EncodeToString(b[:]) + ext, nil
}
func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storytelling.Story) error {
for _, place := range story.Places {
place.Image = strings.TrimPrefix(place.Image, s.domain)
for _, application := range place.Applications {
application.Image = strings.TrimPrefix(application.Image, s.domain)
}
}
if story.Introduction != nil {
story.Introduction.Audio = strings.TrimPrefix(story.Introduction.Audio, s.domain)
@@ -459,8 +451,9 @@ func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storyt
return s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyString)
}
// normalizeStory проверяет уникальность кодов точек, отбрасывает пустые коды
// и возвращает JSON истории. Общая для редактирования и импорта.
// normalizeStory проверяет уникальность кодов точек, отбрасывает пустые коды,
// нормализует file_type улик и возвращает JSON истории.
// Общая для редактирования и импорта.
func normalizeStory(story *storytelling.Story) (string, error) {
codes := map[string]struct{}{}
for _, place := range story.Places {
@@ -475,9 +468,28 @@ func normalizeStory(story *storytelling.Story) (string, error) {
if place.Code == "" {
continue
}
for _, application := range place.Applications {
// file_type принимает только известные значения; невалидные
// (включая значения из импортированных архивов) заменяются
// деривацией из расширения файла.
application.FileType = normalizeFileType(application.FileType, application.Image)
}
cleanPlaces = append(cleanPlaces, place)
}
story.Places = cleanPlaces
return convertStory(story)
}
// normalizeFileType приводит file_type к одному из {pdf, image, audio, ""}.
// Пустое/невалидное значение при сохранении заменяется деривацией из
// расширения файла; пустое значение в legacy-строках деривируется при
// чтении (mapStory).
func normalizeFileType(fileType, image string) string {
switch fileType {
case "pdf", "image", "audio":
return fileType
default:
return file_storage.FileType(image)
}
}