This commit is contained in:
2026-08-21 00:09:32 +07:00
parent 7656100fe6
commit a2cadf6eca
20 changed files with 320 additions and 94 deletions
+4 -1
View File
@@ -28,6 +28,9 @@ import (
"google.golang.org/grpc/status"
)
// stringTools — транслитерация имён файлов для HTTP-заголовков.
var stringTools = string_tools.NewStringTools()
type server struct {
proto.UnsafeEveningDetectiveServerServer
@@ -603,7 +606,7 @@ func (s *server) DownloadScenarioArchive(ctx context.Context, req *proto.Downloa
// Имя файла транслитерируется (заголовки HTTP — только ASCII) и
// санитайзится от header injection; заголовок пробрасывается gateway.
transliterated := sanitizeFilename(string_tools.Transliterate(name))
transliterated := sanitizeFilename(stringTools.Transliterate(name))
if transliterated == "" {
transliterated = "scenario"
}
+8 -5
View File
@@ -9,6 +9,9 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
)
// textFormatter — форматирование текста при конвертации входящих данных.
var textFormatter = formatter_utils.NewFormatter()
func mapScenarios(o []*scenarios_service.Scenario) []*proto.Scenario {
res := make([]*proto.Scenario, 0, len(o))
for _, item := range o {
@@ -117,8 +120,8 @@ func mapKey(o *storytelling.Key) *proto.Key {
func convertPlace(o *proto.Place) *storytelling.Place {
return &storytelling.Place{
Code: o.Code,
Name: formatter_utils.FormatString(o.Name),
Text: formatter_utils.FormatText(o.Text),
Name: textFormatter.FormatString(o.Name),
Text: textFormatter.FormatText(o.Text),
Image: o.Image,
Hidden: o.Hidden,
Applications: convertApplications(o.Applications),
@@ -153,7 +156,7 @@ func convertKeys(o []*proto.Key) []*storytelling.Key {
func convertApplication(o *proto.Application) *storytelling.Application {
return &storytelling.Application{
Name: formatter_utils.FormatString(o.Name),
Name: textFormatter.FormatString(o.Name),
Image: o.Image,
}
}
@@ -161,13 +164,13 @@ func convertApplication(o *proto.Application) *storytelling.Application {
func convertDoor(o *proto.Door) *storytelling.Door {
return &storytelling.Door{
Code: o.Code,
Name: formatter_utils.FormatString(o.Name),
Name: textFormatter.FormatString(o.Name),
Keys: convertKeys(o.Keys),
}
}
func convertKey(o *proto.Key) *storytelling.Key {
return &storytelling.Key{
Name: formatter_utils.FormatString(o.Name),
Name: textFormatter.FormatString(o.Name),
}
}
@@ -0,0 +1,7 @@
package formatter_utils
// IFormatter — форматирование текста сценария для отображения.
type IFormatter interface {
FormatText(text string) string
FormatString(text string) string
}
+13 -2
View File
@@ -5,7 +5,16 @@ import (
"strings"
)
func FormatText(text string) string {
type formatter struct{}
// NewFormatter создаёт реализацию IFormatter.
func NewFormatter() IFormatter {
return &formatter{}
}
// FormatText форматирует многострочный текст: обрезает пробелы по краям
// строк, превращает "--" в тире и добавляет отступ абзаца.
func (f *formatter) FormatText(text string) string {
scanner := bufio.NewScanner(strings.NewReader(text))
scanner.Split(bufio.ScanLines)
@@ -35,7 +44,9 @@ func FormatText(text string) string {
return res.String()
}
func FormatString(text string) string {
// FormatString форматирует одиночную строку: обрезает пробелы и заменяет
// "--" на тире.
func (f *formatter) FormatString(text string) string {
l := strings.TrimSpace(text)
if strings.HasPrefix(l, "--") {
l = strings.Replace(l, "--", "—", 1)
@@ -29,9 +29,10 @@ func Test_service_FormatText(t *testing.T) {
want: " — Привет",
},
}
formatter := NewFormatter()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatText(tt.text)
got := formatter.FormatText(tt.text)
if got != tt.want {
t.Errorf("FormatText() = %v, want %v", got, tt.want)
}
+22
View File
@@ -0,0 +1,22 @@
package roles
// Роли пользователей системы.
const (
Admin = "admin"
Author = "author"
Organizer = "organizer"
User = "user"
)
// AllRoles — список всех ролей.
var AllRoles = []string{
Admin,
Author,
Organizer,
User,
}
// IHasRole — контракт субъекта, у которого можно проверить наличие роли.
type IHasRole interface {
HasRole(role string) bool
}
+2 -18
View File
@@ -1,23 +1,7 @@
package roles
const (
Admin = "admin"
Author = "author"
Organizer = "organizer"
User = "user"
)
var AllRoles = []string{
Admin,
Author,
Organizer,
User,
}
type IHasRole interface {
HasRole(role string) bool
}
// HasRole возвращает true, если у субъекта есть роль; администратор
// проходит проверку любой роли.
func HasRole(claims IHasRole, role string) bool {
if claims.HasRole(Admin) {
return true
+19
View File
@@ -0,0 +1,19 @@
package roles
import "slices"
// subject — реализация IHasRole: субъект с фиксированным набором ролей.
type subject struct {
roles []string
}
// NewSubject создаёт субъект с заданными ролями (например, для тестов
// авторизации или in-memory проверок прав).
func NewSubject(roles ...string) IHasRole {
return &subject{roles: roles}
}
// HasRole возвращает true, если роль есть в наборе ролей субъекта.
func (s *subject) HasRole(role string) bool {
return slices.Contains(s.roles, role)
}
+25
View File
@@ -0,0 +1,25 @@
package roles
import "testing"
func Test_subject_HasRole(t *testing.T) {
subject := NewSubject(Author, Organizer)
if !subject.HasRole(Author) {
t.Errorf("HasRole(%q) = false, want true", Author)
}
if !subject.HasRole(Organizer) {
t.Errorf("HasRole(%q) = false, want true", Organizer)
}
if subject.HasRole(Admin) {
t.Errorf("HasRole(%q) = true, want false", Admin)
}
}
func Test_subject_HasRoleEmpty(t *testing.T) {
subject := NewSubject()
if subject.HasRole(User) {
t.Errorf("HasRole(%q) = true, want false для субъекта без ролей", User)
}
}
+9 -24
View File
@@ -20,12 +20,6 @@ import (
"evening_detective_server/internal/repos"
)
// Имя файла с описанием сценария внутри архива.
const FileName = "scenario.json"
// Текущая версия формата scenario.json.
const Version = 1
// Лимиты на входящие архивы (защита от zip-bomb). Суммарный объём считается
// по фактически распакованным байтам — размеры в заголовках ZIP подделываются.
var (
@@ -36,28 +30,19 @@ var (
maxPackSize = 256 << 20 // 256 МБ — суммарный объём изображений при сборке
)
type scenarioArchive struct{}
// NewScenarioArchive создаёт реализацию IScenarioArchive.
func NewScenarioArchive() IScenarioArchive {
return &scenarioArchive{}
}
// MaxArchiveSize — максимальный размер входящего архива (для проверок
// в хендлере и на HTTP-слое).
func MaxArchiveSize() int {
return maxArchiveSize
}
// ScenarioJSON — описание сценария в архиве; Story — в формате колонки
// scenarios.scenario в БД.
type ScenarioJSON struct {
Version int `json:"version"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Image string `json:"image,omitempty"`
Story *storytelling.Story `json:"story"`
}
// Bundle — результат разбора входящего архива.
type Bundle struct {
Scenario *ScenarioJSON
Files map[string][]byte
}
// ImagePath возвращает путь изображения внутри архива (images/<имя>) и true
// для ссылок нашего хранилища: относительное имя или URL с доменом (legacy).
// Внешние URL и пустые ссылки — ("", false): в архив не кладутся.
@@ -76,7 +61,7 @@ func ImagePath(ref, domain string) (string, bool) {
// Pack собирает ZIP-архив сценария: ссылки на изображения хранилища
// переписываются в images/<имя>, файлы запрашиваются через getFile; внешние
// URL остаются без изменений. Архив детерминирован (порядок записей и метки).
func Pack(
func (a *scenarioArchive) Pack(
ctx context.Context,
scenario *repos.Scenario,
domain string,
@@ -223,7 +208,7 @@ func strPtrValue(p *string) string {
}
// Unpack разбирает входящий архив с проверкой лимитов и путей записей.
func Unpack(data []byte) (*Bundle, error) {
func (a *scenarioArchive) Unpack(data []byte) (*Bundle, error) {
if len(data) == 0 {
return nil, errors.New("архив пуст")
}
@@ -16,6 +16,9 @@ import (
const testDomain = "http://storage.test/api/files/"
// testArchive — экземпляр модуля для тестов.
var testArchive = NewScenarioArchive()
// buildScenario собирает сценарий с историей и ссылками на изображения.
func buildScenario() *repos.Scenario {
description := "Детективная история"
@@ -53,12 +56,12 @@ func newMemoryFiles() memoryFiles {
func TestPackUnpackRoundTrip(t *testing.T) {
files := newMemoryFiles()
data, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
data, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
if err != nil {
t.Fatalf("Pack: %v", err)
}
bundle, err := Unpack(data)
bundle, err := testArchive.Unpack(data)
if err != nil {
t.Fatalf("Unpack: %v", err)
}
@@ -117,7 +120,7 @@ func TestPackExternalURLsKeptAndNotFetched(t *testing.T) {
return files.get(ctx, name)
}
data, err := Pack(context.Background(), scenario, testDomain, getFile)
data, err := testArchive.Pack(context.Background(), scenario, testDomain, getFile)
if err != nil {
t.Fatalf("Pack: %v", err)
}
@@ -125,7 +128,7 @@ func TestPackExternalURLsKeptAndNotFetched(t *testing.T) {
t.Errorf("getFile вызван для внешних URL: %v", fetched)
}
bundle, err := Unpack(data)
bundle, err := testArchive.Unpack(data)
if err != nil {
t.Fatalf("Unpack: %v", err)
}
@@ -147,11 +150,11 @@ func TestPackLegacyFullURLImage(t *testing.T) {
Image: ptr(testDomain + "cover.png"),
Scenario: `{"places":[]}`,
}
data, err := Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
data, err := testArchive.Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
if err != nil {
t.Fatalf("Pack: %v", err)
}
bundle, err := Unpack(data)
bundle, err := testArchive.Unpack(data)
if err != nil {
t.Fatalf("Unpack: %v", err)
}
@@ -164,7 +167,7 @@ func TestPackMissingImage(t *testing.T) {
files := newMemoryFiles()
delete(files, "ticket.jpg")
_, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
_, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
if err == nil {
t.Fatal("Pack должен вернуть ошибку при недоступном изображении")
}
@@ -175,12 +178,12 @@ func TestPackMissingImage(t *testing.T) {
func TestPackDeterministic(t *testing.T) {
files := newMemoryFiles()
first, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
first, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
if err != nil {
t.Fatalf("Pack: %v", err)
}
time.Sleep(10 * time.Millisecond)
second, err := Pack(context.Background(), buildScenario(), testDomain, files.get)
second, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
if err != nil {
t.Fatalf("Pack: %v", err)
}
@@ -190,7 +193,7 @@ func TestPackDeterministic(t *testing.T) {
}
func TestPackNilScenario(t *testing.T) {
_, err := Pack(context.Background(), nil, testDomain, nil)
_, err := testArchive.Pack(context.Background(), nil, testDomain, nil)
if err == nil {
t.Fatal("Pack(nil) должен вернуть ошибку")
}
@@ -207,7 +210,7 @@ func TestPackImageNameCollision(t *testing.T) {
{"code":"b","name":"B","text":"t","image":"dir/a.png"}
]}`,
}
_, err := Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
_, err := testArchive.Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
if err == nil {
t.Fatal("Pack должен вернуть ошибку при конфликте имён изображений")
}
@@ -257,7 +260,7 @@ func TestUnpackRejectsBadPaths(t *testing.T) {
"images/x.png": []byte("x"),
name: []byte("evil"),
})
if _, err := Unpack(data); err == nil {
if _, err := testArchive.Unpack(data); err == nil {
t.Fatalf("Unpack должен отклонить путь %q", name)
}
})
@@ -265,30 +268,30 @@ func TestUnpackRejectsBadPaths(t *testing.T) {
}
func TestUnpackEmpty(t *testing.T) {
if _, err := Unpack(nil); err == nil {
if _, err := testArchive.Unpack(nil); err == nil {
t.Fatal("Unpack(nil) должен вернуть ошибку")
}
if _, err := Unpack([]byte{}); err == nil {
t.Fatal("Unpack(пусто) должен вернуть ошибку")
if _, err := testArchive.Unpack([]byte{}); err == nil {
t.Fatal("testArchive.Unpack(пусто) должен вернуть ошибку")
}
}
func TestUnpackNotAZip(t *testing.T) {
if _, err := Unpack([]byte("this is not a zip")); err == nil {
if _, err := testArchive.Unpack([]byte("this is not a zip")); err == nil {
t.Fatal("Unpack должен отклонить не-zip данные")
}
}
func TestUnpackMissingScenarioJSON(t *testing.T) {
data := writeZip(t, map[string][]byte{"images/x.png": []byte("x")})
if _, err := Unpack(data); err == nil {
if _, err := testArchive.Unpack(data); err == nil {
t.Fatal("Unpack должен требовать scenario.json")
}
}
func TestUnpackInvalidScenarioJSON(t *testing.T) {
data := writeZip(t, map[string][]byte{FileName: []byte("{not json")})
if _, err := Unpack(data); err == nil {
if _, err := testArchive.Unpack(data); err == nil {
t.Fatal("Unpack должен отклонить битый scenario.json")
}
}
@@ -296,7 +299,7 @@ func TestUnpackInvalidScenarioJSON(t *testing.T) {
func TestUnpackBadVersion(t *testing.T) {
b, _ := json.Marshal(ScenarioJSON{Version: 99, Name: "X", Story: &storytelling.Story{}})
data := writeZip(t, map[string][]byte{FileName: b})
if _, err := Unpack(data); err == nil {
if _, err := testArchive.Unpack(data); err == nil {
t.Fatal("Unpack должен отклонить неизвестную версию")
}
}
@@ -304,7 +307,7 @@ func TestUnpackBadVersion(t *testing.T) {
func TestUnpackEmptyName(t *testing.T) {
b, _ := json.Marshal(ScenarioJSON{Version: Version, Name: "", Story: &storytelling.Story{}})
data := writeZip(t, map[string][]byte{FileName: b})
if _, err := Unpack(data); err == nil {
if _, err := testArchive.Unpack(data); err == nil {
t.Fatal("Unpack должен требовать название")
}
}
@@ -312,7 +315,7 @@ func TestUnpackEmptyName(t *testing.T) {
func TestUnpackNilStory(t *testing.T) {
b, _ := json.Marshal(ScenarioJSON{Version: Version, Name: "X"})
data := writeZip(t, map[string][]byte{FileName: b})
if _, err := Unpack(data); err == nil {
if _, err := testArchive.Unpack(data); err == nil {
t.Fatal("Unpack должен требовать story")
}
}
@@ -326,7 +329,7 @@ func TestUnpackEntrySizeLimit(t *testing.T) {
FileName: validScenarioJSON(),
"images/big.png": bytes.Repeat([]byte("x"), 8),
})
if _, err := Unpack(data); err == nil {
if _, err := testArchive.Unpack(data); err == nil {
t.Fatal("Unpack должен отклонить запись больше maxEntrySize")
}
}
@@ -341,7 +344,7 @@ func TestUnpackTotalSizeLimit(t *testing.T) {
"images/a.png": []byte("aaa"),
"images/b.png": []byte("bbb"),
})
if _, err := Unpack(data); err == nil {
if _, err := testArchive.Unpack(data); err == nil {
t.Fatal("Unpack должен отклонить суммарный объём больше maxUnpackedSize")
}
}
@@ -352,7 +355,7 @@ func TestUnpackArchiveSizeLimit(t *testing.T) {
defer func() { maxArchiveSize = prev }()
data := writeZip(t, map[string][]byte{FileName: validScenarioJSON()})
if _, err := Unpack(data); err == nil {
if _, err := testArchive.Unpack(data); err == nil {
t.Fatal("Unpack должен отклонить архив больше maxArchiveSize")
}
}
@@ -367,7 +370,7 @@ func TestUnpackEntriesLimit(t *testing.T) {
"images/a.png": []byte("a"),
"images/b.png": []byte("b"),
})
if _, err := Unpack(data); err == nil {
if _, err := testArchive.Unpack(data); err == nil {
t.Fatal("Unpack должен отклонить архив с числом записей больше maxEntries")
}
}
@@ -388,7 +391,7 @@ func TestUnpackRejectsDuplicateScenarioJSON(t *testing.T) {
t.Fatalf("Close: %v", err)
}
if _, err := Unpack(buf.Bytes()); err == nil {
if _, err := testArchive.Unpack(buf.Bytes()); err == nil {
t.Fatal("Unpack должен отклонить дубли scenario.json")
}
}
@@ -420,7 +423,7 @@ func TestUnpackRejectsDuplicateFiles(t *testing.T) {
t.Fatalf("Close: %v", err)
}
if _, err := Unpack(buf.Bytes()); err == nil {
if _, err := testArchive.Unpack(buf.Bytes()); err == nil {
t.Fatal("Unpack должен отклонить дубли файлов")
}
}
@@ -444,7 +447,7 @@ func TestPackTotalSizeLimit(t *testing.T) {
"a.png": bytes.Repeat([]byte("x"), 6),
"b.png": bytes.Repeat([]byte("y"), 6),
}
if _, err := Pack(context.Background(), scenario, testDomain, files.get); err == nil {
if _, err := testArchive.Pack(context.Background(), scenario, testDomain, files.get); err == nil {
t.Fatal("Pack должен отклонить суммарный объём больше maxPackSize")
}
}
@@ -0,0 +1,47 @@
package scenario_archive
import (
"context"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/repos"
)
// Имя файла с описанием сценария внутри архива.
const FileName = "scenario.json"
// Текущая версия формата scenario.json.
const Version = 1
// ScenarioJSON — описание сценария в архиве; Story — в формате колонки
// scenarios.scenario в БД.
type ScenarioJSON struct {
Version int `json:"version"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Image string `json:"image,omitempty"`
Story *storytelling.Story `json:"story"`
}
// Bundle — результат разбора входящего архива.
type Bundle struct {
Scenario *ScenarioJSON
Files map[string][]byte
}
// IScenarioArchive — контракт модуля scenario_archive: сборка и разбор
// ZIP-архива сценария.
type IScenarioArchive interface {
// Pack собирает ZIP-архив сценария: ссылки на изображения хранилища
// переписываются в images/<имя>, файлы запрашиваются через getFile;
// внешние URL остаются без изменений.
Pack(
ctx context.Context,
scenario *repos.Scenario,
domain string,
getFile func(ctx context.Context, name string) ([]byte, error),
) ([]byte, error)
// Unpack разбирает входящий архив с проверкой лимитов и путей записей.
Unpack(data []byte) (*Bundle, error)
}
@@ -0,0 +1,7 @@
package string_tools
// IStringTools — утилиты работы со строками.
type IStringTools interface {
// Transliterate выполняет транслитерацию русского текста в латиницу.
Transliterate(text string) string
}
@@ -44,9 +44,16 @@ var (
)
)
type stringTools struct{}
// NewStringTools создаёт реализацию IStringTools.
func NewStringTools() IStringTools {
return &stringTools{}
}
// Transliterate выполняет транслитерацию русского текста в латиницу,
// заменяет пробелы на _, удаляет все остальные символы, кроме цифр (знаки препинания и т.д.)
func Transliterate(text string) string {
func (s *stringTools) Transliterate(text string) string {
// Приводим к нижнему регистру и транслитерируем
result := replacer.Replace(strings.ToLower(text))
+4 -1
View File
@@ -8,13 +8,16 @@ import (
type FileService struct {
fileStorage file_storage.IFileStorage
stringTools string_tools.IStringTools
}
func NewFileService(
fileStorage file_storage.IFileStorage,
stringTools string_tools.IStringTools,
) *FileService {
return &FileService{
fileStorage: fileStorage,
stringTools: stringTools,
}
}
@@ -22,7 +25,7 @@ func (s *FileService) UploadFile(
ctx context.Context,
file *file_storage.File,
) (string, error) {
file.Name = string_tools.Transliterate(file.Name)
file.Name = s.stringTools.Transliterate(file.Name)
if err := s.fileStorage.Put(ctx, file); err != nil {
return "", err
}
+13 -10
View File
@@ -31,10 +31,11 @@ type scenariosRepository interface {
}
type ScenarioService struct {
scenariosRepo scenariosRepository
cleaner cleaner.ICleaner
fileStorage file_storage.IFileStorage
domain string
scenariosRepo scenariosRepository
cleaner cleaner.ICleaner
fileStorage file_storage.IFileStorage
scenarioArchive scenario_archive.IScenarioArchive
domain string
}
func NewScenarioService(
@@ -42,12 +43,14 @@ func NewScenarioService(
cleaner cleaner.ICleaner,
domain string,
fileStorage file_storage.IFileStorage,
scenarioArchive scenario_archive.IScenarioArchive,
) *ScenarioService {
return &ScenarioService{
scenariosRepo: scenariosRepo,
cleaner: cleaner,
fileStorage: fileStorage,
domain: domain,
scenariosRepo: scenariosRepo,
cleaner: cleaner,
fileStorage: fileStorage,
scenarioArchive: scenarioArchive,
domain: domain,
}
}
@@ -294,7 +297,7 @@ func (s *ScenarioService) DownloadArchive(
if err != nil {
return nil, "", err
}
data, err := scenario_archive.Pack(
data, err := s.scenarioArchive.Pack(
ctx,
scenario,
s.domain,
@@ -320,7 +323,7 @@ func (s *ScenarioService) UploadArchive(
data []byte,
authorId int,
) (int, error) {
bundle, err := scenario_archive.Unpack(data)
bundle, err := s.scenarioArchive.Unpack(data)
if err != nil {
return 0, err
}
@@ -226,7 +226,7 @@ func newTestService(repo *fakeScenariosRepo, storage *fakeStorage) *ScenarioServ
if storage == nil {
storage = newFakeStorage()
}
return NewScenarioService(repo, nil, testDomain, storage)
return NewScenarioService(repo, nil, testDomain, storage, scenario_archive.NewScenarioArchive())
}
// seedScenario кладёт в repo сценарий с историей и изображениями в storage.
@@ -455,7 +455,7 @@ func TestDownloadArchive(t *testing.T) {
t.Errorf("name = %q", name)
}
bundle, err := scenario_archive.Unpack(data)
bundle, err := scenario_archive.NewScenarioArchive().Unpack(data)
if err != nil {
t.Fatalf("Unpack: %v", err)
}