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

381 lines
14 KiB
Go

// 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
const maxFileSize = 64 << 20
const maxFileUploadBody = (int64(maxFileSize)+2)/3*4 + 2*1024
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)
}
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)
}),
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)
}
})
mainMux.Handle("/api/", limitUploadBody(
"/api/files/upload",
maxFileUploadBody,
limitUploadBody(
"/api/scenarios/archive",
int64(scenario_archive.MaxArchiveSize()),
gwmux,
),
))
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")
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")
w.Header().Set("Access-Control-Expose-Headers", "Content-Disposition, Mcp-Session-Id, Mcp-Protocol-Version")
if r.Method == "OPTIONS" {
return
}
h.ServeHTTP(w, r)
})
}
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)
})
}
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)
}
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),
})
}