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