| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351 |
- // Package main — точка входа HTTP API сервера Photoplaces.
- // Инициализирует конфигурацию, логгер, подключение к БД и Redis,
- // репозитории, сервисы, хендлеры, middleware (CORS, rate limiting, auth)
- // и запускает HTTP сервер на chi-роутере с graceful shutdown.
- package main
- import (
- "context"
- "log/slog"
- "net/http"
- "os"
- "os/signal"
- "syscall"
- "time"
- "github.com/go-chi/chi/v5"
- chimiddleware "github.com/go-chi/chi/v5/middleware"
- "github.com/go-chi/cors"
- "github.com/redis/go-redis/v9"
- "golang.org/x/time/rate"
- "gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/config"
- "gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/handlers"
- "gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/log"
- "gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/middleware"
- "gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/repository"
- "gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/services"
- )
- func main() {
- cfg := config.Load()
- ctx := context.Background()
- // Инициализация структурированного логгера
- logger := log.Init(cfg.AppEnv)
- ctx = log.WithContext(ctx, logger)
- logger.InfoContext(ctx, "starting application",
- slog.String("env", cfg.AppEnv),
- slog.String("port", cfg.ServerPort),
- )
- // Подключение к базе данных
- pool, err := repository.NewPool(ctx, cfg.DatabaseURL)
- if err != nil {
- logger.ErrorContext(ctx, "database connection failed", log.WithError(err))
- os.Exit(1)
- }
- defer pool.Close()
- logger.InfoContext(ctx, "database connected")
- // Репозитории
- userRepo := repository.NewUserRepo(pool)
- refreshTokenRepo := repository.NewRefreshTokenRepo(pool)
- placeRepo := repository.NewPlaceRepo(pool)
- serviceRepo := repository.NewServiceRepo(pool)
- reviewRepo := repository.NewReviewRepo(pool)
- bookingRepo := repository.NewBookingRepo(pool)
- tagRepo := repository.NewTagRepo(pool)
- featureRepo := repository.NewFeatureRepo(pool)
- // Сервисы
- authSvc := services.NewAuthService(userRepo, refreshTokenRepo, cfg.JWTSecret, cfg.JWTRefreshSecret)
- placeSvc := services.NewPlaceService(placeRepo)
- // Set AppEnv for error handling (production hides internal errors)
- handlers.AppEnv = cfg.AppEnv
- // Фоновая очистка истёкших refresh токенов (каждые 6 часов)
- go func() {
- ticker := time.NewTicker(6 * time.Hour)
- defer ticker.Stop()
- for {
- select {
- case <-ticker.C:
- if err := refreshTokenRepo.CleanupExpired(ctx); err != nil {
- logger.ErrorContext(ctx, "cleanup expired tokens failed", log.WithError(err))
- }
- case <-ctx.Done():
- return
- }
- }
- }()
- // Хендлеры
- authHandler := handlers.NewAuthHandler(authSvc, cfg.AppEnv)
- userHandler := handlers.NewUserHandler(userRepo)
- placeHandler := handlers.NewPlaceHandler(placeSvc, authSvc)
- serviceHandler := handlers.NewServiceHandler(serviceRepo, tagRepo)
- reviewHandler := handlers.NewReviewHandler(reviewRepo)
- bookingHandler := handlers.NewBookingHandler(bookingRepo)
- tagHandler := handlers.NewTagHandler(tagRepo, featureRepo)
- wsHub := handlers.NewWSHub(cfg.AllowedOrigins, cfg.AppEnv == "production", authSvc)
- go wsHub.Run()
- setupHandler := handlers.NewSetupHandler(userRepo, authSvc, cfg.AppEnv)
- uploadHandler, err := handlers.NewUploadHandler(cfg.S3Endpoint, cfg.S3PublicEndpoint, cfg.S3AccessKey, cfg.S3SecretKey, cfg.S3Bucket, false)
- if err != nil {
- logger.ErrorContext(ctx, "upload handler init failed", log.WithError(err))
- os.Exit(1)
- }
- // Redis для rate limiting
- redisOpts, err := redis.ParseURL(cfg.RedisURL)
- if err != nil {
- logger.ErrorContext(ctx, "parse redis url failed", log.WithError(err))
- os.Exit(1)
- }
- redisClient := redis.NewClient(redisOpts)
- defer redisClient.Close()
- redisAvailable := true
- if err := redisClient.Ping(ctx).Err(); err != nil {
- logger.WarnContext(ctx, "redis connection failed, falling back to in-memory rate limiting", log.WithError(err))
- redisAvailable = false
- } else {
- logger.InfoContext(ctx, "redis connected")
- }
- failOpen := cfg.AppEnv != "production"
- var (
- authLimiter rateLimiterInterface
- apiReadLimiter rateLimiterInterface
- apiWriteLimiter rateLimiterInterface
- adminLimiter rateLimiterInterface
- )
- // Create rate limiters with Redis fallback to in-memory
- authLimiter = newRateLimiterWithFallback(redisClient, redisAvailable, logger, failOpen,
- func(client *redis.Client, log *slog.Logger, fo bool) (rateLimiterInterface, error) {
- return middleware.RateLimitAuthEndpoints(client, log, fo)
- },
- func() *middleware.RateLimiter { return middleware.NewRateLimiter(rate.Limit(30/60.0), 30) }, // 30 req/min
- )
- apiReadLimiter = newRateLimiterWithFallback(redisClient, redisAvailable, logger, failOpen,
- func(client *redis.Client, log *slog.Logger, fo bool) (rateLimiterInterface, error) {
- return middleware.RateLimitAPIRead(client, log, fo)
- },
- func() *middleware.RateLimiter { return middleware.NewRateLimiter(rate.Limit(60/60.0), 60) }, // 60 req/min
- )
- apiWriteLimiter = newRateLimiterWithFallback(redisClient, redisAvailable, logger, failOpen,
- func(client *redis.Client, log *slog.Logger, fo bool) (rateLimiterInterface, error) {
- return middleware.RateLimitAPIWrite(client, log, fo)
- },
- func() *middleware.RateLimiter { return middleware.NewRateLimiter(rate.Limit(10/60.0), 10) }, // 10 req/min
- )
- adminLimiter = newRateLimiterWithFallback(redisClient, redisAvailable, logger, failOpen,
- func(client *redis.Client, log *slog.Logger, fo bool) (rateLimiterInterface, error) {
- return middleware.RateLimitAdmin(client, log, fo)
- },
- func() *middleware.RateLimiter { return middleware.NewRateLimiter(rate.Limit(100/60.0), 100) }, // 100 req/min
- )
- // Роутер
- r := chi.NewRouter()
- // Middleware для логирования запросов
- r.Use(requestLoggerMiddleware(logger))
- r.Use(chimiddleware.Recoverer)
- r.Use(chimiddleware.RequestID)
- r.Use(cors.Handler(cors.Options{
- AllowedOrigins: cfg.AllowedOrigins,
- AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
- AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
- AllowCredentials: true,
- MaxAge: 300,
- }))
- r.Route("/api/v1", func(r chi.Router) {
- r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
- w.Write([]byte(`{"status":"ok"}`))
- })
- // Admin setup (first superadmin creation)
- r.Get("/admin/setup-status", setupHandler.Status)
- r.Post("/admin/setup", setupHandler.Setup)
- // Public auth - strict rate limiting
- r.Group(func(r chi.Router) {
- if authLimiter != nil {
- r.Use(authLimiter.Middleware())
- }
- r.Post("/auth/register", authHandler.Register)
- r.Post("/auth/login", authHandler.Login)
- r.Post("/auth/refresh", authHandler.Refresh)
- })
- // Public read endpoints - moderate rate limiting
- r.Group(func(r chi.Router) {
- if apiReadLimiter != nil {
- r.Use(apiReadLimiter.Middleware())
- }
- r.Get("/reviews", reviewHandler.List)
- r.Get("/tags", tagHandler.ListTags)
- r.Get("/features", tagHandler.ListFeatures)
- r.Get("/services", serviceHandler.List)
- r.Get("/services/{id}", serviceHandler.GetByID)
- r.Get("/places", placeHandler.List)
- r.Get("/places/{id}", placeHandler.GetByID)
- r.Get("/users/{id}", userHandler.GetProfile)
- r.Get("/geocode/reverse", handlers.GeocodeReverse)
- })
- // WebSocket visitor dots
- r.Get("/ws/visitors", wsHub.HandleWS)
- // Authenticated write endpoints - stricter rate limiting
- r.Group(func(r chi.Router) {
- r.Use(middleware.AuthMiddleware(authSvc))
- if apiWriteLimiter != nil {
- r.Use(apiWriteLimiter.Middleware())
- }
- r.Get("/auth/me", authHandler.Me)
- r.Post("/auth/logout", authHandler.Logout)
- // Users
- r.Patch("/users/me", userHandler.UpdateMe)
- // Upload
- r.Post("/upload/presigned-url", uploadHandler.PresignedURL)
- r.Post("/upload", uploadHandler.UploadFile)
- // Bookings
- r.Post("/bookings", bookingHandler.Create)
- r.Get("/bookings/me", bookingHandler.ListMy)
- r.Patch("/bookings/{id}/cancel", bookingHandler.Cancel)
- // Reviews
- r.Post("/reviews", reviewHandler.Create)
- // Services
- r.Post("/services", serviceHandler.Create)
- r.Patch("/services/{id}", serviceHandler.Update)
- r.Delete("/services/{id}", serviceHandler.Delete)
- // Places
- r.Get("/places/my", placeHandler.ListMy)
- r.Post("/places", placeHandler.Create)
- r.Patch("/places/{id}", placeHandler.Update)
- r.Delete("/places/{id}", placeHandler.Delete)
- })
- // Moderator / Admin - higher limits
- r.Group(func(r chi.Router) {
- r.Use(middleware.AuthMiddleware(authSvc))
- r.Use(middleware.RoleMiddleware("moderator", "superadmin"))
- if adminLimiter != nil {
- r.Use(adminLimiter.Middleware())
- }
- r.Post("/places/{id}/moderate", placeHandler.Moderate)
- r.Get("/admin/users", userHandler.AdminListUsers)
- r.Post("/admin/users", userHandler.AdminCreateUser)
- r.Patch("/admin/users/{id}", userHandler.AdminUpdateUser)
- r.Patch("/admin/bookings/{id}/confirm", bookingHandler.Confirm)
- r.Post("/admin/tags", tagHandler.CreateTag)
- r.Delete("/admin/tags", tagHandler.DeleteTag)
- r.Post("/admin/features", tagHandler.CreateFeature)
- r.Delete("/admin/features", tagHandler.DeleteFeature)
- })
- })
- // HTTP сервер с graceful shutdown
- server := &http.Server{
- Addr: ":" + cfg.ServerPort,
- Handler: r,
- ReadTimeout: 15 * time.Second,
- WriteTimeout: 15 * time.Second,
- IdleTimeout: 60 * time.Second,
- }
- // Запуск сервера в горутине
- go func() {
- logger.InfoContext(ctx, "starting http server", slog.String("addr", server.Addr))
- if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- logger.ErrorContext(ctx, "http server error", log.WithError(err))
- os.Exit(1)
- }
- }()
- // Ожидание сигнала завершения
- quit := make(chan os.Signal, 1)
- signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
- <-quit
- logger.InfoContext(ctx, "shutdown signal received, gracefully stopping...")
- // Graceful shutdown с таймаутом
- shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- if err := server.Shutdown(shutdownCtx); err != nil {
- logger.ErrorContext(ctx, "server shutdown failed", log.WithError(err))
- os.Exit(1)
- }
- logger.InfoContext(ctx, "server stopped gracefully")
- }
- // requestLoggerMiddleware логирует входящие HTTP запросы
- func requestLoggerMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
- return func(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- start := time.Now()
- ww := chimiddleware.NewWrapResponseWriter(w, r.ProtoMajor)
- requestID := chimiddleware.GetReqID(r.Context())
- ctx := log.WithContext(r.Context(), logger.With("request_id", requestID))
- logger.InfoContext(ctx, "request started",
- slog.String("method", r.Method),
- slog.String("path", r.URL.Path),
- slog.String("remote_addr", r.RemoteAddr),
- slog.String("user_agent", r.UserAgent()),
- )
- next.ServeHTTP(ww, r.WithContext(ctx))
- logger.InfoContext(ctx, "request completed",
- slog.Int("status", ww.Status()),
- slog.Int("bytes_written", ww.BytesWritten()),
- slog.Duration("duration", time.Since(start)),
- )
- })
- }
- }
- // rateLimiterInterface defines the common interface for rate limiters
- type rateLimiterInterface interface {
- Middleware() func(http.Handler) http.Handler
- }
- // newRateLimiterWithFallback creates a rate limiter with Redis as primary and in-memory as fallback
- func newRateLimiterWithFallback(
- redisClient *redis.Client,
- redisAvailable bool,
- logger *slog.Logger,
- failOpen bool,
- redisLimiterFn func(*redis.Client, *slog.Logger, bool) (rateLimiterInterface, error),
- inMemoryLimiterFn func() *middleware.RateLimiter,
- ) rateLimiterInterface {
- if redisAvailable {
- if limiter, err := redisLimiterFn(redisClient, logger, failOpen); err == nil {
- logger.InfoContext(context.Background(), "using Redis rate limiter")
- return limiter
- }
- logger.WarnContext(context.Background(), "failed to create Redis rate limiter, falling back to in-memory")
- }
- logger.InfoContext(context.Background(), "using in-memory rate limiter")
- return inMemoryLimiterFn()
- }
|