This commit is contained in:
2026-10-03 13:22:11 +07:00
parent 05090722a0
commit 2caa2c1a88
74 changed files with 44 additions and 676 deletions
+2 -24
View File
@@ -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)
+1 -50
View File
@@ -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)
-5
View File
@@ -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
View File
@@ -291,7 +291,7 @@ go test ./...
## 11. Именование и стиль
- Все комментарии в исходном Go-коде пиши на русском языке. Сгенерированные
- Все комментарии в исходном Go-коде пиши на английском языке. Сгенерированные
файлы и комментарии внешних библиотек не редактируй.
- Имена короткие и предметные: `gameID`, `teamID`, `ctx`, `err` допустимы по
общему Go-стилю.
+2 -3
View File
@@ -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"
-2
View File
@@ -42,8 +42,6 @@ func TestChangeUserPasswordAccess(t *testing.T) {
}
}
// Встраиваем настоящий репозиторий, чтобы неожиданные вызовы завершались
// ошибкой, а не проходили незаметно.
type passwordRepo struct {
*users_repo.UsersRepo
role string
-3
View File
@@ -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
-3
View File
@@ -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, "не удалось установить заголовки файла")
}
-5
View File
@@ -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
View File
@@ -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 {
-1
View File
@@ -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)
}
-3
View File
@@ -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(
`"`, "_",
+1 -6
View File
@@ -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),
-1
View File
@@ -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")
-3
View File
@@ -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
View File
@@ -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 == "" {
+1 -26
View File
@@ -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)
+1 -18
View File
@@ -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 {
-10
View File
@@ -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
-3
View File
@@ -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
}
-2
View File
@@ -1,7 +1,5 @@
package roles
// HasRole возвращает true, если у субъекта есть роль; администратор
// проходит проверку любой роли.
func HasRole(claims IHasRole, role string) bool {
if claims.HasRole(Admin) {
return true
-2
View File
@@ -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)
}
+5 -27
View File
@@ -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)
}
-7
View File
@@ -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 -4
View File
@@ -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
}
-7
View File
@@ -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,
-13
View File
@@ -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,
-7
View File
@@ -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,
-3
View File
@@ -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]
+2 -7
View File
@@ -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
+1 -27
View File
@@ -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`,
+1 -7
View File
@@ -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
+3 -22
View File
@@ -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)
+6 -41
View File
@@ -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
+1 -20
View File
@@ -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()
+1 -3
View File
@@ -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()
-23
View File
@@ -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:
+1 -11
View File
@@ -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.
+1 -3
View File
@@ -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)
+1 -23
View File
@@ -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 {
+2 -33
View File
@@ -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)
}
-1
View File
@@ -2,7 +2,6 @@ package users_service
import "time"
// User — пользователь, доступный прикладным сценариям и выдаче наружу.
// User describes a user exposed by the service layer.
type User struct {
ID int