Compare commits

6 Commits

Author SHA1 Message Date
VLADIMIR bafe22a95d add skill 2026-08-21 23:03:44 +07:00
VLADIMIR a2cadf6eca clear 2026-08-21 00:09:32 +07:00
VLADIMIR 7656100fe6 add zip 2026-08-20 23:17:52 +07:00
VLADIMIR 6d37610348 update mail 2026-08-20 03:27:26 +07:00
VLADIMIR 4ff43cc2dd updates email 2026-08-20 02:31:14 +07:00
VLADIMIR be15264764 build 2026-08-20 00:17:30 +07:00
41 changed files with 4401 additions and 557 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ steps:
- set GOOS=linux
- set GOARCH=amd64
- set CGO_ENABLED=0
- go build -a -ldflags '-extldflags "-static"' -o evening_detective_server cmd/evening_detective_server/main.go
- go build -a -ldflags '-extldflags "-static"' -o evening_detective_server ./cmd/evening_detective_server
- name: test
image: golang
+94
View File
@@ -0,0 +1,94 @@
---
name: create-go-module
description: Создаёт Go-модуль в internal/modules/<snake_case>/ по конвенциям репозитория — обязательный interface.go с интерфейсом (имя начинается с I) и всеми публичными типами, в папке как минимум одна реализация интерфейса (неэкспортируемая структура + конструктор New*, возвращающий интерфейс). Загружай при создании нового модуля или заготовки модуля.
whenToUse: Пользователь просит создать модуль/компонент/сервис/заготовку в internal/modules, либо «сделать по конвенциям проекта», либо явно упоминает interface.go или интерфейс с префиксом I.
---
# Создание Go-модуля (internal/modules)
## Контекст
В этом проекте переиспользуемые компоненты живут в `internal/modules/<имя>/` — по одному модулю на папку. Публичный контракт модуля описывается в **обязательном** файле `interface.go`. Эталонные примеры действующих модулей: `internal/modules/password_generator`, `internal/modules/email_sender`, `internal/modules/cleaner`, `internal/modules/storytelling`, `internal/modules/processor_jwt`.
## Обязательные правила
1. **Папка модуля**`internal/modules/<snake_case>/` (например, `email_sender`). Имя Go-пакета равно имени папки. Никаких вложенных подпакетов внутри модуля.
2. **Файл `interface.go` обязателен.** В нём живёт ВЕСЬ публичный контракт модуля:
- интерфейс(ы) модуля;
- публичные типы: структуры-данные (DTO), перечисления, константы, именованные ошибки.
Ничего другого (реализация, приватные хелперы) в этом файле нет.
3. **Имя интерфейса начинается с `I`**`I<CamelCase>`, например `IEmailSender`, `IPasswordGenerator`, `ICleaner`, `IStory`. Интерфейс без префикса `I` — недопустимо. В интерфейсе — только сигнатуры методов, без полей.
4. **Реализация — в отдельном файле** (например, `<короткое_имя>.go` или `service.go`): неэкспортируемая структура (строчная, например `type emailSender struct`), методы которой реализуют интерфейс, и конструктор `func New<CamelCase>(...) I<CamelCase>`, возвращающий **интерфейс**, а не структуру.
5. **В папке модуля — как минимум одна реализация интерфейса.** Модуль не может состоять из одного `interface.go`: рядом с ним всегда лежит неэкспортируемая структура, реализующая `I<CamelCase>` (см. правило 4), и конструктор, возвращающий интерфейс. Если реализаций несколько — каждая в своём файле.
6. **Сигнатуры методов**: операции с внешним миром (сеть, файлы, БД, таймеры) принимают `context.Context` первым аргументом и возвращают `error`; чистые вычисления — без контекста (пример: `Generate() (string, error)` у `password_generator`).
7. **Комментарии — на русском.** Каждый публичный идентификатор имеет doc-комментарий, начинающийся с имени символа. Пояснения сложных мест — тоже по-русски.
8. **Ошибки** оборачиваются через `fmt.Errorf("<snake_case>: ...: %w", err)` — префикс с именем модуля.
9. **JSON-теги** у сериализуемых структур — `json:"..."` (эталон: `storytelling/interface.go`).
10. **Зависимости между модулями**: вместо импорта чужого модуля объявляется минимальный локальный интерфейс в файле `dependency.go` (эталон: `internal/modules/storytelling/dependency.go` объявляет собственный `ICleaner`). Это исключает циклические импорты и связывает модули только через их интерфейсы.
11. **Тесты** — для модуля желателен файл `*_test.go` рядом с реализацией (примеры: `email_sender/sender_test.go`, `cleaner/service_test.go`).
## Шаги
1. **Определи имя модуля.** Если имя неоднозначно — уточни у пользователя. Приведи имя к двум формам:
- snake_case — папка и пакет: «генератор паролей» → `password_generator`;
- CamelCase — имена типов и интерфейса: `PasswordGenerator``IPasswordGenerator`.
2. **Создай папку** `internal/modules/<snake_case>/`.
3. **Напиши `interface.go`** (обязательно): сначала публичные типы/константы, затем интерфейс `I<CamelCase>`.
4. **Напиши файл(ы) реализации** (обязательно, минимум один) — неэкспортируемая структура + конструктор `New<CamelCase>(...) I<CamelCase>`.
5. **При необходимости** добавь `dependency.go` (локальные интерфейсы зависимостей) и `*_test.go`.
6. **Проверь сборку** из корня проекта: `go build ./...` и `go vet ./...`. Ошибки — исправь.
## Шаблон interface.go
```go
package <snake_case>
import "context"
// <TypeName> — описание публичного типа.
type <TypeName> struct {
Field string `json:"field"`
}
// I<CamelCase> — контракт модуля <snake_case>.
type I<CamelCase> interface {
// <Method> — описание метода.
<Method>(ctx context.Context, in <TypeName>) error
}
```
## Шаблон реализации
```go
package <snake_case>
import (
"context"
"fmt"
)
// <lowerCamelCase> — реализация I<CamelCase>.
type <lowerCamelCase> struct {
// внутренние поля
}
// New<CamelCase> создаёт реализацию I<CamelCase>.
func New<CamelCase>(...) I<CamelCase> {
return &<lowerCamelCase>{}
}
func (s *<lowerCamelCase>) <Method>(ctx context.Context, in <TypeName>) error {
// TODO: реализация
return fmt.Errorf("<snake_case>: не реализовано")
}
```
## Критерии готовности
- [ ] Папка `internal/modules/<snake_case>/` создана, имя пакета равно имени папки.
- [ ] `interface.go` существует; все публичные типы, константы и интерфейсы описаны именно в нём.
- [ ] Имя интерфейса начинается с `I`.
- [ ] В папке модуля есть как минимум одна реализация интерфейса (неэкспортируемая структура + конструктор).
- [ ] Реализация неэкспортируемая; конструктор возвращает интерфейс, а не структуру.
- [ ] doc-комментарии на русском у всех публичных идентификаторов.
- [ ] `go build ./...` и `go vet ./...` проходят без ошибок.
+115
View File
@@ -0,0 +1,115 @@
---
name: task-execution
description: Выполняет любую нетривиальную задачу по процессу «план → ревью сеньёром → правки → выполнение → ревью результата → правки». План и результат проверяет субагент в роли senior developer; каждый цикл повторяется, пока замечаний не останется. При неясностях агент задаёт вопросы пользователю. Загружай для реализации фичи, исправления бага, рефакторинга, написания кода или документации, когда задача не сводится к одному тривиальному действию.
whenToUse: Пользователь просит выполнить задачу (написать код, починить баг, отрефакторить, задокументировать, что-то исследовать и внедрить) и задача не является тривиальным одношаговым действием. Для тривиальных правок скил можно не применять.
---
# Выполнение любой задачи: план → сеньёр → выполнение → сеньёр
## Контекст
Любая нетривиальная задача выполняется строго по циклу: сначала **план**, затем **ревью плана сеньёром** с правками до полного одобрения, затем **выполнение** по одобренному плану, затем **ревью результата сеньёром** с правками, пока замечаний не останется. На каждом этапе, если что-то неясно, задаются вопросы пользователю.
## Роли
- **Исполнитель** — ты (агент). Составляешь план, выполняешь, правишь по замечаниям.
- **Сеньёр (senior developer)** — отдельный субагент, запускаемый через `subagent`. Он **не видит наш диалог**, поэтому каждый промпт ревью должен быть самодостаточным: включай в него всю необходимую информацию (задачу, план, критерии, предыдущие замечания).
- **Пользователь** — источник требований и финальный судья. Ему задаются вопросы при неясностях.
## Обязательные правила
1. **Сначала план — потом выполнение.** Не начинай выполнение, пока сеньёр не одобрил план (ответил `APPROVED`).
2. **Ревью плана и результата — только через субагента в роли senior developer** (см. шаблоны промптов ниже). Запускай его с `run_in_background: false` — следующий шаг зависит от его ответа.
3. **Замечания исправляются все, без выбора.** Нельзя отбрасывать замечание «потому что не согласен»: если не согласен — верни уточняющий вопрос сеньёру или спроси пользователя.
4. **Цикл повторяется, пока сеньёр не вернёт `APPROVED`** — и для плана, и для результата.
5. **Неясно — спроси.** На любом этапе (задача, шаг плана, замечание сеньёра) при неоднозначности задай вопрос пользователю через `ask_user_question`. Догадки вместо вопросов — ошибка.
6. **Отслеживай прогресс** через `todo_write`: план из шага 2 переносится в todo-список, пункты отмечаются по мере выполнения.
7. **Профильные скилы** (например `create-go-module`) — источник правил «как делать» для конкретной задачи. Этот скил управляет процессом «как вести задачу»; оба применяются вместе.
## Шаги
### 1. Уточни задачу
Разберись, что именно нужно сделать. Если неясны цель, объём, ограничения или критерии приёмки — задай вопросы пользователю до составления плана.
### 2. Составь план
План должен содержать:
- **Цель** и ожидаемый результат;
- **Шаги** с конкретными файлами, командами и артефактами;
- **Проверки** (сборка, тесты, линтеры, вёрстка и т.п.);
- **Критерии готовности** — что считается «сделано»;
- **Риски и открытые вопросы** (если есть).
Заведи todo-список через `todo_write` в соответствии с планом.
### 3. Ревью плана сеньёром
Запусти субагента с промптом по шаблону **«Ревью плана»** (ниже). Сеньёр возвращает либо `APPROVED`, либо список замечаний.
### 4. Цикл правок плана
- Есть замечания → исправь план (и todo-список), отправь на ревью **повторно**: в промпт добавь предыдущие замечания и что именно изменилось.
- Повторяй, пока не получишь `APPROVED`.
### 5. Выполнение
Выполняй строго по одобренному плану, отмечая прогресс в `todo_write`. Если в ходе выполнения стало ясно, что план нужно изменить — не отклоняйся молча: вернись к шагу 3 с обновлённым планом.
### 6. Ревью результата
Сначала сам проверь результат (прогони проверки из плана), затем отдай его сеньёру по шаблону **«Ревью результата»**. Сеньёр сверяет результат с планом и критериями, проверяет корректность и соблюдение конвенций проекта.
### 7. Цикл правок результата
Правь по замечаниям, повторяй ревью, пока сеньёр не вернёт `APPROVED`. При каждом повторе сообщай сеньёру, что изменилось с прошлого раза.
### 8. Финальный отчёт
Кратко сообщи пользователю: что сделано, какие файлы затронуты, как проверялось, что план и результат одобрены сеньёром.
## Шаблон: промпт «Ревью плана»
```
Ты — senior developer. Оцени план выполнения задачи.
Задача: <текст задачи>
План: <план>
Критерии готовности: <критерии>
Проверь: полноту (нет ли пропущенных шагов), достижимость, корректность
подхода, риски, соответствие конвенциям проекта, отсутствие лишних шагов.
Ответь СТРОГО одним из двух вариантов:
- APPROVED — если замечаний нет;
- список замечаний, каждое в формате «<что не так> → <как исправить>».
```
## Шаблон: промпт «Ревью результата»
```
Ты — senior developer. Оцени результат выполнения задачи.
Задача: <текст задачи>
План: <план>
Критерии готовности: <критерии>
Результат: <что сделано: файлы, изменения, выводы проверок>
Проверь: результат соответствует плану и критериям готовности, код/текст
корректен, соблюдены конвенции проекта, нет регрессий.
Ответь СТРОГО одним из двух вариантов:
- APPROVED — если замечаний нет;
- список замечаний, каждое в формате «<что не так> → <как исправить>».
```
## Ограничение итераций
Если в одном цикле (план или результат) после **5 раундов** замечания не исчерпались — остановись и спроси пользователя: продолжать цикл или зафиксировать оставшиеся замечания как известные ограничения.
## Критерии готовности
- [ ] План составлен и одобрен сеньёром (`APPROVED`) до начала выполнения.
- [ ] Все замечания сеньёра по плану учтены.
- [ ] Результат соответствует плану и критериям готовности.
- [ ] Все замечания сеньёра по результату учтены, получен `APPROVED`.
- [ ] Пользователю дан финальный отчёт.
+11
View File
@@ -4,9 +4,20 @@ SMTP_HOST=mail.crabs-games.art
SMTP_PORT=465
SMTP_USER=evening_detective@crabs-games.art
SMTP_PASSWORD=your_password
# Отображаемое имя отправителя: Gmail показывает его вместо голого адреса,
# письма от «имени» выглядят человечнее и реже попадают в спам
SMTP_FROM_NAME=Вечерний детектив
# Адрес, на который пользователь сможет ответить на письмо
SMTP_REPLY_TO=evening_detective@crabs-games.art
# Общий таймаут SMTP-диалога (dial + auth + отправка), дефолт 10s
SMTP_TIMEOUT=10s
JWT_SECRET=your_secret
# Публичный адрес фронтенда: используется для ссылки «Войти в игру» в
# письмах. Если не задан или не http(s) — ссылка в письма не добавляется.
APP_BASE_URL=https://evening-detective.crabs-games.art
S3_HOST=http://0.0.0.0:9000
S3_USER=rustfs
S3_PASSWORD=your_password
+4
View File
@@ -27,6 +27,10 @@ go.sum
.env
# Локальные Go-кэши (используются, когда системный кэш недоступен)
.gocache/
.gotmp/
.VSCodeCounter/
docker-compose-prod.yml
+2 -2
View File
@@ -10,7 +10,7 @@ generate:
./api/main.proto
run:
go run ./cmd/evening_detective_server/main.go
go run ./cmd/evening_detective_server
build-builder:
docker build -f Dockerfile.builder -t my-go-builder .
@@ -20,7 +20,7 @@ build-linux:
-v "$$PWD":/app \
-w /app \
my-go-builder sh -c \
"GOOS=linux GOARCH=arm64 go build -o bin/evening_detective_server cmd/evening_detective_server/main.go"
"GOOS=linux GOARCH=arm64 go build -o bin/evening_detective_server ./cmd/evening_detective_server"
test:
go test -count=1 ./...
+30
View File
@@ -365,6 +365,27 @@ service EveningDetectiveServer {
};
}
rpc DownloadScenarioArchive(DownloadScenarioArchiveReq) returns (google.api.HttpBody) {
option (google.api.http) = {
get: "/api/scenarios/{id}/archive"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags : "Сценарии";
summary: "Скачать сценарий архивом (со всеми материалами и картинками)";
};
}
rpc UploadScenarioArchive(google.api.HttpBody) returns (UploadScenarioArchiveRsp) {
option (google.api.http) = {
post: "/api/scenarios/archive"
body: "*"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags : "Сценарии";
summary: "Создать сценарий из архива";
};
}
rpc AddGame(AddGameReq) returns (AddGameRsp) {
option (google.api.http) = {
post: "/api/games"
@@ -847,6 +868,15 @@ message DeleteScenarioPlaceRsp {
string error = 1;
}
message DownloadScenarioArchiveReq {
int32 id = 1;
}
message UploadScenarioArchiveRsp {
string error = 1;
int32 id = 2;
}
message AddGameReq {
string name = 1;
string description = 2;
Binary file not shown.
@@ -0,0 +1,183 @@
package main
import (
"bytes"
"context"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"evening_detective_server/internal/modules/scenario_archive"
proto "evening_detective_server/proto"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/genproto/googleapis/api/httpbody"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
)
// archiveStub — минимальный gRPC-сервер, реализующий только архивные RPC.
type archiveStub struct {
proto.UnimplementedEveningDetectiveServerServer
mu sync.Mutex
uploaded []byte
}
func (s *archiveStub) UploadScenarioArchive(_ context.Context, req *httpbody.HttpBody) (*proto.UploadScenarioArchiveRsp, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.uploaded = append([]byte(nil), req.Data...)
return &proto.UploadScenarioArchiveRsp{Id: 42}, nil
}
func (s *archiveStub) DownloadScenarioArchive(ctx context.Context, _ *proto.DownloadScenarioArchiveReq) (*httpbody.HttpBody, error) {
_ = grpc.SetHeader(ctx, metadata.Pairs("Content-Disposition", `attachment; filename="test.zip"`))
return &httpbody.HttpBody{
Data: []byte("zip-data"),
ContentType: "application/zip",
}, nil
}
func (s *archiveStub) getUploaded() []byte {
s.mu.Lock()
defer s.mu.Unlock()
return append([]byte(nil), s.uploaded...)
}
// newTestGateway поднимает gRPC-сервер со stub и grpc-gateway с теми же
// опциями, что и в main.go (outgoing matcher + rawBodyMarshaler).
func newTestGateway(t *testing.T, stub proto.EveningDetectiveServerServer) *httptest.Server {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
gs := grpc.NewServer()
proto.RegisterEveningDetectiveServerServer(gs, stub)
go func() { _ = gs.Serve(lis) }()
t.Cleanup(gs.Stop)
conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
mux := runtime.NewServeMux(
runtime.WithOutgoingHeaderMatcher(func(key string) (string, bool) {
if strings.EqualFold(key, "Content-Disposition") {
return "Content-Disposition", true
}
return runtime.DefaultHeaderMatcher(key)
}),
runtime.WithMarshalerOption("application/zip", &rawBodyMarshaler{
Marshaler: &runtime.HTTPBodyMarshaler{Marshaler: &runtime.JSONPb{}},
}),
)
if err := proto.RegisterEveningDetectiveServerHandler(context.Background(), mux, conn); err != nil {
t.Fatalf("register gateway: %v", err)
}
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
return ts
}
func TestGatewayUploadRawZip(t *testing.T) {
stub := &archiveStub{}
ts := newTestGateway(t, stub)
raw := []byte("PK\x03\x04raw-zip-bytes")
resp, err := http.Post(ts.URL+"/api/scenarios/archive", "application/zip", bytes.NewReader(raw))
if err != nil {
t.Fatalf("POST: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
}
if !strings.Contains(string(body), `"id":42`) {
t.Errorf("ответ = %s, want id 42", body)
}
if got := stub.getUploaded(); !bytes.Equal(got, raw) {
t.Errorf("сервер получил %q, want %q", got, raw)
}
}
func TestGatewayDownloadZipWithContentDisposition(t *testing.T) {
stub := &archiveStub{}
ts := newTestGateway(t, stub)
resp, err := http.Get(ts.URL + "/api/scenarios/1/archive")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != "application/zip" {
t.Errorf("Content-Type = %q, want application/zip", ct)
}
if cd := resp.Header.Get("Content-Disposition"); cd != `attachment; filename="test.zip"` {
t.Errorf("Content-Disposition = %q", cd)
}
if string(body) != "zip-data" {
t.Errorf("body = %q, want zip-data", body)
}
}
// TestCORSExposesContentDisposition — фронт через fetch должен читать
// Content-Disposition (имя файла архива) из ответа.
func TestCORSExposesContentDisposition(t *testing.T) {
h := cors(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/scenarios/1/archive", nil))
if got := rec.Header().Get("Access-Control-Expose-Headers"); got != "Content-Disposition" {
t.Errorf("Access-Control-Expose-Headers = %q, want Content-Disposition", got)
}
}
// TestLimitArchiveUploadBody — тело загрузки архива ограничено на HTTP-слое
// (защита от OOM), прочие маршруты не затронуты.
func TestLimitArchiveUploadBody(t *testing.T) {
h := limitArchiveUploadBody(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusRequestEntityTooLarge)
return
}
w.WriteHeader(http.StatusOK)
}))
t.Run("upload route rejected when over limit", func(t *testing.T) {
big := bytes.Repeat([]byte("x"), scenario_archive.MaxArchiveSize()+1)
req := httptest.NewRequest(http.MethodPost, "/api/scenarios/archive", bytes.NewReader(big))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge)
}
})
t.Run("other routes not limited", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/files/upload", bytes.NewReader([]byte("small")))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
})
}
+53 -4
View File
@@ -9,7 +9,9 @@ import (
"evening_detective_server/internal/modules/file_storage"
"evening_detective_server/internal/modules/password_generator"
"evening_detective_server/internal/modules/processor_jwt"
"evening_detective_server/internal/modules/scenario_archive"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/modules/string_tools"
"evening_detective_server/internal/repos/actions_repo"
"evening_detective_server/internal/repos/applications_repo"
"evening_detective_server/internal/repos/games_repo"
@@ -28,12 +30,14 @@ import (
"net/http"
"os"
"strings"
"time"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/encoding/protojson"
"github.com/swaggest/swgui/v5emb"
)
@@ -58,16 +62,25 @@ func main() {
}
usersRepo := users_repo.NewUserRepo(dbpool)
passwordGenerator := password_generator.NewGenerator(8)
passwordGenerator := password_generator.NewGenerator(12)
smtpTimeout := 10 * time.Second
if v := os.Getenv("SMTP_TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
smtpTimeout = d
}
}
emailSender := email_sender.NewSender(
os.Getenv("SMTP_HOST"),
os.Getenv("SMTP_PORT"),
os.Getenv("SMTP_USER"),
os.Getenv("SMTP_PASSWORD"),
os.Getenv("SMTP_FROM_NAME"),
os.Getenv("SMTP_REPLY_TO"),
smtpTimeout,
)
processorJWT := processor_jwt.NewProcessor(os.Getenv("JWT_SECRET"))
refreshTokensRepo := refresh_tokens_repo.NewRefreshTokensRepo(dbpool)
usersService := users_service.NewUsersService(usersRepo, passwordGenerator, emailSender, processorJWT, refreshTokensRepo)
usersService := users_service.NewUsersService(usersRepo, passwordGenerator, emailSender, processorJWT, refreshTokensRepo, os.Getenv("APP_BASE_URL"))
uiService := ui_service.NewUiService()
fileStorage, err := file_storage.NewRustFSStorage(
os.Getenv("S3_HOST"),
@@ -78,13 +91,15 @@ func main() {
if err != nil {
log.Fatalf("Unable to create connection rustfs: %v\n", err)
}
fileService := file_service.NewFileService(fileStorage)
fileService := file_service.NewFileService(fileStorage, string_tools.NewStringTools())
scenariosRepo := scenarios_repo.NewScenariosRepo(dbpool)
cleaner := cleaner.NewCleaner()
scenarioService := scenarios_service.NewScenarioService(
scenariosRepo,
cleaner,
os.Getenv("FILE_PREFIX_DOMAIN"),
fileStorage,
scenario_archive.NewScenarioArchive(),
)
gameRepo := games_repo.NewGamesRepo(dbpool)
teamRepo := teams_repo.NewTeamsRepo(dbpool)
@@ -168,6 +183,15 @@ func main() {
// акцепте соглашений (ст. 9 152-ФЗ). X-Password — подтверждение паролем
// при удалении аккаунта (не в query, чтобы не светить креденшел в URL
// и логах).
// Сырая загрузка архива (без base64) для распространённых Content-Type;
// JSON-вариант {"data": "<base64>"} работает через application/json.
rawBody := &rawBodyMarshaler{
Marshaler: &runtime.HTTPBodyMarshaler{
Marshaler: &runtime.JSONPb{
UnmarshalOptions: protojson.UnmarshalOptions{DiscardUnknown: true},
},
},
}
gwmux := runtime.NewServeMux(
runtime.WithIncomingHeaderMatcher(func(key string) (string, bool) {
switch {
@@ -178,6 +202,16 @@ func main() {
}
return runtime.DefaultHeaderMatcher(key)
}),
// Проброс Content-Disposition от сервиса в HTTP-ответ.
runtime.WithOutgoingHeaderMatcher(func(key string) (string, bool) {
if strings.EqualFold(key, "Content-Disposition") {
return "Content-Disposition", true
}
return runtime.DefaultHeaderMatcher(key)
}),
runtime.WithMarshalerOption("application/zip", rawBody),
runtime.WithMarshalerOption("application/octet-stream", rawBody),
runtime.WithMarshalerOption("application/x-zip-compressed", rawBody),
)
// Register Greeter
err = proto.RegisterEveningDetectiveServerHandler(context.Background(), gwmux, conn)
@@ -193,7 +227,9 @@ func main() {
w.Header().Set("Content-Type", "application/json")
w.Write(swaggerJSON)
})
mainMux.Handle("/api/", gwmux)
// MaxBytesReader на HTTP-слое: gateway буферизует тело целиком, без лимита
// проверки в хендлере не спасут от OOM.
mainMux.Handle("/api/", limitArchiveUploadBody(gwmux))
gwServer := &http.Server{
Addr: ":8090",
@@ -210,9 +246,22 @@ func cors(h http.Handler) http.Handler {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, Authorization, ResponseType, X-Id, X-Password")
// Content-Disposition нужен фронту, чтобы прочитать имя файла архива.
w.Header().Set("Access-Control-Expose-Headers", "Content-Disposition")
if r.Method == "OPTIONS" {
return
}
h.ServeHTTP(w, r)
})
}
// limitArchiveUploadBody ограничивает тело запроса загрузки архива
// (см. rawBodyDecoder: gateway читает тело в память целиком).
func limitArchiveUploadBody(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == "/api/scenarios/archive" {
r.Body = http.MaxBytesReader(w, r.Body, int64(scenario_archive.MaxArchiveSize()))
}
next.ServeHTTP(w, r)
})
}
@@ -735,6 +735,40 @@
]
}
},
"/api/scenarios/archive": {
"post": {
"summary": "Создать сценарий из архива",
"operationId": "EveningDetectiveServer_UploadScenarioArchive",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/evening_detective_serverUploadScenarioArchiveRsp"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "body",
"description": "Message that represents an arbitrary HTTP body. It should only be used for\npayload formats that can't be represented as JSON, such as raw binary or\nan HTML page.\n\n\nThis message can be used both in streaming and non-streaming API methods in\nthe request as well as the response.\n\nIt can be used as a top-level request field, which is convenient if one\nwants to extract parameters from either the URL or HTTP template into the\nrequest fields and also want access to the raw HTTP body.\n\nExample:\n\n message GetResourceRequest {\n // A unique request id.\n string request_id = 1;\n\n // The raw HTTP body is bound to this field.\n google.api.HttpBody http_body = 2;\n\n }\n\n service ResourceService {\n rpc GetResource(GetResourceRequest)\n returns (google.api.HttpBody);\n rpc UpdateResource(google.api.HttpBody)\n returns (google.protobuf.Empty);\n\n }\n\nExample with streaming methods:\n\n service CaldavService {\n rpc GetCalendar(stream google.api.HttpBody)\n returns (stream google.api.HttpBody);\n rpc UpdateCalendar(stream google.api.HttpBody)\n returns (stream google.api.HttpBody);\n\n }\n\nUse of this type only changes how the request and response bodies are\nhandled, all other features will continue to work unchanged.",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/apiHttpBody"
}
}
],
"tags": [
"Сценарии"
]
}
},
"/api/scenarios/{id}": {
"get": {
"summary": "Получить сценарий по id",
@@ -835,6 +869,38 @@
]
}
},
"/api/scenarios/{id}/archive": {
"get": {
"summary": "Скачать сценарий архивом (со всеми материалами и картинками)",
"operationId": "EveningDetectiveServer_DownloadScenarioArchive",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/apiHttpBody"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"type": "integer",
"format": "int32"
}
],
"tags": [
"Сценарии"
]
}
},
"/api/scenarios/{id}/draft": {
"put": {
"summary": "Снять с публикации сценарий",
@@ -2477,6 +2543,18 @@
}
}
},
"evening_detective_serverUploadScenarioArchiveRsp": {
"type": "object",
"properties": {
"error": {
"type": "string"
},
"id": {
"type": "integer",
"format": "int32"
}
}
},
"evening_detective_serverUser": {
"type": "object",
"properties": {
+41
View File
@@ -0,0 +1,41 @@
package main
import (
"io"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/genproto/googleapis/api/httpbody"
)
// rawBodyMarshaler — маршалер для бинарного тела (application/zip и др.):
// запрос попадает в HttpBody.Data без base64-JSON, для остального делегирует
// JSON-маршалеру (работает и {"data": "<base64>"}).
type rawBodyMarshaler struct {
runtime.Marshaler
}
func (m *rawBodyMarshaler) NewDecoder(r io.Reader) runtime.Decoder {
return rawBodyDecoder{
Decoder: m.Marshaler.NewDecoder(r),
r: r,
}
}
// rawBodyDecoder — Decoder: для *httpbody.HttpBody читает тело целиком в Data,
// для protobuf-сообщений декодирует JSON как обычно.
type rawBodyDecoder struct {
runtime.Decoder
r io.Reader
}
func (d rawBodyDecoder) Decode(v interface{}) error {
if body, ok := v.(*httpbody.HttpBody); ok {
data, err := io.ReadAll(d.r)
if err != nil {
return err
}
body.Data = data
return nil
}
return d.Decoder.Decode(v)
}
@@ -0,0 +1,46 @@
package main
import (
"bytes"
"strings"
"testing"
proto "evening_detective_server/proto"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/genproto/googleapis/api/httpbody"
)
func newTestRawBodyMarshaler() *rawBodyMarshaler {
return &rawBodyMarshaler{
Marshaler: &runtime.HTTPBodyMarshaler{
Marshaler: &runtime.JSONPb{},
},
}
}
func TestRawBodyDecoderDecodesHttpBody(t *testing.T) {
m := newTestRawBodyMarshaler()
raw := []byte("PK\x03\x04fake-zip-bytes")
protoReq := &httpbody.HttpBody{}
if err := m.NewDecoder(bytes.NewReader(raw)).Decode(protoReq); err != nil {
t.Fatalf("Decode: %v", err)
}
if !bytes.Equal(protoReq.Data, raw) {
t.Errorf("Data = %q, want %q", protoReq.Data, raw)
}
}
func TestRawBodyMarshalerDelegatesToJSONForProtos(t *testing.T) {
m := newTestRawBodyMarshaler()
// Для protobuf-сообщения (не HttpBody) декодирование идёт через JSON.
req := &proto.EchoReq{}
if err := m.NewDecoder(strings.NewReader(`{"text":"hi"}`)).Decode(req); err != nil {
t.Fatalf("Decode: %v", err)
}
if req.Text != "hi" {
t.Errorf("Text = %q, want hi", req.Text)
}
}
+85
View File
@@ -2,6 +2,8 @@ package app
import (
"context"
"errors"
"fmt"
"net/netip"
"strings"
@@ -9,6 +11,9 @@ import (
"evening_detective_server/internal/modules/file_storage"
"evening_detective_server/internal/modules/processor_jwt"
"evening_detective_server/internal/modules/roles"
"evening_detective_server/internal/modules/scenario_archive"
"evening_detective_server/internal/modules/string_tools"
"evening_detective_server/internal/repos/scenarios_repo"
"evening_detective_server/internal/services/file_service"
"evening_detective_server/internal/services/game_service"
"evening_detective_server/internal/services/scenarios_service"
@@ -17,11 +22,15 @@ import (
proto "evening_detective_server/proto"
"google.golang.org/genproto/googleapis/api/httpbody"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
// stringTools — транслитерация имён файлов для HTTP-заголовков.
var stringTools = string_tools.NewStringTools()
type server struct {
proto.UnsafeEveningDetectiveServerServer
@@ -571,6 +580,82 @@ func (s *server) DeleteScenarioPlace(ctx context.Context, req *proto.DeleteScena
return &proto.DeleteScenarioPlaceRsp{}, nil
}
// DownloadScenarioArchive отдаёт ZIP-архив сценария; ошибки — gRPC-статусами.
func (s *server) DownloadScenarioArchive(ctx context.Context, req *proto.DownloadScenarioArchiveReq) (*httpbody.HttpBody, error) {
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
if !roles.HasRole(claims, roles.Author) {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
data, name, err := s.scenarioService.DownloadArchive(
ctx,
int(req.Id),
claims.UserID,
roles.HasRole(claims, roles.Admin),
)
if err != nil {
switch {
case errors.Is(err, scenarios_repo.ErrScenarioNotFound):
return nil, status.Errorf(codes.NotFound, "%v", err)
case errors.Is(err, scenarios_service.ErrScenarioNotOwner):
return nil, status.Errorf(codes.PermissionDenied, "%v", err)
default:
return nil, status.Errorf(codes.Internal, "%v", err)
}
}
// Имя файла транслитерируется (заголовки HTTP — только ASCII) и
// санитайзится от header injection; заголовок пробрасывается gateway.
transliterated := sanitizeFilename(stringTools.Transliterate(name))
if transliterated == "" {
transliterated = "scenario"
}
filename := transliterated + ".zip"
if err := grpc.SetHeader(ctx, metadata.Pairs("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))); err != nil {
return nil, status.Errorf(codes.Internal, "failed to set response header: %v", err)
}
return &httpbody.HttpBody{
Data: data,
ContentType: "application/zip",
}, nil
}
// UploadScenarioArchive создаёт сценарий из ZIP-архива. Тело — сырые байты;
// JSON base64 тоже поддерживается.
func (s *server) UploadScenarioArchive(ctx context.Context, req *httpbody.HttpBody) (*proto.UploadScenarioArchiveRsp, error) {
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
if !roles.HasRole(claims, roles.Author) {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
if req == nil || len(req.Data) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "пустое тело запроса")
}
if len(req.Data) > scenario_archive.MaxArchiveSize() {
return nil, status.Errorf(codes.InvalidArgument, "архив больше максимального размера (%d байт)", scenario_archive.MaxArchiveSize())
}
id, err := s.scenarioService.UploadArchive(ctx, req.Data, claims.UserID)
if err != nil {
return &proto.UploadScenarioArchiveRsp{
Error: err.Error(),
}, nil
}
return &proto.UploadScenarioArchiveRsp{
Id: int32(id),
}, nil
}
// sanitizeFilename — защита от инъекции заголовков в Content-Disposition.
func sanitizeFilename(name string) string {
replacer := strings.NewReplacer(
`"`, "_",
"\r", "_",
"\n", "_",
)
return replacer.Replace(name)
}
func (s *server) AddGame(ctx context.Context, req *proto.AddGameReq) (*proto.AddGameRsp, error) {
claims := ctx.Value("claims").(*processor_jwt.JWTClaims)
if !roles.HasRole(claims, roles.Organizer) && !roles.HasRole(claims, roles.Author) {
+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),
}
}
+5 -1
View File
@@ -5,7 +5,11 @@ import "context"
type Message struct {
To string
Subject string
Body string
// Body — текстовая версия письма (text/plain).
Body string
// HTML — версия письма для почтовых клиентов (text/html).
// Если пусто, письмо уходит только в text/plain.
HTML string
}
type IEmailSender interface {
+183 -46
View File
@@ -2,9 +2,16 @@ package email_sender
import (
"context"
"crypto/rand"
"crypto/tls"
"errors"
"fmt"
"mime"
"net"
"net/mail"
"net/smtp"
"strings"
"time"
)
type sender struct {
@@ -12,6 +19,11 @@ type sender struct {
smtpPort string
smtpUser string
smtpPassword string
from string
fromName string
replyTo string
timeout time.Duration
tlsConfig *tls.Config
}
func NewSender(
@@ -19,76 +31,201 @@ func NewSender(
smtpPort string,
smtpUser string,
smtpPassword string,
fromName string,
replyTo string,
timeout time.Duration,
) IEmailSender {
// From заголовка письма по умолчанию совпадает с учётной записью SMTP.
from := smtpUser
if timeout <= 0 {
timeout = 10 * time.Second
}
return &sender{
smtpHost: smtpHost,
smtpPort: smtpPort,
smtpUser: smtpUser,
smtpPassword: smtpPassword,
from: from,
fromName: sanitizeHeader(fromName),
replyTo: sanitizeHeader(replyTo),
timeout: timeout,
// Проверка имени сервера включена всегда; поле переопределяется
// только в тестах (свой RootCAs для самоподписанного сертификата).
tlsConfig: &tls.Config{ServerName: smtpHost},
}
}
func (s *sender) Send(_ context.Context, message Message) error {
emailMessage := []byte(fmt.Sprintf(
"To: %s\r\n"+
"Subject: %s\r\n"+
"MIME-Version: 1.0\r\n"+
"Content-Type: text/plain; charset=utf-8\r\n"+
"\r\n"+
"%s\r\n",
message.To,
message.Subject,
message.Body,
))
// Настраиваем TLS
tlsConfig := &tls.Config{
ServerName: s.smtpHost,
}
// Подключаемся к серверу
conn, err := tls.Dial("tcp", s.smtpHost+":"+s.smtpPort, tlsConfig)
if err != nil {
// Send доставляет письмо через SMTP (implicit TLS) с учётом контекста и
// таймаута: соединение устанавливается через DialContext, а все фазы SMTP-
// диалога ограничены общим deadline, производным от ctx.
func (s *sender) Send(ctx context.Context, message Message) error {
// Санитизация пользовательского ввода: CR/LF/NUL в заголовках ломают
// формат письма и позволяют инъекцию произвольных заголовков (Bcc и т.п.).
to := sanitizeHeader(message.To)
subject := sanitizeHeader(message.Subject)
if err := validateMessage(to, subject); err != nil {
return err
}
defer conn.Close()
body := buildMessage(s.from, s.fromName, to, subject, s.replyTo, message.Body, message.HTML)
ctx, cancel := context.WithTimeout(ctx, s.timeout)
defer cancel()
addr := net.JoinHostPort(s.smtpHost, s.smtpPort)
rawConn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("email: smtp dial %s: %w", addr, err)
}
defer rawConn.Close()
conn := tls.Client(rawConn, s.tlsConfig)
if deadline, ok := ctx.Deadline(); ok {
// Единый бюджет времени на весь SMTP-диалог: зависший сервер не
// должен держать запрос дольше таймаута.
if err = conn.SetDeadline(deadline); err != nil {
return fmt.Errorf("email: set deadline: %w", err)
}
}
// Создаем SMTP клиент
client, err := smtp.NewClient(conn, s.smtpHost)
if err != nil {
return err
return fmt.Errorf("email: smtp greeting: %w", err)
}
defer client.Quit()
// Close гарантированно закрывает соединение при раннем выходе; в конце
// штатного пути вместо него вызывается Quit.
defer client.Close() //nolint:errcheck
// Аутентификация
auth := smtp.PlainAuth("", s.smtpUser, s.smtpPassword, s.smtpHost)
if err = client.Auth(auth); err != nil {
return err
return fmt.Errorf("email: smtp auth: %w", err)
}
if err = client.Mail(s.from); err != nil {
return fmt.Errorf("email: smtp mail from: %w", err)
}
if err = client.Rcpt(to); err != nil {
return fmt.Errorf("email: smtp rcpt to: %w", err)
}
// Указываем отправителя
if err = client.Mail(s.smtpUser); err != nil {
return err
}
// Указываем получателя
if err = client.Rcpt(message.To); err != nil {
return err
}
// Отправляем данные письма
w, err := client.Data()
if err != nil {
return err
return fmt.Errorf("email: smtp data: %w", err)
}
_, err = w.Write(emailMessage)
if err != nil {
return err
if _, err = w.Write(body); err != nil {
return fmt.Errorf("email: smtp write: %w", err)
}
err = w.Close()
if err != nil {
return err
if err = w.Close(); err != nil {
return fmt.Errorf("email: smtp data close: %w", err)
}
if err = client.Quit(); err != nil {
return fmt.Errorf("email: smtp quit: %w", err)
}
return nil
}
// buildMessage собирает тело письма. Заголовки обязаны быть ASCII, поэтому
// не-ASCII значения (имя отправителя, тема) кодируются по RFC 2047, иначе
// кириллица может быть испорчена промежуточными серверами. Заголовки
// Date и Message-ID добавляются явно: их отсутствие — типичный признак
// спама для почтовых фильтров (включая Gmail). Если задана HTML-версия,
// письмо собирается как multipart/alternative (text/plain + text/html).
func buildMessage(from, fromName, to, subject, replyTo, body, html string) []byte {
var b strings.Builder
b.Grow(len(from) + len(fromName) + len(to) + len(subject) + len(body) + len(html) + 512)
b.WriteString("From: ")
if fromName != "" {
b.WriteString(mime.QEncoding.Encode("utf-8", fromName))
b.WriteString(" <")
b.WriteString(from)
b.WriteString(">\r\n")
} else {
b.WriteString(from)
b.WriteString("\r\n")
}
b.WriteString("To: ")
b.WriteString(to)
b.WriteString("\r\n")
b.WriteString("Subject: ")
b.WriteString(mime.QEncoding.Encode("utf-8", subject))
b.WriteString("\r\n")
b.WriteString("Date: ")
b.WriteString(time.Now().UTC().Format(time.RFC1123Z))
b.WriteString("\r\n")
b.WriteString("Message-ID: ")
b.WriteString(messageID(from))
b.WriteString("\r\n")
if replyTo != "" {
b.WriteString("Reply-To: ")
b.WriteString(replyTo)
b.WriteString("\r\n")
}
b.WriteString("MIME-Version: 1.0\r\n")
if html == "" {
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
b.WriteString("\r\n")
b.WriteString(body)
b.WriteString("\r\n")
return []byte(b.String())
}
boundary := fmt.Sprintf("----=_evening_detective_%s", randomHex(10))
b.WriteString("Content-Type: multipart/alternative; boundary=\"")
b.WriteString(boundary)
b.WriteString("\"\r\n\r\n")
b.WriteString("--")
b.WriteString(boundary)
b.WriteString("\r\n")
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
b.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
b.WriteString(body)
b.WriteString("\r\n--")
b.WriteString(boundary)
b.WriteString("\r\n")
b.WriteString("Content-Type: text/html; charset=utf-8\r\n")
b.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
b.WriteString(html)
b.WriteString("\r\n--")
b.WriteString(boundary)
b.WriteString("--\r\n")
return []byte(b.String())
}
// messageID генерирует уникальный Message-ID в домене отправителя.
func messageID(from string) string {
domain := ""
if i := strings.LastIndex(from, "@"); i >= 0 && i < len(from)-1 {
domain = from[i+1:]
}
if domain == "" {
domain = "localhost"
}
return "<" + randomHex(12) + "@" + domain + ">"
}
// randomHex возвращает случайную hex-строку (криптостойкий генератор;
// при сбое — значение из таймера, чтобы отправка не падала).
func randomHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%x", time.Now().UnixNano())
}
return fmt.Sprintf("%x", b)
}
// sanitizeHeader удаляет символы, ломающие структуру заголовков письма
// (CRLF-инъекция заголовков, NUL).
func sanitizeHeader(s string) string {
r := strings.NewReplacer("\r", "", "\n", "", "\x00", "")
return r.Replace(s)
}
// validateMessage проверяет адрес получателя и наличие темы до обращения к
// SMTP-серверу.
func validateMessage(to, subject string) error {
if _, err := mail.ParseAddress(to); err != nil {
return fmt.Errorf("email: некорректный адрес получателя: %w", err)
}
if subject == "" {
return errors.New("email: пустая тема письма")
}
return nil
}
@@ -0,0 +1,356 @@
package email_sender
import (
"bufio"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"net"
"strings"
"testing"
"time"
)
// startSMTPTestServer поднимает минимальный SMTP-сервер (implicit TLS) на
// случайном порту 127.0.0.1 и возвращает его адрес и канал с принятыми
// телами писем. При hang=true сервер отправляет приветствие и молчит —
// для проверки таймаутов. Сертификат генерируется один на тест и
// передаётся и серверу, и клиенту (testSender).
func startSMTPTestServer(t *testing.T, hang bool, cert tls.Certificate) (addr string, messages chan []byte) {
t.Helper()
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{
Certificates: []tls.Certificate{cert},
})
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = ln.Close() })
messages = make(chan []byte, 8)
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
if hang {
go func() {
// Приветствие отправляем, дальше молчим.
_, _ = conn.Write([]byte("220 hang.example.com ESMTP\r\n"))
}()
continue
}
go handleSMTPConn(conn, messages)
}
}()
return ln.Addr().String(), messages
}
// handleSMTPConn обслуживает один SMTP-диалог, достаточный для net/smtp:
// EHLO, AUTH PLAIN, MAIL, RCPT, DATA, QUIT.
func handleSMTPConn(conn net.Conn, messages chan<- []byte) {
defer conn.Close()
r := bufio.NewReader(conn)
write := func(s string) { _, _ = conn.Write([]byte(s)) }
write("220 test.example.com ESMTP\r\n")
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
cmd := strings.ToUpper(strings.TrimSpace(line))
switch {
case strings.HasPrefix(cmd, "EHLO"):
write("250-test.example.com\r\n250-AUTH PLAIN\r\n250 OK\r\n")
case strings.HasPrefix(cmd, "AUTH"):
write("235 2.7.0 Authentication successful\r\n")
case strings.HasPrefix(cmd, "MAIL"):
write("250 OK\r\n")
case strings.HasPrefix(cmd, "RCPT"):
write("250 OK\r\n")
case strings.HasPrefix(cmd, "DATA"):
write("354 End data with <CR><LF>.<CR><LF>\r\n")
var body []byte
for {
b, err := r.ReadBytes('\n')
if err != nil {
return
}
body = append(body, b...)
if string(b) == ".\r\n" {
break
}
}
select {
case messages <- body:
default:
}
write("250 OK\r\n")
case strings.HasPrefix(cmd, "QUIT"):
write("221 Bye\r\n")
return
}
}
}
// testTLSCert генерирует самоподписанный сертификат для 127.0.0.1 и
// возвращает его вместе с распарсенным x509-представлением (Leaf), чтобы
// тест мог добавить его в корни доверия клиента.
func testTLSCert(t *testing.T) tls.Certificate {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "127.0.0.1"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
leaf, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("parse certificate: %v", err)
}
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}
}
// testSender собирает sender, доверяющий самоподписанному сертификату
// тестового SMTP-сервера (в проде NewSender использует системные корни).
func testSender(t *testing.T, addr string, timeout time.Duration, cert tls.Certificate) *sender {
t.Helper()
host, port := splitHostPort(t, addr)
pool := x509.NewCertPool()
pool.AddCert(cert.Leaf)
return &sender{
smtpHost: host,
smtpPort: port,
smtpUser: "sender@example.com",
smtpPassword: "secret",
from: "sender@example.com",
fromName: "Вечерний детектив",
replyTo: "support@example.com",
timeout: timeout,
tlsConfig: &tls.Config{ServerName: host, RootCAs: pool},
}
}
func splitHostPort(t *testing.T, addr string) (host, port string) {
t.Helper()
host, port, err := net.SplitHostPort(addr)
if err != nil {
t.Fatalf("split host port: %v", err)
}
return host, port
}
func TestSendSuccess(t *testing.T) {
cert := testTLSCert(t)
addr, messages := startSMTPTestServer(t, false, cert)
s := testSender(t, addr, 5*time.Second, cert)
err := s.Send(context.Background(), Message{
To: "user@example.com",
Subject: "Привет, детектив!",
Body: "Текст письма",
HTML: "<html><body>Текст письма</body></html>",
})
if err != nil {
t.Fatalf("Send: %v", err)
}
select {
case data := <-messages:
raw := string(data)
for _, want := range []string{
// From с отображаемым именем (RFC 2047).
"From: =?utf-8?q?",
"<sender@example.com>",
"To: user@example.com",
"Subject: =?utf-8?q?",
"Date: ",
"Message-ID: <",
"@example.com>",
"Reply-To: support@example.com",
"MIME-Version: 1.0",
"Content-Type: multipart/alternative; boundary=",
"Content-Type: text/plain; charset=utf-8",
"Текст письма",
"Content-Type: text/html; charset=utf-8",
"<html><body>Текст письма</body></html>",
} {
if !strings.Contains(raw, want) {
t.Errorf("письмо не содержит %q:\n%s", want, raw)
}
}
case <-time.After(2 * time.Second):
t.Fatal("письмо не получено сервером")
}
}
func TestSendRejectsCRLFInjection(t *testing.T) {
cert := testTLSCert(t)
addr, messages := startSMTPTestServer(t, false, cert)
s := testSender(t, addr, 5*time.Second, cert)
// CRLF в адресе — попытка инъекции заголовков (Bcc и т.п.): письмо
// должно быть отклонено до обращения к SMTP-серверу.
err := s.Send(context.Background(), Message{
To: "user@example.com\r\nBcc: victim@example.com",
Subject: "Test",
Body: "body",
})
if err == nil {
t.Fatal("ожидалась ошибка для адреса с CRLF")
}
select {
case <-messages:
t.Fatal("письмо не должно было уйти на сервер")
default:
}
}
func TestSendContextCancelled(t *testing.T) {
cert := testTLSCert(t)
addr, _ := startSMTPTestServer(t, false, cert)
s := testSender(t, addr, 10*time.Second, cert)
ctx, cancel := context.WithCancel(context.Background())
cancel()
start := time.Now()
err := s.Send(ctx, Message{To: "user@example.com", Subject: "Test", Body: "body"})
if err == nil {
t.Fatal("ожидалась ошибка при отменённом контексте")
}
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Fatalf("отмена контекста должна прерывать отправку быстро, заняло %v", elapsed)
}
}
func TestSendTimeout(t *testing.T) {
cert := testTLSCert(t)
addr, _ := startSMTPTestServer(t, true, cert) // сервер молчит после приветствия
s := testSender(t, addr, 500*time.Millisecond, cert)
start := time.Now()
err := s.Send(context.Background(), Message{To: "user@example.com", Subject: "Test", Body: "body"})
if err == nil {
t.Fatal("ожидалась ошибка таймаута")
}
if elapsed := time.Since(start); elapsed > 3*time.Second {
t.Fatalf("таймаут должен сработать быстро, заняло %v", elapsed)
}
}
func TestBuildMessage(t *testing.T) {
// С HTML-версией — multipart/alternative.
raw := string(buildMessage(
"sender@example.com", "Вечерний детектив",
"user@example.com", "Привет", "support@example.com",
"текст", "<html><body>текст</body></html>",
))
for _, want := range []string{
"From: =?utf-8?q?",
"<sender@example.com>",
"To: user@example.com",
"Subject: =?utf-8?q?",
"Date: ",
"Message-ID: <",
"Reply-To: support@example.com",
"MIME-Version: 1.0",
"Content-Type: multipart/alternative; boundary=",
"Content-Type: text/plain; charset=utf-8",
"текст",
"Content-Type: text/html; charset=utf-8",
"<html><body>текст</body></html>",
} {
if !strings.Contains(raw, want) {
t.Errorf("письмо не содержит %q:\n%s", want, raw)
}
}
// Без HTML-версии — только text/plain (обратная совместимость).
raw = string(buildMessage(
"sender@example.com", "Вечерний детектив",
"user@example.com", "Привет", "",
"текст", "",
))
for _, want := range []string{
"Content-Type: text/plain; charset=utf-8",
"текст",
} {
if !strings.Contains(raw, want) {
t.Errorf("plain-письмо не содержит %q:\n%s", want, raw)
}
}
if strings.Contains(raw, "multipart/alternative") {
t.Errorf("plain-письмо не должно быть multipart:\n%s", raw)
}
}
func TestBuildMessageNoFromName(t *testing.T) {
raw := string(buildMessage(
"sender@example.com", "",
"user@example.com", "Привет", "",
"текст", "",
))
if !strings.Contains(raw, "From: sender@example.com\r\n") {
t.Errorf("адрес без имени отправителя должен идти без угловых скобок:\n%s", raw)
}
}
func TestBuildMessageMessageIDDomain(t *testing.T) {
raw := string(buildMessage(
"evening_detective@crabs-games.art", "Вечерний детектив",
"user@example.com", "Привет", "",
"текст", "",
))
for _, want := range []string{
"Message-ID: <",
"@crabs-games.art>",
} {
if !strings.Contains(raw, want) {
t.Errorf("письмо не содержит %q:\n%s", want, raw)
}
}
}
func TestSanitizeHeader(t *testing.T) {
got := sanitizeHeader("a@b.c\r\nBcc: x@y.z\x00")
want := "a@b.cBcc: x@y.z"
if got != want {
t.Errorf("sanitizeHeader = %q, want %q", got, want)
}
}
func TestValidateMessage(t *testing.T) {
if err := validateMessage("user@example.com", "тема"); err != nil {
t.Errorf("валидное письмо отклонено: %v", err)
}
if err := validateMessage("not-an-email", "тема"); err == nil {
t.Error("невалидный адрес принят")
}
if err := validateMessage("user@example.com", ""); err == nil {
t.Error("пустая тема принята")
}
}
@@ -11,4 +11,6 @@ type File struct {
type IFileStorage interface {
Put(ctx context.Context, file *File) error
Get(ctx context.Context, filename string) (*File, error)
Delete(ctx context.Context, filename string) error
MimeType(filename string) string
}
+7 -3
View File
@@ -47,7 +47,7 @@ func NewRustFSStorage(
}
func (s *storage) Put(ctx context.Context, file *File) error {
mime := s.getMimeType(file.Name)
mime := s.MimeType(file.Name)
return s.client.PutObjectBytes(ctx, s.bucket, file.Name, file.Data, mime)
}
@@ -59,11 +59,15 @@ func (s *storage) Get(ctx context.Context, filename string) (*File, error) {
return &File{
Name: filename,
Data: data,
Mime: s.getMimeType(filename),
Mime: s.MimeType(filename),
}, nil
}
func (s *storage) getMimeType(filename string) string {
func (s *storage) Delete(ctx context.Context, filename string) error {
return s.client.DeleteObject(ctx, s.bucket, filename)
}
func (s *storage) MimeType(filename string) string {
if mimeType := mime.TypeByExtension(filepath.Ext(filename)); mimeType != "" {
return mimeType
}
@@ -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)
}
}
@@ -0,0 +1,325 @@
// Пакет scenario_archive — сборка и разбор ZIP-архива сценария.
// Архив: scenario.json (описание и история) + images/<имя> (файлы изображений).
// Все пути внутри архива относительные.
package scenario_archive
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"path"
"sort"
"strings"
"time"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/repos"
)
// Лимиты на входящие архивы (защита от zip-bomb). Суммарный объём считается
// по фактически распакованным байтам — размеры в заголовках ZIP подделываются.
var (
maxArchiveSize = 32 << 20 // 32 МБ — сырой размер архива
maxUnpackedSize = 128 << 20 // 128 МБ — суммарный объём распакованного
maxEntries = 500 // число записей
maxEntrySize = 32 << 20 // 32 МБ — размер одной записи
maxPackSize = 256 << 20 // 256 МБ — суммарный объём изображений при сборке
)
type scenarioArchive struct{}
// NewScenarioArchive создаёт реализацию IScenarioArchive.
func NewScenarioArchive() IScenarioArchive {
return &scenarioArchive{}
}
// MaxArchiveSize — максимальный размер входящего архива (для проверок
// в хендлере и на HTTP-слое).
func MaxArchiveSize() int {
return maxArchiveSize
}
// ImagePath возвращает путь изображения внутри архива (images/<имя>) и true
// для ссылок нашего хранилища: относительное имя или URL с доменом (legacy).
// Внешние URL и пустые ссылки — ("", false): в архив не кладутся.
func ImagePath(ref, domain string) (string, bool) {
ref = strings.TrimPrefix(ref, domain)
if ref == "" {
return "", false
}
low := strings.ToLower(ref)
if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") {
return "", false
}
return "images/" + path.Base(ref), true
}
// Pack собирает ZIP-архив сценария: ссылки на изображения хранилища
// переписываются в images/<имя>, файлы запрашиваются через getFile; внешние
// URL остаются без изменений. Архив детерминирован (порядок записей и метки).
func (a *scenarioArchive) Pack(
ctx context.Context,
scenario *repos.Scenario,
domain string,
getFile func(ctx context.Context, name string) ([]byte, error),
) ([]byte, error) {
if scenario == nil {
return nil, errors.New("сценарий не задан")
}
story := &storytelling.Story{}
if scenario.Scenario != "" {
if err := json.Unmarshal([]byte(scenario.Scenario), story); err != nil {
return nil, fmt.Errorf("не удалось разобрать историю сценария: %w", err)
}
}
// Имя в хранилище → путь в архиве; конфликт базовых имён — ошибка.
archivePathOf := map[string]string{}
addRef := func(ref string) error {
if ref == "" {
return nil
}
archivePath, ok := ImagePath(ref, domain)
if !ok {
return nil
}
if _, exists := archivePathOf[ref]; exists {
return nil
}
for storageName, existingPath := range archivePathOf {
if existingPath == archivePath {
return fmt.Errorf("конфликт имён изображений в архиве: %q и %q", storageName, ref)
}
}
archivePathOf[ref] = archivePath
return nil
}
if scenario.Image != nil {
if err := addRef(*scenario.Image); err != nil {
return nil, err
}
}
for _, place := range story.Places {
if err := addRef(place.Image); err != nil {
return nil, err
}
for _, application := range place.Applications {
if err := addRef(application.Image); err != nil {
return nil, err
}
}
}
// Загружаем файлы из хранилища (имя = ссылка без домена), с суммарным лимитом.
images := make(map[string][]byte, len(archivePathOf))
var totalPacked int64
for ref, archivePath := range archivePathOf {
storageName := strings.TrimPrefix(ref, domain)
data, err := getFile(ctx, storageName)
if err != nil {
return nil, fmt.Errorf("не удалось получить изображение %q: %w", storageName, err)
}
totalPacked += int64(len(data))
if totalPacked > int64(maxPackSize) {
return nil, fmt.Errorf("суммарный объём изображений больше %d байт", maxPackSize)
}
images[archivePath] = data
}
for _, place := range story.Places {
place.Image = rewriteRef(place.Image, archivePathOf)
for _, application := range place.Applications {
application.Image = rewriteRef(application.Image, archivePathOf)
}
}
doc := ScenarioJSON{
Version: Version,
Name: scenario.Name,
Description: strPtrValue(scenario.Description),
Story: story,
}
if scenario.Image != nil {
doc.Image = rewriteRef(*scenario.Image, archivePathOf)
}
docJSON, err := json.Marshal(doc)
if err != nil {
return nil, fmt.Errorf("не удалось собрать %s: %w", FileName, err)
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
if err := writeZipEntry(zw, FileName, docJSON); err != nil {
return nil, err
}
names := make([]string, 0, len(images))
for name := range images {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
if err := writeZipEntry(zw, name, images[name]); err != nil {
return nil, err
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("не удалось закрыть архив: %w", err)
}
return buf.Bytes(), nil
}
func writeZipEntry(zw *zip.Writer, name string, data []byte) error {
header := &zip.FileHeader{
Name: name,
Method: zip.Deflate,
}
// Нулевое время — детерминизм архива между запусками.
header.Modified = time.Time{}
w, err := zw.CreateHeader(header)
if err != nil {
return fmt.Errorf("не удалось создать запись %q: %w", name, err)
}
if _, err := w.Write(data); err != nil {
return fmt.Errorf("не удалось записать %q: %w", name, err)
}
return nil
}
func rewriteRef(ref string, archivePathOf map[string]string) string {
if ref == "" {
return ""
}
if archivePath, ok := archivePathOf[ref]; ok {
return archivePath
}
return ref
}
func strPtrValue(p *string) string {
if p == nil {
return ""
}
return *p
}
// Unpack разбирает входящий архив с проверкой лимитов и путей записей.
func (a *scenarioArchive) Unpack(data []byte) (*Bundle, error) {
if len(data) == 0 {
return nil, errors.New("архив пуст")
}
if len(data) > maxArchiveSize {
return nil, fmt.Errorf("архив больше максимального размера (%d байт)", maxArchiveSize)
}
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("не удалось открыть архив: %w", err)
}
if len(zr.File) > maxEntries {
return nil, fmt.Errorf("в архиве больше %d записей", maxEntries)
}
bundle := &Bundle{Files: make(map[string][]byte, len(zr.File))}
var totalUnpacked int64
var scenarioRaw []byte
for _, f := range zr.File {
name := f.Name
if f.FileInfo().IsDir() {
continue
}
if err := validateEntryPath(name); err != nil {
return nil, err
}
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("не удалось открыть запись %q: %w", name, err)
}
content, err := readEntry(rc)
rc.Close()
if err != nil {
return nil, fmt.Errorf("запись %q: %w", name, err)
}
totalUnpacked += int64(len(content))
if totalUnpacked > int64(maxUnpackedSize) {
return nil, fmt.Errorf("суммарный размер распакованного архива больше %d байт", maxUnpackedSize)
}
// Дубли записей отвергаем — «последний победил» портит данные.
if name == FileName {
if scenarioRaw != nil {
return nil, fmt.Errorf("в архиве несколько записей %s", FileName)
}
scenarioRaw = content
continue
}
if _, exists := bundle.Files[name]; exists {
return nil, fmt.Errorf("в архиве несколько записей %q", name)
}
bundle.Files[name] = content
}
if scenarioRaw == nil {
return nil, fmt.Errorf("в архиве нет файла %s", FileName)
}
scenario := &ScenarioJSON{}
if err := json.Unmarshal(scenarioRaw, scenario); err != nil {
return nil, fmt.Errorf("не удалось разобрать %s: %w", FileName, err)
}
if scenario.Version != Version {
return nil, fmt.Errorf("неподдерживаемая версия архива: %d", scenario.Version)
}
if scenario.Name == "" {
return nil, errors.New("в сценарии не указано название")
}
if scenario.Story == nil {
return nil, errors.New("в сценарии не указана история")
}
bundle.Scenario = scenario
return bundle, nil
}
// readEntry читает запись с лимитом по фактическим байтам (заголовки ZIP
// подделываются тривиально).
func readEntry(r io.Reader) ([]byte, error) {
lr := io.LimitReader(r, int64(maxEntrySize)+1)
content, err := io.ReadAll(lr)
if err != nil {
return nil, fmt.Errorf("не удалось прочитать: %w", err)
}
if int64(len(content)) > int64(maxEntrySize) {
return nil, fmt.Errorf("размер больше максимального (%d байт)", maxEntrySize)
}
return content, nil
}
// validateEntryPath отклоняет небезопасные пути записей.
func validateEntryPath(name string) error {
if name == "" {
return errors.New("пустое имя записи в архиве")
}
if strings.Contains(name, "\\") {
return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
}
if strings.HasPrefix(name, "/") {
return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
}
for _, part := range strings.Split(name, "/") {
if part == ".." {
return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
}
}
if clean := path.Clean(name); clean != name {
return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
}
return nil
}
@@ -0,0 +1,478 @@
package scenario_archive
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/repos"
)
const testDomain = "http://storage.test/api/files/"
// testArchive — экземпляр модуля для тестов.
var testArchive = NewScenarioArchive()
// buildScenario собирает сценарий с историей и ссылками на изображения.
func buildScenario() *repos.Scenario {
description := "Детективная история"
image := "cover.png"
return &repos.Scenario{
ID: 1,
Name: "Ночной клуб",
Description: &description,
Image: &image,
Scenario: `{"places":[
{"code":"club","name":"Клуб","text":"Тёмный зал","image":"club.png","applications":[{"name":"Билет","image":"ticket.jpg"}]},
{"code":"parking","name":"Парковка","text":"Пусто","image":"club.png"}
]}`,
}
}
// memoryFiles — замена хранилища для Pack: имя → содержимое.
type memoryFiles map[string][]byte
func (m memoryFiles) get(_ context.Context, name string) ([]byte, error) {
data, ok := m[name]
if !ok {
return nil, fmt.Errorf("file not found: %s", name)
}
return data, nil
}
func newMemoryFiles() memoryFiles {
return memoryFiles{
"cover.png": []byte("cover-bytes"),
"club.png": []byte("club-bytes"),
"ticket.jpg": []byte("ticket-bytes"),
}
}
func TestPackUnpackRoundTrip(t *testing.T) {
files := newMemoryFiles()
data, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
if err != nil {
t.Fatalf("Pack: %v", err)
}
bundle, err := testArchive.Unpack(data)
if err != nil {
t.Fatalf("Unpack: %v", err)
}
if bundle.Scenario.Version != Version {
t.Errorf("Version = %d, want %d", bundle.Scenario.Version, Version)
}
if bundle.Scenario.Name != "Ночной клуб" {
t.Errorf("Name = %q, want %q", bundle.Scenario.Name, "Ночной клуб")
}
if bundle.Scenario.Description != "Детективная история" {
t.Errorf("Description = %q", bundle.Scenario.Description)
}
if bundle.Scenario.Image != "images/cover.png" {
t.Errorf("Image = %q, want %q", bundle.Scenario.Image, "images/cover.png")
}
if bundle.Scenario.Story == nil || len(bundle.Scenario.Story.Places) != 2 {
t.Fatalf("Story.Places = %+v, want 2 places", bundle.Scenario.Story)
}
club := bundle.Scenario.Story.Places[0]
if club.Image != "images/club.png" {
t.Errorf("place image = %q, want %q", club.Image, "images/club.png")
}
if len(club.Applications) != 1 || club.Applications[0].Image != "images/ticket.jpg" {
t.Errorf("application image = %+v, want images/ticket.jpg", club.Applications)
}
for _, want := range []string{"images/cover.png", "images/club.png", "images/ticket.jpg"} {
if _, ok := bundle.Files[want]; !ok {
t.Errorf("в архиве нет файла %q", want)
}
}
// Дублирующаяся ссылка (club.png в двух точках) кладётся в архив один раз.
if len(bundle.Files) != 3 {
t.Errorf("Files = %v, want 3 files", bundle.Files)
}
}
func TestPackExternalURLsKeptAndNotFetched(t *testing.T) {
description := "desc"
scenario := &repos.Scenario{
ID: 1,
Name: "Внешний",
Description: &description,
Image: ptr("https://example.com/cover.png"),
Scenario: `{"places":[
{"code":"p","name":"P","text":"t","image":"http://other.example/x.png"}
]}`,
}
files := newMemoryFiles()
var fetched []string
getFile := func(ctx context.Context, name string) ([]byte, error) {
fetched = append(fetched, name)
return files.get(ctx, name)
}
data, err := testArchive.Pack(context.Background(), scenario, testDomain, getFile)
if err != nil {
t.Fatalf("Pack: %v", err)
}
if len(fetched) != 0 {
t.Errorf("getFile вызван для внешних URL: %v", fetched)
}
bundle, err := testArchive.Unpack(data)
if err != nil {
t.Fatalf("Unpack: %v", err)
}
if bundle.Scenario.Image != "https://example.com/cover.png" {
t.Errorf("Image = %q, внешний URL должен остаться без изменений", bundle.Scenario.Image)
}
if bundle.Scenario.Story.Places[0].Image != "http://other.example/x.png" {
t.Errorf("place image = %q, внешний URL должен остаться без изменений", bundle.Scenario.Story.Places[0].Image)
}
}
func TestPackLegacyFullURLImage(t *testing.T) {
// Legacy-данные: полный URL хранилища срезается до базового имени файла.
description := "desc"
scenario := &repos.Scenario{
ID: 1,
Name: "Legacy",
Description: &description,
Image: ptr(testDomain + "cover.png"),
Scenario: `{"places":[]}`,
}
data, err := testArchive.Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
if err != nil {
t.Fatalf("Pack: %v", err)
}
bundle, err := testArchive.Unpack(data)
if err != nil {
t.Fatalf("Unpack: %v", err)
}
if bundle.Scenario.Image != "images/cover.png" {
t.Errorf("Image = %q, want %q", bundle.Scenario.Image, "images/cover.png")
}
}
func TestPackMissingImage(t *testing.T) {
files := newMemoryFiles()
delete(files, "ticket.jpg")
_, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
if err == nil {
t.Fatal("Pack должен вернуть ошибку при недоступном изображении")
}
if !strings.Contains(err.Error(), "ticket.jpg") {
t.Errorf("ошибка должна содержать имя файла: %v", err)
}
}
func TestPackDeterministic(t *testing.T) {
files := newMemoryFiles()
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 := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
if err != nil {
t.Fatalf("Pack: %v", err)
}
if !bytes.Equal(first, second) {
t.Error("архив недетерминирован: повторный Pack дал другие байты")
}
}
func TestPackNilScenario(t *testing.T) {
_, err := testArchive.Pack(context.Background(), nil, testDomain, nil)
if err == nil {
t.Fatal("Pack(nil) должен вернуть ошибку")
}
}
func TestPackImageNameCollision(t *testing.T) {
description := "desc"
scenario := &repos.Scenario{
ID: 1,
Name: "Конфликт",
Description: &description,
Scenario: `{"places":[
{"code":"a","name":"A","text":"t","image":"a.png"},
{"code":"b","name":"B","text":"t","image":"dir/a.png"}
]}`,
}
_, err := testArchive.Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
if err == nil {
t.Fatal("Pack должен вернуть ошибку при конфликте имён изображений")
}
}
// writeZip собирает архив с заданными записями (для негативных тестов Unpack).
func writeZip(t *testing.T, entries map[string][]byte) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, content := range entries {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("Create(%q): %v", name, err)
}
if _, err := w.Write(content); err != nil {
t.Fatalf("Write(%q): %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
return buf.Bytes()
}
// validScenarioJSON — минимальный корректный scenario.json.
func validScenarioJSON() []byte {
b, _ := json.Marshal(ScenarioJSON{
Version: Version,
Name: "Тест",
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
})
return b
}
func TestUnpackRejectsBadPaths(t *testing.T) {
for _, name := range []string{
"../evil",
"a/../../evil",
"/abs",
"dir\\evil",
"a/../b",
} {
t.Run(name, func(t *testing.T) {
data := writeZip(t, map[string][]byte{
FileName: validScenarioJSON(),
"images/x.png": []byte("x"),
name: []byte("evil"),
})
if _, err := testArchive.Unpack(data); err == nil {
t.Fatalf("Unpack должен отклонить путь %q", name)
}
})
}
}
func TestUnpackEmpty(t *testing.T) {
if _, err := testArchive.Unpack(nil); err == nil {
t.Fatal("Unpack(nil) должен вернуть ошибку")
}
if _, err := testArchive.Unpack([]byte{}); err == nil {
t.Fatal("testArchive.Unpack(пусто) должен вернуть ошибку")
}
}
func TestUnpackNotAZip(t *testing.T) {
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 := 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 := testArchive.Unpack(data); err == nil {
t.Fatal("Unpack должен отклонить битый scenario.json")
}
}
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 := testArchive.Unpack(data); err == nil {
t.Fatal("Unpack должен отклонить неизвестную версию")
}
}
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 := testArchive.Unpack(data); err == nil {
t.Fatal("Unpack должен требовать название")
}
}
func TestUnpackNilStory(t *testing.T) {
b, _ := json.Marshal(ScenarioJSON{Version: Version, Name: "X"})
data := writeZip(t, map[string][]byte{FileName: b})
if _, err := testArchive.Unpack(data); err == nil {
t.Fatal("Unpack должен требовать story")
}
}
func TestUnpackEntrySizeLimit(t *testing.T) {
prev := maxEntrySize
maxEntrySize = 4
defer func() { maxEntrySize = prev }()
data := writeZip(t, map[string][]byte{
FileName: validScenarioJSON(),
"images/big.png": bytes.Repeat([]byte("x"), 8),
})
if _, err := testArchive.Unpack(data); err == nil {
t.Fatal("Unpack должен отклонить запись больше maxEntrySize")
}
}
func TestUnpackTotalSizeLimit(t *testing.T) {
prev := maxUnpackedSize
maxUnpackedSize = 6
defer func() { maxUnpackedSize = prev }()
data := writeZip(t, map[string][]byte{
FileName: validScenarioJSON(),
"images/a.png": []byte("aaa"),
"images/b.png": []byte("bbb"),
})
if _, err := testArchive.Unpack(data); err == nil {
t.Fatal("Unpack должен отклонить суммарный объём больше maxUnpackedSize")
}
}
func TestUnpackArchiveSizeLimit(t *testing.T) {
prev := maxArchiveSize
maxArchiveSize = 4
defer func() { maxArchiveSize = prev }()
data := writeZip(t, map[string][]byte{FileName: validScenarioJSON()})
if _, err := testArchive.Unpack(data); err == nil {
t.Fatal("Unpack должен отклонить архив больше maxArchiveSize")
}
}
func TestUnpackEntriesLimit(t *testing.T) {
prev := maxEntries
maxEntries = 2
defer func() { maxEntries = prev }()
data := writeZip(t, map[string][]byte{
FileName: validScenarioJSON(),
"images/a.png": []byte("a"),
"images/b.png": []byte("b"),
})
if _, err := testArchive.Unpack(data); err == nil {
t.Fatal("Unpack должен отклонить архив с числом записей больше maxEntries")
}
}
func TestUnpackRejectsDuplicateScenarioJSON(t *testing.T) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for i := 0; i < 2; i++ {
w, err := zw.Create(FileName)
if err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := w.Write(validScenarioJSON()); err != nil {
t.Fatalf("Write: %v", err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if _, err := testArchive.Unpack(buf.Bytes()); err == nil {
t.Fatal("Unpack должен отклонить дубли scenario.json")
}
}
func TestUnpackRejectsDuplicateFiles(t *testing.T) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for i := 0; i < 3; i++ {
name := FileName
content := validScenarioJSON()
switch i {
case 1:
name = "images/x.png"
content = []byte("x")
case 2:
// Дубль записи images/x.png.
name = "images/x.png"
content = []byte("y")
}
w, err := zw.Create(name)
if err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := w.Write(content); err != nil {
t.Fatalf("Write: %v", err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if _, err := testArchive.Unpack(buf.Bytes()); err == nil {
t.Fatal("Unpack должен отклонить дубли файлов")
}
}
func TestPackTotalSizeLimit(t *testing.T) {
prev := maxPackSize
maxPackSize = 8
defer func() { maxPackSize = prev }()
description := "desc"
scenario := &repos.Scenario{
ID: 1,
Name: "Большой",
Description: &description,
Scenario: `{"places":[
{"code":"a","name":"A","text":"t","image":"a.png"},
{"code":"b","name":"B","text":"t","image":"b.png"}
]}`,
}
files := memoryFiles{
"a.png": bytes.Repeat([]byte("x"), 6),
"b.png": bytes.Repeat([]byte("y"), 6),
}
if _, err := testArchive.Pack(context.Background(), scenario, testDomain, files.get); err == nil {
t.Fatal("Pack должен отклонить суммарный объём больше maxPackSize")
}
}
func TestImagePath(t *testing.T) {
cases := []struct {
ref string
domain string
want string
ok bool
}{
{"cover.png", "", "images/cover.png", true},
{"dir/cover.png", "", "images/cover.png", true},
{testDomain + "cover.png", testDomain, "images/cover.png", true},
{"", "", "", false},
{"http://example.com/x.png", "", "", false},
{"https://example.com/x.png", testDomain, "", false},
}
for _, tc := range cases {
got, ok := ImagePath(tc.ref, tc.domain)
if got != tc.want || ok != tc.ok {
t.Errorf("ImagePath(%q, %q) = (%q, %v), want (%q, %v)",
tc.ref, tc.domain, got, ok, tc.want, tc.ok)
}
}
}
func ptr(s string) *string { return &s }
@@ -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
}
+184 -17
View File
@@ -2,29 +2,55 @@ package scenarios_service
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"evening_detective_server/internal/modules/cleaner"
"evening_detective_server/internal/modules/file_storage"
"evening_detective_server/internal/modules/scenario_archive"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/repos"
"evening_detective_server/internal/repos/scenarios_repo"
"fmt"
"path/filepath"
"strings"
)
// scenariosRepository — граница доступа к данным сценариев, реализуемая
// *scenarios_repo.ScenariosRepo (интерфейс — для тестов без БД).
type scenariosRepository interface {
AddScenario(ctx context.Context, name string, authorId int) (int, error)
GetScenariosByAuthorID(ctx context.Context, authorId int) ([]*repos.Scenario, error)
GetScenariosByStatus(ctx context.Context, status string) ([]*repos.Scenario, error)
GetScenarioByID(ctx context.Context, id int) (*repos.Scenario, error)
UpdateScenarioByID(ctx context.Context, id int, name string, description string, image string) error
UpdateScenarioStatusByID(ctx context.Context, id int, status string, allowPublished bool) (bool, error)
DeleteScenarioByID(ctx context.Context, id int, allowPublished bool) (bool, error)
GetStoryByScenarioID(ctx context.Context, id int) (string, error)
UpdateStoryByScenarioID(ctx context.Context, id int, story string) error
}
type ScenarioService struct {
scenariosRepo *scenarios_repo.ScenariosRepo
cleaner cleaner.ICleaner
domain string
scenariosRepo scenariosRepository
cleaner cleaner.ICleaner
fileStorage file_storage.IFileStorage
scenarioArchive scenario_archive.IScenarioArchive
domain string
}
func NewScenarioService(
scenariosRepo *scenarios_repo.ScenariosRepo,
scenariosRepo scenariosRepository,
cleaner cleaner.ICleaner,
domain string,
fileStorage file_storage.IFileStorage,
scenarioArchive scenario_archive.IScenarioArchive,
) *ScenarioService {
return &ScenarioService{
scenariosRepo: scenariosRepo,
cleaner: cleaner,
domain: domain,
scenariosRepo: scenariosRepo,
cleaner: cleaner,
fileStorage: fileStorage,
scenarioArchive: scenarioArchive,
domain: domain,
}
}
@@ -260,14 +286,160 @@ func (s *ScenarioService) getStory(ctx context.Context, id int) (*storytelling.S
return mapStory(storyString, s.domain)
}
// DownloadArchive собирает ZIP-архив сценария
func (s *ScenarioService) DownloadArchive(
ctx context.Context,
id int,
actorId int,
isAdmin bool,
) ([]byte, string, error) {
scenario, err := s.getScenarioForChange(ctx, id, actorId, isAdmin)
if err != nil {
return nil, "", err
}
data, err := s.scenarioArchive.Pack(
ctx,
scenario,
s.domain,
func(ctx context.Context, name string) ([]byte, error) {
file, err := s.fileStorage.Get(ctx, name)
if err != nil {
return nil, err
}
return file.Data, nil
},
)
if err != nil {
return nil, "", err
}
return data, scenario.Name, nil
}
// UploadArchive импортирует сценарий из ZIP-архива: изображения сохраняются
// под новыми случайными именами (не перетирают чужие файлы), ссылки
// переписываются. Новый сценарий всегда draft — статус из архива игнорируется.
func (s *ScenarioService) UploadArchive(
ctx context.Context,
data []byte,
authorId int,
) (int, error) {
bundle, err := s.scenarioArchive.Unpack(data)
if err != nil {
return 0, err
}
scenario := bundle.Scenario
// При ошибке удаляем загруженные файлы (best-effort) — без orphan-объектов.
uploaded := map[string]string{}
ok := false
defer func() {
if ok {
return
}
for _, name := range uploaded {
_ = s.fileStorage.Delete(ctx, name)
}
}()
rewrite := func(ref string) (string, error) {
// Берём только ссылки images/... из архива; остальное — как есть.
archiveRef := strings.TrimPrefix(ref, s.domain)
if !strings.HasPrefix(archiveRef, "images/") {
return ref, nil
}
if name, exists := uploaded[archiveRef]; exists {
return name, nil
}
content, exists := bundle.Files[archiveRef]
if !exists {
return "", fmt.Errorf("изображение %q не найдено в архиве", archiveRef)
}
name, err := newStorageName(archiveRef)
if err != nil {
return "", err
}
if err := s.fileStorage.Put(ctx, &file_storage.File{
Name: name,
Data: content,
Mime: s.fileStorage.MimeType(name),
}); err != nil {
return "", fmt.Errorf("не удалось сохранить изображение %q: %w", archiveRef, err)
}
uploaded[archiveRef] = name
return name, nil
}
image, err := rewrite(scenario.Image)
if err != nil {
return 0, err
}
for _, place := range scenario.Story.Places {
place.Image, err = rewrite(place.Image)
if err != nil {
return 0, err
}
for _, application := range place.Applications {
application.Image, err = rewrite(application.Image)
if err != nil {
return 0, err
}
}
}
storyJSON, err := normalizeStory(scenario.Story)
if err != nil {
return 0, err
}
id, err := s.scenariosRepo.AddScenario(ctx, scenario.Name, authorId)
if err != nil {
return 0, err
}
if err := s.scenariosRepo.UpdateScenarioByID(ctx, id, scenario.Name, scenario.Description, image); err != nil {
// Зачищаем строку сценария (best-effort), как и файлы.
_, _ = s.scenariosRepo.DeleteScenarioByID(ctx, id, true)
return 0, err
}
if err := s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyJSON); err != nil {
_, _ = s.scenariosRepo.DeleteScenarioByID(ctx, id, true)
return 0, err
}
ok = true
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 {
mapCodes := map[string]struct{}{}
for _, place := range story.Places {
mapCodes[place.Code] = struct{}{}
place.Image = strings.TrimPrefix(place.Image, s.domain)
}
if len(mapCodes) != len(story.Places) {
return errors.New("Такой код точки уже существует")
storyString, err := normalizeStory(story)
if err != nil {
return err
}
return s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyString)
}
// normalizeStory проверяет уникальность кодов точек, отбрасывает пустые коды
// и возвращает JSON истории. Общая для редактирования и импорта.
func normalizeStory(story *storytelling.Story) (string, error) {
codes := map[string]struct{}{}
for _, place := range story.Places {
codes[place.Code] = struct{}{}
}
if len(codes) != len(story.Places) {
return "", errors.New("Такой код точки уже существует")
}
cleanPlaces := make([]*storytelling.Place, 0, len(story.Places))
@@ -279,10 +451,5 @@ func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storyt
}
story.Places = cleanPlaces
storyString, err := convertStory(story)
if err != nil {
return err
}
return s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyString)
return convertStory(story)
}
@@ -0,0 +1,539 @@
package scenarios_service
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
"evening_detective_server/internal/modules/file_storage"
"evening_detective_server/internal/modules/scenario_archive"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/repos"
"evening_detective_server/internal/repos/scenarios_repo"
)
const testDomain = "http://storage.test/api/files/"
// fakeStorage — in-memory реализация IFileStorage.
type fakeStorage struct {
mu sync.Mutex
files map[string]*file_storage.File
getErr error
}
func newFakeStorage() *fakeStorage {
return &fakeStorage{files: map[string]*file_storage.File{}}
}
func (s *fakeStorage) Put(_ context.Context, f *file_storage.File) error {
s.mu.Lock()
defer s.mu.Unlock()
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) {
s.mu.Lock()
defer s.mu.Unlock()
if s.getErr != nil {
return nil, s.getErr
}
f, ok := s.files[name]
if !ok {
return nil, fmt.Errorf("file not found: %s", name)
}
cp := *f
cp.Data = append([]byte(nil), f.Data...)
return &cp, nil
}
func (s *fakeStorage) Delete(_ context.Context, name string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.files, name)
return nil
}
func (s *fakeStorage) MimeType(filename string) string {
return ""
}
func (s *fakeStorage) names() []string {
s.mu.Lock()
defer s.mu.Unlock()
res := make([]string, 0, len(s.files))
for name := range s.files {
res = append(res, name)
}
return res
}
// fakeScenariosRepo — минимальная in-memory реализация scenariosRepository.
type fakeScenariosRepo struct {
mu sync.Mutex
byID map[int]*repos.Scenario
nextID int
addErr error
updateErr error
storyErr error
}
func newFakeScenariosRepo() *fakeScenariosRepo {
return &fakeScenariosRepo{
byID: map[int]*repos.Scenario{},
nextID: 0,
}
}
func (r *fakeScenariosRepo) AddScenario(_ context.Context, name string, authorId int) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.addErr != nil {
return 0, r.addErr
}
r.nextID++
description := ""
r.byID[r.nextID] = &repos.Scenario{
ID: r.nextID,
Name: name,
Description: &description,
Author: &repos.User{ID: authorId},
Status: "draft",
}
return r.nextID, nil
}
func (r *fakeScenariosRepo) GetScenariosByAuthorID(_ context.Context, authorId int) ([]*repos.Scenario, error) {
r.mu.Lock()
defer r.mu.Unlock()
var res []*repos.Scenario
for _, s := range r.byID {
if s.Author != nil && s.Author.ID == authorId {
res = append(res, s)
}
}
return res, nil
}
func (r *fakeScenariosRepo) GetScenariosByStatus(_ context.Context, status string) ([]*repos.Scenario, error) {
r.mu.Lock()
defer r.mu.Unlock()
var res []*repos.Scenario
for _, s := range r.byID {
if s.Status == status {
res = append(res, s)
}
}
return res, nil
}
func (r *fakeScenariosRepo) GetScenarioByID(_ context.Context, id int) (*repos.Scenario, error) {
r.mu.Lock()
defer r.mu.Unlock()
s, ok := r.byID[id]
if !ok {
return nil, scenarios_repo.ErrScenarioNotFound
}
return s, nil
}
func (r *fakeScenariosRepo) UpdateScenarioByID(_ context.Context, id int, name string, description string, image string) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.updateErr != nil {
return r.updateErr
}
s, ok := r.byID[id]
if !ok {
return scenarios_repo.ErrScenarioNotFound
}
s.Name = name
s.Description = &description
s.Image = &image
return nil
}
func (r *fakeScenariosRepo) UpdateScenarioStatusByID(_ context.Context, id int, status string, allowPublished bool) (bool, error) {
r.mu.Lock()
defer r.mu.Unlock()
s, ok := r.byID[id]
if !ok {
return false, scenarios_repo.ErrScenarioNotFound
}
if !allowPublished && s.Status == "public" {
return false, nil
}
s.Status = status
return true, nil
}
func (r *fakeScenariosRepo) DeleteScenarioByID(_ context.Context, id int, allowPublished bool) (bool, error) {
r.mu.Lock()
defer r.mu.Unlock()
s, ok := r.byID[id]
if !ok {
return false, scenarios_repo.ErrScenarioNotFound
}
if !allowPublished && s.Status == "public" {
return false, nil
}
s.IsDeleted = true
return true, nil
}
func (r *fakeScenariosRepo) GetStoryByScenarioID(_ context.Context, id int) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.storyErr != nil {
return "", r.storyErr
}
s, ok := r.byID[id]
if !ok {
return "", scenarios_repo.ErrScenarioNotFound
}
return s.Scenario, nil
}
func (r *fakeScenariosRepo) UpdateStoryByScenarioID(_ context.Context, id int, story string) error {
r.mu.Lock()
defer r.mu.Unlock()
s, ok := r.byID[id]
if !ok {
return scenarios_repo.ErrScenarioNotFound
}
s.Scenario = story
return nil
}
func (r *fakeScenariosRepo) get(id int) *repos.Scenario {
r.mu.Lock()
defer r.mu.Unlock()
return r.byID[id]
}
func newTestService(repo *fakeScenariosRepo, storage *fakeStorage) *ScenarioService {
if repo == nil {
repo = newFakeScenariosRepo()
}
if storage == nil {
storage = newFakeStorage()
}
return NewScenarioService(repo, nil, testDomain, storage, scenario_archive.NewScenarioArchive())
}
// seedScenario кладёт в repo сценарий с историей и изображениями в storage.
func seedScenario(t *testing.T, repo *fakeScenariosRepo, storage *fakeStorage) *repos.Scenario {
t.Helper()
description := "Детективная история"
image := "cover.png"
scenario := &repos.Scenario{
ID: 1,
Name: "Ночной клуб",
Description: &description,
Image: &image,
Author: &repos.User{ID: 7},
Status: "draft",
Scenario: `{"places":[
{"code":"club","name":"Клуб","text":"Тёмный зал","image":"club.png","applications":[{"name":"Билет","image":"ticket.jpg"}]},
{"code":"parking","name":"Парковка","text":"Пусто","image":"club.png"}
]}`,
}
repo.byID[scenario.ID] = scenario
_ = storage.Put(context.Background(), &file_storage.File{Name: "cover.png", Data: []byte("cover-bytes")})
_ = storage.Put(context.Background(), &file_storage.File{Name: "club.png", Data: []byte("club-bytes")})
_ = storage.Put(context.Background(), &file_storage.File{Name: "ticket.jpg", Data: []byte("ticket-bytes")})
return scenario
}
func TestUploadArchive(t *testing.T) {
repo := newFakeScenariosRepo()
srcStorage := newFakeStorage()
seedScenario(t, repo, srcStorage)
// Собираем архив как при скачивании (Pack через storage).
svc := newTestService(repo, srcStorage)
archive, _, err := svc.DownloadArchive(context.Background(), 1, 7, false)
if err != nil {
t.Fatalf("DownloadArchive: %v", err)
}
// Импортируем в «чистые» repo и storage.
importRepo := newFakeScenariosRepo()
importStorage := newFakeStorage()
importSvc := newTestService(importRepo, importStorage)
id, err := importSvc.UploadArchive(context.Background(), archive, 42)
if err != nil {
t.Fatalf("UploadArchive: %v", err)
}
if id != 1 {
t.Errorf("id = %d, want 1", id)
}
saved := importRepo.get(id)
if saved == nil {
t.Fatal("сценарий не создан")
}
if saved.Name != "Ночной клуб" {
t.Errorf("Name = %q", saved.Name)
}
if saved.Description == nil || *saved.Description != "Детективная история" {
t.Errorf("Description = %v", saved.Description)
}
if saved.Status != "draft" {
t.Errorf("Status = %q, want draft", saved.Status)
}
if saved.Author == nil || saved.Author.ID != 42 {
t.Errorf("Author = %+v, want id 42", saved.Author)
}
// Ссылки переписаны на новые имена без префикса images/.
if saved.Image == nil || strings.HasPrefix(*saved.Image, "images/") || *saved.Image == "cover.png" {
t.Errorf("Image = %v, want новое случайное имя без images/", saved.Image)
}
story := &storytelling.Story{}
if err := json.Unmarshal([]byte(saved.Scenario), story); err != nil {
t.Fatalf("story не разобрался: %v", err)
}
if len(story.Places) != 2 {
t.Fatalf("places = %d, want 2", len(story.Places))
}
club := story.Places[0]
if club.Image == "" || strings.HasPrefix(club.Image, "images/") || club.Image == "club.png" {
t.Errorf("place image = %q, want новое имя", club.Image)
}
if len(club.Applications) != 1 || strings.HasPrefix(club.Applications[0].Image, "images/") {
t.Errorf("application image = %+v, want новое имя", club.Applications)
}
// Одна и та же картинка в двух точках — одно новое имя.
if story.Places[1].Image != club.Image {
t.Errorf("дублирующаяся картинка переписана по-разному: %q и %q", story.Places[1].Image, club.Image)
}
// Файлы загружены в storage под новыми именами с правильным содержимым.
files := importStorage.names()
if len(files) != 3 {
t.Fatalf("storage files = %v, want 3", files)
}
contents := map[string]bool{}
for _, name := range files {
file, err := importStorage.Get(context.Background(), name)
if err != nil {
t.Fatalf("Get(%q): %v", name, err)
}
contents[string(file.Data)] = true
}
for _, want := range []string{"cover-bytes", "club-bytes", "ticket-bytes"} {
if !contents[want] {
t.Errorf("в storage нет содержимого %q, есть: %v", want, contents)
}
}
}
func TestUploadArchiveMissingImage(t *testing.T) {
repo := newFakeScenariosRepo()
storage := newFakeStorage()
svc := newTestService(repo, storage)
// Архив ссылается на images/missing.png, файла в архиве нет.
archive := buildRawArchive(t, map[string][]byte{
scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
Version: scenario_archive.Version,
Name: "Тест",
Image: "images/missing.png",
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
}),
})
if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil {
t.Fatal("UploadArchive должен вернуть ошибку при отсутствующем файле")
}
if len(storage.names()) != 0 {
t.Errorf("storage не должен содержать файлов: %v", storage.names())
}
}
func TestUploadArchiveExternalURLKept(t *testing.T) {
repo := newFakeScenariosRepo()
storage := newFakeStorage()
svc := newTestService(repo, storage)
archive := buildRawArchive(t, map[string][]byte{
scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
Version: scenario_archive.Version,
Name: "Тест",
Image: "https://example.com/cover.png",
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P", Image: "http://other.example/x.png"}}},
}),
})
id, err := svc.UploadArchive(context.Background(), archive, 1)
if err != nil {
t.Fatalf("UploadArchive: %v", err)
}
saved := repo.get(id)
if saved.Image == nil || *saved.Image != "https://example.com/cover.png" {
t.Errorf("Image = %v, внешний URL должен остаться без изменений", saved.Image)
}
if len(storage.names()) != 0 {
t.Errorf("storage не должен содержать файлов: %v", storage.names())
}
}
func TestUploadArchiveCleanupOnRepoError(t *testing.T) {
repo := newFakeScenariosRepo()
repo.addErr = errors.New("db is down")
storage := newFakeStorage()
svc := newTestService(repo, storage)
archive := buildRawArchive(t, map[string][]byte{
scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
Version: scenario_archive.Version,
Name: "Тест",
Image: "images/x.png",
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
}),
"images/x.png": []byte("x"),
})
if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil {
t.Fatal("UploadArchive должен вернуть ошибку репозитория")
}
// Загруженные файлы удалены (best-effort), orphan-объектов не осталось.
if len(storage.names()) != 0 {
t.Errorf("после ошибки БД storage должен быть пуст: %v", storage.names())
}
}
func TestUploadArchiveDeletesRowOnPartialFailure(t *testing.T) {
repo := newFakeScenariosRepo()
repo.updateErr = errors.New("db is down")
storage := newFakeStorage()
svc := newTestService(repo, storage)
archive := buildRawArchive(t, map[string][]byte{
scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
Version: scenario_archive.Version,
Name: "Тест",
Image: "images/x.png",
Story: &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
}),
"images/x.png": []byte("x"),
})
if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil {
t.Fatal("UploadArchive должен вернуть ошибку репозитория")
}
// Строка помечена удалённой (best-effort, soft delete как в проде), файлы зачищены.
if got := repo.get(1); got == nil || !got.IsDeleted {
t.Errorf("строка сценария должна быть удалена после частичного сбоя, осталась: %+v", got)
}
if len(storage.names()) != 0 {
t.Errorf("после частичного сбоя storage должен быть пуст: %v", storage.names())
}
}
func TestDownloadArchive(t *testing.T) {
repo := newFakeScenariosRepo()
storage := newFakeStorage()
seedScenario(t, repo, storage)
svc := newTestService(repo, storage)
data, name, err := svc.DownloadArchive(context.Background(), 1, 7, false)
if err != nil {
t.Fatalf("DownloadArchive: %v", err)
}
if name != "Ночной клуб" {
t.Errorf("name = %q", name)
}
bundle, err := scenario_archive.NewScenarioArchive().Unpack(data)
if err != nil {
t.Fatalf("Unpack: %v", err)
}
if bundle.Scenario.Name != "Ночной клуб" {
t.Errorf("scenario name = %q", bundle.Scenario.Name)
}
if bundle.Scenario.Image != "images/cover.png" {
t.Errorf("scenario image = %q", bundle.Scenario.Image)
}
for _, want := range []string{"images/cover.png", "images/club.png", "images/ticket.jpg"} {
if _, ok := bundle.Files[want]; !ok {
t.Errorf("в архиве нет %q", want)
}
}
}
func TestDownloadArchiveNotOwner(t *testing.T) {
repo := newFakeScenariosRepo()
storage := newFakeStorage()
seedScenario(t, repo, storage)
svc := newTestService(repo, storage)
if _, _, err := svc.DownloadArchive(context.Background(), 1, 99, false); !errors.Is(err, ErrScenarioNotOwner) {
t.Errorf("err = %v, want ErrScenarioNotOwner", err)
}
// Админ может скачать чужой сценарий.
if _, _, err := svc.DownloadArchive(context.Background(), 1, 99, true); err != nil {
t.Errorf("админ: err = %v", err)
}
}
func TestDownloadArchiveNotFound(t *testing.T) {
repo := newFakeScenariosRepo()
storage := newFakeStorage()
svc := newTestService(repo, storage)
if _, _, err := svc.DownloadArchive(context.Background(), 404, 1, true); !errors.Is(err, scenarios_repo.ErrScenarioNotFound) {
t.Errorf("err = %v, want ErrScenarioNotFound", err)
}
}
func TestDownloadArchiveMissingImage(t *testing.T) {
repo := newFakeScenariosRepo()
storage := newFakeStorage()
seedScenario(t, repo, storage)
_ = storage.Delete(context.Background(), "ticket.jpg")
svc := newTestService(repo, storage)
if _, _, err := svc.DownloadArchive(context.Background(), 1, 7, false); err == nil {
t.Fatal("DownloadArchive должен вернуть ошибку при недоступном изображении")
}
}
// buildRawArchive собирает zip из произвольных записей (для тестов импорта).
func buildRawArchive(t *testing.T, entries map[string][]byte) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, content := range entries {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("Create(%q): %v", name, err)
}
if _, err := w.Write(content); err != nil {
t.Fatalf("Write(%q): %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
return buf.Bytes()
}
func mustJSON(t *testing.T, v scenario_archive.ScenarioJSON) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
return b
}
+197
View File
@@ -0,0 +1,197 @@
package users_service
import (
"fmt"
"html"
"strings"
)
// Шаблоны писем сервиса. Структура по базе знаний копирайтера (email.md):
// приветствие → контекст («почему это письмо») → ценность (данные для входа)
// → CTA → подпись → P.S. Письма транзакционные, но по форме — «человек
// человеку»: Gmail и другие почтовики агрессивнее фильтруют короткие
// «роботные» письма без структуры.
//
// Каждое письмо формируется в двух версиях: text/plain (для старых клиентов
// и фильтров) и text/html (для отображения); sender собирает их в
// multipart/alternative.
const (
// welcomeSubject — тема письма о регистрации: конкретика без КАПСА и
// кликбейта, до 50 символов (видно на мобильных целиком).
welcomeSubject = "Твой доступ к «Вечернему детективу»"
// resetSubject — тема письма о сбросе пароля.
resetSubject = "Новый пароль для «Вечернего детектива»"
)
// welcomePlain возвращает текстовую версию письма о регистрации.
func welcomePlain(username, email, password, loginURL string) string {
var b strings.Builder
b.WriteString(greeting(username))
b.WriteString("\n\n")
b.WriteString("Твоя регистрация в «Вечернем детективе» прошла. Данные для входа:\n\n")
b.WriteString(" Логин: ")
b.WriteString(email)
b.WriteString("\n")
b.WriteString(" Пароль: ")
b.WriteString(password)
b.WriteString("\n\n")
if loginURL != "" {
b.WriteString("Войти в игру: ")
b.WriteString(loginURL)
b.WriteString("\n\n")
}
b.WriteString("Если ты не регистрировался — просто проигнорируй это письмо.\n\n")
b.WriteString("— Команда «Вечернего детектива»\n")
b.WriteString("P.S. Сохрани это письмо: пароль понадобится для входа.")
return sanitizeLine(b.String())
}
// welcomeHTML возвращает HTML-версию письма о регистрации.
func welcomeHTML(username, email, password, loginURL string) string {
var content strings.Builder
content.WriteString("<p>Привет, <strong>")
content.WriteString(html.EscapeString(username))
content.WriteString("</strong>!</p>\n")
content.WriteString("<p>Твоя регистрация в «Вечернем детективе» прошла. Данные для входа:</p>\n")
content.WriteString(credentialsBlockHTML(email, password))
if loginURL != "" {
content.WriteString(buttonHTML(loginURL, "Войти в игру"))
}
content.WriteString("<p style=\"color:#777777; font-size:13px;\">Если ты не регистрировался — просто проигнорируй это письмо.</p>")
return wrapHTML(content.String())
}
// resetPlain возвращает текстовую версию письма о сбросе пароля.
func resetPlain(username, email, password, loginURL string) string {
var b strings.Builder
b.WriteString(greeting(username))
b.WriteString("\n\n")
b.WriteString("Ты запросил сброс пароля в «Вечернем детективе». Новый пароль для входа:\n\n")
b.WriteString(" Логин: ")
b.WriteString(email)
b.WriteString("\n")
b.WriteString(" Пароль: ")
b.WriteString(password)
b.WriteString("\n\n")
if loginURL != "" {
b.WriteString("Войти в игру: ")
b.WriteString(loginURL)
b.WriteString("\n\n")
}
b.WriteString("Никому не сообщай пароль и не пересылай это письмо.\n")
b.WriteString("Если это был не ты — ответь на это письмо: мы поможем защитить аккаунт.\n\n")
b.WriteString("— Команда «Вечернего детектива»\n")
return sanitizeLine(b.String())
}
// resetHTML возвращает HTML-версию письма о сбросе пароля.
func resetHTML(username, email, password, loginURL string) string {
var content strings.Builder
content.WriteString("<p>Привет, <strong>")
content.WriteString(html.EscapeString(username))
content.WriteString("</strong>!</p>\n")
content.WriteString("<p>Ты запросил сброс пароля в «Вечернем детективе». Новый пароль для входа:</p>\n")
content.WriteString(credentialsBlockHTML(email, password))
if loginURL != "" {
content.WriteString(buttonHTML(loginURL, "Войти в игру"))
}
content.WriteString("<p>Никому не сообщай пароль и не пересылай это письмо.</p>\n")
content.WriteString("<p style=\"color:#777777; font-size:13px;\">Если это был не ты — ответь на это письмо: мы поможем защитить аккаунт.</p>")
return wrapHTML(content.String())
}
// greeting возвращает приветствие с именем пользователя; при пустом имени —
// без обращения, чтобы не было «Привет, !».
func greeting(username string) string {
username = sanitizeLine(strings.TrimSpace(username))
if username == "" {
return "Привет!"
}
return fmt.Sprintf("Привет, %s!", username)
}
// credentialsBlockHTML — блок «Логин / Пароль» в табличной вёрстке,
// совместимой с Gmail.
func credentialsBlockHTML(email, password string) string {
return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:16px 0; border:1px solid #e3ddd2; border-radius:6px; background-color:#faf8f5; width:100%;">
<tr>
<td style="padding:12px 16px; font-size:14px; color:#555555; width:120px;">Логин</td>
<td style="padding:12px 16px; font-size:14px; color:#222222;"><strong>` + html.EscapeString(email) + `</strong></td>
</tr>
<tr>
<td style="padding:12px 16px; font-size:14px; color:#555555; width:120px; border-top:1px solid #e3ddd2;">Пароль</td>
<td style="padding:12px 16px; font-size:14px; color:#1a1a2e; border-top:1px solid #e3ddd2;"><strong>` + html.EscapeString(password) + `</strong></td>
</tr>
</table>`
}
// buttonHTML — CTA-кнопка табличной вёрсткой (Gmail не поддерживает
// margin/padding на <a>, поэтому отступы — на ячейке <td>).
func buttonHTML(link, label string) string {
return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px 0;">
<tr>
<td style="border-radius:6px; background-color:#c9a227;">
<a href="` + html.EscapeString(link) + `" style="display:inline-block; padding:12px 28px; color:#1a1a2e; font-weight:bold; text-decoration:none; font-size:15px; border-radius:6px;">` + html.EscapeString(label) + `</a>
</td>
</tr>
</table>`
}
// wrapHTML — каркас письма: тёмная шапка с названием сервиса, контент,
// подвал с дисклеймером. Только табличная вёрстка и inline-стили: внешние
// таблицы стилей Gmail вырезает.
func wrapHTML(content string) string {
return `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0; padding:0; background-color:#f4f1ec; font-family:Arial, Helvetica, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f1ec; padding:24px 0;">
<tr><td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px; width:100%; background-color:#ffffff; border-radius:8px; overflow:hidden;">
<tr>
<td style="background-color:#1a1a2e; padding:20px 28px;">
<span style="color:#ffffff; font-size:20px; font-weight:bold; letter-spacing:1px;">ВЕЧЕРНИЙ ДЕТЕКТИВ</span>
</td>
</tr>
<tr>
<td style="padding:28px; color:#222222; font-size:15px; line-height:1.6;">
` + content + `
</td>
</tr>
<tr>
<td style="padding:20px 28px; background-color:#faf8f5; color:#777777; font-size:12px; line-height:1.5;">
Это автоматическое письмо сервиса «Вечерний детектив».<br>
Если ты получил его по ошибке — просто ответь на это письмо.
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>`
}
// sanitizeLine убирает переносы строк из пользовательского ввода,
// вставляемого в тело письма (защита от инъекции строк в DATA-фазу SMTP).
func sanitizeLine(s string) string {
return strings.NewReplacer("\r", "", "\n", "").Replace(s)
}
// loginURL нормализует адрес фронтенда для ссылки в письме: срезает
// завершающий слэш и допускает только http(s). Пустое или некорректное
// значение — ссылка в письма не добавляется.
func loginURL(baseURL string) string {
baseURL = strings.TrimSpace(baseURL)
baseURL = strings.TrimRight(baseURL, "/")
if baseURL == "" {
return ""
}
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
return ""
}
return baseURL
}
+102 -19
View File
@@ -12,7 +12,10 @@ import (
"evening_detective_server/internal/repos/user_agreements_repo"
"evening_detective_server/internal/repos/users_repo"
"fmt"
"log"
"net/mail"
"slices"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
@@ -29,20 +32,44 @@ const (
privacyVersion = "1.0"
)
// usersRepository — граница доступа к данным пользователей, реализуемая
// *users_repo.UsersRepo; интерфейс позволяет тестировать сервис без БД.
type usersRepository interface {
AddUserWithAgreements(
ctx context.Context,
username string,
email string,
passwordHash string,
roles []string,
agreements []user_agreements_repo.Agreement,
) (int, error)
UpdateUserPassword(ctx context.Context, email string, passwordHash string) error
GetUserByEmail(ctx context.Context, email string) (*repos.User, error)
GetUserByID(ctx context.Context, id int) (*repos.User, error)
GetUsers(ctx context.Context) ([]*repos.User, error)
AddUserRole(ctx context.Context, userId int, role string) error
DeleteUserRole(ctx context.Context, userId int, role string) error
DeleteUser(ctx context.Context, userId int) error
}
type UsersService struct {
usersRepo *users_repo.UsersRepo
usersRepo usersRepository
passwordGenerator password_generator.IPasswordGenerator
emailSender email_sender.IEmailSender
processorJWT processor_jwt.IProcessorJWT
refreshTokensRepo *refresh_tokens_repo.RefreshTokensRepo
// appBaseURL — публичный адрес фронтенда для ссылки «Войти в игру» в
// письмах; пустое значение — ссылка не добавляется.
appBaseURL string
}
func NewUsersService(
usersRepo *users_repo.UsersRepo,
usersRepo usersRepository,
passwordGenerator password_generator.IPasswordGenerator,
emailSender email_sender.IEmailSender,
processorJWT processor_jwt.IProcessorJWT,
refreshTokensRepo *refresh_tokens_repo.RefreshTokensRepo,
appBaseURL string,
) *UsersService {
return &UsersService{
usersRepo: usersRepo,
@@ -50,6 +77,7 @@ func NewUsersService(
emailSender: emailSender,
processorJWT: processorJWT,
refreshTokensRepo: refreshTokensRepo,
appBaseURL: loginURL(appBaseURL),
}
}
@@ -69,6 +97,11 @@ func (s *UsersService) AddUser(
return ErrTermsNotAccepted
}
email = normalizeEmail(email)
if err := validateEmail(email); err != nil {
return err
}
password, err := s.passwordGenerator.Generate()
if err != nil {
return err
@@ -79,7 +112,7 @@ func (s *UsersService) AddUser(
}
// Создание пользователя и фиксация акцептов — в одной транзакции:
// без записей о согласиях пользователь не существует.
_, err = s.usersRepo.AddUserWithAgreements(
id, err := s.usersRepo.AddUserWithAgreements(
ctx,
username,
email,
@@ -104,13 +137,26 @@ func (s *UsersService) AddUser(
return err
}
// Консистентность ответа и состояния БД: если письмо с паролем не
// ушло, пользователь не должен оставаться в системе «полусозданным»
// (клиент получит ошибку и повторит регистрацию). Компенсация —
// удаление созданного пользователя. Выполняется вне отменённого
// контекста: письмо могло упасть именно из-за истечения таймаута ctx,
// и тогда тот же ctx не дал бы выполнить удаление.
err = s.emailSender.Send(ctx, email_sender.Message{
To: email,
Subject: "Приветствую тебя, детектив!",
Body: fmt.Sprintf("Вот твой пароль для входа в систему: %s", password),
Subject: welcomeSubject,
Body: welcomePlain(username, email, password, s.appBaseURL),
HTML: welcomeHTML(username, email, password, s.appBaseURL),
})
if err != nil {
if delErr := s.usersRepo.DeleteUser(context.WithoutCancel(ctx), id); delErr != nil {
log.Printf("email: отправка не удалась (%v) и компенсация не выполнена: %v", err, delErr)
}
return fmt.Errorf("email send: %w", err)
}
return err
return nil
}
// DeleteAccount удаляет учётную запись и все персональные данные пользователя
@@ -135,6 +181,21 @@ func (s *UsersService) RefreshPassword(
ctx context.Context,
email string,
) error {
email = normalizeEmail(email)
if err := validateEmail(email); err != nil {
return err
}
// Анти-enumeration: ответ одинаков для существующего и несуществующего
// адреса, письмо отправляется только реальному пользователю.
user, err := s.usersRepo.GetUserByEmail(ctx, email)
if err != nil {
if errors.Is(err, users_repo.ErrUserNotFound) {
return nil
}
return err
}
password, err := s.passwordGenerator.Generate()
if err != nil {
return err
@@ -144,22 +205,42 @@ func (s *UsersService) RefreshPassword(
return err
}
err = s.usersRepo.UpdateUserPassword(
ctx,
email,
string(hashedPassword),
)
// Письмо отправляется ДО смены пароля: при сбое отправки пароль
// остаётся прежним и аккаунт не блокируется (иначе старый пароль уже
// не работает, а новый пользователь так и не узнал).
err = s.emailSender.Send(ctx, email_sender.Message{
To: user.Email,
Subject: resetSubject,
Body: resetPlain(user.Username, user.Email, password, s.appBaseURL),
HTML: resetHTML(user.Username, user.Email, password, s.appBaseURL),
})
if err != nil {
return err
return fmt.Errorf("email send: %w", err)
}
err = s.emailSender.Send(ctx, email_sender.Message{
To: email,
Subject: "Приветствую тебя, детектив!",
Body: fmt.Sprintf("Вот твой новый пароль для входа в систему, не теряй: %s", password),
})
return s.usersRepo.UpdateUserPassword(ctx, user.Email, string(hashedPassword))
}
return err
// normalizeEmail приводит адрес к каноническому виду (без пробелов, нижний
// регистр): "User@Example.COM " и "user@example.com" — один аккаунт.
func normalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
// validateEmail проверяет, что строка является одиночным email-адресом.
func validateEmail(email string) error {
if email == "" {
return errors.New("Email не указан")
}
addr, err := mail.ParseAddress(email)
if err != nil {
return errors.New("Некорректный email")
}
// Отклоняем форму "Имя <a@b.c>" и всё, что не является чистым адресом.
if !strings.EqualFold(addr.Address, email) {
return errors.New("Некорректный email")
}
return nil
}
func (s *UsersService) Login(
@@ -167,9 +248,11 @@ func (s *UsersService) Login(
email string,
password string,
) (string, string, error) {
// Email нормализуется так же, как при регистрации: иначе пользователь,
// зарегистрировавшийся как user@example.com, не войдёт с User@Example.COM.
user, err := s.usersRepo.GetUserByEmail(
ctx,
email,
normalizeEmail(email),
)
if err != nil {
return "", "", err
@@ -0,0 +1,348 @@
package users_service
import (
"context"
"errors"
"evening_detective_server/internal/modules/email_sender"
"evening_detective_server/internal/modules/password_generator"
"evening_detective_server/internal/modules/processor_jwt"
"evening_detective_server/internal/repos"
"evening_detective_server/internal/repos/refresh_tokens_repo"
"evening_detective_server/internal/repos/user_agreements_repo"
"evening_detective_server/internal/repos/users_repo"
"strings"
"sync"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
type fakeSender struct {
mu sync.Mutex
messages []email_sender.Message
err error
}
func (f *fakeSender) Send(_ context.Context, m email_sender.Message) error {
f.mu.Lock()
defer f.mu.Unlock()
f.messages = append(f.messages, m)
return f.err
}
func (f *fakeSender) sent() []email_sender.Message {
f.mu.Lock()
defer f.mu.Unlock()
return append([]email_sender.Message(nil), f.messages...)
}
// fakeRepo — минимальная in-memory реализация usersRepository для тестов
// сервиса без БД.
type fakeRepo struct {
mu sync.Mutex
users map[string]*repos.User
byID map[int]*repos.User
nextID int
}
func newFakeRepo() *fakeRepo {
return &fakeRepo{
users: map[string]*repos.User{},
byID: map[int]*repos.User{},
nextID: 1,
}
}
func (r *fakeRepo) AddUserWithAgreements(
_ context.Context,
username, email, passwordHash string,
roles []string,
_ []user_agreements_repo.Agreement,
) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.users[email]; ok {
return 0, errors.New("Username или email уже используется")
}
id := r.nextID
r.nextID++
u := &repos.User{ID: id, Username: username, Email: email, PasswordHash: passwordHash, Roles: roles}
r.users[email] = u
r.byID[id] = u
return id, nil
}
func (r *fakeRepo) UpdateUserPassword(_ context.Context, email, passwordHash string) error {
r.mu.Lock()
defer r.mu.Unlock()
u, ok := r.users[email]
if !ok {
return users_repo.ErrUserNotFound
}
u.PasswordHash = passwordHash
return nil
}
func (r *fakeRepo) GetUserByEmail(_ context.Context, email string) (*repos.User, error) {
r.mu.Lock()
defer r.mu.Unlock()
u, ok := r.users[email]
if !ok {
return nil, users_repo.ErrUserNotFound
}
return u, nil
}
func (r *fakeRepo) GetUserByID(_ context.Context, id int) (*repos.User, error) {
r.mu.Lock()
defer r.mu.Unlock()
u, ok := r.byID[id]
if !ok {
return nil, users_repo.ErrUserNotFound
}
return u, nil
}
func (r *fakeRepo) GetUsers(_ context.Context) ([]*repos.User, error) {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]*repos.User, 0, len(r.users))
for _, u := range r.users {
out = append(out, u)
}
return out, nil
}
func (r *fakeRepo) AddUserRole(_ context.Context, _ int, _ string) error { return nil }
func (r *fakeRepo) DeleteUserRole(_ context.Context, _ int, _ string) error { return nil }
func (r *fakeRepo) DeleteUser(_ context.Context, userId int) error {
r.mu.Lock()
defer r.mu.Unlock()
u, ok := r.byID[userId]
if !ok {
return users_repo.ErrUserNotFound
}
delete(r.byID, userId)
delete(r.users, u.Email)
return nil
}
func (r *fakeRepo) userCount() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.users)
}
func (r *fakeRepo) seed(email, passwordHash string) {
r.mu.Lock()
defer r.mu.Unlock()
u := &repos.User{ID: r.nextID, Email: email, PasswordHash: passwordHash}
r.nextID++
r.users[email] = u
r.byID[u.ID] = u
}
func newTestService(repo usersRepository, sender email_sender.IEmailSender) *UsersService {
return newTestServiceWithBaseURL(repo, sender, "")
}
func newTestServiceWithBaseURL(repo usersRepository, sender email_sender.IEmailSender, baseURL string) *UsersService {
// Пул создаётся лениво (pgxpool.New не контактирует с БД) и в этих
// тестах не используется.
pool, err := pgxpool.New(context.Background(), "postgres://postgres:postgres@localhost:5432/none")
if err != nil {
panic(err)
}
return NewUsersService(
repo,
password_generator.NewGenerator(12),
sender,
processor_jwt.NewProcessor("test-secret"),
refresh_tokens_repo.NewRefreshTokensRepo(pool),
baseURL,
)
}
func TestAddUserSuccess(t *testing.T) {
repo := newFakeRepo()
sender := &fakeSender{}
svc := newTestService(repo, sender)
err := svc.AddUser(context.Background(), "detective", " User@Example.COM ", true, true, "1.2.3.4", "ua")
if err != nil {
t.Fatalf("AddUser: %v", err)
}
// Email нормализован: пробелы убраны, регистр приведён.
if _, ok := repo.users["user@example.com"]; !ok {
t.Errorf("пользователь не создан с нормализованным email: %+v", repo.users)
}
msgs := sender.sent()
if len(msgs) != 1 {
t.Fatalf("ожидалось 1 письмо, отправлено %d", len(msgs))
}
if msgs[0].To != "user@example.com" {
t.Errorf("письмо ушло на %q, want user@example.com", msgs[0].To)
}
if !strings.Contains(msgs[0].Subject, "Вечернему детективу") {
t.Errorf("неожиданная тема письма: %q", msgs[0].Subject)
}
if !strings.Contains(msgs[0].Body, "пароль") {
t.Error("в текстовой версии письма не упоминается пароль")
}
if msgs[0].HTML == "" {
t.Error("у письма нет HTML-версии — Gmail хуже рендерит такие письма")
}
// APP_BASE_URL в тесте пуст — кнопка «Войти в игру» не добавляется,
// но каркас письма (шапка сервиса) обязан присутствовать.
if !strings.Contains(msgs[0].HTML, "ВЕЧЕРНИЙ ДЕТЕКТИВ") {
t.Error("HTML-версия письма не содержит шапку сервиса")
}
if strings.Contains(msgs[0].HTML, "Войти в игру") {
t.Error("кнопка входа не должна добавляться без APP_BASE_URL")
}
}
func TestAddUserWithBaseURLAddsLoginLink(t *testing.T) {
repo := newFakeRepo()
sender := &fakeSender{}
svc := newTestServiceWithBaseURL(repo, sender, "https://evening-detective.crabs-games.art/")
err := svc.AddUser(context.Background(), "detective", "user@example.com", true, true, "", "")
if err != nil {
t.Fatalf("AddUser: %v", err)
}
msgs := sender.sent()
if len(msgs) != 1 {
t.Fatalf("ожидалось 1 письмо, отправлено %d", len(msgs))
}
// Завершающий слэш срезается, ссылка есть и в plain, и в HTML-версии.
if !strings.Contains(msgs[0].Body, "https://evening-detective.crabs-games.art") {
t.Errorf("plain-версия не содержит ссылку на вход:\n%s", msgs[0].Body)
}
if !strings.Contains(msgs[0].HTML, "https://evening-detective.crabs-games.art") {
t.Error("HTML-версия не содержит ссылку на вход")
}
if !strings.Contains(msgs[0].HTML, "Войти в игру") {
t.Error("HTML-версия не содержит кнопку «Войти в игру»")
}
}
func TestLoginURLInvalid(t *testing.T) {
for _, in := range []string{"", " ", "ftp://example.com", "not-a-url", "javascript:alert(1)"} {
if got := loginURL(in); got != "" {
t.Errorf("loginURL(%q) = %q, want пусто", in, got)
}
}
}
func TestAddUserSendFailureCompensates(t *testing.T) {
repo := newFakeRepo()
sender := &fakeSender{err: errors.New("smtp down")}
svc := newTestService(repo, sender)
err := svc.AddUser(context.Background(), "detective", "user@example.com", true, true, "", "")
if err == nil {
t.Fatal("ожидалась ошибка при сбое отправки письма")
}
// Компенсация: клиенту ошибка, пользователя в БД нет.
if repo.userCount() != 0 {
t.Errorf("пользователь остался после ошибки отправки: %+v", repo.users)
}
}
func TestAddUserInvalidEmail(t *testing.T) {
repo := newFakeRepo()
svc := newTestService(repo, &fakeSender{})
err := svc.AddUser(context.Background(), "detective", "not-an-email", true, true, "", "")
if err == nil {
t.Fatal("ожидалась ошибка валидации email")
}
if repo.userCount() != 0 {
t.Error("пользователь не должен создаваться при невалидном email")
}
}
func TestAddUserRequiresTerms(t *testing.T) {
repo := newFakeRepo()
svc := newTestService(repo, &fakeSender{})
err := svc.AddUser(context.Background(), "detective", "user@example.com", false, true, "", "")
if !errors.Is(err, ErrTermsNotAccepted) {
t.Fatalf("ожидался ErrTermsNotAccepted, получено %v", err)
}
if repo.userCount() != 0 {
t.Error("пользователь не должен создаваться без акцепта")
}
}
func TestRefreshPasswordSuccess(t *testing.T) {
repo := newFakeRepo()
repo.seed("user@example.com", "old-hash")
sender := &fakeSender{}
svc := newTestService(repo, sender)
// Ввод с пробелами и другим регистром — нормализуется.
err := svc.RefreshPassword(context.Background(), " User@Example.COM ")
if err != nil {
t.Fatalf("RefreshPassword: %v", err)
}
if len(sender.sent()) != 1 {
t.Fatalf("ожидалось 1 письмо, отправлено %d", len(sender.sent()))
}
u, _ := repo.GetUserByEmail(context.Background(), "user@example.com")
if u.PasswordHash == "old-hash" {
t.Error("пароль не изменён")
}
}
func TestRefreshPasswordSendFailureKeepsPassword(t *testing.T) {
repo := newFakeRepo()
repo.seed("user@example.com", "old-hash")
sender := &fakeSender{err: errors.New("smtp down")}
svc := newTestService(repo, sender)
err := svc.RefreshPassword(context.Background(), "user@example.com")
if err == nil {
t.Fatal("ожидалась ошибка при сбое отправки")
}
u, _ := repo.GetUserByEmail(context.Background(), "user@example.com")
if u.PasswordHash != "old-hash" {
t.Error("пароль изменён, хотя письмо не ушло — аккаунт заблокирован")
}
}
func TestRefreshPasswordUnknownEmail(t *testing.T) {
repo := newFakeRepo()
sender := &fakeSender{}
svc := newTestService(repo, sender)
// Анти-enumeration: для несуществующего адреса — успех и без письма.
if err := svc.RefreshPassword(context.Background(), "nobody@example.com"); err != nil {
t.Fatalf("для несуществующего email ожидался успех, получено %v", err)
}
if len(sender.sent()) != 0 {
t.Error("письмо не должно уходить несуществующему пользователю")
}
}
func TestRefreshPasswordInvalidEmail(t *testing.T) {
repo := newFakeRepo()
sender := &fakeSender{}
svc := newTestService(repo, sender)
if err := svc.RefreshPassword(context.Background(), "not-an-email"); err == nil {
t.Fatal("ожидалась ошибка валидации email")
}
if len(sender.sent()) != 0 {
t.Error("письмо не должно уходить при невалидном email")
}
}
+406 -295
View File
File diff suppressed because it is too large Load Diff
+239 -94
View File
@@ -16,6 +16,7 @@ import (
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/grpc-ecosystem/grpc-gateway/v2/utilities"
"google.golang.org/genproto/googleapis/api/httpbody"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/grpclog"
@@ -1023,6 +1024,72 @@ func local_request_EveningDetectiveServer_DeleteScenarioPlace_0(ctx context.Cont
return msg, metadata, err
}
func request_EveningDetectiveServer_DownloadScenarioArchive_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq DownloadScenarioArchiveReq
metadata runtime.ServerMetadata
err error
)
if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
val, ok := pathParams["id"]
if !ok {
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
}
protoReq.Id, err = runtime.Int32(val)
if err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
}
msg, err := client.DownloadScenarioArchive(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_EveningDetectiveServer_DownloadScenarioArchive_0(ctx context.Context, marshaler runtime.Marshaler, server EveningDetectiveServerServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq DownloadScenarioArchiveReq
metadata runtime.ServerMetadata
err error
)
val, ok := pathParams["id"]
if !ok {
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id")
}
protoReq.Id, err = runtime.Int32(val)
if err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err)
}
msg, err := server.DownloadScenarioArchive(ctx, &protoReq)
return msg, metadata, err
}
func request_EveningDetectiveServer_UploadScenarioArchive_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq httpbody.HttpBody
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
msg, err := client.UploadScenarioArchive(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_EveningDetectiveServer_UploadScenarioArchive_0(ctx context.Context, marshaler runtime.Marshaler, server EveningDetectiveServerServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq httpbody.HttpBody
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.UploadScenarioArchive(ctx, &protoReq)
return msg, metadata, err
}
func request_EveningDetectiveServer_AddGame_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq AddGameReq
@@ -2318,6 +2385,46 @@ func RegisterEveningDetectiveServerHandlerServer(ctx context.Context, mux *runti
}
forward_EveningDetectiveServer_DeleteScenarioPlace_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_EveningDetectiveServer_DownloadScenarioArchive_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/DownloadScenarioArchive", runtime.WithHTTPPathPattern("/api/scenarios/{id}/archive"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_EveningDetectiveServer_DownloadScenarioArchive_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_EveningDetectiveServer_DownloadScenarioArchive_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_UploadScenarioArchive_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/UploadScenarioArchive", runtime.WithHTTPPathPattern("/api/scenarios/archive"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_EveningDetectiveServer_UploadScenarioArchive_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_EveningDetectiveServer_UploadScenarioArchive_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_AddGame_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@@ -3208,6 +3315,40 @@ func RegisterEveningDetectiveServerHandlerClient(ctx context.Context, mux *runti
}
forward_EveningDetectiveServer_DeleteScenarioPlace_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_EveningDetectiveServer_DownloadScenarioArchive_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/DownloadScenarioArchive", runtime.WithHTTPPathPattern("/api/scenarios/{id}/archive"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_EveningDetectiveServer_DownloadScenarioArchive_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_EveningDetectiveServer_DownloadScenarioArchive_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_UploadScenarioArchive_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/UploadScenarioArchive", runtime.WithHTTPPathPattern("/api/scenarios/archive"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_EveningDetectiveServer_UploadScenarioArchive_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_EveningDetectiveServer_UploadScenarioArchive_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_AddGame_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@@ -3501,101 +3642,105 @@ func RegisterEveningDetectiveServerHandlerClient(ctx context.Context, mux *runti
}
var (
pattern_EveningDetectiveServer_Ping_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "test", "ping"}, ""))
pattern_EveningDetectiveServer_Echo_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "test", "echo"}, ""))
pattern_EveningDetectiveServer_Signup_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "auth", "signup"}, ""))
pattern_EveningDetectiveServer_RefreshPassword_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "auth", "refresh-password"}, ""))
pattern_EveningDetectiveServer_Login_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "auth", "login"}, ""))
pattern_EveningDetectiveServer_Refresh_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "auth", "refresh"}, ""))
pattern_EveningDetectiveServer_DeleteAccount_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "auth", "delete-account"}, ""))
pattern_EveningDetectiveServer_GetTerms_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "terms"}, ""))
pattern_EveningDetectiveServer_GetPrivacy_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "privacy"}, ""))
pattern_EveningDetectiveServer_GetConsent_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "consent"}, ""))
pattern_EveningDetectiveServer_GetUsers_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "users"}, ""))
pattern_EveningDetectiveServer_GetUserById_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "users", "id"}, ""))
pattern_EveningDetectiveServer_GetMe_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "users", "me"}, ""))
pattern_EveningDetectiveServer_AddUserRole_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3, 2, 4}, []string{"api", "users", "id", "role", "add"}, ""))
pattern_EveningDetectiveServer_DeleteUserRole_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3, 2, 4}, []string{"api", "users", "id", "role", "delete"}, ""))
pattern_EveningDetectiveServer_GetPermissions_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "ui", "permissions"}, ""))
pattern_EveningDetectiveServer_UploadFile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "files", "upload"}, ""))
pattern_EveningDetectiveServer_DownloadFile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "files", "filename"}, ""))
pattern_EveningDetectiveServer_AddScenario_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "scenario"}, ""))
pattern_EveningDetectiveServer_GetMyScenarios_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "my-scenarios"}, ""))
pattern_EveningDetectiveServer_GetScenariosCatalog_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "catalog"}, ""))
pattern_EveningDetectiveServer_GetFullScenario_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "scenarios", "id", "full"}, ""))
pattern_EveningDetectiveServer_GetScenario_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "scenarios", "id"}, ""))
pattern_EveningDetectiveServer_UpdateScenario_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "scenarios", "id"}, ""))
pattern_EveningDetectiveServer_PublicScenario_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "scenarios", "id", "public"}, ""))
pattern_EveningDetectiveServer_DraftScenario_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "scenarios", "id", "draft"}, ""))
pattern_EveningDetectiveServer_DeleteScenario_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "scenarios", "id"}, ""))
pattern_EveningDetectiveServer_AddScenarioPlace_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "scenarios", "id", "places"}, ""))
pattern_EveningDetectiveServer_UpdateScenarioPlace_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3, 1, 0, 4, 1, 5, 4}, []string{"api", "scenarios", "id", "places", "code"}, ""))
pattern_EveningDetectiveServer_DeleteScenarioPlace_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3, 1, 0, 4, 1, 5, 4}, []string{"api", "scenarios", "id", "places", "code"}, ""))
pattern_EveningDetectiveServer_AddGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "games"}, ""))
pattern_EveningDetectiveServer_GetGames_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "games"}, ""))
pattern_EveningDetectiveServer_GetGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "games", "id"}, ""))
pattern_EveningDetectiveServer_UpdateGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "games", "id"}, ""))
pattern_EveningDetectiveServer_StartGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "games", "id", "start"}, ""))
pattern_EveningDetectiveServer_PauseGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "games", "id", "pause"}, ""))
pattern_EveningDetectiveServer_PlayGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "games", "id", "play"}, ""))
pattern_EveningDetectiveServer_FinishGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "games", "id", "finish"}, ""))
pattern_EveningDetectiveServer_ResetGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "games", "id", "reset"}, ""))
pattern_EveningDetectiveServer_DeleteGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "games", "id"}, ""))
pattern_EveningDetectiveServer_AddTeam_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "teams"}, ""))
pattern_EveningDetectiveServer_UpdateTeam_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "teams", "id"}, ""))
pattern_EveningDetectiveServer_DeleteTeam_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "teams", "id"}, ""))
pattern_EveningDetectiveServer_GiveTeamApplications_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "teams", "id", "applications"}, ""))
pattern_EveningDetectiveServer_GetTeamStory_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "teams", "id", "story"}, ""))
pattern_EveningDetectiveServer_AddTeamAction_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "teams", "id", "actions"}, ""))
pattern_EveningDetectiveServer_DeleteLastTeamAction_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "teams", "id", "last-actions"}, ""))
pattern_EveningDetectiveServer_Ping_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "test", "ping"}, ""))
pattern_EveningDetectiveServer_Echo_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "test", "echo"}, ""))
pattern_EveningDetectiveServer_Signup_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "auth", "signup"}, ""))
pattern_EveningDetectiveServer_RefreshPassword_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "auth", "refresh-password"}, ""))
pattern_EveningDetectiveServer_Login_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "auth", "login"}, ""))
pattern_EveningDetectiveServer_Refresh_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "auth", "refresh"}, ""))
pattern_EveningDetectiveServer_DeleteAccount_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "auth", "delete-account"}, ""))
pattern_EveningDetectiveServer_GetTerms_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "terms"}, ""))
pattern_EveningDetectiveServer_GetPrivacy_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "privacy"}, ""))
pattern_EveningDetectiveServer_GetConsent_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "consent"}, ""))
pattern_EveningDetectiveServer_GetUsers_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "users"}, ""))
pattern_EveningDetectiveServer_GetUserById_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "users", "id"}, ""))
pattern_EveningDetectiveServer_GetMe_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "users", "me"}, ""))
pattern_EveningDetectiveServer_AddUserRole_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3, 2, 4}, []string{"api", "users", "id", "role", "add"}, ""))
pattern_EveningDetectiveServer_DeleteUserRole_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3, 2, 4}, []string{"api", "users", "id", "role", "delete"}, ""))
pattern_EveningDetectiveServer_GetPermissions_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "ui", "permissions"}, ""))
pattern_EveningDetectiveServer_UploadFile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "files", "upload"}, ""))
pattern_EveningDetectiveServer_DownloadFile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "files", "filename"}, ""))
pattern_EveningDetectiveServer_AddScenario_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "scenario"}, ""))
pattern_EveningDetectiveServer_GetMyScenarios_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "my-scenarios"}, ""))
pattern_EveningDetectiveServer_GetScenariosCatalog_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "catalog"}, ""))
pattern_EveningDetectiveServer_GetFullScenario_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "scenarios", "id", "full"}, ""))
pattern_EveningDetectiveServer_GetScenario_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "scenarios", "id"}, ""))
pattern_EveningDetectiveServer_UpdateScenario_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "scenarios", "id"}, ""))
pattern_EveningDetectiveServer_PublicScenario_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "scenarios", "id", "public"}, ""))
pattern_EveningDetectiveServer_DraftScenario_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "scenarios", "id", "draft"}, ""))
pattern_EveningDetectiveServer_DeleteScenario_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "scenarios", "id"}, ""))
pattern_EveningDetectiveServer_AddScenarioPlace_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "scenarios", "id", "places"}, ""))
pattern_EveningDetectiveServer_UpdateScenarioPlace_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3, 1, 0, 4, 1, 5, 4}, []string{"api", "scenarios", "id", "places", "code"}, ""))
pattern_EveningDetectiveServer_DeleteScenarioPlace_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3, 1, 0, 4, 1, 5, 4}, []string{"api", "scenarios", "id", "places", "code"}, ""))
pattern_EveningDetectiveServer_DownloadScenarioArchive_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "scenarios", "id", "archive"}, ""))
pattern_EveningDetectiveServer_UploadScenarioArchive_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "scenarios", "archive"}, ""))
pattern_EveningDetectiveServer_AddGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "games"}, ""))
pattern_EveningDetectiveServer_GetGames_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "games"}, ""))
pattern_EveningDetectiveServer_GetGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "games", "id"}, ""))
pattern_EveningDetectiveServer_UpdateGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "games", "id"}, ""))
pattern_EveningDetectiveServer_StartGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "games", "id", "start"}, ""))
pattern_EveningDetectiveServer_PauseGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "games", "id", "pause"}, ""))
pattern_EveningDetectiveServer_PlayGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "games", "id", "play"}, ""))
pattern_EveningDetectiveServer_FinishGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "games", "id", "finish"}, ""))
pattern_EveningDetectiveServer_ResetGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "games", "id", "reset"}, ""))
pattern_EveningDetectiveServer_DeleteGame_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "games", "id"}, ""))
pattern_EveningDetectiveServer_AddTeam_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "teams"}, ""))
pattern_EveningDetectiveServer_UpdateTeam_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "teams", "id"}, ""))
pattern_EveningDetectiveServer_DeleteTeam_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "teams", "id"}, ""))
pattern_EveningDetectiveServer_GiveTeamApplications_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "teams", "id", "applications"}, ""))
pattern_EveningDetectiveServer_GetTeamStory_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "teams", "id", "story"}, ""))
pattern_EveningDetectiveServer_AddTeamAction_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "teams", "id", "actions"}, ""))
pattern_EveningDetectiveServer_DeleteLastTeamAction_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "teams", "id", "last-actions"}, ""))
)
var (
forward_EveningDetectiveServer_Ping_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_Echo_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_Signup_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_RefreshPassword_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_Login_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_Refresh_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_DeleteAccount_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetTerms_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetPrivacy_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetConsent_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetUsers_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetUserById_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetMe_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_AddUserRole_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_DeleteUserRole_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetPermissions_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_UploadFile_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_DownloadFile_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_AddScenario_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetMyScenarios_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetScenariosCatalog_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetFullScenario_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetScenario_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_UpdateScenario_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_PublicScenario_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_DraftScenario_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_DeleteScenario_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_AddScenarioPlace_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_UpdateScenarioPlace_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_DeleteScenarioPlace_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_AddGame_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetGames_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetGame_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_UpdateGame_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_StartGame_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_PauseGame_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_PlayGame_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_FinishGame_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_ResetGame_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_DeleteGame_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_AddTeam_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_UpdateTeam_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_DeleteTeam_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GiveTeamApplications_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetTeamStory_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_AddTeamAction_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_DeleteLastTeamAction_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_Ping_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_Echo_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_Signup_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_RefreshPassword_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_Login_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_Refresh_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_DeleteAccount_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetTerms_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetPrivacy_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetConsent_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetUsers_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetUserById_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetMe_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_AddUserRole_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_DeleteUserRole_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetPermissions_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_UploadFile_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_DownloadFile_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_AddScenario_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetMyScenarios_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetScenariosCatalog_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetFullScenario_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetScenario_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_UpdateScenario_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_PublicScenario_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_DraftScenario_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_DeleteScenario_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_AddScenarioPlace_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_UpdateScenarioPlace_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_DeleteScenarioPlace_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_DownloadScenarioArchive_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_UploadScenarioArchive_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_AddGame_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetGames_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetGame_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_UpdateGame_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_StartGame_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_PauseGame_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_PlayGame_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_FinishGame_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_ResetGame_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_DeleteGame_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_AddTeam_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_UpdateTeam_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_DeleteTeam_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GiveTeamApplications_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_GetTeamStory_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_AddTeamAction_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_DeleteLastTeamAction_0 = runtime.ForwardResponseMessage
)
+123 -47
View File
@@ -20,53 +20,55 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
EveningDetectiveServer_Ping_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/Ping"
EveningDetectiveServer_Echo_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/Echo"
EveningDetectiveServer_Signup_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/Signup"
EveningDetectiveServer_RefreshPassword_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/RefreshPassword"
EveningDetectiveServer_Login_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/Login"
EveningDetectiveServer_Refresh_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/Refresh"
EveningDetectiveServer_DeleteAccount_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeleteAccount"
EveningDetectiveServer_GetTerms_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetTerms"
EveningDetectiveServer_GetPrivacy_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetPrivacy"
EveningDetectiveServer_GetConsent_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetConsent"
EveningDetectiveServer_GetUsers_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetUsers"
EveningDetectiveServer_GetUserById_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetUserById"
EveningDetectiveServer_GetMe_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetMe"
EveningDetectiveServer_AddUserRole_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/AddUserRole"
EveningDetectiveServer_DeleteUserRole_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeleteUserRole"
EveningDetectiveServer_GetPermissions_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetPermissions"
EveningDetectiveServer_UploadFile_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UploadFile"
EveningDetectiveServer_DownloadFile_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DownloadFile"
EveningDetectiveServer_AddScenario_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/AddScenario"
EveningDetectiveServer_GetMyScenarios_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetMyScenarios"
EveningDetectiveServer_GetScenariosCatalog_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetScenariosCatalog"
EveningDetectiveServer_GetFullScenario_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetFullScenario"
EveningDetectiveServer_GetScenario_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetScenario"
EveningDetectiveServer_UpdateScenario_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UpdateScenario"
EveningDetectiveServer_PublicScenario_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/PublicScenario"
EveningDetectiveServer_DraftScenario_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DraftScenario"
EveningDetectiveServer_DeleteScenario_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeleteScenario"
EveningDetectiveServer_AddScenarioPlace_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/AddScenarioPlace"
EveningDetectiveServer_UpdateScenarioPlace_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UpdateScenarioPlace"
EveningDetectiveServer_DeleteScenarioPlace_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeleteScenarioPlace"
EveningDetectiveServer_AddGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/AddGame"
EveningDetectiveServer_GetGames_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetGames"
EveningDetectiveServer_GetGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetGame"
EveningDetectiveServer_UpdateGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UpdateGame"
EveningDetectiveServer_StartGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/StartGame"
EveningDetectiveServer_PauseGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/PauseGame"
EveningDetectiveServer_PlayGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/PlayGame"
EveningDetectiveServer_FinishGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/FinishGame"
EveningDetectiveServer_ResetGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/ResetGame"
EveningDetectiveServer_DeleteGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeleteGame"
EveningDetectiveServer_AddTeam_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/AddTeam"
EveningDetectiveServer_UpdateTeam_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UpdateTeam"
EveningDetectiveServer_DeleteTeam_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeleteTeam"
EveningDetectiveServer_GiveTeamApplications_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GiveTeamApplications"
EveningDetectiveServer_GetTeamStory_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetTeamStory"
EveningDetectiveServer_AddTeamAction_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/AddTeamAction"
EveningDetectiveServer_DeleteLastTeamAction_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeleteLastTeamAction"
EveningDetectiveServer_Ping_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/Ping"
EveningDetectiveServer_Echo_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/Echo"
EveningDetectiveServer_Signup_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/Signup"
EveningDetectiveServer_RefreshPassword_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/RefreshPassword"
EveningDetectiveServer_Login_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/Login"
EveningDetectiveServer_Refresh_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/Refresh"
EveningDetectiveServer_DeleteAccount_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeleteAccount"
EveningDetectiveServer_GetTerms_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetTerms"
EveningDetectiveServer_GetPrivacy_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetPrivacy"
EveningDetectiveServer_GetConsent_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetConsent"
EveningDetectiveServer_GetUsers_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetUsers"
EveningDetectiveServer_GetUserById_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetUserById"
EveningDetectiveServer_GetMe_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetMe"
EveningDetectiveServer_AddUserRole_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/AddUserRole"
EveningDetectiveServer_DeleteUserRole_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeleteUserRole"
EveningDetectiveServer_GetPermissions_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetPermissions"
EveningDetectiveServer_UploadFile_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UploadFile"
EveningDetectiveServer_DownloadFile_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DownloadFile"
EveningDetectiveServer_AddScenario_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/AddScenario"
EveningDetectiveServer_GetMyScenarios_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetMyScenarios"
EveningDetectiveServer_GetScenariosCatalog_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetScenariosCatalog"
EveningDetectiveServer_GetFullScenario_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetFullScenario"
EveningDetectiveServer_GetScenario_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetScenario"
EveningDetectiveServer_UpdateScenario_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UpdateScenario"
EveningDetectiveServer_PublicScenario_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/PublicScenario"
EveningDetectiveServer_DraftScenario_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DraftScenario"
EveningDetectiveServer_DeleteScenario_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeleteScenario"
EveningDetectiveServer_AddScenarioPlace_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/AddScenarioPlace"
EveningDetectiveServer_UpdateScenarioPlace_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UpdateScenarioPlace"
EveningDetectiveServer_DeleteScenarioPlace_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeleteScenarioPlace"
EveningDetectiveServer_DownloadScenarioArchive_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DownloadScenarioArchive"
EveningDetectiveServer_UploadScenarioArchive_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UploadScenarioArchive"
EveningDetectiveServer_AddGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/AddGame"
EveningDetectiveServer_GetGames_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetGames"
EveningDetectiveServer_GetGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetGame"
EveningDetectiveServer_UpdateGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UpdateGame"
EveningDetectiveServer_StartGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/StartGame"
EveningDetectiveServer_PauseGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/PauseGame"
EveningDetectiveServer_PlayGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/PlayGame"
EveningDetectiveServer_FinishGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/FinishGame"
EveningDetectiveServer_ResetGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/ResetGame"
EveningDetectiveServer_DeleteGame_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeleteGame"
EveningDetectiveServer_AddTeam_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/AddTeam"
EveningDetectiveServer_UpdateTeam_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UpdateTeam"
EveningDetectiveServer_DeleteTeam_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeleteTeam"
EveningDetectiveServer_GiveTeamApplications_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GiveTeamApplications"
EveningDetectiveServer_GetTeamStory_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetTeamStory"
EveningDetectiveServer_AddTeamAction_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/AddTeamAction"
EveningDetectiveServer_DeleteLastTeamAction_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeleteLastTeamAction"
)
// EveningDetectiveServerClient is the client API for EveningDetectiveServer service.
@@ -103,6 +105,8 @@ type EveningDetectiveServerClient interface {
AddScenarioPlace(ctx context.Context, in *AddScenarioPlaceReq, opts ...grpc.CallOption) (*AddScenarioPlaceRsp, error)
UpdateScenarioPlace(ctx context.Context, in *UpdateScenarioPlaceReq, opts ...grpc.CallOption) (*UpdateScenarioPlaceRsp, error)
DeleteScenarioPlace(ctx context.Context, in *DeleteScenarioPlaceReq, opts ...grpc.CallOption) (*DeleteScenarioPlaceRsp, error)
DownloadScenarioArchive(ctx context.Context, in *DownloadScenarioArchiveReq, opts ...grpc.CallOption) (*httpbody.HttpBody, error)
UploadScenarioArchive(ctx context.Context, in *httpbody.HttpBody, opts ...grpc.CallOption) (*UploadScenarioArchiveRsp, error)
AddGame(ctx context.Context, in *AddGameReq, opts ...grpc.CallOption) (*AddGameRsp, error)
GetGames(ctx context.Context, in *GetGamesReq, opts ...grpc.CallOption) (*GetGamesRsp, error)
GetGame(ctx context.Context, in *GetGameReq, opts ...grpc.CallOption) (*GetGameRsp, error)
@@ -430,6 +434,26 @@ func (c *eveningDetectiveServerClient) DeleteScenarioPlace(ctx context.Context,
return out, nil
}
func (c *eveningDetectiveServerClient) DownloadScenarioArchive(ctx context.Context, in *DownloadScenarioArchiveReq, opts ...grpc.CallOption) (*httpbody.HttpBody, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(httpbody.HttpBody)
err := c.cc.Invoke(ctx, EveningDetectiveServer_DownloadScenarioArchive_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *eveningDetectiveServerClient) UploadScenarioArchive(ctx context.Context, in *httpbody.HttpBody, opts ...grpc.CallOption) (*UploadScenarioArchiveRsp, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UploadScenarioArchiveRsp)
err := c.cc.Invoke(ctx, EveningDetectiveServer_UploadScenarioArchive_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *eveningDetectiveServerClient) AddGame(ctx context.Context, in *AddGameReq, opts ...grpc.CallOption) (*AddGameRsp, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AddGameRsp)
@@ -634,6 +658,8 @@ type EveningDetectiveServerServer interface {
AddScenarioPlace(context.Context, *AddScenarioPlaceReq) (*AddScenarioPlaceRsp, error)
UpdateScenarioPlace(context.Context, *UpdateScenarioPlaceReq) (*UpdateScenarioPlaceRsp, error)
DeleteScenarioPlace(context.Context, *DeleteScenarioPlaceReq) (*DeleteScenarioPlaceRsp, error)
DownloadScenarioArchive(context.Context, *DownloadScenarioArchiveReq) (*httpbody.HttpBody, error)
UploadScenarioArchive(context.Context, *httpbody.HttpBody) (*UploadScenarioArchiveRsp, error)
AddGame(context.Context, *AddGameReq) (*AddGameRsp, error)
GetGames(context.Context, *GetGamesReq) (*GetGamesRsp, error)
GetGame(context.Context, *GetGameReq) (*GetGameRsp, error)
@@ -751,6 +777,12 @@ func (UnimplementedEveningDetectiveServerServer) UpdateScenarioPlace(context.Con
func (UnimplementedEveningDetectiveServerServer) DeleteScenarioPlace(context.Context, *DeleteScenarioPlaceReq) (*DeleteScenarioPlaceRsp, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteScenarioPlace not implemented")
}
func (UnimplementedEveningDetectiveServerServer) DownloadScenarioArchive(context.Context, *DownloadScenarioArchiveReq) (*httpbody.HttpBody, error) {
return nil, status.Error(codes.Unimplemented, "method DownloadScenarioArchive not implemented")
}
func (UnimplementedEveningDetectiveServerServer) UploadScenarioArchive(context.Context, *httpbody.HttpBody) (*UploadScenarioArchiveRsp, error) {
return nil, status.Error(codes.Unimplemented, "method UploadScenarioArchive not implemented")
}
func (UnimplementedEveningDetectiveServerServer) AddGame(context.Context, *AddGameReq) (*AddGameRsp, error) {
return nil, status.Error(codes.Unimplemented, "method AddGame not implemented")
}
@@ -1364,6 +1396,42 @@ func _EveningDetectiveServer_DeleteScenarioPlace_Handler(srv interface{}, ctx co
return interceptor(ctx, in, info, handler)
}
func _EveningDetectiveServer_DownloadScenarioArchive_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DownloadScenarioArchiveReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(EveningDetectiveServerServer).DownloadScenarioArchive(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: EveningDetectiveServer_DownloadScenarioArchive_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(EveningDetectiveServerServer).DownloadScenarioArchive(ctx, req.(*DownloadScenarioArchiveReq))
}
return interceptor(ctx, in, info, handler)
}
func _EveningDetectiveServer_UploadScenarioArchive_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(httpbody.HttpBody)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(EveningDetectiveServerServer).UploadScenarioArchive(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: EveningDetectiveServer_UploadScenarioArchive_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(EveningDetectiveServerServer).UploadScenarioArchive(ctx, req.(*httpbody.HttpBody))
}
return interceptor(ctx, in, info, handler)
}
func _EveningDetectiveServer_AddGame_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AddGameReq)
if err := dec(in); err != nil {
@@ -1797,6 +1865,14 @@ var EveningDetectiveServer_ServiceDesc = grpc.ServiceDesc{
MethodName: "DeleteScenarioPlace",
Handler: _EveningDetectiveServer_DeleteScenarioPlace_Handler,
},
{
MethodName: "DownloadScenarioArchive",
Handler: _EveningDetectiveServer_DownloadScenarioArchive_Handler,
},
{
MethodName: "UploadScenarioArchive",
Handler: _EveningDetectiveServer_UploadScenarioArchive_Handler,
},
{
MethodName: "AddGame",
Handler: _EveningDetectiveServer_AddGame_Handler,