Files
evening_detective_server/cmd/evening_detective_server/main.go
T
2026-10-03 13:07:50 +07:00

430 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package main starts the evening detective backend.
package main
import (
"context"
_ "embed"
"errors"
"evening_detective_server/internal/app"
"evening_detective_server/internal/modules/cleaner"
"evening_detective_server/internal/modules/email_sender"
"evening_detective_server/internal/modules/file_storage"
"evening_detective_server/internal/modules/network_utils"
"evening_detective_server/internal/modules/password_generator"
"evening_detective_server/internal/modules/processor_jwt"
"evening_detective_server/internal/modules/scenario_archive"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/repos/actions_repo"
"evening_detective_server/internal/repos/applications_repo"
"evening_detective_server/internal/repos/game_map_repo"
"evening_detective_server/internal/repos/game_reviews_repo"
"evening_detective_server/internal/repos/games_repo"
"evening_detective_server/internal/repos/health_repo"
"evening_detective_server/internal/repos/refresh_tokens_repo"
"evening_detective_server/internal/repos/scenarios_repo"
"evening_detective_server/internal/repos/screen_sessions_repo"
"evening_detective_server/internal/repos/team_answers_repo"
"evening_detective_server/internal/repos/teams_repo"
"evening_detective_server/internal/repos/tickets_repo"
"evening_detective_server/internal/repos/users_repo"
"evening_detective_server/internal/services/answer_service"
"evening_detective_server/internal/services/file_service"
"evening_detective_server/internal/services/game_review_service"
"evening_detective_server/internal/services/game_service"
"evening_detective_server/internal/services/gameplay_service"
"evening_detective_server/internal/services/map_service"
"evening_detective_server/internal/services/mcp_service"
"evening_detective_server/internal/services/participation_service"
"evening_detective_server/internal/services/scenarios_service"
"evening_detective_server/internal/services/screen_service"
"evening_detective_server/internal/services/team_service"
"evening_detective_server/internal/services/ticket_service"
"evening_detective_server/internal/services/ui_service"
"evening_detective_server/internal/services/users_service"
proto "evening_detective_server/proto"
"fmt"
"log"
"net"
"net/http"
"os"
"strings"
"time"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
"github.com/mark3labs/mcp-go/server"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson"
"github.com/swaggest/swgui/v5emb"
)
//go:embed main.swagger.json
var swaggerJSON []byte
// maxFileSize — максимальный размер файла, передаваемого через gRPC
const maxFileSize = 64 << 20 // 64 МБ
// 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() {
_ = godotenv.Load()
dbpool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatalf("Unable to create connection pool: %v\n", err)
}
defer dbpool.Close()
if err := health_repo.New(dbpool).Check(context.Background()); err != nil {
log.Fatalf("database check failed: %v\n", err)
}
usersRepo := users_repo.NewUserRepo(dbpool)
passwordGenerator := password_generator.NewGenerator(8)
smtpTimeout := 10 * time.Second
if v := os.Getenv("SMTP_TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
smtpTimeout = d
}
}
emailSender := email_sender.NewSender(
os.Getenv("SMTP_HOST"),
os.Getenv("SMTP_PORT"),
os.Getenv("SMTP_USER"),
os.Getenv("SMTP_PASSWORD"),
"Вечерний детектив",
os.Getenv("SMTP_REPLY_TO"),
smtpTimeout,
)
processorJWT := processor_jwt.NewProcessor(os.Getenv("JWT_SECRET"))
refreshTokensRepo := refresh_tokens_repo.NewRefreshTokensRepo(dbpool)
usersService := users_service.NewUsersService(usersRepo, passwordGenerator, emailSender, processorJWT, refreshTokensRepo, os.Getenv("APP_BASE_URL"))
uiService := ui_service.NewUIService()
fileStorage, err := file_storage.NewRustFSStorage(
context.Background(),
os.Getenv("S3_HOST"),
os.Getenv("S3_USER"),
os.Getenv("S3_PASSWORD"),
os.Getenv("S3_BUCKET"),
)
if err != nil {
log.Fatalf("Unable to create connection rustfs: %v\n", err)
}
fileService := file_service.NewFileService(fileStorage, maxFileSize)
scenariosRepo := scenarios_repo.NewScenariosRepo(dbpool)
cleaner := cleaner.NewCleaner()
scenarioService := scenarios_service.NewScenarioService(
scenariosRepo,
cleaner,
os.Getenv("FILE_PREFIX_DOMAIN"),
fileStorage,
scenario_archive.NewScenarioArchive(),
)
gameRepo := games_repo.NewGamesRepo(dbpool)
teamRepo := teams_repo.NewTeamsRepo(dbpool)
ticketRepo := tickets_repo.New(dbpool)
storyteller := storytelling.NewStory(cleaner)
actionsRepo := actions_repo.NewActionsRepo(dbpool)
applicationsRepo := applications_repo.NewApplicationsRepo(dbpool)
answersRepo := team_answers_repo.NewTeamAnswersRepo(dbpool)
screenRepo := screen_sessions_repo.NewScreenSessionsRepo(dbpool)
screenService := screen_service.NewScreenService(screenRepo)
mapService := map_service.NewMapService(game_map_repo.NewGameMapRepo(dbpool))
reviewsRepo := game_reviews_repo.NewGameReviewsRepo(dbpool)
gameService := game_service.NewGameService(
gameRepo,
teamRepo,
scenariosRepo,
scenarioService,
os.Getenv("FILE_PREFIX_DOMAIN"),
actionsRepo,
applicationsRepo,
)
gameplayService := gameplay_service.NewGameplayService(gameRepo, teamRepo, scenarioService, storyteller, actionsRepo, applicationsRepo, cleaner, os.Getenv("FILE_PREFIX_DOMAIN"))
answerService := answer_service.NewAnswerService(gameRepo, teamRepo, scenarioService, answersRepo, actionsRepo)
reviewService := game_review_service.NewGameReviewService(reviewsRepo, gameRepo, teamRepo, os.Getenv("APP_BASE_URL"))
teamService := team_service.NewTeamService(gameRepo, teamRepo, passwordGenerator, os.Getenv("APP_BASE_URL"), emailSender)
participationService := participation_service.NewParticipationService(teamRepo, os.Getenv("APP_BASE_URL"))
ticketService := ticket_service.NewTicketService(ticketRepo, os.Getenv("APP_BASE_URL"))
go participationService.RunParticipationEmails(context.Background(), emailSender, os.Getenv("PARTICIPATION_SECRET"), os.Getenv("GAME_TIMEZONE"))
go reviewService.RunReviewReminderEmails(context.Background(), emailSender)
lis, err := net.Listen("tcp", ":8080")
if err != nil {
log.Fatalln("Failed to listen:", err)
}
s := grpc.NewServer(
grpc.MaxRecvMsgSize(grpcMsgLimit),
grpc.UnaryInterceptor(
processor_jwt.NewAuthorizationInterceptor(
map[string]bool{
"/crabs.evening_detective_server.EveningDetectiveServer/GetParticipation": true,
"/crabs.evening_detective_server.EveningDetectiveServer/GetTicket": true,
"/crabs.evening_detective_server.EveningDetectiveServer/AnswerParticipation": true,
"/crabs.evening_detective_server.EveningDetectiveServer/Ping": true,
"/crabs.evening_detective_server.EveningDetectiveServer/Echo": true,
"/crabs.evening_detective_server.EveningDetectiveServer/Signup": true,
"/crabs.evening_detective_server.EveningDetectiveServer/RefreshPassword": true,
"/crabs.evening_detective_server.EveningDetectiveServer/Login": true,
"/crabs.evening_detective_server.EveningDetectiveServer/Refresh": true,
"/crabs.evening_detective_server.EveningDetectiveServer/DownloadFile": true,
"/crabs.evening_detective_server.EveningDetectiveServer/GetScenario": true,
"/crabs.evening_detective_server.EveningDetectiveServer/GetScenariosCatalog": true,
"/crabs.evening_detective_server.EveningDetectiveServer/GetTeamStory": true,
"/crabs.evening_detective_server.EveningDetectiveServer/AddTeamAction": true,
"/crabs.evening_detective_server.EveningDetectiveServer/GetTeamAnswers": true,
"/crabs.evening_detective_server.EveningDetectiveServer/SubmitTeamAnswers": true,
"/crabs.evening_detective_server.EveningDetectiveServer/GetTerms": true,
"/crabs.evening_detective_server.EveningDetectiveServer/GetPrivacy": true,
"/crabs.evening_detective_server.EveningDetectiveServer/GetConsent": true,
"/crabs.evening_detective_server.EveningDetectiveServer/CreateScreenSession": true,
"/crabs.evening_detective_server.EveningDetectiveServer/GetScreenState": true,
"/crabs.evening_detective_server.EveningDetectiveServer/GetScreenResults": true,
"/crabs.evening_detective_server.EveningDetectiveServer/CreateGameReview": true,
"/crabs.evening_detective_server.EveningDetectiveServer/GetGameReviews": true,
},
processorJWT,
),
),
)
proto.RegisterEveningDetectiveServerServer(
s,
app.NewServer(
usersService,
uiService,
fileService,
scenarioService,
gameService,
gameplayService,
answerService,
reviewService,
teamService,
ticketService,
participationService,
screenService,
mapService,
),
)
log.Println("Serving gRPC on 0.0.0.0:8080")
go func() {
log.Fatalln(s.Serve(lis))
}()
conn, err := grpc.NewClient(
"0.0.0.0:8080",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultCallOptions(
grpc.MaxCallSendMsgSize(grpcMsgLimit),
grpc.MaxCallRecvMsgSize(grpcMsgLimit),
),
)
if err != nil {
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{
UnmarshalOptions: protojson.UnmarshalOptions{DiscardUnknown: true},
},
},
}
gwmux := runtime.NewServeMux(
runtime.WithIncomingHeaderMatcher(func(key string) (string, bool) {
switch {
case strings.EqualFold(key, "X-Real-IP"):
return "x-real-ip", true
case strings.EqualFold(key, "X-Password"):
return "x-password", true
}
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"):
return "Content-Disposition", true
case strings.EqualFold(key, "X-Content-Type-Options"):
return "X-Content-Type-Options", true
case strings.EqualFold(key, "Cache-Control"):
return "Cache-Control", true
}
return runtime.DefaultHeaderMatcher(key)
}),
runtime.WithErrorHandler(customErrorHandler),
runtime.WithMarshalerOption("application/zip", rawBody),
runtime.WithMarshalerOption("application/octet-stream", rawBody),
runtime.WithMarshalerOption("application/x-zip-compressed", rawBody),
)
err = proto.RegisterEveningDetectiveServerHandler(context.Background(), gwmux, conn)
if err != nil {
log.Fatalln("Failed to register gateway:", err)
}
mainMux := http.NewServeMux()
mainMux.Handle("POST /api/games/{id}/registrations", app.TeamRegistrationHandler(os.Getenv("YANDEX_FORMS_TOKEN"), teamService.RegisterTeam))
docsHandler := v5emb.New("API", "/swagger/doc.json", "/docs/")
mainMux.Handle("/docs/", docsHandler)
mainMux.HandleFunc("/swagger/doc.json", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write(swaggerJSON); err != nil {
log.Printf("write swagger document: %v", err)
}
})
// MaxBytesReader на HTTP-слое: gateway буферизует тело целиком, без лимита
// проверки в хендлере не спасут от OOM. Лимиты по маршрутам: файлы —
// maxFileUploadBody, архивы — MaxArchiveSize().
mainMux.Handle("/api/", limitUploadBody(
"/api/files/upload",
maxFileUploadBody,
limitUploadBody(
"/api/scenarios/archive",
int64(scenario_archive.MaxArchiveSize()),
gwmux,
),
))
// 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),
)
mainMux.Handle("/api/mcp", mcpHTTP)
gwServer := &http.Server{
Addr: ":8090",
Handler: cors(mainMux),
}
log.Println("Serving gRPC-Gateway on http://0.0.0.0:8090")
log.Println("Serving docs on http://0.0.0.0:8090/docs")
logLocalAddresses()
log.Fatalln(gwServer.ListenAndServe())
}
func logLocalAddresses() {
addrs, err := network_utils.GetLocalIPv4Addresses()
if err != nil {
log.Printf("Unable to detect local network address: %v", err)
return
}
for _, ip := range addrs {
log.Printf("Local network: http://%s:8090 (docs: http://%s:8090/docs/)", ip, ip)
}
}
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
}
h.ServeHTTP(w, r)
})
}
// 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 {
r.Body = http.MaxBytesReader(w, r.Body, limit)
}
next.ServeHTTP(w, r)
})
}
// 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,
marshaler runtime.Marshaler,
w http.ResponseWriter,
r *http.Request,
err error,
) {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeTooLargeError(w, marshaler)
return
}
if status.Code(err) == codes.ResourceExhausted {
switch r.URL.Path {
case "/api/files/upload", "/api/scenarios/archive":
writeTooLargeError(w, marshaler)
return
}
}
if status.Code(err) == codes.InvalidArgument &&
strings.Contains(status.Convert(err).Message(), "request body too large") {
writeTooLargeError(w, marshaler)
return
}
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)
_ = marshaler.NewEncoder(w).Encode(map[string]string{
"error": fmt.Sprintf("файл слишком большой (лимит %d МБ)", maxFileSize>>20),
})
}