Files
evening_detective_server/cmd/evening_detective_server/main.go
T
2026-08-30 21:17:32 +07:00

374 lines
15 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
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/password_generator"
"evening_detective_server/internal/modules/processor_jwt"
"evening_detective_server/internal/modules/scenario_archive"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/modules/string_tools"
"evening_detective_server/internal/repos/actions_repo"
"evening_detective_server/internal/repos/applications_repo"
"evening_detective_server/internal/repos/games_repo"
"evening_detective_server/internal/repos/refresh_tokens_repo"
"evening_detective_server/internal/repos/scenarios_repo"
"evening_detective_server/internal/repos/teams_repo"
"evening_detective_server/internal/repos/users_repo"
"evening_detective_server/internal/services/file_service"
"evening_detective_server/internal/services/game_service"
"evening_detective_server/internal/services/mcp_service"
"evening_detective_server/internal/services/scenarios_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()
// PostgreSQL
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()
// Test request to DB
var greeting string
if err = dbpool.QueryRow(context.Background(), "select 'Hello, world!'").Scan(&greeting); err != nil {
log.Fatalf("QueryRow failed: %v\n", err)
}
usersRepo := users_repo.NewUserRepo(dbpool)
passwordGenerator := password_generator.NewGenerator(12)
smtpTimeout := 10 * time.Second
if v := os.Getenv("SMTP_TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
smtpTimeout = d
}
}
emailSender := email_sender.NewSender(
os.Getenv("SMTP_HOST"),
os.Getenv("SMTP_PORT"),
os.Getenv("SMTP_USER"),
os.Getenv("SMTP_PASSWORD"),
os.Getenv("SMTP_FROM_NAME"),
os.Getenv("SMTP_REPLY_TO"),
smtpTimeout,
)
processorJWT := processor_jwt.NewProcessor(os.Getenv("JWT_SECRET"))
refreshTokensRepo := refresh_tokens_repo.NewRefreshTokensRepo(dbpool)
usersService := users_service.NewUsersService(usersRepo, passwordGenerator, emailSender, processorJWT, refreshTokensRepo, os.Getenv("APP_BASE_URL"))
uiService := ui_service.NewUiService()
fileStorage, err := file_storage.NewRustFSStorage(
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, string_tools.NewStringTools(), 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)
storyteller := storytelling.NewStory(cleaner)
actionsRepo := actions_repo.NewActionsRepo(dbpool)
applicationsRepo := applications_repo.NewApplicationsRepo(dbpool)
gameService := game_service.NewGameService(
gameRepo,
teamRepo,
scenariosRepo,
scenarioService,
os.Getenv("FILE_PREFIX_DOMAIN"),
passwordGenerator,
storyteller,
actionsRepo,
applicationsRepo,
cleaner,
)
// Create a listener on TCP port
lis, err := net.Listen("tcp", ":8080")
if err != nil {
log.Fatalln("Failed to listen:", err)
}
// Create a gRPC server object
s := grpc.NewServer(
grpc.MaxRecvMsgSize(grpcMsgLimit),
grpc.UnaryInterceptor(
processor_jwt.NewAuthorizationInterceptor(
map[string]bool{
"/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/GetTerms": true,
"/crabs.evening_detective_server.EveningDetectiveServer/GetPrivacy": true,
"/crabs.evening_detective_server.EveningDetectiveServer/GetConsent": true,
},
processorJWT,
),
),
)
// Attach the Greeter service to the server
proto.RegisterEveningDetectiveServerServer(
s,
app.NewServer(
usersService,
uiService,
fileService,
scenarioService,
gameService,
),
)
// Serve gRPC server
log.Println("Serving gRPC on 0.0.0.0:8080")
go func() {
log.Fatalln(s.Serve(lis))
}()
// Create a client connection to the gRPC server we just started
// This is where the gRPC-Gateway proxies the requests
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
}
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),
)
// Register Greeter
err = proto.RegisterEveningDetectiveServerHandler(context.Background(), gwmux, conn)
if err != nil {
log.Fatalln("Failed to register gateway:", err)
}
mainMux := http.NewServeMux()
docsHandler := v5emb.New("API", "/swagger/doc.json", "/docs/")
mainMux.Handle("/docs/", docsHandler)
mainMux.HandleFunc("/swagger/doc.json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write(swaggerJSON)
})
// 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(gameService).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")
log.Fatalln(gwServer.ListenAndServe())
}
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),
})
}