// 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() }