generated from VLADIMIR/template
fix
This commit is contained in:
@@ -26,7 +26,6 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// testStub — минимальный gRPC-сервер, реализующий архивные и файловые RPC.
|
||||
type testStub struct {
|
||||
proto.UnimplementedEveningDetectiveServerServer
|
||||
|
||||
@@ -128,8 +127,6 @@ func TestGatewayDownloadTeamQR(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// newTestGateway поднимает gRPC-сервер со stub и grpc-gateway с теми же
|
||||
// опциями, что и в main.go (outgoing matcher, error handler, лимиты,
|
||||
// rawBodyMarshaler).
|
||||
func newTestGateway(t *testing.T, stub proto.EveningDetectiveServerServer) *httptest.Server {
|
||||
return newTestGatewayWithLimit(t, stub, maxFileUploadBody)
|
||||
@@ -237,8 +234,6 @@ func TestGatewayDownloadZipWithContentDisposition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGatewayDownloadFileNosniff — скачивание файла отдаёт содержимое,
|
||||
// Content-Type и X-Content-Type-Options: nosniff.
|
||||
func TestGatewayDownloadFileNosniff(t *testing.T) {
|
||||
stub := &testStub{}
|
||||
ts := newTestGateway(t, stub)
|
||||
@@ -264,7 +259,6 @@ func TestGatewayDownloadFileNosniff(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// uploadJSONBody собирает JSON-тело загрузки файла (base64 data).
|
||||
func uploadJSONBody(filename string, data []byte) []byte {
|
||||
body, _ := json.Marshal(proto.UploadFileReq{
|
||||
Filename: filename,
|
||||
@@ -273,8 +267,6 @@ func uploadJSONBody(filename string, data []byte) []byte {
|
||||
return body
|
||||
}
|
||||
|
||||
// TestGatewayUploadArchiveOverLimit — тело архива больше HTTP-лимита
|
||||
// (MaxBytesReader) → 413 с понятным текстом (связка middleware → декодер →
|
||||
// customErrorHandler).
|
||||
func TestGatewayUploadArchiveOverLimit(t *testing.T) {
|
||||
stub := &testStub{}
|
||||
@@ -296,14 +288,11 @@ func TestGatewayUploadArchiveOverLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGatewayUploadFileOverHTTPLimit — тело загрузки больше HTTP-лимита
|
||||
// (MaxBytesReader) → 413 с понятным текстом.
|
||||
func TestGatewayUploadFileOverHTTPLimit(t *testing.T) {
|
||||
stub := &testStub{}
|
||||
const limit = 1 << 20 // 1 MiB
|
||||
ts := newTestGatewayWithLimit(t, stub, limit)
|
||||
|
||||
// 1 MiB данных в base64 ~1.33 MiB > лимит 1 MiB.
|
||||
data := bytes.Repeat([]byte("x"), limit)
|
||||
resp, err := http.Post(ts.URL+"/api/files/upload", "application/json", bytes.NewReader(uploadJSONBody("clue.pdf", data)))
|
||||
if err != nil {
|
||||
@@ -320,14 +309,12 @@ func TestGatewayUploadFileOverHTTPLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGatewayUploadFileExactLimit — тело ровно в пределах лимита проходит
|
||||
// через HTTP-слой и gRPC-транспорт → 200.
|
||||
func TestGatewayUploadFileExactLimit(t *testing.T) {
|
||||
stub := &testStub{}
|
||||
const limit = 2 << 20 // 2 MiB — лимит HTTP-тела
|
||||
const limit = 2 << 20
|
||||
ts := newTestGatewayWithLimit(t, stub, limit)
|
||||
|
||||
data := bytes.Repeat([]byte("x"), 1<<20) // 1 MiB данных → тело ~1.33 MiB < 2 MiB
|
||||
data := bytes.Repeat([]byte("x"), 1<<20)
|
||||
resp, err := http.Post(ts.URL+"/api/files/upload", "application/json", bytes.NewReader(uploadJSONBody("clue.pdf", data)))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
@@ -343,8 +330,6 @@ func TestGatewayUploadFileExactLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGatewayUploadFileResourceExhausted — бизнес-ветка: сервис отклоняет
|
||||
// файл больше лимита (ResourceExhausted) → 413.
|
||||
func TestGatewayUploadFileResourceExhausted(t *testing.T) {
|
||||
stub := &testStub{}
|
||||
stub.uploadErr = status.Error(codes.ResourceExhausted, "файл слишком большой (лимит 64 МБ)")
|
||||
@@ -365,7 +350,6 @@ func TestGatewayUploadFileResourceExhausted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCustomErrorHandler — маппинг ошибок превышения размера в 413.
|
||||
func TestCustomErrorHandler(t *testing.T) {
|
||||
mux := runtime.NewServeMux()
|
||||
marshaler := &runtime.JSONPb{}
|
||||
@@ -403,15 +387,12 @@ func TestCustomErrorHandler(t *testing.T) {
|
||||
t.Run("ResourceExhausted on other route delegates", func(t *testing.T) {
|
||||
rec := do(t, httptest.NewRequest(http.MethodPost, "/api/test/echo", nil),
|
||||
status.Error(codes.ResourceExhausted, "rate limit"))
|
||||
// DefaultHTTPErrorHandler мапит ResourceExhausted в 429.
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("status = %d, want 429 (дефолтный маппинг)", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestLimitUploadBody — тело загрузки ограничено на HTTP-слое
|
||||
// (защита от OOM), прочие маршруты не затронуты.
|
||||
func TestLimitUploadBody(t *testing.T) {
|
||||
limited := func(route string, limit int64) http.Handler {
|
||||
return limitUploadBody(route, limit, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -467,9 +448,6 @@ func TestLimitUploadBody(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestCORSHeaders — фронт через fetch должен читать Content-Disposition
|
||||
// (имя файла архива) из ответа, а браузерный MCP-клиент — заголовки
|
||||
// streamable HTTP /mcp (Mcp-Session-Id, Mcp-Protocol-Version) в allow/expose.
|
||||
func TestCORSHeaders(t *testing.T) {
|
||||
h := cors(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
@@ -66,17 +66,10 @@ import (
|
||||
//go:embed main.swagger.json
|
||||
var swaggerJSON []byte
|
||||
|
||||
// maxFileSize — максимальный размер файла, передаваемого через gRPC
|
||||
const maxFileSize = 64 << 20 // 64 МБ
|
||||
const maxFileSize = 64 << 20
|
||||
|
||||
// maxFileUploadBody — лимит HTTP-тела POST /api/files/upload (JSON с base64):
|
||||
// ceil(maxFileSize·4/3) — точный размер base64, +2 КБ запаса на JSON-обёртку
|
||||
// {"filename":...,"data":...} с коротким именем файла.
|
||||
const maxFileUploadBody = (int64(maxFileSize)+2)/3*4 + 2*1024
|
||||
|
||||
// grpcMsgLimit — лимит размера gRPC-сообщения (сервер и клиент gateway):
|
||||
// maxFileSize + 1 MiB на прото-оверхед (tag+varint) поверх ровно 64 MiB
|
||||
// данных; авторитетная проверка размера — в file_service.
|
||||
const grpcMsgLimit = maxFileSize + (1 << 20)
|
||||
|
||||
func main() {
|
||||
@@ -240,14 +233,6 @@ func main() {
|
||||
log.Fatalln("Failed to dial server:", err)
|
||||
}
|
||||
|
||||
// X-Real-IP пробрасывается от доверенного обратного прокси (если фронт-
|
||||
// контур развёрнут перед gateway, см. docs/COMPLIANCE.md п. 6.6) и
|
||||
// используется в clientInfoFromContext для фиксации IP клиента при
|
||||
// акцепте соглашений (ст. 9 152-ФЗ). X-Password — подтверждение паролем
|
||||
// при удалении аккаунта (не в query, чтобы не светить креденшел в URL
|
||||
// и логах).
|
||||
// Сырая загрузка архива (без base64) для распространённых Content-Type;
|
||||
// JSON-вариант {"data": "<base64>"} работает через application/json.
|
||||
rawBody := &rawBodyMarshaler{
|
||||
Marshaler: &runtime.HTTPBodyMarshaler{
|
||||
Marshaler: &runtime.JSONPb{
|
||||
@@ -265,8 +250,6 @@ func main() {
|
||||
}
|
||||
return runtime.DefaultHeaderMatcher(key)
|
||||
}),
|
||||
// Проброс Content-Disposition и X-Content-Type-Options (nosniff)
|
||||
// от сервиса в HTTP-ответ.
|
||||
runtime.WithOutgoingHeaderMatcher(func(key string) (string, bool) {
|
||||
switch {
|
||||
case strings.EqualFold(key, "Content-Disposition"):
|
||||
@@ -299,9 +282,6 @@ func main() {
|
||||
log.Printf("write swagger document: %v", err)
|
||||
}
|
||||
})
|
||||
// MaxBytesReader на HTTP-слое: gateway буферизует тело целиком, без лимита
|
||||
// проверки в хендлере не спасут от OOM. Лимиты по маршрутам: файлы —
|
||||
// maxFileUploadBody, архивы — MaxArchiveSize().
|
||||
mainMux.Handle("/api/", limitUploadBody(
|
||||
"/api/files/upload",
|
||||
maxFileUploadBody,
|
||||
@@ -312,14 +292,6 @@ func main() {
|
||||
),
|
||||
))
|
||||
|
||||
// MCP-сервер (Model Context Protocol): эндпоинт /api/mcp (streamable HTTP).
|
||||
// Инструменты MCP вызывают игровые сервисы напрямую (см. mcp_service).
|
||||
// WithDisableLocalhostProtection: mcp-go отклоняет loopback-запросы с
|
||||
// не-localhost Host-заголовком (защита от DNS-rebinding), что ломает
|
||||
// работу за reverse-proxy (nginx/caddy) — контур деплоя проекта.
|
||||
// Эндпоинт публичный, секрет — пароль команды (аналогично публичному
|
||||
// REST /api/teams/{id}/story). Путь /api/mcp длиннее /api/, поэтому
|
||||
// http.ServeMux отдаёт MCP-запросы этому хендлеру, а не gateway.
|
||||
mcpHTTP := server.NewStreamableHTTPServer(
|
||||
mcp_service.NewMCPService(gameplayService, answerService).Server(),
|
||||
server.WithDisableLocalhostProtection(true),
|
||||
@@ -353,13 +325,7 @@ func cors(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE")
|
||||
// Mcp-Session-Id / Mcp-Protocol-Version / Last-Event-ID — заголовки
|
||||
// streamable HTTP MCP-эндпоинта /api/mcp (сессия, версия протокола,
|
||||
// SSE-реконнект); без них браузерные MCP-клиенты заблокируются preflight.
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, Authorization, ResponseType, X-Id, X-Password, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID")
|
||||
// Content-Disposition нужен фронту, чтобы прочитать имя файла архива;
|
||||
// Mcp-Session-Id / Mcp-Protocol-Version — браузерному MCP-клиенту,
|
||||
// чтобы читать сессионные заголовки ответов /api/mcp.
|
||||
w.Header().Set("Access-Control-Expose-Headers", "Content-Disposition, Mcp-Session-Id, Mcp-Protocol-Version")
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
@@ -368,8 +334,6 @@ func cors(h http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// limitUploadBody ограничивает тело POST-запроса на заданном маршруте
|
||||
// (gateway читает тело в память целиком).
|
||||
func limitUploadBody(route string, limit int64, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost && r.URL.Path == route {
|
||||
@@ -379,16 +343,6 @@ func limitUploadBody(route string, limit int64, next http.Handler) http.Handler
|
||||
})
|
||||
}
|
||||
|
||||
// customErrorHandler мапит ошибки превышения размера тела в HTTP 413
|
||||
// с понятным текстом:
|
||||
// - *http.MaxBytesError (raw) — только для юнит-тестов: сгенерированный
|
||||
// gateway-код строкифицирует ошибку декодера через %v и тип теряется;
|
||||
// - gRPC ResourceExhausted на путях загрузки (файл больше лимита);
|
||||
// - InvalidArgument с текстом MaxBytesError ("http: request body too
|
||||
// large") — единственная реальная защита 413 на HTTP-слое, ветку нельзя
|
||||
// удалять при «упрощении» обработчика.
|
||||
//
|
||||
// Остальные ошибки обрабатываются как обычно (DefaultHTTPErrorHandler).
|
||||
func customErrorHandler(
|
||||
ctx context.Context,
|
||||
mux *runtime.ServeMux,
|
||||
@@ -417,9 +371,6 @@ func customErrorHandler(
|
||||
runtime.DefaultHTTPErrorHandler(ctx, mux, marshaler, w, r, err)
|
||||
}
|
||||
|
||||
// writeTooLargeError — ответ 413 в том же формате {"error": ...}, что и
|
||||
// DefaultHTTPErrorHandler. Текст лимита формируется из maxFileSize, чтобы
|
||||
// не расходиться с реальным лимитом при его изменении.
|
||||
func writeTooLargeError(w http.ResponseWriter, marshaler runtime.Marshaler) {
|
||||
w.Header().Set("Content-Type", marshaler.ContentType(nil))
|
||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||
|
||||
@@ -7,9 +7,6 @@ import (
|
||||
"google.golang.org/genproto/googleapis/api/httpbody"
|
||||
)
|
||||
|
||||
// rawBodyMarshaler — маршалер для бинарного тела (application/zip и др.):
|
||||
// запрос попадает в HttpBody.Data без base64-JSON, для остального делегирует
|
||||
// JSON-маршалеру (работает и {"data": "<base64>"}).
|
||||
type rawBodyMarshaler struct {
|
||||
runtime.Marshaler
|
||||
}
|
||||
@@ -21,8 +18,6 @@ func (m *rawBodyMarshaler) NewDecoder(r io.Reader) runtime.Decoder {
|
||||
}
|
||||
}
|
||||
|
||||
// rawBodyDecoder — Decoder: для *httpbody.HttpBody читает тело целиком в Data,
|
||||
// для protobuf-сообщений декодирует JSON как обычно.
|
||||
type rawBodyDecoder struct {
|
||||
runtime.Decoder
|
||||
r io.Reader
|
||||
|
||||
@@ -35,7 +35,6 @@ func TestRawBodyDecoderDecodesHttpBody(t *testing.T) {
|
||||
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)
|
||||
|
||||
+1
-1
@@ -291,7 +291,7 @@ go test ./...
|
||||
|
||||
## 11. Именование и стиль
|
||||
|
||||
- Все комментарии в исходном Go-коде пиши на русском языке. Сгенерированные
|
||||
- Все комментарии в исходном Go-коде пиши на английском языке. Сгенерированные
|
||||
файлы и комментарии внешних библиотек не редактируй.
|
||||
- Имена короткие и предметные: `gameID`, `teamID`, `ctx`, `err` допустимы по
|
||||
общему Go-стилю.
|
||||
|
||||
+2
-3
@@ -1,6 +1,5 @@
|
||||
// Package docs встраивает юридические документы в бинарник и раздаёт их
|
||||
// по постоянным URL (/terms, /privacy, /consent), на которые сервис
|
||||
// ссылается при фиксации акцептов соглашений (user_agreements).
|
||||
// Package docs embeds legal documents into the binary and serves them from
|
||||
// stable URLs (/terms, /privacy, /consent) used when recording agreements.
|
||||
package docs
|
||||
|
||||
import "embed"
|
||||
|
||||
@@ -42,8 +42,6 @@ func TestChangeUserPasswordAccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Встраиваем настоящий репозиторий, чтобы неожиданные вызовы завершались
|
||||
// ошибкой, а не проходили незаметно.
|
||||
type passwordRepo struct {
|
||||
*users_repo.UsersRepo
|
||||
role string
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// fakeStorage — in-memory реализация IFileStorage для app-слоя.
|
||||
type fakeStorage struct {
|
||||
files map[string]*file_storage.File
|
||||
putErr error
|
||||
@@ -90,7 +89,6 @@ func TestUploadFilePermissionDenied(t *testing.T) {
|
||||
|
||||
func TestUploadFileValidationInvalidArgument(t *testing.T) {
|
||||
s := newTestFileServer(nil, 1<<20)
|
||||
// Имя без расширения.
|
||||
_, err := s.UploadFile(ctxWithClaims("author"), &proto.UploadFileReq{Filename: "clue", Data: pdfData(16)})
|
||||
if status.Code(err) != codes.InvalidArgument {
|
||||
t.Errorf("code = %v, want InvalidArgument", status.Code(err))
|
||||
@@ -133,7 +131,6 @@ func TestUploadFileSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// mockServerTransportStream — заглушка для grpc.SetHeader в юнит-тестах.
|
||||
type mockServerTransportStream struct {
|
||||
grpc.ServerTransportStream
|
||||
header metadata.MD
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
)
|
||||
|
||||
func (s *server) UploadFile(ctx context.Context, req *proto.UploadFileReq) (*proto.UploadFileRsp, error) {
|
||||
// Файлы (в т.ч. улики) загружают авторы сценариев в редакторе.
|
||||
claims, claimsErr := claimsFromContext(ctx)
|
||||
if claimsErr != nil {
|
||||
return nil, claimsErr
|
||||
@@ -55,8 +54,6 @@ func (s *server) DownloadFile(ctx context.Context, req *proto.DownloadFileReq) (
|
||||
return nil, status.Error(codes.Internal, "не удалось скачать файл")
|
||||
}
|
||||
}
|
||||
// nosniff — при публичном скачивании браузер не должен угадывать
|
||||
// Content-Type по содержимому (защита от stored-XSS при расхождении).
|
||||
if err := grpc.SetHeader(ctx, metadata.Pairs("X-Content-Type-Options", "nosniff")); err != nil {
|
||||
return nil, status.Error(codes.Internal, "не удалось установить заголовки файла")
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// GetTerms возвращает текст Пользовательского соглашения (постоянный URL
|
||||
// /api/terms, на который ссылается форма регистрации при акцепте).
|
||||
func (s *server) GetTerms(_ context.Context, _ *proto.GetTermsReq) (*proto.GetTermsRsp, error) {
|
||||
text, err := docs.FS.ReadFile("TERMS.md")
|
||||
if err != nil {
|
||||
@@ -19,7 +17,6 @@ func (s *server) GetTerms(_ context.Context, _ *proto.GetTermsReq) (*proto.GetTe
|
||||
return &proto.GetTermsRsp{Text: string(text)}, nil
|
||||
}
|
||||
|
||||
// GetPrivacy возвращает текст Политики конфиденциальности (постоянный URL
|
||||
// /api/privacy).
|
||||
func (s *server) GetPrivacy(_ context.Context, _ *proto.GetPrivacyReq) (*proto.GetPrivacyRsp, error) {
|
||||
text, err := docs.FS.ReadFile("PRIVACY.md")
|
||||
@@ -29,8 +26,6 @@ func (s *server) GetPrivacy(_ context.Context, _ *proto.GetPrivacyReq) (*proto.G
|
||||
return &proto.GetPrivacyRsp{Text: string(text)}, nil
|
||||
}
|
||||
|
||||
// GetConsent возвращает текст согласия на обработку персональных данных
|
||||
// (постоянный URL /api/consent).
|
||||
func (s *server) GetConsent(_ context.Context, _ *proto.GetConsentReq) (*proto.GetConsentRsp, error) {
|
||||
text, err := docs.FS.ReadFile("CONSENT.md")
|
||||
if err != nil {
|
||||
|
||||
@@ -6,9 +6,6 @@ import (
|
||||
proto "evening_detective_server/proto"
|
||||
)
|
||||
|
||||
// mapResultsQuestions мапит вопросы сценария в шапку итоговой таблицы:
|
||||
// правильный ответ (поле Answer) не передаётся — он виден только в панели
|
||||
// оценки (GetGameAnswers).
|
||||
func mapResultsQuestions(o []*storytelling.Question) []*proto.ResultsQuestion {
|
||||
res := make([]*proto.ResultsQuestion, 0, len(o))
|
||||
for _, item := range o {
|
||||
@@ -20,9 +17,6 @@ func mapResultsQuestions(o []*storytelling.Question) []*proto.ResultsQuestion {
|
||||
return res
|
||||
}
|
||||
|
||||
// mapTeamResults мапит строки итоговой таблицы. Команда мапится целиком
|
||||
// (включая password/applications — аудитория та же, что у GetGameAnswers:
|
||||
// организатор/автор); actionsCount продублирован на уровне строки таблицы.
|
||||
func mapTeamResults(o []answer_service.TeamResult) []*proto.TeamResult {
|
||||
res := make([]*proto.TeamResult, 0, len(o))
|
||||
for _, item := range o {
|
||||
|
||||
@@ -33,7 +33,6 @@ func TestMapTeamResults(t *testing.T) {
|
||||
if row.Team == nil || row.Team.ID != 1 || row.Team.Name != "Команда А" {
|
||||
t.Fatalf("row.Team = %+v, want id 1, name Команда А", row.Team)
|
||||
}
|
||||
// actionsCount продублирован на уровне строки таблицы.
|
||||
if row.ActionsCount != 7 {
|
||||
t.Fatalf("row.ActionsCount = %d, want 7", row.ActionsCount)
|
||||
}
|
||||
|
||||
@@ -44,8 +44,6 @@ func (s *server) DownloadScenarioArchive(ctx context.Context, req *proto.Downloa
|
||||
}
|
||||
}
|
||||
|
||||
// Имя файла транслитерируется (заголовки HTTP — только ASCII) и
|
||||
// санитайзится от header injection; заголовок пробрасывается gateway.
|
||||
transliterated := sanitizeFilename(string_tools.Transliterate(name))
|
||||
if transliterated == "" {
|
||||
transliterated = "scenario"
|
||||
@@ -88,7 +86,6 @@ func (s *server) UploadScenarioArchive(ctx context.Context, req *httpbody.HttpBo
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sanitizeFilename — защита от инъекции заголовков в Content-Disposition.
|
||||
func sanitizeFilename(name string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
`"`, "_",
|
||||
|
||||
@@ -146,7 +146,7 @@ func (s *server) PublicScenario(ctx context.Context, req *proto.PublicScenarioRe
|
||||
int(req.ID),
|
||||
"public",
|
||||
claims.UserID,
|
||||
roles.HasRole(claims, roles.Admin), // публикация = момент отчуждения прав
|
||||
roles.HasRole(claims, roles.Admin),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, scenarioErrorStatus(err, "не удалось опубликовать сценарий")
|
||||
@@ -160,9 +160,6 @@ func (s *server) DraftScenario(ctx context.Context, req *proto.DraftScenarioReq)
|
||||
if claimsErr != nil {
|
||||
return nil, claimsErr
|
||||
}
|
||||
// Снять с публикации опубликованный сценарий может только админ:
|
||||
// исключительное право отчуждено Оператору в момент публикации
|
||||
// (п. 7.2 Пользовательского соглашения, ст. 1234, 1269 ГК РФ).
|
||||
if !roles.HasRole(claims, roles.Admin) {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
@@ -190,8 +187,6 @@ func (s *server) DeleteScenario(ctx context.Context, req *proto.DeleteScenarioRe
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
// Автор не может удалить опубликованный сценарий (исключительное право
|
||||
// отчуждено); админ может.
|
||||
err := s.scenarioService.DeleteScenarioByID(
|
||||
ctx,
|
||||
int(req.ID),
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TeamRegistrationHandler принимает вебхук Яндекс Форм отдельно от пользовательского JWT.
|
||||
func TeamRegistrationHandler(token string, register func(context.Context, int, string, team_service.Registration) (int, error)) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -7,9 +7,6 @@ import (
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
// mapUser преобразует пользователя в proto-структуру. Возвращает nil для
|
||||
// обезличенного автора (аккаунт удалён, контент сохранён) — клиент отображает
|
||||
// такого автора как «Автор удалён».
|
||||
func mapUser(o *users_service.User) *proto.User {
|
||||
if o == nil {
|
||||
return nil
|
||||
|
||||
+1
-19
@@ -38,9 +38,6 @@ func (s *server) DeleteAccount(ctx context.Context, _ *proto.DeleteAccountReq) (
|
||||
if !ok {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "claims is not provided")
|
||||
}
|
||||
// Пароль приходит HTTP-заголовком X-Password (пробрасывается матчером
|
||||
// в main.go), чтобы креденшел не попадал в URL и логи. Для прямых
|
||||
// gRPC-клиентов заголовок передаётся в metadata под тем же ключом.
|
||||
password := ""
|
||||
if md, ok := metadata.FromIncomingContext(ctx); ok {
|
||||
if values := md.Get("x-password"); len(values) > 0 {
|
||||
@@ -54,28 +51,15 @@ func (s *server) DeleteAccount(ctx context.Context, _ *proto.DeleteAccountReq) (
|
||||
return &proto.DeleteAccountRsp{}, nil
|
||||
}
|
||||
|
||||
// clientInfoFromContext извлекает IP-адрес и user-agent из gRPC-метаданных.
|
||||
//
|
||||
// grpc-gateway пробрасывает HTTP-заголовки в метаданные так
|
||||
// (runtime.annotateContext / runtime.DefaultHeaderMatcher):
|
||||
// - x-forwarded-for — всегда, при этом gateway дописывает реальный RemoteAddr
|
||||
// клиента последним элементом списка;
|
||||
// - постоянные HTTP-заголовки (включая User-Agent) — с префиксом
|
||||
// grpcgateway-;
|
||||
// - остальные заголовки по умолчанию не пробрасываются: X-Real-IP и
|
||||
// X-Password включаются явным матчером в main.go.
|
||||
//
|
||||
// Полученный IP валидируется netip.ParseAddr: при прямой экспозиции gateway
|
||||
// клиент может подделать эти заголовки, поэтому в БД попадает только
|
||||
// корректный адрес.
|
||||
// grpcgateway-;
|
||||
func clientInfoFromContext(ctx context.Context) (ip string, userAgent string) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// X-Real-IP от доверенного прокси — приоритетен: в этом случае последним
|
||||
// элементом X-Forwarded-For является адрес прокси, а не клиента.
|
||||
if values := md.Get("x-real-ip"); len(values) > 0 {
|
||||
if v := strings.TrimSpace(values[0]); v != "" {
|
||||
if _, err := netip.ParseAddr(v); err == nil {
|
||||
@@ -86,8 +70,6 @@ func clientInfoFromContext(ctx context.Context) (ip string, userAgent string) {
|
||||
if ip == "" {
|
||||
if values := md.Get("x-forwarded-for"); len(values) > 0 {
|
||||
parts := strings.Split(values[0], ",")
|
||||
// Идём с конца списка: последний элемент — реальный RemoteAddr
|
||||
// клиента (первые элементы мог подделать сам клиент).
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
v := strings.TrimSpace(parts[i])
|
||||
if v == "" {
|
||||
|
||||
@@ -36,7 +36,6 @@ func NewSender(
|
||||
replyTo string,
|
||||
timeout time.Duration,
|
||||
) IEmailSender {
|
||||
// From заголовка письма по умолчанию совпадает с учётной записью SMTP.
|
||||
from := smtpUser
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
@@ -50,18 +49,11 @@ func NewSender(
|
||||
fromName: sanitizeHeader(fromName),
|
||||
replyTo: sanitizeHeader(replyTo),
|
||||
timeout: timeout,
|
||||
// Проверка имени сервера включена всегда; поле переопределяется
|
||||
// только в тестах (свой RootCAs для самоподписанного сертификата).
|
||||
tlsConfig: &tls.Config{ServerName: smtpHost},
|
||||
tlsConfig: &tls.Config{ServerName: smtpHost},
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -81,8 +73,6 @@ func (s *sender) Send(ctx context.Context, message Message) error {
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -92,8 +82,6 @@ func (s *sender) Send(ctx context.Context, message Message) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("email: smtp greeting: %w", err)
|
||||
}
|
||||
// Close гарантированно закрывает соединение при раннем выходе; в конце
|
||||
// штатного пути вместо него вызывается Quit.
|
||||
defer client.Close() //nolint:errcheck
|
||||
|
||||
auth := smtp.PlainAuth("", s.smtpUser, s.smtpPassword, s.smtpHost)
|
||||
@@ -123,12 +111,6 @@ func (s *sender) Send(ctx context.Context, message Message) error {
|
||||
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)
|
||||
@@ -190,7 +172,6 @@ func buildMessage(from, fromName, to, subject, replyTo, body, html string) []byt
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// messageID генерирует уникальный Message-ID в домене отправителя.
|
||||
func messageID(from string) string {
|
||||
domain := ""
|
||||
if i := strings.LastIndex(from, "@"); i >= 0 && i < len(from)-1 {
|
||||
@@ -202,8 +183,6 @@ func messageID(from string) string {
|
||||
return "<" + randomHex(12) + "@" + domain + ">"
|
||||
}
|
||||
|
||||
// randomHex возвращает случайную hex-строку (криптостойкий генератор;
|
||||
// при сбое — значение из таймера, чтобы отправка не падала).
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
@@ -212,15 +191,11 @@ func randomHex(n int) string {
|
||||
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)
|
||||
|
||||
@@ -16,11 +16,6 @@ import (
|
||||
"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()
|
||||
|
||||
@@ -41,7 +36,6 @@ func startSMTPTestServer(t *testing.T, hang bool, cert tls.Certificate) (addr st
|
||||
}
|
||||
if hang {
|
||||
go func() {
|
||||
// Приветствие отправляем, дальше молчим.
|
||||
_, _ = conn.Write([]byte("220 hang.example.com ESMTP\r\n"))
|
||||
}()
|
||||
continue
|
||||
@@ -53,7 +47,6 @@ func startSMTPTestServer(t *testing.T, hang bool, cert tls.Certificate) (addr st
|
||||
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 func() { _ = conn.Close() }()
|
||||
@@ -102,9 +95,6 @@ func handleSMTPConn(conn net.Conn, messages chan<- []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
// testTLSCert генерирует самоподписанный сертификат для 127.0.0.1 и
|
||||
// возвращает его вместе с распарсенным x509-представлением (Leaf), чтобы
|
||||
// тест мог добавить его в корни доверия клиента.
|
||||
func testTLSCert(t *testing.T) tls.Certificate {
|
||||
t.Helper()
|
||||
|
||||
@@ -133,8 +123,6 @@ func testTLSCert(t *testing.T) tls.Certificate {
|
||||
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)
|
||||
@@ -183,7 +171,6 @@ func TestSendSuccess(t *testing.T) {
|
||||
case data := <-messages:
|
||||
raw := string(data)
|
||||
for _, want := range []string{
|
||||
// From с отображаемым именем (RFC 2047).
|
||||
"From: =?utf-8?b?",
|
||||
"<sender@example.com>",
|
||||
"To: user@example.com",
|
||||
@@ -213,8 +200,6 @@ func TestSendRejectsCRLFInjection(t *testing.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",
|
||||
@@ -250,7 +235,7 @@ func TestSendContextCancelled(t *testing.T) {
|
||||
|
||||
func TestSendTimeout(t *testing.T) {
|
||||
cert := testTLSCert(t)
|
||||
addr, _ := startSMTPTestServer(t, true, cert) // сервер молчит после приветствия
|
||||
addr, _ := startSMTPTestServer(t, true, cert)
|
||||
s := testSender(t, addr, 500*time.Millisecond, cert)
|
||||
|
||||
start := time.Now()
|
||||
@@ -264,7 +249,6 @@ func TestSendTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildMessage(t *testing.T) {
|
||||
// С HTML-версией — multipart/alternative.
|
||||
raw := string(buildMessage(
|
||||
"sender@example.com", "Вечерний детектив",
|
||||
"user@example.com", "Привет", "support@example.com",
|
||||
@@ -290,7 +274,6 @@ func TestBuildMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Без HTML-версии — только text/plain (обратная совместимость).
|
||||
raw = string(buildMessage(
|
||||
"sender@example.com", "Вечерний детектив",
|
||||
"user@example.com", "Привет", "",
|
||||
|
||||
@@ -10,7 +10,6 @@ func ButtonHTML(link, label string) string {
|
||||
</td></tr></table>`
|
||||
}
|
||||
|
||||
// WrapHTML возвращает общую светлую/тёмную HTML-обёртку для писем сервиса.
|
||||
func WrapHTML(content, baseURL string) string {
|
||||
fontURL := ""
|
||||
if baseURL != "" {
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrFileNotFound — файл не найден в хранилище. Возвращается Get для
|
||||
// отсутствующих объектов (маппинг smithy-ошибок NoSuchKey/NotFound).
|
||||
var ErrFileNotFound = errors.New("файл не найден")
|
||||
|
||||
// File describes a stored file.
|
||||
@@ -28,10 +26,6 @@ type IFileStorage interface {
|
||||
MimeType(filename string) string
|
||||
}
|
||||
|
||||
// FileType возвращает категорию файла по расширению имени: pdf → "pdf",
|
||||
// jpg/jpeg/png/gif/webp → "image", mp3/ogg/wav → "audio", mp4/webm → "video", иначе "".
|
||||
// Регистр расширения и query-параметры игнорируются.
|
||||
// Используется для деривации file_type улик (см. storytelling.Application).
|
||||
func FileType(filename string) string {
|
||||
ext := strings.ToLower(extension(filename))
|
||||
switch ext {
|
||||
@@ -48,9 +42,6 @@ func FileType(filename string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// NewName возвращает случайное имя файла в хранилище (hex-токен 16 байт
|
||||
// без расширения): исключает коллизии в общем бакете. Расширение добавляет
|
||||
// вызывающий код.
|
||||
func NewName() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
|
||||
@@ -59,8 +59,6 @@ func (s *storage) Put(ctx context.Context, file *File) error {
|
||||
func (s *storage) Get(ctx context.Context, filename string) (*File, error) {
|
||||
data, err := s.client.GetObjectBytes(ctx, s.bucket, filename)
|
||||
if err != nil {
|
||||
// s3kit отдаёт сырые smithy-ошибки: маппим отсутствие объекта
|
||||
// (NoSuchKey/NotFound) в sentinel для прикладного слоя.
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
switch apiErr.ErrorCode() {
|
||||
@@ -88,8 +86,6 @@ func (s *storage) MimeType(filename string) string {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// mimeFor — Content-Type для Put: явный Mime файла приоритетнее вывода из
|
||||
// расширения, чтобы объект в S3 соответствовал фактическому содержимому.
|
||||
func mimeFor(f *File) string {
|
||||
if f.Mime != "" {
|
||||
return f.Mime
|
||||
@@ -97,10 +93,6 @@ func mimeFor(f *File) string {
|
||||
return mimeFromData(f.Data, f.Name)
|
||||
}
|
||||
|
||||
// mimeFromData — Content-Type по содержимому (первые 512 байт) с запасным вариантом
|
||||
// на расширение. text/* (например, HTML) трактуется как octet-stream и
|
||||
// уходит в запасной вариант: при публичном скачивании не должно отдаваться как
|
||||
// html с доверенного домена.
|
||||
func mimeFromData(data []byte, filename string) string {
|
||||
detected := http.DetectContentType(data)
|
||||
if strings.HasPrefix(detected, "text/") || detected == "application/octet-stream" {
|
||||
@@ -112,8 +104,6 @@ func mimeFromData(data []byte, filename string) string {
|
||||
return detected
|
||||
}
|
||||
|
||||
// extension — расширение имени файла в нижнем регистре, без query/fragment
|
||||
// (например, "photo.PNG?v=2" → ".png").
|
||||
func extension(filename string) string {
|
||||
if i := strings.IndexAny(filename, "?#"); i >= 0 {
|
||||
filename = filename[:i]
|
||||
|
||||
@@ -48,7 +48,6 @@ func TestNewName(t *testing.T) {
|
||||
if a == b {
|
||||
t.Errorf("NewName вернул одинаковые имена %q и %q", a, b)
|
||||
}
|
||||
// hex-токен 16 байт = 32 символа
|
||||
if len(a) != 32 {
|
||||
t.Errorf("длина имени = %d, want 32", len(a))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package network_utils
|
||||
|
||||
import "net"
|
||||
|
||||
// GetLocalIPv4Addresses возвращает IPv4-адреса хоста, кроме loopback-адресов.
|
||||
func GetLocalIPv4Addresses() ([]string, error) {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
|
||||
@@ -10,7 +10,6 @@ func ContextWithClaims(ctx context.Context, claims *JWTClaims) context.Context {
|
||||
return context.WithValue(ctx, claimsContextKey{}, claims)
|
||||
}
|
||||
|
||||
// ClaimsFromContext возвращает данные аутентификации, сохранённые в ctx.
|
||||
func ClaimsFromContext(ctx context.Context) (*JWTClaims, bool) {
|
||||
claims, ok := ctx.Value(claimsContextKey{}).(*JWTClaims)
|
||||
return claims, ok && claims != nil
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Package roles defines application roles and role checks.
|
||||
package roles
|
||||
|
||||
// Роли пользователей системы.
|
||||
const (
|
||||
Admin = "admin"
|
||||
Author = "author"
|
||||
@@ -9,7 +8,6 @@ const (
|
||||
User = "user"
|
||||
)
|
||||
|
||||
// AllRoles — список всех ролей.
|
||||
var AllRoles = []string{
|
||||
Admin,
|
||||
Author,
|
||||
@@ -17,7 +15,6 @@ var AllRoles = []string{
|
||||
User,
|
||||
}
|
||||
|
||||
// IHasRole — контракт субъекта, у которого можно проверить наличие роли.
|
||||
type IHasRole interface {
|
||||
HasRole(role string) bool
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package roles
|
||||
|
||||
// HasRole возвращает true, если у субъекта есть роль; администратор
|
||||
// проходит проверку любой роли.
|
||||
func HasRole(claims IHasRole, role string) bool {
|
||||
if claims.HasRole(Admin) {
|
||||
return true
|
||||
|
||||
@@ -2,7 +2,6 @@ package roles
|
||||
|
||||
import "slices"
|
||||
|
||||
// subject — реализация IHasRole: субъект с фиксированным набором ролей.
|
||||
type subject struct {
|
||||
roles []string
|
||||
}
|
||||
@@ -12,7 +11,6 @@ func NewSubject(roles ...string) IHasRole {
|
||||
return &subject{roles: roles}
|
||||
}
|
||||
|
||||
// HasRole возвращает true, если роль есть в наборе ролей субъекта.
|
||||
func (s *subject) HasRole(role string) bool {
|
||||
return slices.Contains(s.roles, role)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// Package scenario_archive собирает и разбирает ZIP-архивы сценариев.
|
||||
// Архив: scenario.json (описание и история) + images/<имя> (файлы изображений).
|
||||
// Все пути внутри архива относительные.
|
||||
package scenario_archive
|
||||
|
||||
import (
|
||||
@@ -19,32 +16,24 @@ import (
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
)
|
||||
|
||||
// Лимиты на входящие архивы (защита от zip-bomb). Суммарный объём считается
|
||||
// по фактически распакованным байтам — размеры в заголовках ZIP подделываются.
|
||||
var (
|
||||
maxArchiveSize = 32 << 20 // 32 МБ — сырой размер архива
|
||||
maxUnpackedSize = 128 << 20 // 128 МБ — суммарный объём распакованного
|
||||
maxEntries = 500 // число записей
|
||||
maxEntrySize = 32 << 20 // 32 МБ — размер одной записи
|
||||
maxPackSize = 256 << 20 // 256 МБ — суммарный объём изображений при сборке
|
||||
maxArchiveSize = 32 << 20
|
||||
maxUnpackedSize = 128 << 20
|
||||
maxEntries = 500
|
||||
maxEntrySize = 32 << 20
|
||||
maxPackSize = 256 << 20
|
||||
)
|
||||
|
||||
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 == "" {
|
||||
@@ -57,9 +46,6 @@ func ImagePath(ref, domain string) (string, bool) {
|
||||
return "images/" + path.Base(ref), true
|
||||
}
|
||||
|
||||
// Pack собирает ZIP-архив сценария: ссылки на изображения хранилища
|
||||
// переписываются в images/<имя>, файлы запрашиваются через getFile; внешние
|
||||
// URL остаются без изменений. Архив детерминирован (порядок записей и метки).
|
||||
func (a *scenarioArchive) Pack(
|
||||
ctx context.Context,
|
||||
scenario *Scenario,
|
||||
@@ -77,7 +63,6 @@ func (a *scenarioArchive) Pack(
|
||||
}
|
||||
}
|
||||
|
||||
// Имя в хранилище → путь в архиве; конфликт базовых имён — ошибка.
|
||||
archivePathOf := map[string]string{}
|
||||
addRef := func(ref string) error {
|
||||
if ref == "" {
|
||||
@@ -127,7 +112,6 @@ func (a *scenarioArchive) Pack(
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем файлы из хранилища (имя = ссылка без домена), с суммарным лимитом.
|
||||
images := make(map[string][]byte, len(archivePathOf))
|
||||
var totalPacked int64
|
||||
for ref, archivePath := range archivePathOf {
|
||||
@@ -197,7 +181,6 @@ func writeZipEntry(zw *zip.Writer, name string, data []byte) error {
|
||||
Name: name,
|
||||
Method: zip.Deflate,
|
||||
}
|
||||
// Нулевое время — детерминизм архива между запусками.
|
||||
header.Modified = time.Time{}
|
||||
w, err := zw.CreateHeader(header)
|
||||
if err != nil {
|
||||
@@ -226,7 +209,6 @@ func strPtrValue(p *string) string {
|
||||
return *p
|
||||
}
|
||||
|
||||
// Unpack разбирает входящий архив с проверкой лимитов и путей записей.
|
||||
func (a *scenarioArchive) Unpack(data []byte) (*Bundle, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("архив пуст")
|
||||
@@ -273,7 +255,6 @@ func (a *scenarioArchive) Unpack(data []byte) (*Bundle, error) {
|
||||
return nil, fmt.Errorf("суммарный размер распакованного архива больше %d байт", maxUnpackedSize)
|
||||
}
|
||||
|
||||
// Дубли записей отвергаем — «последний победил» портит данные.
|
||||
if name == FileName {
|
||||
if scenarioRaw != nil {
|
||||
return nil, fmt.Errorf("в архиве несколько записей %s", FileName)
|
||||
@@ -309,8 +290,6 @@ func (a *scenarioArchive) Unpack(data []byte) (*Bundle, error) {
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
// readEntry читает запись с лимитом по фактическим байтам (заголовки ZIP
|
||||
// подделываются тривиально).
|
||||
func readEntry(r io.Reader) ([]byte, error) {
|
||||
lr := io.LimitReader(r, int64(maxEntrySize)+1)
|
||||
content, err := io.ReadAll(lr)
|
||||
@@ -323,7 +302,6 @@ func readEntry(r io.Reader) ([]byte, error) {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// validateEntryPath отклоняет небезопасные пути записей.
|
||||
func validateEntryPath(name string) error {
|
||||
if name == "" {
|
||||
return errors.New("пустое имя записи в архиве")
|
||||
|
||||
@@ -90,7 +90,6 @@ func TestPackUnpackRoundTrip(t *testing.T) {
|
||||
t.Errorf("в архиве нет файла %q", want)
|
||||
}
|
||||
}
|
||||
// Дублирующаяся ссылка (club.png в двух точках) кладётся в архив один раз.
|
||||
if len(bundle.Files) != 3 {
|
||||
t.Errorf("Files = %v, want 3 files", bundle.Files)
|
||||
}
|
||||
@@ -176,7 +175,6 @@ func TestPackExternalURLsKeptAndNotFetched(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPackLegacyFullURLImage(t *testing.T) {
|
||||
// Устаревшие данные: полный URL хранилища срезается до базового имени файла.
|
||||
description := "desc"
|
||||
scenario := &Scenario{
|
||||
Name: "Legacy",
|
||||
@@ -438,7 +436,6 @@ func TestUnpackRejectsDuplicateFiles(t *testing.T) {
|
||||
name = "images/x.png"
|
||||
content = []byte("x")
|
||||
case 2:
|
||||
// Дубль записи images/x.png.
|
||||
name = "images/x.png"
|
||||
content = []byte("y")
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ const FileName = "scenario.json"
|
||||
// Version is the current scenario.json format version.
|
||||
const Version = 1
|
||||
|
||||
// ScenarioJSON — описание сценария в архиве; Story — в формате колонки
|
||||
// scenarios.scenario в БД.
|
||||
type ScenarioJSON struct {
|
||||
Version int `json:"version"`
|
||||
Name string `json:"name"`
|
||||
@@ -22,7 +20,6 @@ type ScenarioJSON struct {
|
||||
Story *storytelling.Story `json:"story"`
|
||||
}
|
||||
|
||||
// Scenario — данные сценария, необходимые для сборки архива.
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Description *string
|
||||
@@ -36,12 +33,7 @@ type Bundle struct {
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
// IScenarioArchive — контракт модуля scenario_archive: сборка и разбор
|
||||
// ZIP-архива сценария.
|
||||
type IScenarioArchive interface {
|
||||
// Pack собирает ZIP-архив сценария: ссылки на изображения хранилища
|
||||
// переписываются в images/<имя>, файлы запрашиваются через getFile;
|
||||
// внешние URL остаются без изменений.
|
||||
Pack(
|
||||
ctx context.Context,
|
||||
scenario *Scenario,
|
||||
@@ -49,6 +41,5 @@ type IScenarioArchive interface {
|
||||
getFile func(ctx context.Context, name string) ([]byte, error),
|
||||
) ([]byte, error)
|
||||
|
||||
// Unpack разбирает входящий архив с проверкой лимитов и путей записей.
|
||||
Unpack(data []byte) (*Bundle, error)
|
||||
}
|
||||
|
||||
@@ -22,17 +22,12 @@ func (s *story) GetStory(
|
||||
codesWithUseActions := map[string]struct{}{}
|
||||
codesHidden := map[string]struct{}{}
|
||||
givenApplications := map[string]struct{}{}
|
||||
// Введение передаётся игроку всегда — и до первого хода (начало игры),
|
||||
// и в дальнейшем (как заголовок истории).
|
||||
if scenario.Introduction != nil {
|
||||
story.Introduction = &Introduction{
|
||||
Text: s.cleaner.ClearText(scenario.Introduction.Text),
|
||||
Audio: scenario.Introduction.Audio,
|
||||
}
|
||||
}
|
||||
// Вопросы дела передаются команде всегда — с начала игры; правильные
|
||||
// ответы (Answer) команде не передаются: они нужны только автору и
|
||||
// организаторам при оценке ответов.
|
||||
for _, question := range scenario.Questions {
|
||||
story.Questions = append(story.Questions, &Question{
|
||||
Code: question.Code,
|
||||
@@ -49,7 +44,6 @@ func (s *story) GetStory(
|
||||
if i < len(codes)-1 {
|
||||
nextPlace, _ = s.findPlace(scenario, codes[i+1])
|
||||
}
|
||||
// Если следующая точка это открытие двери в текущей точке, то текущая точка считается использованной для действия
|
||||
if nextPlace != nil && currentPlace != nil {
|
||||
openDoor := false
|
||||
for _, door := range currentPlace.Doors {
|
||||
@@ -60,7 +54,6 @@ func (s *story) GetStory(
|
||||
}
|
||||
}
|
||||
if openDoor {
|
||||
// Запрещаем смотреть не выбранные действия
|
||||
for _, door := range currentPlace.Doors {
|
||||
if door.Code != nextPlace.Code {
|
||||
codesHidden[door.Code] = struct{}{}
|
||||
|
||||
@@ -45,8 +45,6 @@ var (
|
||||
)
|
||||
)
|
||||
|
||||
// Transliterate выполняет транслитерацию русского текста в латиницу,
|
||||
// заменяет пробелы на _, удаляет все остальные символы, кроме цифр (знаки препинания и т.д.)
|
||||
func Transliterate(text string) string {
|
||||
|
||||
result := replacer.Replace(strings.ToLower(text))
|
||||
|
||||
@@ -61,8 +61,6 @@ func Generate(cards []Card) ([]byte, error) {
|
||||
}
|
||||
x := margin + float64(i%3)*width
|
||||
y := margin + float64(i%12/3)*height
|
||||
// Растровый режим включает свободное поле QR; векторные квадраты сохраняют
|
||||
// чёткость при печати без растрового масштабирования.
|
||||
bitmap := qr.Bitmap()
|
||||
pixel := qrSize / float64(len(bitmap))
|
||||
for row, cells := range bitmap {
|
||||
@@ -73,8 +71,6 @@ func Generate(cards []Card) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
name := strings.Join(strings.Fields(card.Name), " ")
|
||||
// ponytail: fpdf поддерживает BMP; для символов вне BMP (например,
|
||||
// emoji) нужен другой движок шрифтов. Возвращаем ошибку вместо паники.
|
||||
for _, r := range name {
|
||||
if r > 0xffff {
|
||||
return nil, fmt.Errorf("название команды %d содержит неподдерживаемый символ %q", i+1, r)
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
// Package repos contains database repositories and persistence models.
|
||||
package repos
|
||||
|
||||
// TeamAnswer — ответ команды на вопрос дела (вопросы живут в сценарии,
|
||||
// ответы — в таблице team_answers).
|
||||
type TeamAnswer struct {
|
||||
TeamID int
|
||||
QuestionCode string
|
||||
Text string
|
||||
// Score — балл, выставленный организатором; nil — ещё не оценено.
|
||||
Score *int
|
||||
Score *int
|
||||
}
|
||||
|
||||
@@ -8,10 +8,8 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrItemNotFound означает, что объект карты не найден.
|
||||
var ErrItemNotFound = errors.New("объект карты не найден")
|
||||
|
||||
// Item — объект на карте игры.
|
||||
type Item struct {
|
||||
GameID int
|
||||
Kind string
|
||||
@@ -21,7 +19,6 @@ type Item struct {
|
||||
Hidden bool
|
||||
}
|
||||
|
||||
// Repo работает с объектами карт в PostgreSQL.
|
||||
type Repo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
@@ -31,7 +28,6 @@ func NewGameMapRepo(pool *pgxpool.Pool) *Repo {
|
||||
return &Repo{pool: pool}
|
||||
}
|
||||
|
||||
// Ensure добавляет объект карты, если он ещё не существует.
|
||||
func (r *Repo) Ensure(ctx context.Context, gameID int, kind string, itemID int) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
INSERT INTO game_map_items (game_id, kind, item_id)
|
||||
@@ -44,7 +40,6 @@ func (r *Repo) Ensure(ctx context.Context, gameID int, kind string, itemID int)
|
||||
return err
|
||||
}
|
||||
|
||||
// List возвращает объекты карты игры.
|
||||
func (r *Repo) List(ctx context.Context, gameID int) ([]Item, error) {
|
||||
rows, err := r.pool.Query(
|
||||
ctx,
|
||||
@@ -75,7 +70,6 @@ func (r *Repo) List(ctx context.Context, gameID int) ([]Item, error) {
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// Update изменяет координаты и видимость объекта карты.
|
||||
func (r *Repo) Update(ctx context.Context, item Item) error {
|
||||
result, err := r.pool.Exec(
|
||||
ctx,
|
||||
@@ -103,7 +97,6 @@ func (r *Repo) Update(ctx context.Context, item Item) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete удаляет объект карты.
|
||||
func (r *Repo) Delete(ctx context.Context, item Item) error {
|
||||
_, err := r.pool.Exec(
|
||||
ctx,
|
||||
|
||||
@@ -224,7 +224,6 @@ func (r *ScenariosRepo) GetScenarioByID(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Автор может отсутствовать (обезличен после удаления аккаунта).
|
||||
if authorID != nil && authorUsername != nil {
|
||||
scenario.Author = &repos.User{ID: *authorID, Username: *authorUsername}
|
||||
}
|
||||
@@ -232,7 +231,6 @@ func (r *ScenariosRepo) GetScenarioByID(
|
||||
return scenario, nil
|
||||
}
|
||||
|
||||
// UpdateScenarioByID меняет основные поля сценария.
|
||||
func (r *ScenariosRepo) UpdateScenarioByID(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
@@ -263,12 +261,6 @@ func (r *ScenariosRepo) UpdateScenarioByID(
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateScenarioStatusByID меняет статус сценария. allowPublished=true
|
||||
// разрешает менять статус опубликованного сценария (только для админа).
|
||||
// Возвращает false, если строка не обновлена (например, не-админ пытается
|
||||
// снять с публикации опубликованный сценарий). published_at фиксируется
|
||||
// только при ПЕРВОЙ публикации: повторная публикация не двигает «момент
|
||||
// отчуждения» исключительного права (п. 7.2 Пользовательского соглашения).
|
||||
func (r *ScenariosRepo) UpdateScenarioStatusByID(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
@@ -296,10 +288,6 @@ func (r *ScenariosRepo) UpdateScenarioStatusByID(
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
|
||||
// DeleteScenarioByID помечает сценарий удалённым (soft delete).
|
||||
// allowPublished=true разрешает удаление опубликованного сценария
|
||||
// (только для админа). Возвращает false, если строка не обновлена
|
||||
// (например, не-админ пытается удалить опубликованный сценарий).
|
||||
func (r *ScenariosRepo) DeleteScenarioByID(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
@@ -347,7 +335,6 @@ func (r *ScenariosRepo) GetStoryByScenarioID(
|
||||
return story, nil
|
||||
}
|
||||
|
||||
// UpdateStoryByScenarioID обновляет содержимое (историю) сценария.
|
||||
func (r *ScenariosRepo) UpdateStoryByScenarioID(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
|
||||
@@ -20,8 +20,6 @@ func NewTeamAnswersRepo(pool *pgxpool.Pool) *TeamAnswersRepo {
|
||||
}
|
||||
}
|
||||
|
||||
// UpsertAnswers сохраняет ответы команды: существующие перезаписываются,
|
||||
// отсутствующие — вставляются (UNIQUE (team_id, question_code)).
|
||||
func (r *TeamAnswersRepo) UpsertAnswers(
|
||||
ctx context.Context,
|
||||
teamID int,
|
||||
@@ -90,7 +88,6 @@ func (r *TeamAnswersRepo) GetAnswersByTeamID(
|
||||
return answers, nil
|
||||
}
|
||||
|
||||
// GetAnswersByGameID возвращает ответы всех команд игры (вместе с баллами).
|
||||
func (r *TeamAnswersRepo) GetAnswersByGameID(
|
||||
ctx context.Context,
|
||||
gameID int,
|
||||
@@ -134,10 +131,6 @@ func (r *TeamAnswersRepo) GetAnswersByGameID(
|
||||
return answers, nil
|
||||
}
|
||||
|
||||
// SetAnswerScore выставляет балл за ответ; score == nil сбрасывает балл.
|
||||
// Балл можно выставить и за неотвеченный вопрос (команда может не дать
|
||||
// ответ): если строки (team_id, question_code) ещё нет, она создаётся с
|
||||
// пустым ответом и выставленным баллом.
|
||||
func (r *TeamAnswersRepo) SetAnswerScore(
|
||||
ctx context.Context,
|
||||
teamID int,
|
||||
|
||||
@@ -86,7 +86,6 @@ func (r *TeamsRepo) AnswerParticipation(ctx context.Context, token, answer strin
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Условие проверяется повторно после снятия блокировки строки конкурентным ответом.
|
||||
_, err = r.pool.Exec(ctx, `WITH eligible_game AS (SELECT g.id FROM games g JOIN teams t ON t.game_id=g.id
|
||||
WHERE t.participation_token_hash=$1 AND g.status='draft' AND g.start_at > (clock_timestamp() AT TIME ZONE 'UTC') FOR SHARE OF g)
|
||||
UPDATE teams t SET participation_status=$2,
|
||||
|
||||
@@ -191,7 +191,6 @@ func (r *TeamsRepo) DeleteTeam(ctx context.Context, teamID int) error {
|
||||
// ErrRegistrationClosed indicates that team registration is unavailable.
|
||||
var ErrRegistrationClosed = errors.New("Игра не найдена или регистрация уже закрыта")
|
||||
|
||||
// RegisterTeam сохраняет одну команду на доставку, включая одновременные повторы.
|
||||
func (r *TeamsRepo) RegisterTeam(ctx context.Context, gameID int, deliveryID, name, email string, participantsCount int32, password string) (int, error) {
|
||||
return r.RegisterTeamWithParticipation(ctx, gameID, deliveryID, name, email, participantsCount, password, false)
|
||||
}
|
||||
@@ -199,7 +198,6 @@ func (r *TeamsRepo) RegisterTeam(ctx context.Context, gameID int, deliveryID, na
|
||||
// RegisterTeamWithParticipation registers a team and its participation data.
|
||||
func (r *TeamsRepo) RegisterTeamWithParticipation(ctx context.Context, gameID int, deliveryID, name, email string, participantsCount int32, password string, autoConfirm bool) (int, error) {
|
||||
var id int
|
||||
// Повтор уже принятой доставки успешен и после старта игры.
|
||||
err := r.pool.QueryRow(ctx, `SELECT id FROM teams WHERE game_id = $1 AND registration_delivery_id = $2`, gameID, deliveryID).Scan(&id)
|
||||
if err == nil {
|
||||
return id, nil
|
||||
@@ -223,7 +221,6 @@ func (r *TeamsRepo) RegisterTeamWithParticipation(ctx context.Context, gameID in
|
||||
return id, err
|
||||
}
|
||||
|
||||
// GetActiveTeamsByGameID исключает отменённые регистрации из игровых выгрузок и результатов.
|
||||
func (r *TeamsRepo) GetActiveTeamsByGameID(ctx context.Context, gameID int) ([]*repos.Team, error) {
|
||||
teams, err := r.GetTeamsByGameID(ctx, gameID)
|
||||
active := teams[:0]
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
// Package user_agreements_repo persists user agreements.
|
||||
package user_agreements_repo
|
||||
|
||||
// AgreementType — типы документов, акцептуемых пользователем.
|
||||
const (
|
||||
AgreementTypeTerms = "terms" // Пользовательское соглашение
|
||||
AgreementTypePrivacy = "privacy" // Политика конфиденциальности
|
||||
AgreementTypeTerms = "terms"
|
||||
AgreementTypePrivacy = "privacy"
|
||||
)
|
||||
|
||||
// Agreement — фиксируемый факт акцепта документа (ст. 9 152-ФЗ — согласие в
|
||||
// форме, позволяющей подтвердить факт его получения). Version должна
|
||||
// соответствовать опубликованной редакции документа; при изменении текста
|
||||
// документа версия обязана увеличиваться.
|
||||
type Agreement struct {
|
||||
AgreementType string
|
||||
Version string
|
||||
|
||||
@@ -30,10 +30,6 @@ func NewUserRepo(pool *pgxpool.Pool) *UsersRepo {
|
||||
}
|
||||
}
|
||||
|
||||
// AddUserWithAgreements создаёт пользователя и фиксирует акцепты документов
|
||||
// (user_agreements) в одной транзакции: пользователь либо существует вместе с
|
||||
// записями о согласиях, либо не существует вовсе (доказуемое согласие,
|
||||
// ст. 9 152-ФЗ). Возвращает id созданного пользователя.
|
||||
func (s *UsersRepo) AddUserWithAgreements(
|
||||
ctx context.Context,
|
||||
username string,
|
||||
@@ -42,8 +38,6 @@ func (s *UsersRepo) AddUserWithAgreements(
|
||||
roles []string,
|
||||
agreements []user_agreements_repo.Agreement,
|
||||
) (int, error) {
|
||||
// Инвариант «пользователь не существует без записей о согласиях»
|
||||
// держится на том, что акцепты передаются всегда.
|
||||
if len(agreements) == 0 {
|
||||
return 0, errors.New("Не переданы записи об акцепте соглашений")
|
||||
}
|
||||
@@ -73,15 +67,11 @@ func (s *UsersRepo) AddUserWithAgreements(
|
||||
email,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
// Ничего не вставлено — занят username или email.
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, errors.New("Username или email уже используется")
|
||||
}
|
||||
// Гонка: между проверкой NOT EXISTS и INSERT другой запрос успел
|
||||
// создать пользователя с тем же username/email — нарушение
|
||||
// уникальности. Возвращаем тот же понятный клиенту ответ.
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" { // нарушение уникальности
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return 0, errors.New("Username или email уже используется")
|
||||
}
|
||||
return 0, err
|
||||
@@ -266,12 +256,6 @@ func (s *UsersRepo) DeleteUserRole(
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteUser удаляет учётную запись и персональные данные пользователя
|
||||
// (ст. 21 152-ФЗ — уничтожение ПДн при удалении аккаунта). Выполняется в одной
|
||||
// транзакции. Публичный контент пользователя (сценарии, команды) сохраняется:
|
||||
// FK-ограничения с ON DELETE SET NULL (миграция
|
||||
// 20260818232725_keep_content_on_user_delete.sql) обнуляют привязку к автору —
|
||||
// контент остаётся в обезличенном виде.
|
||||
func (s *UsersRepo) DeleteUser(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
@@ -282,7 +266,6 @@ func (s *UsersRepo) DeleteUser(
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
|
||||
// Refresh-токены (FK CASCADE, удаляем явно для предсказуемости).
|
||||
if _, err = tx.Exec(
|
||||
ctx,
|
||||
`DELETE FROM refresh_tokens WHERE user_id = $1`,
|
||||
@@ -291,13 +274,6 @@ func (s *UsersRepo) DeleteUser(
|
||||
return err
|
||||
}
|
||||
|
||||
// Зафиксированные акцепты соглашений НЕ удаляются: ст. 9 ч. 4 152-ФЗ
|
||||
// возлагает на оператора бремя доказывания получения согласия, поэтому
|
||||
// факт акцепта (документ, версия, время) сохраняется в обезличенном виде:
|
||||
// ссылка на пользователя и персональные данные внутри записи (ip,
|
||||
// user_agent) обнуляются. FK ON DELETE SET NULL (миграция
|
||||
// 20260818231943_create_user_agreements_table.sql) гарантирует, что при
|
||||
// удалении users строки акцептов остаются с user_id = NULL.
|
||||
if _, err = tx.Exec(
|
||||
ctx,
|
||||
`UPDATE user_agreements
|
||||
@@ -308,8 +284,6 @@ func (s *UsersRepo) DeleteUser(
|
||||
return err
|
||||
}
|
||||
|
||||
// Пользователь. Связанные сценарии (author_id) и команды (creator_id)
|
||||
// обнуляются автоматически через ON DELETE SET NULL.
|
||||
tag, err := tx.Exec(
|
||||
ctx,
|
||||
`DELETE FROM users WHERE id = $1`,
|
||||
|
||||
@@ -6,28 +6,22 @@ import (
|
||||
"evening_detective_server/internal/services/game_model"
|
||||
)
|
||||
|
||||
// Answer — ответ команды на вопрос дела (для публичного API команды).
|
||||
type Answer struct {
|
||||
QuestionCode string
|
||||
Text string
|
||||
}
|
||||
|
||||
// AnswerScore — ответ команды с баллом организатора (для панели оценки).
|
||||
type AnswerScore struct {
|
||||
QuestionCode string
|
||||
Text string
|
||||
// Score — балл; nil — ещё не оценено.
|
||||
Score *int
|
||||
Score *int
|
||||
}
|
||||
|
||||
// TeamAnswers — ответы одной команды игры.
|
||||
type TeamAnswers struct {
|
||||
Team *game_model.Team
|
||||
Answers []AnswerScore
|
||||
}
|
||||
|
||||
// GameAnswers — полная картина для оценки организатором: вопросы сценария
|
||||
// (с правильными ответами) и ответы всех команд с баллами.
|
||||
type GameAnswers struct {
|
||||
Questions []*storytelling.Question
|
||||
Teams []TeamAnswers
|
||||
|
||||
@@ -7,31 +7,18 @@ import (
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
)
|
||||
|
||||
// TeamResult — строка итоговой таблицы: команда, баллы по вопросам (в порядке
|
||||
// сценария), сумма баллов и место.
|
||||
type TeamResult struct {
|
||||
Team *game_model.Team
|
||||
Answers []AnswerScore
|
||||
// TotalScore — сумма баллов по всем вопросам сценария; неоценённые
|
||||
// вопросы считаются как 0.
|
||||
Team *game_model.Team
|
||||
Answers []AnswerScore
|
||||
TotalScore int
|
||||
// Place — место в итоговой таблице. Команды с одинаковой суммой баллов
|
||||
// и одинаковым числом ходов занимают одно место (следующее место
|
||||
// пропускается); проставляется функцией rankTeams.
|
||||
Place int
|
||||
Place int
|
||||
}
|
||||
|
||||
// GameResults — итоговая таблица игры: вопросы сценария (шапка таблицы) и
|
||||
// результаты команд, отсортированные по сумме баллов.
|
||||
type GameResults struct {
|
||||
Questions []*storytelling.Question
|
||||
Teams []TeamResult
|
||||
}
|
||||
|
||||
// buildTeamResults собирает строки итоговой таблицы по командам: баллы в
|
||||
// порядке вопросов сценария (неотвеченный/неоценённый вопрос — с пустым
|
||||
// текстом и Score=nil), сумма баллов (nil считается как 0). Команды в
|
||||
// результате — в порядке переданного списка; сортировку и места делает
|
||||
// rankTeams.
|
||||
func buildTeamResults(
|
||||
questions []*storytelling.Question,
|
||||
@@ -63,12 +50,6 @@ func buildTeamResults(
|
||||
return res
|
||||
}
|
||||
|
||||
// rankTeams сортирует строки итоговой таблицы по сумме баллов (убывание);
|
||||
// при равенстве суммы выше команда с меньшим числом ходов. Команды с
|
||||
// одинаковой суммой баллов И одинаковым числом ходов занимают одно место
|
||||
// (следующее место пропускается — схема 1,2,2,4 по ключу (сумма, ходы)).
|
||||
// id команды влияет только на порядок показа внутри группы (меньший id
|
||||
// первым), не на место. Входной срез не изменяется.
|
||||
func rankTeams(rows []TeamResult) []TeamResult {
|
||||
sorted := make([]TeamResult, len(rows))
|
||||
copy(sorted, rows)
|
||||
|
||||
@@ -17,7 +17,6 @@ func teamResult(id, actions, total int) TeamResult {
|
||||
}
|
||||
|
||||
func TestRankTeamsSortsByTotalThenActions(t *testing.T) {
|
||||
// У всех команд разный ключ (сумма, ходы) — места идут подряд 1..N.
|
||||
rows := []TeamResult{
|
||||
teamResult(3, 5, 10),
|
||||
teamResult(1, 3, 10),
|
||||
@@ -36,14 +35,12 @@ func TestRankTeamsSortsByTotalThenActions(t *testing.T) {
|
||||
t.Fatalf("rankTeams[%d].Place = %d, want %d", i, got[i].Place, wantPlaces[i])
|
||||
}
|
||||
}
|
||||
// Команды 1 и 3 имеют одинаковую сумму (10), но разные ходы — разные места.
|
||||
if got[1].Place == got[2].Place {
|
||||
t.Fatalf("равные суммы при разных ходах должны давать разные места: places = %d, %d", got[1].Place, got[2].Place)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankTeamsSharesPlaceOnEqualTotalAndActions(t *testing.T) {
|
||||
// Одинаковая сумма И одинаковое число ходов — одно место у всех трёх.
|
||||
rows := []TeamResult{
|
||||
teamResult(5, 4, 9),
|
||||
teamResult(2, 4, 9),
|
||||
@@ -51,7 +48,6 @@ func TestRankTeamsSharesPlaceOnEqualTotalAndActions(t *testing.T) {
|
||||
}
|
||||
got := rankTeams(rows)
|
||||
|
||||
// Внутри группы порядок показа — по id asc (детерминированный).
|
||||
wantIDs := []int{2, 5, 7}
|
||||
for i, id := range wantIDs {
|
||||
if got[i].Team.ID != id {
|
||||
@@ -64,7 +60,6 @@ func TestRankTeamsSharesPlaceOnEqualTotalAndActions(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRankTeamsSkipsPlaceAfterSharedTie(t *testing.T) {
|
||||
// Две команды делят 1-е место, следующая получает 3-е (схема 1,1,3).
|
||||
rows := []TeamResult{
|
||||
teamResult(3, 2, 7),
|
||||
teamResult(1, 5, 10),
|
||||
@@ -116,7 +111,7 @@ func TestBuildTeamResultsScenarioOrderAndNilScores(t *testing.T) {
|
||||
"q3": {QuestionCode: "q3", Text: "ответ 3", Score: intPtr(-1)},
|
||||
},
|
||||
2: {
|
||||
"q2": {QuestionCode: "q2", Text: "ответ 2"}, // балл не выставлен
|
||||
"q2": {QuestionCode: "q2", Text: "ответ 2"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -126,7 +121,6 @@ func TestBuildTeamResultsScenarioOrderAndNilScores(t *testing.T) {
|
||||
t.Fatalf("len(buildTeamResults) = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
// Команда 1: q1(5), q2 не отвечен, q3(-1) → сумма 4.
|
||||
first := got[0]
|
||||
if first.Team.ID != 1 || first.TotalScore != 4 {
|
||||
t.Fatalf("команда 1: Team.ID = %d, TotalScore = %d; want ID 1, Total 4", first.Team.ID, first.TotalScore)
|
||||
@@ -143,7 +137,6 @@ func TestBuildTeamResultsScenarioOrderAndNilScores(t *testing.T) {
|
||||
t.Fatalf("команда 1: неотвеченный q2 = %+v, want Score=nil и Text=\"\"", first.Answers[1])
|
||||
}
|
||||
|
||||
// Команда 2: все не оценены → сумма 0.
|
||||
second := got[1]
|
||||
if second.Team.ID != 2 || second.TotalScore != 0 {
|
||||
t.Fatalf("команда 2: Team.ID = %d, TotalScore = %d; want ID 2, Total 0", second.Team.ID, second.TotalScore)
|
||||
|
||||
@@ -40,24 +40,15 @@ func NewAnswerService(
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrTeamNotFound — команда не найдена или пароль неверен.
|
||||
ErrTeamNotFound = errors.New("Команда не найдена")
|
||||
// ErrGameNotFound — игра не найдена.
|
||||
ErrGameNotFound = errors.New("Игра не найдена")
|
||||
// ErrGameFinished — после завершения игры ответы команд больше нельзя
|
||||
// изменять: организатор оценивает их в панели игры.
|
||||
ErrGameFinished = errors.New("Игра завершена, ответы больше нельзя изменять")
|
||||
// ErrGameNotStarted — игра ещё не начата, отвечать на вопросы нельзя.
|
||||
ErrGameNotStarted = errors.New("Игра не начата, отвечать на вопросы нельзя")
|
||||
// ErrQuestionNotFound — вопрос с таким кодом отсутствует в сценарии игры.
|
||||
ErrTeamNotFound = errors.New("Команда не найдена")
|
||||
ErrGameNotFound = errors.New("Игра не найдена")
|
||||
ErrGameFinished = errors.New("Игра завершена, ответы больше нельзя изменять")
|
||||
ErrGameNotStarted = errors.New("Игра не начата, отвечать на вопросы нельзя")
|
||||
ErrQuestionNotFound = errors.New("Вопрос не найден")
|
||||
// ErrGameNotFinished — итоги игры доступны только после её завершения.
|
||||
ErrGameNotFinished = errors.New("Итоги доступны после завершения игры")
|
||||
// ErrOfflineAnswers — ответы для offline-игры недоступны через сайт.
|
||||
ErrOfflineAnswers = errors.New("В offline-игре ответы через сайт недоступны")
|
||||
ErrGameNotFinished = errors.New("Итоги доступны после завершения игры")
|
||||
ErrOfflineAnswers = errors.New("В offline-игре ответы через сайт недоступны")
|
||||
)
|
||||
|
||||
// getTeamAndGame проверяет команду по паролю и возвращает её вместе с игрой.
|
||||
func (s *AnswerService) getTeamAndGame(ctx context.Context, teamID int, password string) (*repos.Team, *repos.Game, error) {
|
||||
team, err := s.teamsRepo.GetTeamByID(ctx, teamID)
|
||||
if err != nil {
|
||||
@@ -79,7 +70,6 @@ func (s *AnswerService) getTeamAndGame(ctx context.Context, teamID int, password
|
||||
return team, game, nil
|
||||
}
|
||||
|
||||
// getScenarioQuestionsByCode возвращает вопросы сценария игры с индексацией по коду.
|
||||
func (s *AnswerService) getScenarioQuestionsByCode(ctx context.Context, scenarioID int) ([]*storytelling.Question, map[string]*storytelling.Question, error) {
|
||||
scenario, err := s.scenarioService.GetFullScenarioByID(ctx, scenarioID)
|
||||
if err != nil {
|
||||
@@ -92,9 +82,6 @@ func (s *AnswerService) getScenarioQuestionsByCode(ctx context.Context, scenario
|
||||
return scenario.Story.Questions, byCode, nil
|
||||
}
|
||||
|
||||
// GetTeamAnswers возвращает ответы команды на вопросы дела (без баллов:
|
||||
// баллы видны только организаторам). Ответы на удалённые из сценария
|
||||
// вопросы не возвращаются.
|
||||
func (s *AnswerService) GetTeamAnswers(ctx context.Context, teamID int, password string) ([]Answer, error) {
|
||||
_, game, err := s.getTeamAndGame(ctx, teamID, password)
|
||||
if err != nil {
|
||||
@@ -124,10 +111,6 @@ func (s *AnswerService) GetTeamAnswers(ctx context.Context, teamID int, password
|
||||
return answers, nil
|
||||
}
|
||||
|
||||
// SubmitTeamAnswers сохраняет ответы команды на вопросы дела (upsert).
|
||||
// Доступно только пока игра идёт (статусы run/pause): после завершения
|
||||
// ответы фиксируются для оценки организатором. Переданные коды должны
|
||||
// существовать в сценарии игры; пустая строка очищает ответ.
|
||||
func (s *AnswerService) SubmitTeamAnswers(ctx context.Context, teamID int, password string, answers []Answer) error {
|
||||
_, game, err := s.getTeamAndGame(ctx, teamID, password)
|
||||
if err != nil {
|
||||
@@ -138,7 +121,6 @@ func (s *AnswerService) SubmitTeamAnswers(ctx context.Context, teamID int, passw
|
||||
}
|
||||
switch game.Status {
|
||||
case "run", "pause":
|
||||
// игра идёт — можно отвечать
|
||||
default:
|
||||
if game.Status == "finish" {
|
||||
return ErrGameFinished
|
||||
@@ -162,12 +144,6 @@ func (s *AnswerService) SubmitTeamAnswers(ctx context.Context, teamID int, passw
|
||||
return s.answersRepo.UpsertAnswers(ctx, teamID, rows)
|
||||
}
|
||||
|
||||
// GetGameAnswers возвращает полную картину для оценки организатором:
|
||||
// вопросы сценария с правильными ответами и ответы всех команд с баллами.
|
||||
// Команда может не дать ответ на вопрос — такой вопрос попадает в список
|
||||
// ответов команды с пустым текстом и без балла (организатор видит его и
|
||||
// может выставить балл, в т.ч. штрафной). Ответы на удалённые из сценария
|
||||
// вопросы не возвращаются (как в GetTeamAnswers).
|
||||
func (s *AnswerService) GetGameAnswers(ctx context.Context, gameID int) (*GameAnswers, error) {
|
||||
game, err := s.gamesRepo.GetGameByID(ctx, gameID)
|
||||
if err != nil {
|
||||
@@ -211,8 +187,6 @@ func (s *AnswerService) GetGameAnswers(ctx context.Context, gameID int) (*GameAn
|
||||
teamAnswers := make([]TeamAnswers, 0, len(teams))
|
||||
for _, team := range teams {
|
||||
byCode := answerByTeamAndCode[team.ID]
|
||||
// Все вопросы сценария в порядке сценария; неотвеченные — с пустым
|
||||
// текстом и без балла, чтобы организатор видел пропуски.
|
||||
answers := make([]AnswerScore, 0, len(questions))
|
||||
for _, question := range questions {
|
||||
if answer, ok := byCode[question.Code]; ok {
|
||||
@@ -234,10 +208,6 @@ func (s *AnswerService) GetGameAnswers(ctx context.Context, gameID int) (*GameAn
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetTeamAnswerScore выставляет балл команде за ответ на вопрос дела;
|
||||
// score == nil сбрасывает балл. Балл может быть любым целым числом
|
||||
// (в т.ч. отрицательным — штрафные баллы). Вопрос должен существовать
|
||||
// в сценарии игры.
|
||||
func (s *AnswerService) SetTeamAnswerScore(ctx context.Context, teamID int, questionCode string, score *int) error {
|
||||
team, err := s.teamsRepo.GetTeamByID(ctx, teamID)
|
||||
if err != nil {
|
||||
@@ -257,11 +227,6 @@ func (s *AnswerService) SetTeamAnswerScore(ctx context.Context, teamID int, ques
|
||||
return s.answersRepo.SetAnswerScore(ctx, teamID, questionCode, score)
|
||||
}
|
||||
|
||||
// GetGameResults возвращает итоговую таблицу игры: вопросы сценария (шапка
|
||||
// таблицы, без правильных ответов) и команды, отсортированные по сумме
|
||||
// баллов, с баллами по каждому вопросу (в порядке сценария), числом ходов и
|
||||
// местом. Доступно только после завершения игры (статус finish); неоценённые
|
||||
// вопросы в сумме считаются как 0.
|
||||
func (s *AnswerService) GetGameResults(ctx context.Context, gameID int) (*GameResults, error) {
|
||||
game, err := s.gamesRepo.GetGameByID(ctx, gameID)
|
||||
if err != nil {
|
||||
|
||||
@@ -39,7 +39,6 @@ func TestAnswerAccessPostgres(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Отклонённые запросы не должны доходить до хранилищ сценариев и ответов.
|
||||
service := NewAnswerService(games_repo.NewGamesRepo(pool), teams_repo.NewTeamsRepo(pool), nil, nil, nil)
|
||||
for _, tc := range []struct {
|
||||
status string
|
||||
|
||||
@@ -13,20 +13,15 @@ import (
|
||||
"evening_detective_server/internal/modules/string_tools"
|
||||
)
|
||||
|
||||
// Ошибки валидации загружаемого файла. Маппятся в gRPC-статусы в app-слое:
|
||||
// ErrFileTooLarge → ResourceExhausted, остальные → InvalidArgument.
|
||||
var (
|
||||
ErrEmptyName = errors.New("пустое имя файла")
|
||||
ErrEmptyData = errors.New("пустые данные файла")
|
||||
ErrInvalidExtension = errors.New("недопустимое расширение файла (разрешены pdf, jpg, jpeg, png, gif, webp, mp3, ogg, wav, mp4, webm)")
|
||||
ErrInvalidContentType = errors.New("содержимое файла не соответствует разрешённым типам (pdf, изображения, аудио или видео)")
|
||||
ErrFileTooLarge = errors.New("файл слишком большой")
|
||||
// ErrFileNotFound — файл отсутствует в хранилище.
|
||||
ErrFileNotFound = errors.New("файл не найден")
|
||||
ErrFileNotFound = errors.New("файл не найден")
|
||||
)
|
||||
|
||||
// allowedExtensions — расширения, разрешённые для загрузки: PDF, изображения
|
||||
// и аудио/видео (введение и закрывашка загружаются тем же эндпоинтом).
|
||||
var allowedExtensions = map[string]struct{}{
|
||||
".pdf": {},
|
||||
".jpg": {},
|
||||
@@ -58,12 +53,6 @@ func NewFileService(
|
||||
}
|
||||
}
|
||||
|
||||
// UploadFile сохраняет файл в хранилище под уникальным именем и возвращает
|
||||
// сохранённое имя и категорию файла (image|pdf|audio|video).
|
||||
//
|
||||
// Порядок проверок: имя/данные → расширение → base → размер → содержимое.
|
||||
// Проверка размера — defense-in-depth: фактический лимит запроса
|
||||
// устанавливается на HTTP-слое (MaxBytesReader) и gRPC-лимитами.
|
||||
func (s *FileService) UploadFile(
|
||||
ctx context.Context,
|
||||
filename string,
|
||||
@@ -86,8 +75,6 @@ func (s *FileService) UploadFile(
|
||||
return "", "", ErrEmptyName
|
||||
}
|
||||
|
||||
// Транслитерация может обнулить base (например, «!!!.pdf»): хранимое
|
||||
// имя без base недопустимо — проверяем до размера и содержимого.
|
||||
transliterated := string_tools.Transliterate(base)
|
||||
if transliterated == "" {
|
||||
return "", "", ErrEmptyName
|
||||
@@ -97,8 +84,6 @@ func (s *FileService) UploadFile(
|
||||
return "", "", fmt.Errorf("%w (лимит %d МБ)", ErrFileTooLarge, s.maxFileSize>>20)
|
||||
}
|
||||
|
||||
// Категория по содержимому (сырой sniff): text/* и octet-stream не
|
||||
// проходят вообще, расширение должно соответствовать категории.
|
||||
sniffed := http.DetectContentType(data)
|
||||
if ext == ".mp3" && isMP3(data) {
|
||||
sniffed = "audio/mpeg"
|
||||
@@ -124,16 +109,12 @@ func (s *FileService) UploadFile(
|
||||
return storedName, fileType, nil
|
||||
}
|
||||
|
||||
// isMP3 распознаёт MP3 без необязательного ID3-тега по заголовку MPEG-фрейма.
|
||||
func isMP3(data []byte) bool {
|
||||
return len(data) >= 4 && data[0] == 0xff && data[1]&0xe0 == 0xe0 &&
|
||||
data[1]&0x18 != 0x08 && data[1]&0x06 != 0 &&
|
||||
data[2]&0xf0 != 0 && data[2]&0xf0 != 0xf0 && data[2]&0x0c != 0x0c
|
||||
}
|
||||
|
||||
// contentTypeCategory возвращает категорию по sniffed-типу:
|
||||
// application/pdf → pdf; image/* → image; audio/* и application/ogg → audio;
|
||||
// всё остальное (text/*, application/octet-stream и пр.) → "".
|
||||
func contentTypeCategory(sniffed string) string {
|
||||
switch {
|
||||
case sniffed == "application/pdf":
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
)
|
||||
|
||||
// fakeStorage — in-memory реализация IFileStorage для тестов.
|
||||
type fakeStorage struct {
|
||||
files map[string]*file_storage.File
|
||||
}
|
||||
@@ -87,7 +86,6 @@ func TestUploadFileValid(t *testing.T) {
|
||||
if !strings.Contains(name, "_") {
|
||||
t.Errorf("%s: имя %q не содержит уникальный hex-суффикс", c.filename, name)
|
||||
}
|
||||
// Сохранённый MIME соответствует содержимому (sniffed-тип).
|
||||
stored, err := svc.fileStorage.Get(context.Background(), name)
|
||||
if err != nil {
|
||||
t.Fatalf("Get(%q): %v", name, err)
|
||||
@@ -144,11 +142,9 @@ func TestUploadFileValidation(t *testing.T) {
|
||||
func TestUploadFileTooLarge(t *testing.T) {
|
||||
svc := newService(1024)
|
||||
|
||||
// ровно лимит — проходит
|
||||
if _, _, err := svc.UploadFile(context.Background(), "clue.pdf", bytes1024()); err != nil {
|
||||
t.Errorf("ровно лимит: err = %v, want nil", err)
|
||||
}
|
||||
// больше лимита — ErrFileTooLarge
|
||||
_, _, err := svc.UploadFile(context.Background(), "clue.pdf", bytes1025())
|
||||
if !errors.Is(err, ErrFileTooLarge) {
|
||||
t.Errorf("больше лимита: err = %v, want ErrFileTooLarge", err)
|
||||
|
||||
@@ -9,9 +9,6 @@ import (
|
||||
"evening_detective_server/internal/repos"
|
||||
)
|
||||
|
||||
// mapApplications — улики команды для ответа организатору (GetFullGame):
|
||||
// имена файлов префиксуются доменом идемпотентно, file_type деривируется
|
||||
// из расширения при пустом значении (легаси-строки таблицы).
|
||||
func mapApplications(o []*repos.Application, domain string) []*storytelling.Application {
|
||||
res := make([]*storytelling.Application, 0, len(o))
|
||||
for _, item := range o {
|
||||
@@ -33,8 +30,6 @@ func mapApplication(o *repos.Application, domain string) *storytelling.Applicati
|
||||
return app
|
||||
}
|
||||
|
||||
// prefixDomain — идемпотентная префиксация ссылки доменом хранилища
|
||||
// (пустые ссылки и внешние http/https URL не трогаются).
|
||||
func prefixDomain(ref, domain string) string {
|
||||
if ref == "" || strings.HasPrefix(ref, domain) {
|
||||
return ref
|
||||
|
||||
@@ -19,7 +19,6 @@ func TestMapApplications(t *testing.T) {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
// Относительное имя префиксуется доменом, file_type деривируется.
|
||||
if got[0].Image != testDomain+"clue.pdf" {
|
||||
t.Errorf("Image[0] = %q, want %q", got[0].Image, testDomain+"clue.pdf")
|
||||
}
|
||||
@@ -27,7 +26,6 @@ func TestMapApplications(t *testing.T) {
|
||||
t.Errorf("FileType[0] = %q, want pdf", got[0].FileType)
|
||||
}
|
||||
|
||||
// Внешний URL не префиксуется повторно.
|
||||
if got[1].Image != "https://external.example.com/pic.jpg" {
|
||||
t.Errorf("Image[1] = %q, want внешний URL без изменений", got[1].Image)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestServicesLifecyclePostgres(t *testing.T) {
|
||||
}
|
||||
config, err := pgxpool.ParseConfig(dsn)
|
||||
check(err)
|
||||
config.MaxConns = 1 // Временные таблицы должны оставаться в одном соединении.
|
||||
config.MaxConns = 1
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
check(err)
|
||||
defer pool.Close()
|
||||
|
||||
@@ -25,9 +25,7 @@ type GameService struct {
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrGameNotFound — игра не найдена.
|
||||
ErrGameNotFound = errors.New("Игра не найдена")
|
||||
// ErrInvalidGameFormat — формат игры не поддерживается.
|
||||
ErrGameNotFound = errors.New("Игра не найдена")
|
||||
ErrInvalidGameFormat = errors.New("Формат игры должен быть online или offline")
|
||||
)
|
||||
|
||||
|
||||
@@ -9,11 +9,6 @@ import (
|
||||
"evening_detective_server/internal/repos"
|
||||
)
|
||||
|
||||
// newApplication — улика для сохранения команде (AddTeamAction): имена
|
||||
// приходят из истории уже с доменным префиксом (mapStory), в БД хранятся
|
||||
// относительные пути — префикс снимается идемпотентно; file_type
|
||||
// деривируется при пустом значении. Name не меняется (очистка — в
|
||||
// вызывающем коде, cleaner'ом).
|
||||
func newApplication(app *storytelling.Application, domain string) *repos.Application {
|
||||
image := strings.TrimPrefix(app.Image, domain)
|
||||
fileType := app.FileType
|
||||
|
||||
@@ -12,8 +12,8 @@ func TestNewApplication(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
app *storytelling.Application
|
||||
image string // ожидаемое хранимое имя (без домена)
|
||||
ftype string // ожидаемый file_type
|
||||
image string
|
||||
ftype string
|
||||
}{
|
||||
{
|
||||
name: "доменный префикс снимается, file_type сохраняется",
|
||||
|
||||
@@ -2,7 +2,6 @@ package gameplay_service
|
||||
|
||||
import "evening_detective_server/internal/modules/storytelling"
|
||||
|
||||
// filterTeamStory применяется только к истории команды, общей для сайта и MCP.
|
||||
func filterTeamStory(story *storytelling.Story, format string) {
|
||||
if format != "offline" {
|
||||
return
|
||||
|
||||
@@ -15,10 +15,6 @@ import (
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
// Интеграционный тест HTTP-«клея»: MCP-сервер монтируется как
|
||||
// streamable HTTP (эндпоинт /api/mcp) и вызывается через HTTP-клиент mcp-go.
|
||||
// Проверяются initialize, tools/list (точное множество) и tools/call
|
||||
// connect — как это будет делать внешний MCP-клиент.
|
||||
func TestStreamableHTTPEndToEnd(t *testing.T) {
|
||||
svc := NewMCPService(&fakeGamePlayer{story: defaultStory(), game: defaultGame()}, &fakeAnswerSubmitter{})
|
||||
|
||||
@@ -29,8 +25,6 @@ func TestStreamableHTTPEndToEnd(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
// ВАЖНО: клиент POST-ит строго на переданный baseURL без добавления
|
||||
// пути — эндпоинт /api/mcp передаём явно.
|
||||
mcpClient, err := client.NewStreamableHttpClient(srv.URL + "/api/mcp")
|
||||
if err != nil {
|
||||
t.Fatalf("NewStreamableHttpClient() error = %v", err)
|
||||
@@ -77,8 +71,6 @@ func TestStreamableHTTPEndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка, что структуры моков сериализуемы (защита от изменения типов
|
||||
// storytelling/game_service без обновления моков).
|
||||
func TestMockTypesUsable(_ *testing.T) {
|
||||
_ = defaultStory()
|
||||
_ = defaultGame()
|
||||
|
||||
@@ -19,27 +19,17 @@ import (
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
// defaultCallTimeout — таймаут на выполнение одного инструмента:
|
||||
// защита от зависших запросов к БД (10s — как у прежнего HTTP-прокси).
|
||||
const defaultCallTimeout = 10 * time.Second
|
||||
|
||||
// GamePlayer — граница доступа к игровым сервисам, реализуемая
|
||||
// *gameplay_service.GameplayService; интерфейс позволяет тестировать сервис
|
||||
// без БД.
|
||||
type GamePlayer interface {
|
||||
GetTeamActions(ctx context.Context, teamID int, password string) (*storytelling.Story, *game_model.Game, []actions_repo.Action, error)
|
||||
AddTeamAction(ctx context.Context, teamID int, password, actionCode string, viaMCP bool) error
|
||||
}
|
||||
|
||||
// AnswerSubmitter сохраняет ответы команды независимо от прохождения истории.
|
||||
type AnswerSubmitter interface {
|
||||
SubmitTeamAnswers(ctx context.Context, teamID int, password string, answers []answer_service.Answer) error
|
||||
}
|
||||
|
||||
// MCPService собирает MCP-сервер с игровыми инструментами. Сервис не
|
||||
// хранит состояние между вызовами: каждый вызов получает id команды и
|
||||
// пароль явно — либо из ссылки на игру через инструмент connect, либо
|
||||
// напрямую.
|
||||
type MCPService struct {
|
||||
game GamePlayer
|
||||
answers AnswerSubmitter
|
||||
@@ -124,13 +114,6 @@ type connectArgs struct {
|
||||
password string
|
||||
}
|
||||
|
||||
// parseConnectURL разбирает ссылку на игру вида /team-story/{id}?password=...
|
||||
// и возвращает id команды и пароль.
|
||||
//
|
||||
// Ссылка используется ТОЛЬКО как носитель id и пароля: MCP-сервер не
|
||||
// выполняет HTTP-запросов по ней, поэтому схема и хост не проверяются
|
||||
// (протокол-относительная ссылка //host/team-story/{id}?password=...
|
||||
// принимается, «mailto:...» отклоняется на проверке пути).
|
||||
func parseConnectURL(rawURL string) (connectArgs, error) {
|
||||
if strings.TrimSpace(rawURL) == "" {
|
||||
return connectArgs{}, fmt.Errorf("требуется url")
|
||||
@@ -280,10 +263,6 @@ type submitAnswersArgs struct {
|
||||
answers []answer_service.Answer
|
||||
}
|
||||
|
||||
// parseSubmitAnswersArgs разбирает аргументы инструмента submit_answers:
|
||||
// answers — массив объектов {questionCode, answer} (JSON-массив приходит
|
||||
// как []any с map[string]any элементами). Ключ questionCode совпадает с
|
||||
// REST JSON (camelCase) — единый формат для клиентов.
|
||||
func parseSubmitAnswersArgs(args map[string]any) (submitAnswersArgs, error) {
|
||||
teamID, ok := argInt64(args["team_id"])
|
||||
if !ok {
|
||||
@@ -345,8 +324,6 @@ func (s *MCPService) handleSubmitAnswers(ctx context.Context, req mcp.CallToolRe
|
||||
return mcp.NewToolResultText(jsonBody), nil
|
||||
}
|
||||
|
||||
// argInt64 достаёт int64 из аргумента инструмента: числа приходят как
|
||||
// float64 (JSON), строки — как string.
|
||||
func argInt64(v any) (int64, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
|
||||
@@ -16,8 +16,6 @@ import (
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
// fakeGamePlayer — мок GamePlayer с предзаданными историей, игрой и
|
||||
// ошибками (реализует интерфейс GamePlayer, БД не нужна).
|
||||
type fakeGamePlayer struct {
|
||||
story *storytelling.Story
|
||||
game *game_model.Game
|
||||
@@ -82,8 +80,6 @@ func defaultGame() *game_model.Game {
|
||||
}
|
||||
}
|
||||
|
||||
// mcpFixture поднимает встроенный MCP-клиент над сервисом с моком
|
||||
// GamePlayer. Каждый кейс создаёт СВЕЖИЙ сервис.
|
||||
type mcpFixture struct {
|
||||
client *client.Client
|
||||
answers *fakeAnswerSubmitter
|
||||
@@ -118,7 +114,6 @@ func newMCPFixture(t *testing.T, player *fakeGamePlayer) *mcpFixture {
|
||||
return &mcpFixture{client: mcpClient, answers: answers}
|
||||
}
|
||||
|
||||
// call вызывает инструмент с аргументами и возвращает текст результата.
|
||||
func (f *mcpFixture) call(t *testing.T, name string, args map[string]any) string {
|
||||
t.Helper()
|
||||
request := mcp.CallToolRequest{}
|
||||
@@ -141,7 +136,6 @@ func (f *mcpFixture) call(t *testing.T, name string, args map[string]any) string
|
||||
return text.String()
|
||||
}
|
||||
|
||||
// toolNames возвращает имена инструментов сервера.
|
||||
func (f *mcpFixture) toolNames(t *testing.T) []string {
|
||||
t.Helper()
|
||||
toolsResult, err := f.client.ListTools(context.Background(), mcp.ListToolsRequest{})
|
||||
@@ -199,8 +193,6 @@ func TestConnectRelativeURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectProtocolRelativeURL: протокол-относительная ссылка принимается
|
||||
// (запросы по ней не выполняются — из ссылки берутся только id и пароль).
|
||||
func TestConnectProtocolRelativeURL(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()})
|
||||
|
||||
@@ -313,7 +305,7 @@ func TestMakeMove(t *testing.T) {
|
||||
f := newMCPFixture(t, player)
|
||||
|
||||
got := f.call(t, "make_move", map[string]any{
|
||||
"team_id": "10", // строка тоже принимается
|
||||
"team_id": "10",
|
||||
"password": "team-pass-1",
|
||||
"code": "hall",
|
||||
})
|
||||
@@ -408,7 +400,6 @@ func TestParseConnectURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewMCPService — конструктор не требует БД и возвращает сервис.
|
||||
func TestNewMCPService(t *testing.T) {
|
||||
svc := NewMCPService(&fakeGamePlayer{}, &fakeAnswerSubmitter{})
|
||||
if svc == nil {
|
||||
@@ -419,7 +410,6 @@ func TestNewMCPService(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultCallTimeout — защита от случайного изменения таймаута.
|
||||
func TestDefaultCallTimeout(t *testing.T) {
|
||||
if defaultCallTimeout != 10*time.Second {
|
||||
t.Fatalf("defaultCallTimeout = %v; want 10s", defaultCallTimeout)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Participation — данные участия, доступные транспортному и email-сценариям.
|
||||
type Participation struct {
|
||||
TeamID int
|
||||
TeamName string
|
||||
|
||||
@@ -99,7 +99,7 @@ func TestParticipationPostgres(t *testing.T) {
|
||||
}
|
||||
}
|
||||
state(1, "pending")
|
||||
state(2, "pending") // Существующие команды миграция не изменяет.
|
||||
state(2, "pending")
|
||||
// Model registration output explicitly: direct SQL inserts do not auto-confirm teams.
|
||||
exec(`UPDATE teams SET captain_email='captain@example.org',participants_count=5 WHERE id=1;
|
||||
INSERT INTO teams(game_id,created_at,captain_email) SELECT 1,start_at-INTERVAL '24 hours','boundary@example.org' FROM games WHERE id=1;
|
||||
@@ -113,7 +113,6 @@ func TestParticipationPostgres(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state(5, "confirmed")
|
||||
// Независимые рабочие процессы должны захватывать каждую подходящую команду только один раз.
|
||||
var wg sync.WaitGroup
|
||||
ids := make(chan int, 8)
|
||||
errs := make(chan error, 8)
|
||||
@@ -158,7 +157,6 @@ func TestParticipationPostgres(t *testing.T) {
|
||||
t.Fatalf("failed send not recorded: %+v %v", team, err)
|
||||
}
|
||||
sender.err = nil
|
||||
// Истёкшая аренда восстанавливается после перезапуска рабочего процесса.
|
||||
exec(`UPDATE teams SET participation_next_attempt_at=NOW()-INTERVAL '1 minute' WHERE id IN (1,3)`)
|
||||
if err := service.sendParticipationEmails(ctx, sender, "secret", time.UTC); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -209,7 +207,6 @@ func TestParticipationPostgres(t *testing.T) {
|
||||
if _, err := service.AnswerParticipation(ctx, token, p.Status); err != nil {
|
||||
t.Fatalf("same answer not idempotent: %v", err)
|
||||
}
|
||||
// Отмена исключает команды из общих активных списков и игры по паролю.
|
||||
token3 := participationToken("secret", 3)
|
||||
if _, err := service.AnswerParticipation(ctx, token3, "cancelled"); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -58,10 +58,6 @@ func mapParticipationError(err error) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Стабильные токены для разных целей сохраняют работоспособность ссылок при
|
||||
// повторе после неоднозначной ошибки SMTP.
|
||||
// В базе хранится только хеш; секрет подписи должен быть постоянным между
|
||||
// рабочими процессами и перезапусками.
|
||||
func participationToken(secret string, id int) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = fmt.Fprintf(mac, "team-participation:v1:%d", id)
|
||||
@@ -129,7 +125,6 @@ func (s *ParticipationService) sendParticipationEmails(ctx context.Context, send
|
||||
err = sender.Send(jobCtx, participationEmail(p, s.appBaseURL, token, location))
|
||||
}
|
||||
cancel()
|
||||
// Сохраняем результат даже если отправка исчерпала время задачи.
|
||||
saveCtx, saveCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
saveErr := s.teamsRepo.FinishParticipationEmail(saveCtx, p.TeamID, p.Attempts, err)
|
||||
saveCancel()
|
||||
|
||||
@@ -91,9 +91,6 @@ func mapStory(o string, domain string) (*storytelling.Story, error) {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// prefixDomain — идемпотентная префиксация ссылки доменом хранилища:
|
||||
// пустые ссылки и внешние http/https URL не трогаются, повторный префикс
|
||||
// исключён. Единое правило для картинок точек, аудио введения и улик.
|
||||
func prefixDomain(ref, domain string) string {
|
||||
if ref == "" || strings.HasPrefix(ref, domain) {
|
||||
return ref
|
||||
|
||||
@@ -7,9 +7,6 @@ import (
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
)
|
||||
|
||||
// TestMapStoryPrefixesEvidence — улики получают полный URL (домен + имя),
|
||||
// как картинки точек и аудио введения; префиксация идемпотентна, внешние
|
||||
// URL не трогаются; file_type деривируется при пустом значении.
|
||||
func TestMapStoryPrefixesEvidence(t *testing.T) {
|
||||
storyJSON := `{
|
||||
"introduction": {"text": "Вступление", "audio": "intro.mp3"},
|
||||
@@ -38,7 +35,6 @@ func TestMapStoryPrefixesEvidence(t *testing.T) {
|
||||
t.Errorf("place.Image = %q, want %q", place.Image, testDomain+"place.png")
|
||||
}
|
||||
|
||||
// Улика из хранилища: полный URL + деривированный file_type.
|
||||
app := place.Applications[0]
|
||||
if app.Image != testDomain+"contract.pdf" {
|
||||
t.Errorf("application.Image = %q, want %q", app.Image, testDomain+"contract.pdf")
|
||||
@@ -47,7 +43,6 @@ func TestMapStoryPrefixesEvidence(t *testing.T) {
|
||||
t.Errorf("application.FileType = %q, want pdf", app.FileType)
|
||||
}
|
||||
|
||||
// Внешний URL не префиксуется, file_type сохраняется.
|
||||
external := place.Applications[1]
|
||||
if external.Image != "https://external.example.com/pic.jpg" {
|
||||
t.Errorf("external.Image = %q, want без изменений", external.Image)
|
||||
@@ -56,7 +51,6 @@ func TestMapStoryPrefixesEvidence(t *testing.T) {
|
||||
t.Errorf("external.FileType = %q, want image", external.FileType)
|
||||
}
|
||||
|
||||
// Идемпотентность: повторный прогон не даёт двойного префикса.
|
||||
jsonOut, err := convertStory(story)
|
||||
if err != nil {
|
||||
t.Fatalf("convertStory: %v", err)
|
||||
@@ -70,8 +64,6 @@ func TestMapStoryPrefixesEvidence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeFileType — невалидный file_type заменяется деривацией из
|
||||
// расширения файла, известные значения сохраняются.
|
||||
func TestNormalizeFileType(t *testing.T) {
|
||||
cases := []struct {
|
||||
fileType string
|
||||
@@ -92,8 +84,6 @@ func TestNormalizeFileType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeStoryBanana — файл-улика с невалидным file_type проходит
|
||||
// normalizeStory и получает деривированный тип.
|
||||
func TestNormalizeStoryBanana(t *testing.T) {
|
||||
story := &storytelling.Story{
|
||||
Places: []*storytelling.Place{
|
||||
@@ -121,14 +111,11 @@ func TestNormalizeStoryBanana(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateStoryTrimsEvidenceDomain — сохранение истории снимает доменный
|
||||
// префикс с улик (в БД хранятся относительные имена).
|
||||
func TestUpdateStoryTrimsEvidenceDomain(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
// Сценарий с историей, где улика уже с доменным префиксом.
|
||||
scenario := seedScenario(t, repo, storage)
|
||||
scenario.Scenario = `{"places":[{"code":"p1","name":"Место","text":"","image":"place.png","applications":[{"name":"Договор","image":"http://storage.test/api/files/contract.pdf","file_type":"pdf"}]}]}`
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IScenariosRepository — граница доступа к данным сценариев, реализуемая
|
||||
// *scenarios_repo.ScenariosRepo (интерфейс — для тестов без БД).
|
||||
type IScenariosRepository interface {
|
||||
AddScenario(ctx context.Context, name string, authorID int) (int, error)
|
||||
GetScenariosByAuthorID(ctx context.Context, authorID int) ([]*repos.Scenario, error)
|
||||
@@ -130,32 +128,20 @@ func (s *ScenarioService) UpdateScenarioByID(
|
||||
return s.scenariosRepo.UpdateScenarioByID(ctx, id, name, description, image)
|
||||
}
|
||||
|
||||
// ErrScenarioPublished — опубликованный сценарий нельзя снять с публикации
|
||||
// или удалить: при публикации исключительное право отчуждается Оператору
|
||||
// (п. 7.2 Пользовательского соглашения, ст. 1234 ГК РФ), а право на отзыв
|
||||
// действует только до обнародования (ст. 1269 ГК РФ).
|
||||
var ErrScenarioPublished = errors.New("Опубликованный сценарий нельзя снять с публикации или удалить: исключительное право отчуждено Оператору")
|
||||
|
||||
// ErrScenarioNotOwner — сценарий принадлежит другому пользователю.
|
||||
var ErrScenarioNotOwner = errors.New("Сценарий принадлежит другому пользователю")
|
||||
|
||||
// ErrScenarioNotFound — сценарий не найден или уже удалён.
|
||||
var ErrScenarioNotFound = errors.New("Сценарий не найден")
|
||||
|
||||
// ErrInvalidArchive — архив не соответствует формату сценария.
|
||||
var ErrInvalidArchive = errors.New("Некорректный архив сценария")
|
||||
|
||||
// ErrInvalidScenarioData — данные истории сценария не проходят валидацию.
|
||||
var ErrInvalidScenarioData = errors.New("Некорректные данные сценария")
|
||||
|
||||
// MaxArchiveSize возвращает максимальный размер загружаемого архива.
|
||||
func MaxArchiveSize() int {
|
||||
return scenario_archive.MaxArchiveSize()
|
||||
}
|
||||
|
||||
// getScenarioForChange проверяет права актора на изменение сценария:
|
||||
// не-админ должен быть автором сценария, сценарий не должен быть удалён.
|
||||
// Возвращает сценарий, чтобы вызывающий код не делал повторное чтение.
|
||||
func (s *ScenarioService) getScenarioForChange(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
@@ -198,15 +184,11 @@ func (s *ScenarioService) UpdateScenarioStatusByID(
|
||||
return err
|
||||
}
|
||||
if !isAdmin && scenario.Status == "public" {
|
||||
// Повторная публикация уже опубликованного — идемпотентный успех
|
||||
// (published_at не двигается: CASE WHEN published_at IS NULL).
|
||||
if status == "public" {
|
||||
return nil
|
||||
}
|
||||
return ErrScenarioPublished
|
||||
}
|
||||
// Guard в SQL защищает от гонки «чтение статуса → запись»: не-админ не
|
||||
// сможет перевести в draft сценарий, опубликованный между чтением и UPDATE.
|
||||
updated, err := s.scenariosRepo.UpdateScenarioStatusByID(ctx, id, status, isAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -227,8 +209,6 @@ func (s *ScenarioService) DeleteScenarioByID(
|
||||
if _, err := s.getScenarioForChange(ctx, id, actorID, isAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
// Guard в SQL защищает от гонки «чтение статуса → запись»: не-админ не
|
||||
// сможет удалить сценарий, опубликованный между чтением и UPDATE.
|
||||
updated, err := s.scenariosRepo.DeleteScenarioByID(ctx, id, isAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -310,7 +290,6 @@ func (s *ScenarioService) DeleteScenarioPlace(
|
||||
return s.updateStory(ctx, id, story)
|
||||
}
|
||||
|
||||
// UpdateScenarioBackground устанавливает фон; пустое значение снимает его с дела.
|
||||
func (s *ScenarioService) UpdateScenarioBackground(ctx context.Context, id int, background string, actorID int, isAdmin bool) error {
|
||||
if _, err := s.getScenarioForChange(ctx, id, actorID, isAdmin); err != nil {
|
||||
return err
|
||||
@@ -326,7 +305,6 @@ func (s *ScenarioService) UpdateScenarioBackground(ctx context.Context, id int,
|
||||
return s.updateStory(ctx, id, story)
|
||||
}
|
||||
|
||||
// UpdateScenarioIntro сохраняет введение сценария (текст и аудио).
|
||||
func (s *ScenarioService) UpdateScenarioIntro(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
@@ -358,7 +336,6 @@ func (s *ScenarioService) UpdateScenarioConclusion(ctx context.Context, id int,
|
||||
return s.updateStory(ctx, id, story)
|
||||
}
|
||||
|
||||
// AddScenarioQuestion добавляет вопрос дела в конец списка вопросов сценария.
|
||||
func (s *ScenarioService) AddScenarioQuestion(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
@@ -377,8 +354,6 @@ func (s *ScenarioService) AddScenarioQuestion(
|
||||
return s.updateStory(ctx, id, story)
|
||||
}
|
||||
|
||||
// UpdateScenarioQuestion обновляет вопрос дела по его коду. Смена кода
|
||||
// оставляет строки team_answers старого кода в БД
|
||||
func (s *ScenarioService) UpdateScenarioQuestion(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
@@ -403,7 +378,6 @@ func (s *ScenarioService) UpdateScenarioQuestion(
|
||||
return s.updateStory(ctx, id, story)
|
||||
}
|
||||
|
||||
// DeleteScenarioQuestion удаляет вопрос дела по его коду.
|
||||
func (s *ScenarioService) DeleteScenarioQuestion(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
@@ -435,7 +409,6 @@ 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,
|
||||
@@ -472,9 +445,6 @@ func (s *ScenarioService) DownloadArchive(
|
||||
return data, scenario.Name, nil
|
||||
}
|
||||
|
||||
// UploadArchive импортирует сценарий из ZIP-архива: изображения сохраняются
|
||||
// под новыми случайными именами (не перетирают чужие файлы), ссылки
|
||||
// переписываются. Новый сценарий всегда draft — статус из архива игнорируется.
|
||||
func (s *ScenarioService) UploadArchive(
|
||||
ctx context.Context,
|
||||
data []byte,
|
||||
@@ -486,7 +456,6 @@ func (s *ScenarioService) UploadArchive(
|
||||
}
|
||||
scenario := bundle.Scenario
|
||||
|
||||
// При ошибке удаляем загруженные файлы (best-effort) — без orphan-объектов.
|
||||
uploaded := map[string]string{}
|
||||
ok := false
|
||||
defer func() {
|
||||
@@ -499,7 +468,6 @@ func (s *ScenarioService) UploadArchive(
|
||||
}()
|
||||
|
||||
rewrite := func(ref string) (string, error) {
|
||||
// Берём только ссылки images/... из архива; остальное — как есть.
|
||||
archiveRef := strings.TrimPrefix(ref, s.domain)
|
||||
if !strings.HasPrefix(archiveRef, "images/") {
|
||||
return ref, nil
|
||||
@@ -570,7 +538,6 @@ func (s *ScenarioService) UploadArchive(
|
||||
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
|
||||
}
|
||||
@@ -604,9 +571,6 @@ func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storyt
|
||||
return s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyString)
|
||||
}
|
||||
|
||||
// normalizeStory проверяет уникальность кодов точек, отбрасывает пустые коды,
|
||||
// нормализует file_type улик и возвращает JSON истории.
|
||||
// Общая для редактирования и импорта.
|
||||
func normalizeStory(story *storytelling.Story) (string, error) {
|
||||
if story.Conclusion != nil && story.Conclusion.Video != "" && file_storage.FileType(story.Conclusion.Video) != "video" {
|
||||
return "", errors.New("видео закрывашки должно быть в формате MP4 или WebM")
|
||||
@@ -625,16 +589,12 @@ func normalizeStory(story *storytelling.Story) (string, error) {
|
||||
continue
|
||||
}
|
||||
for _, application := range place.Applications {
|
||||
// file_type принимает только известные значения; невалидные
|
||||
// (включая значения из импортированных архивов) заменяются
|
||||
// деривацией из расширения файла.
|
||||
application.FileType = normalizeFileType(application.FileType, application.Image)
|
||||
}
|
||||
cleanPlaces = append(cleanPlaces, place)
|
||||
}
|
||||
story.Places = cleanPlaces
|
||||
|
||||
// Коды вопросов уникальны, пустые коды отбрасываются (как у точек).
|
||||
questionCodes := map[string]struct{}{}
|
||||
for _, question := range story.Questions {
|
||||
questionCodes[question.Code] = struct{}{}
|
||||
@@ -654,10 +614,6 @@ func normalizeStory(story *storytelling.Story) (string, error) {
|
||||
return convertStory(story)
|
||||
}
|
||||
|
||||
// normalizeFileType приводит file_type к одному из {pdf, image, audio, ""}.
|
||||
// Пустое/невалидное значение при сохранении заменяется деривацией из
|
||||
// расширения файла; пустое значение в legacy-строках деривируется при
|
||||
// чтении (mapStory).
|
||||
func normalizeFileType(fileType, image string) string {
|
||||
switch fileType {
|
||||
case "pdf", "image", "audio":
|
||||
|
||||
@@ -92,7 +92,6 @@ func TestConclusionLifecycle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// fakeStorage — in-memory реализация IFileStorage.
|
||||
type fakeStorage struct {
|
||||
mu sync.Mutex
|
||||
files map[string]*file_storage.File
|
||||
@@ -148,7 +147,6 @@ func (s *fakeStorage) names() []string {
|
||||
return res
|
||||
}
|
||||
|
||||
// fakeScenariosRepo — минимальная in-memory реализация IScenariosRepository.
|
||||
type fakeScenariosRepo struct {
|
||||
mu sync.Mutex
|
||||
byID map[int]*repos.Scenario
|
||||
@@ -316,7 +314,6 @@ func TestPublishDraftScenario(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// seedScenario кладёт в repo сценарий с историей и изображениями в storage.
|
||||
func seedScenario(t *testing.T, repo *fakeScenariosRepo, storage *fakeStorage) *repos.Scenario {
|
||||
t.Helper()
|
||||
description := "Детективная история"
|
||||
@@ -345,14 +342,12 @@ func TestUploadArchive(t *testing.T) {
|
||||
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)
|
||||
@@ -382,7 +377,6 @@ func TestUploadArchive(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
@@ -400,12 +394,10 @@ func TestUploadArchive(t *testing.T) {
|
||||
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)
|
||||
@@ -446,8 +438,6 @@ func TestUploadArchiveWithIntro(t *testing.T) {
|
||||
_ = srcStorage.Put(context.Background(), &file_storage.File{Name: "club.png", Data: []byte("club-bytes")})
|
||||
_ = srcStorage.Put(context.Background(), &file_storage.File{Name: "intro.mp3", Data: []byte("intro-audio-bytes")})
|
||||
|
||||
// Экспорт (Pack кладёт аудио введения в архив), затем импорт в «чистые»
|
||||
// repo и storage: ссылки должны быть переписаны на новые имена.
|
||||
svc := newTestService(repo, srcStorage)
|
||||
archive, _, err := svc.DownloadArchive(context.Background(), 1, 7, false)
|
||||
if err != nil {
|
||||
@@ -478,7 +468,6 @@ func TestUploadArchiveWithIntro(t *testing.T) {
|
||||
t.Errorf("Introduction.Audio = %q, want новое случайное имя без images/", story.Introduction.Audio)
|
||||
}
|
||||
|
||||
// Аудио введения загружено в storage под новым именем.
|
||||
files := importStorage.names()
|
||||
found := false
|
||||
for _, name := range files {
|
||||
@@ -500,7 +489,6 @@ func TestUploadArchiveMissingImage(t *testing.T) {
|
||||
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,
|
||||
@@ -564,7 +552,6 @@ func TestUploadArchiveCleanupOnRepoError(t *testing.T) {
|
||||
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())
|
||||
}
|
||||
@@ -589,7 +576,6 @@ func TestUploadArchiveDeletesRowOnPartialFailure(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
@@ -638,7 +624,6 @@ func TestDownloadArchiveNotOwner(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
@@ -672,7 +657,6 @@ func TestUpdateScenarioIntro(t *testing.T) {
|
||||
seedScenario(t, repo, storage)
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
// Установка введения автором.
|
||||
err := svc.UpdateScenarioIntro(context.Background(), 1, &storytelling.Introduction{
|
||||
Text: "Введение",
|
||||
Audio: "intro.mp3",
|
||||
@@ -681,7 +665,6 @@ func TestUpdateScenarioIntro(t *testing.T) {
|
||||
t.Fatalf("UpdateScenarioIntro: %v", err)
|
||||
}
|
||||
|
||||
// В хранилище — голое имя аудио, без домена.
|
||||
parsed := &storytelling.Story{}
|
||||
if err := json.Unmarshal([]byte(repo.get(1).Scenario), parsed); err != nil {
|
||||
t.Fatalf("json.Unmarshal: %v", err)
|
||||
@@ -696,7 +679,6 @@ func TestUpdateScenarioIntro(t *testing.T) {
|
||||
t.Errorf("Introduction.Audio = %q, want %q (без домена)", parsed.Introduction.Audio, "intro.mp3")
|
||||
}
|
||||
|
||||
// Наружу — аудио с доменом.
|
||||
full, err := svc.GetFullScenarioByID(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFullScenarioByID: %v", err)
|
||||
@@ -708,7 +690,6 @@ func TestUpdateScenarioIntro(t *testing.T) {
|
||||
t.Errorf("Introduction.Audio = %q, want %q", full.Story.Introduction.Audio, testDomain+"intro.mp3")
|
||||
}
|
||||
|
||||
// Редактирование точки не должно затрагивать введение и голое имя аудио.
|
||||
err = svc.UpdateScenarioPlace(context.Background(), 1, "club", &storytelling.Place{
|
||||
Code: "club",
|
||||
Name: "Клуб",
|
||||
@@ -725,7 +706,6 @@ func TestUpdateScenarioIntro(t *testing.T) {
|
||||
t.Errorf("введение повреждено после UpdateScenarioPlace: %+v", parsed.Introduction)
|
||||
}
|
||||
|
||||
// Очистка введения (nil) — поле исчезает из истории.
|
||||
err = svc.UpdateScenarioIntro(context.Background(), 1, nil, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateScenarioIntro(nil): %v", err)
|
||||
@@ -738,14 +718,12 @@ func TestUpdateScenarioIntro(t *testing.T) {
|
||||
t.Errorf("Introduction = %+v, want nil после очистки", parsed.Introduction)
|
||||
}
|
||||
|
||||
// Чужой автор не может менять введение.
|
||||
err = svc.UpdateScenarioIntro(context.Background(), 1, &storytelling.Introduction{Text: "x"}, 99, false)
|
||||
if !errors.Is(err, ErrScenarioNotOwner) {
|
||||
t.Errorf("err = %v, want ErrScenarioNotOwner", err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildRawArchive собирает zip из произвольных записей (для тестов импорта).
|
||||
func buildRawArchive(t *testing.T, entries map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
@@ -780,7 +758,6 @@ func TestScenarioQuestionsCRUD(t *testing.T) {
|
||||
seedScenario(t, repo, storage)
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
// Добавление вопроса автором.
|
||||
err := svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{
|
||||
Code: "q1",
|
||||
Text: "Кто убийца?",
|
||||
@@ -805,7 +782,6 @@ func TestScenarioQuestionsCRUD(t *testing.T) {
|
||||
if len(full.Story.Questions) != 2 {
|
||||
t.Fatalf("len(Questions) = %d, want 2", len(full.Story.Questions))
|
||||
}
|
||||
// Вопросы добавляются в конец списка: q1 добавлен первым.
|
||||
if full.Story.Questions[0].Code != "q1" || full.Story.Questions[0].Answer != "Дворецкий" {
|
||||
t.Errorf("Questions[0] = %+v, want q1 с правильным ответом", full.Story.Questions[0])
|
||||
}
|
||||
@@ -813,7 +789,6 @@ func TestScenarioQuestionsCRUD(t *testing.T) {
|
||||
t.Errorf("Questions[1] = %+v, want q2 с правильным ответом", full.Story.Questions[1])
|
||||
}
|
||||
|
||||
// Обновление вопроса по коду.
|
||||
err = svc.UpdateScenarioQuestion(context.Background(), 1, "q1", &storytelling.Question{
|
||||
Code: "q1",
|
||||
Text: "Кто убийца на самом деле?",
|
||||
@@ -839,7 +814,6 @@ func TestScenarioQuestionsCRUD(t *testing.T) {
|
||||
t.Error("вопрос q1 не найден после обновления")
|
||||
}
|
||||
|
||||
// Удаление вопроса.
|
||||
err = svc.DeleteScenarioQuestion(context.Background(), 1, "q2", 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteScenarioQuestion: %v", err)
|
||||
@@ -852,7 +826,6 @@ func TestScenarioQuestionsCRUD(t *testing.T) {
|
||||
t.Errorf("Questions = %+v, want только q1", full.Story.Questions)
|
||||
}
|
||||
|
||||
// Чужой автор не может менять вопросы.
|
||||
err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{Code: "q3"}, 99, false)
|
||||
if !errors.Is(err, ErrScenarioNotOwner) {
|
||||
t.Errorf("AddScenarioQuestion чужой автор: err = %v, want ErrScenarioNotOwner", err)
|
||||
@@ -862,7 +835,6 @@ func TestScenarioQuestionsCRUD(t *testing.T) {
|
||||
t.Errorf("DeleteScenarioQuestion чужой автор: err = %v, want ErrScenarioNotOwner", err)
|
||||
}
|
||||
|
||||
// Админ может менять вопросы чужого сценария.
|
||||
err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{
|
||||
Code: "q3", Text: "Вопрос админа",
|
||||
}, 1, true)
|
||||
@@ -877,7 +849,6 @@ func TestNormalizeStoryQuestionCodes(t *testing.T) {
|
||||
seedScenario(t, repo, storage)
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
// Дубликат кода вопроса — ошибка.
|
||||
err := svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{Code: "q1"}, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("AddScenarioQuestion q1: %v", err)
|
||||
@@ -887,7 +858,6 @@ func TestNormalizeStoryQuestionCodes(t *testing.T) {
|
||||
t.Errorf("AddScenarioQuestion с дублем кода: err = %v, want ошибку о дубле", err)
|
||||
}
|
||||
|
||||
// Пустой код — вопрос отбрасывается при сохранении.
|
||||
err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{Code: "", Text: "без кода"}, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("AddScenarioQuestion с пустым кодом: %v", err)
|
||||
|
||||
@@ -11,14 +11,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidCaptainEmail — почта капитана команды некорректна.
|
||||
ErrInvalidCaptainEmail = errors.New("Укажите корректную почту капитана")
|
||||
// ErrTeamNotFound — команда не найдена.
|
||||
ErrTeamNotFound = errors.New("Команда не найдена")
|
||||
// ErrInvalidRegistration — входные данные регистрации команды некорректны.
|
||||
ErrTeamNotFound = errors.New("Команда не найдена")
|
||||
ErrInvalidRegistration = errors.New("Укажите название команды (до 255 символов), почту капитана и положительное количество участников")
|
||||
// ErrRegistrationClosed — игра не найдена или регистрация в неё закрыта.
|
||||
ErrRegistrationClosed = errors.New("Игра не найдена или регистрация уже закрыта")
|
||||
ErrRegistrationClosed = errors.New("Игра не найдена или регистрация уже закрыта")
|
||||
)
|
||||
|
||||
// Registration contains data required to register a team.
|
||||
|
||||
@@ -12,10 +12,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrGameNotFound — игра для генерации QR-кодов не найдена.
|
||||
ErrGameNotFound = errors.New("Игра не найдена")
|
||||
// ErrNoCards — для игры нет активных команд, которым нужны QR-коды.
|
||||
ErrNoCards = errors.New("нет карточек")
|
||||
ErrNoCards = errors.New("нет карточек")
|
||||
)
|
||||
|
||||
func teamQRData(baseURL string, game *repos.Game, teams []*repos.Team) ([]team_qr_pdf.Card, string, error) {
|
||||
|
||||
@@ -48,8 +48,6 @@ func TestChangeUserPassword(t *testing.T) {
|
||||
r.byID[u.ID] = u
|
||||
r.users[u.Email] = u
|
||||
}
|
||||
// Пустые JWT, репозиторий токенов и отправитель писем подтверждают,
|
||||
// что операция их не использует.
|
||||
s := &UsersService{usersRepo: r}
|
||||
err = s.ChangeUserPassword(context.Background(), tc.actor, tc.target, tc.password)
|
||||
if !errors.Is(err, tc.want) {
|
||||
@@ -91,7 +89,6 @@ func TestLoginAfterPasswordChangePreservesEmailCase(t *testing.T) {
|
||||
if err := svc.ChangeUserPassword(ctx, 1, 2, "new password 123"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Эта ошибка возможна только после успешного поиска email и проверки bcrypt.
|
||||
_, _, err := svc.Login(ctx, " User@Example.COM ", "new password 123")
|
||||
if err == nil || err.Error() != "Не получилось получить доступ" {
|
||||
t.Fatalf("lookup/password verification failed: %v", err)
|
||||
|
||||
@@ -6,17 +6,11 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Письма содержат текстовую и HTML-версию; sender собирает multipart/alternative.
|
||||
|
||||
const (
|
||||
// welcomeSubject — тема письма о регистрации: конкретика без КАПСА и
|
||||
// кликбейта, до 50 символов (видно на мобильных целиком).
|
||||
welcomeSubject = "Твой доступ к «Вечернему детективу»"
|
||||
// resetSubject — тема письма о сбросе пароля.
|
||||
resetSubject = "Новый пароль для «Вечернего детектива»"
|
||||
resetSubject = "Новый пароль для «Вечернего детектива»"
|
||||
)
|
||||
|
||||
// welcomePlain возвращает текстовую версию письма о регистрации.
|
||||
func welcomePlain(username, email, password, loginURL string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(greeting(username))
|
||||
@@ -39,7 +33,6 @@ func welcomePlain(username, email, password, loginURL string) string {
|
||||
return sanitizeLine(b.String())
|
||||
}
|
||||
|
||||
// welcomeHTML возвращает HTML-версию письма о регистрации.
|
||||
func welcomeHTML(username, email, password, loginURL string) string {
|
||||
var content strings.Builder
|
||||
content.WriteString("<p class=\"email-text\" style=\"color:#211a14;\">Привет, <strong>")
|
||||
@@ -54,7 +47,6 @@ func welcomeHTML(username, email, password, loginURL string) string {
|
||||
return wrapHTML(content.String(), loginURL)
|
||||
}
|
||||
|
||||
// resetPlain возвращает текстовую версию письма о сбросе пароля.
|
||||
func resetPlain(username, email, password, loginURL string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(greeting(username))
|
||||
@@ -77,7 +69,6 @@ func resetPlain(username, email, password, loginURL string) string {
|
||||
return sanitizeLine(b.String())
|
||||
}
|
||||
|
||||
// resetHTML возвращает HTML-версию письма о сбросе пароля.
|
||||
func resetHTML(username, email, password, loginURL string) string {
|
||||
var content strings.Builder
|
||||
content.WriteString("<p class=\"email-text\" style=\"color:#211a14;\">Привет, <strong>")
|
||||
@@ -93,8 +84,6 @@ func resetHTML(username, email, password, loginURL string) string {
|
||||
return wrapHTML(content.String(), loginURL)
|
||||
}
|
||||
|
||||
// greeting возвращает приветствие с именем пользователя; при пустом имени —
|
||||
// без обращения, чтобы не было «Привет, !».
|
||||
func greeting(username string) string {
|
||||
username = sanitizeLine(strings.TrimSpace(username))
|
||||
if username == "" {
|
||||
@@ -103,8 +92,6 @@ func greeting(username string) string {
|
||||
return fmt.Sprintf("Привет, %s!", username)
|
||||
}
|
||||
|
||||
// credentialsBlockHTML — блок «Логин / Пароль» в табличной вёрстке,
|
||||
// совместимой с Gmail.
|
||||
func credentialsBlockHTML(email, password string) string {
|
||||
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:24px 0; border-top:1px dashed #888888; border-bottom:1px dashed #888888; table-layout:fixed;">
|
||||
<tr><td class="email-text" style="padding:16px 0 4px; font-family:Arial,Helvetica,sans-serif; font-size:12px; color:#211a14;">Логин</td></tr>
|
||||
@@ -114,7 +101,6 @@ func credentialsBlockHTML(email, password string) string {
|
||||
</table>`
|
||||
}
|
||||
|
||||
// buttonHTML — кнопка входа с inline-стилями и табличным фоном.
|
||||
func buttonHTML(link, label string) string {
|
||||
return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px 0;">
|
||||
<tr>
|
||||
@@ -125,8 +111,6 @@ func buttonHTML(link, label string) string {
|
||||
</table>`
|
||||
}
|
||||
|
||||
// wrapHTML — светлая палитра по умолчанию, тёмная через media query.
|
||||
// Клиенты без поддержки CSS темы могут перекрашивать обычные цвета сами.
|
||||
func wrapHTML(content, baseURL string) string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
@@ -181,7 +165,6 @@ func wrapHTML(content, baseURL string) string {
|
||||
</html>`
|
||||
}
|
||||
|
||||
// Шрифт находится в public/email фронтенда.
|
||||
func emailAssetURL(baseURL, filename string) string {
|
||||
if baseURL == "" {
|
||||
return ""
|
||||
@@ -189,15 +172,10 @@ func emailAssetURL(baseURL, filename string) string {
|
||||
return html.EscapeString(strings.TrimRight(baseURL, "/") + "/email/" + filename)
|
||||
}
|
||||
|
||||
// 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, "/")
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestEmailThemeAndContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if n.Type == html.ElementNode && n.Data == "a" {
|
||||
protected = true // У кнопки собственные согласованные цвета фона и текста.
|
||||
protected = true
|
||||
}
|
||||
if paper && n.Type == html.TextNode && strings.TrimSpace(n.Data) != "" {
|
||||
if !protected {
|
||||
|
||||
@@ -22,19 +22,12 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Версии документов, акцепт которых фиксируется. Должны совпадать с
|
||||
// редакциями, опубликованными по постоянным URL (/api/terms и /api/privacy,
|
||||
// rpc GetTerms/GetPrivacy, текст встраивается из пакета docs): при изменении
|
||||
// текста документа версия ОБЯЗАНА увеличиваться, иначе повторный акцепт
|
||||
// не будет зафиксирован как согласие с новой редакцией
|
||||
// (UNIQUE (user_id, agreement_type, version)).
|
||||
const (
|
||||
termsVersion = "1.0"
|
||||
privacyVersion = "1.0"
|
||||
)
|
||||
|
||||
// usersRepository — граница доступа к данным пользователей, реализуемая
|
||||
// *users_repo.UsersRepo; интерфейс позволяет тестировать сервис без БД.
|
||||
type usersRepository interface {
|
||||
AddUserWithAgreements(
|
||||
ctx context.Context,
|
||||
@@ -60,9 +53,7 @@ type UsersService struct {
|
||||
emailSender email_sender.IEmailSender
|
||||
processorJWT processor_jwt.IProcessorJWT
|
||||
refreshTokensRepo *refresh_tokens_repo.RefreshTokensRepo
|
||||
// appBaseURL — публичный адрес фронтенда для ссылки «Войти в игру» в
|
||||
// письмах; пустое значение — ссылка не добавляется.
|
||||
appBaseURL string
|
||||
appBaseURL string
|
||||
}
|
||||
|
||||
// NewUsersService creates a users service.
|
||||
@@ -85,7 +76,6 @@ func NewUsersService(
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrTermsNotAccepted — регистрация невозможна без акцепта соглашений.
|
||||
ErrTermsNotAccepted = errors.New("Необходимо принять Пользовательское соглашение и Политику конфиденциальности")
|
||||
// ErrInvalidEmail indicates that an email address is invalid.
|
||||
ErrInvalidEmail = errors.New("Некорректный email")
|
||||
@@ -126,8 +116,6 @@ func (s *UsersService) AddUser(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Создание пользователя и фиксация акцептов — в одной транзакции:
|
||||
// без записей о согласиях пользователь не существует.
|
||||
id, err := s.usersRepo.AddUserWithAgreements(
|
||||
ctx,
|
||||
username,
|
||||
@@ -153,12 +141,6 @@ func (s *UsersService) AddUser(
|
||||
return err
|
||||
}
|
||||
|
||||
// Консистентность ответа и состояния БД: если письмо с паролем не
|
||||
// ушло, пользователь не должен оставаться в системе «полусозданным»
|
||||
// (клиент получит ошибку и повторит регистрацию). Компенсация —
|
||||
// удаление созданного пользователя. Выполняется вне отменённого
|
||||
// контекста: письмо могло упасть именно из-за истечения таймаута ctx,
|
||||
// и тогда тот же ctx не дал бы выполнить удаление.
|
||||
err = s.emailSender.Send(ctx, email_sender.Message{
|
||||
To: email,
|
||||
Subject: welcomeSubject,
|
||||
@@ -175,9 +157,6 @@ func (s *UsersService) AddUser(
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAccount удаляет учётную запись и все персональные данные пользователя
|
||||
// (ст. 21 152-ФЗ). Требует подтверждения паролем: обладатель украденного
|
||||
// access-токена не должен мочь удалить чужой аккаунт.
|
||||
func (s *UsersService) DeleteAccount(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
@@ -210,8 +189,6 @@ func (s *UsersService) RefreshPassword(
|
||||
return err
|
||||
}
|
||||
|
||||
// Анти-enumeration: ответ одинаков для существующего и несуществующего
|
||||
// адреса, письмо отправляется только реальному пользователю.
|
||||
user, err := s.usersRepo.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, users_repo.ErrUserNotFound) {
|
||||
@@ -229,9 +206,6 @@ func (s *UsersService) RefreshPassword(
|
||||
return err
|
||||
}
|
||||
|
||||
// Письмо отправляется ДО смены пароля: при сбое отправки пароль
|
||||
// остаётся прежним и аккаунт не блокируется (иначе старый пароль уже
|
||||
// не работает, а новый пользователь так и не узнал).
|
||||
err = s.emailSender.Send(ctx, email_sender.Message{
|
||||
To: user.Email,
|
||||
Subject: resetSubject,
|
||||
@@ -245,7 +219,6 @@ func (s *UsersService) RefreshPassword(
|
||||
return s.usersRepo.UpdateUserPassword(ctx, user.Email, string(hashedPassword))
|
||||
}
|
||||
|
||||
// validateEmail проверяет, что строка является одиночным email-адресом.
|
||||
func validateEmail(email string) error {
|
||||
if email == "" {
|
||||
return ErrInvalidEmail
|
||||
@@ -254,7 +227,6 @@ func validateEmail(email string) error {
|
||||
if err != nil {
|
||||
return ErrInvalidEmail
|
||||
}
|
||||
// Отклоняем форму "Имя <a@b.c>" и всё, что не является чистым адресом.
|
||||
if !strings.EqualFold(addr.Address, email) {
|
||||
return ErrInvalidEmail
|
||||
}
|
||||
@@ -267,7 +239,6 @@ func (s *UsersService) Login(
|
||||
email string,
|
||||
password string,
|
||||
) (string, string, error) {
|
||||
// Сохраняем регистр email, как при регистрации; убираем только внешние пробелы.
|
||||
user, err := s.usersRepo.GetUserByEmail(
|
||||
ctx,
|
||||
strings.TrimSpace(email),
|
||||
@@ -418,11 +389,9 @@ var (
|
||||
ErrPasswordChangeForbidden = errors.New("Изменять чужой пароль может только администратор")
|
||||
// ErrInvalidPasswordChange indicates that the password change request is invalid.
|
||||
ErrInvalidPasswordChange = errors.New("Укажите чужой положительный ID и пароль от 8 до 16 символов")
|
||||
// ErrUserNotFound — целевой пользователь не найден.
|
||||
ErrUserNotFound = errors.New("Пользователь не найден")
|
||||
ErrUserNotFound = errors.New("Пользователь не найден")
|
||||
)
|
||||
|
||||
// ChangeUserPassword сохраняет только хеш; действующие сеансы не изменяются.
|
||||
// ChangeUserPassword changes a user's password subject to authorization checks.
|
||||
func (s *UsersService) ChangeUserPassword(ctx context.Context, actorID, userID int, password string) error {
|
||||
actor, err := s.usersRepo.GetUserByID(ctx, actorID)
|
||||
|
||||
@@ -36,8 +36,6 @@ func (f *fakeSender) sent() []email_sender.Message {
|
||||
return append([]email_sender.Message(nil), f.messages...)
|
||||
}
|
||||
|
||||
// fakeRepo — минимальная in-memory реализация usersRepository для тестов
|
||||
// сервиса без БД.
|
||||
type fakeRepo struct {
|
||||
mu sync.Mutex
|
||||
users map[string]*repos.User
|
||||
@@ -149,8 +147,6 @@ func newTestService(repo usersRepository, sender email_sender.IEmailSender) *Use
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -175,7 +171,6 @@ func TestAddUserSuccess(t *testing.T) {
|
||||
t.Fatalf("AddUser: %v", err)
|
||||
}
|
||||
|
||||
// Убираем внешние пробелы, сохраняя исходный регистр email.
|
||||
if _, ok := repo.users["User@Example.COM"]; !ok {
|
||||
t.Errorf("пользователь не создан с исходным регистром email: %+v", repo.users)
|
||||
}
|
||||
@@ -196,8 +191,6 @@ func TestAddUserSuccess(t *testing.T) {
|
||||
if msgs[0].HTML == "" {
|
||||
t.Error("у письма нет HTML-версии — Gmail хуже рендерит такие письма")
|
||||
}
|
||||
// APP_BASE_URL в тесте пуст — кнопка «Войти в игру» не добавляется,
|
||||
// но каркас письма (шапка сервиса) обязан присутствовать.
|
||||
if !strings.Contains(msgs[0].HTML, "ВЕЧЕРНИЙ ДЕТЕКТИВ") {
|
||||
t.Error("HTML-версия письма не содержит шапку сервиса")
|
||||
}
|
||||
@@ -220,7 +213,6 @@ func TestAddUserWithBaseURLAddsLoginLink(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
@@ -250,7 +242,6 @@ func TestAddUserSendFailureCompensates(t *testing.T) {
|
||||
t.Fatal("ожидалась ошибка при сбое отправки письма")
|
||||
}
|
||||
|
||||
// Компенсация: клиенту ошибка, пользователя в БД нет.
|
||||
if repo.userCount() != 0 {
|
||||
t.Errorf("пользователь остался после ошибки отправки: %+v", repo.users)
|
||||
}
|
||||
@@ -288,7 +279,6 @@ func TestRefreshPasswordSuccess(t *testing.T) {
|
||||
sender := &fakeSender{}
|
||||
svc := newTestService(repo, sender)
|
||||
|
||||
// Внешние пробелы убираются, регистр остаётся исходным.
|
||||
err := svc.RefreshPassword(context.Background(), " User@Example.COM ")
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshPassword: %v", err)
|
||||
@@ -325,7 +315,6 @@ func TestRefreshPasswordUnknownEmail(t *testing.T) {
|
||||
sender := &fakeSender{}
|
||||
svc := newTestService(repo, sender)
|
||||
|
||||
// Анти-enumeration: для несуществующего адреса — успех и без письма.
|
||||
if err := svc.RefreshPassword(context.Background(), "nobody@example.com"); err != nil {
|
||||
t.Fatalf("для несуществующего email ожидался успех, получено %v", err)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package users_service
|
||||
|
||||
import "time"
|
||||
|
||||
// User — пользователь, доступный прикладным сценариям и выдаче наружу.
|
||||
// User describes a user exposed by the service layer.
|
||||
type User struct {
|
||||
ID int
|
||||
|
||||
Reference in New Issue
Block a user