generated from VLADIMIR/template
374 lines
15 KiB
Go
374 lines
15 KiB
Go
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),
|
||
})
|
||
}
|