generated from VLADIMIR/template
add load applications
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user