package main import ( "context" "fmt" "io" "log/slog" "os" "os/signal" "syscall" "time" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" flog "github.com/gofiber/fiber/v2/middleware/logger" "github.com/gofiber/fiber/v2/middleware/limiter" "github.com/gofiber/fiber/v2/middleware/recover" "github.com/minio/minio-go/v7" "github.com/buhapp/backend/internal/audit" "github.com/buhapp/backend/internal/auth" "github.com/buhapp/backend/internal/chat" "github.com/buhapp/backend/internal/config" "github.com/buhapp/backend/internal/consent" "github.com/buhapp/backend/internal/db" "github.com/buhapp/backend/internal/handlers" "github.com/buhapp/backend/internal/locations" "github.com/buhapp/backend/internal/preferences" "github.com/buhapp/backend/internal/redis" "github.com/buhapp/backend/internal/reviews" "github.com/buhapp/backend/internal/storage" "github.com/buhapp/backend/internal/telegram" "github.com/buhapp/backend/internal/users" "github.com/buhapp/backend/internal/ws" ) func main() { // структурный логгер — JSON для prod logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) slog.SetDefault(logger) cfg, err := config.Load() if err != nil { logger.Error("config load failed", "err", err) os.Exit(1) } ctx := context.Background() pg, err := db.New(ctx, cfg.PostgresDSN()) if err != nil { logger.Error("postgres connect failed", "err", err) os.Exit(1) } defer pg.Close() logger.Info("postgres connected") migDir := os.Getenv("MIGRATIONS_DIR") if migDir == "" { migDir = "./migrations" } if err := db.RunMigrations(ctx, pg, migDir); err != nil { logger.Error("migrations failed", "err", err) os.Exit(1) } rdb, err := redis.New(ctx, cfg.RedisAddr(), cfg.RedisPassword, cfg.RedisDB) if err != nil { logger.Warn("redis not available", "err", err) } else { logger.Info("redis connected") } st, err := storage.New(ctx, cfg.MinIOEndpoint, cfg.MinIOAccessKey, cfg.MinIOSecretKey, cfg.MinIOBucket, cfg.MinIOUseSSL) if err != nil { logger.Warn("minio not available", "err", err) } else { logger.Info("minio connected") } usersRepo := users.NewRepo(pg.Pool) consentRepo := consent.NewRepo(pg.Pool) auditRepo := audit.NewRepo(pg.Pool) prefsRepo := preferences.NewRepo(pg.Pool) locsRepo := locations.NewRepo(pg.Pool) chatRepo := chat.NewRepo(pg.Pool) reviewRepo := reviews.NewRepo(pg.Pool) authSvc := auth.NewService(cfg.JWTSecret, cfg.JwTAccessTTL(), cfg.JwtRefreshTTL()) hub := ws.NewHub() var bot *telegram.Bot var tgH *handlers.TGHandler if cfg.TelegramBotToken != "" { bot = telegram.NewBot(cfg.TelegramBotToken) tgH = handlers.NewTGHandler(bot, usersRepo, authSvc, consentRepo, auditRepo, cfg.TelegramWebhookSecret) logger.Info("telegram bot configured") } else { logger.Warn("telegram bot NOT configured") } authH := handlers.NewAuthHandler(cfg, usersRepo, consentRepo, auditRepo, authSvc) h := handlers.New(cfg, usersRepo, prefsRepo, locsRepo, consentRepo, auditRepo, authSvc) chatH := handlers.NewChatHandlers(chatRepo, auditRepo, hub) reviewH := handlers.NewReviewHandlers(reviewRepo, chatRepo, auditRepo) consentH := handlers.NewConsentHandlers(consentRepo, auditRepo) photoH := handlers.NewPhotoHandlers(usersRepo, st, auditRepo) _ = rdb app := fiber.New(fiber.Config{ AppName: "buhapp-api", ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, }) app.Use(recover.New()) app.Use(flog.New()) app.Use(cors.New(cors.Config{ AllowOrigins: "https://buhapp.mygoodservice.ru,https://t.me,https://web.telegram.org", AllowHeaders: "Origin, Content-Type, Accept, Authorization, X-Telegram-Bot-Api-Secret-Token", AllowMethods: "GET, POST, PUT, DELETE, OPTIONS", })) loginLimiter := limiter.New(limiter.Config{ Max: 10, Expiration: 1 * time.Minute, KeyGenerator: func(c *fiber.Ctx) string { return c.IP() }, LimitReached: func(c *fiber.Ctx) error { return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "too many login attempts"}) }, }) registerLimiter := limiter.New(limiter.Config{ Max: 5, Expiration: 1 * time.Hour, KeyGenerator: func(c *fiber.Ctx) string { return c.IP() }, LimitReached: func(c *fiber.Ctx) error { return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "too many registrations"}) }, }) app.Get("/health", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{ "status": "ok", "time": time.Now().UTC().Format(time.RFC3339), "version": "0.6.0", "db": pg.Pool != nil, "redis": rdb != nil, "storage": st != nil, }) }) app.Get("/api/v1/legal/terms", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{"version": cfg.TermsVersion, "url": "/legal/TERMS.md"}) }) app.Get("/api/v1/legal/privacy", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{"version": cfg.PrivacyVersion, "url": "/legal/PRIVACY.md"}) }) app.Get("/api/v1/legal/disclaimer", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{"version": cfg.DisclaimerVersion, "url": "/legal/DISCLAIMER.md"}) }) if st != nil { app.Get("/storage/*", func(c *fiber.Ctx) error { key := c.Params("*") obj, err := st.Client.GetObject(c.UserContext(), st.Bucket, key, minio.GetObjectOptions{}) if err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "not found"}) } defer obj.Close() stat, err := obj.Stat() if err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "not found"}) } c.Set("Content-Type", stat.ContentType) c.Set("Content-Length", fmt.Sprintf("%d", stat.Size)) c.Set("Cache-Control", "public, max-age=31536000") buf, err := io.ReadAll(obj) if err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "read failed"}) } return c.Send(buf) }) } api := app.Group("/api/v1") if tgH != nil { api.Post("/auth/telegram", tgH.TGAuth) api.Post("/telegram/webhook", tgH.BotWebhook) } api.Post("/auth/register", registerLimiter, authH.Register) api.Post("/auth/login", loginLimiter, authH.Login) protected := api.Group("", auth.Middleware(authSvc)) protected.Get("/me", h.GetMe) protected.Put("/me", h.UpdateMe) protected.Get("/me/prefs", h.GetPrefs) protected.Put("/me/prefs", h.UpdatePrefs) protected.Put("/me/location", h.UpdateLocation) protected.Put("/me/visibility", h.SetVisibility) protected.Post("/me/photo", photoH.UploadPhoto) protected.Get("/search/nearby", h.SearchNearby) protected.Get("/users/:id", h.GetUserPublic) protected.Post("/chats", chatH.EnsureChat) protected.Get("/chats", chatH.ListChats) protected.Get("/chats/:id/messages", chatH.ListMessages) protected.Post("/chats/:id/messages", chatH.SendMessage) protected.Put("/chats/:id/read", chatH.MarkRead) protected.Put("/messages/:msgId", chatH.EditMessage) protected.Delete("/messages/:msgId", chatH.DeleteMessage) protected.Post("/blocks", chatH.Block) protected.Delete("/blocks/:id", chatH.Unblock) protected.Post("/reports", chatH.Report) protected.Post("/reviews", reviewH.Create) protected.Get("/users/:id/reviews", reviewH.ListForUser) protected.Get("/users/:id/stats", reviewH.Stats) protected.Post("/auth/consents", consentH.Accept) protected.Get("/auth/consents", consentH.Status) app.Get("/ws", ws.AuthMiddleware(authSvc), ws.Upgrade, ws.Handler(hub)) go func() { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) <-sigCh logger.Info("shutdown initiated") _ = app.ShutdownWithTimeout(10 * time.Second) }() if bot != nil && tgH != nil { webhookURL := cfg.TelegramWebhookURL if webhookURL != "" { if err := bot.SetWebhook(webhookURL, cfg.TelegramWebhookSecret); err != nil { logger.Warn("telegram setWebhook failed, will use long poll", "err", err) } else { logger.Info("telegram bot webhook set", "url", webhookURL) } } else { go func() { logger.Info("telegram bot starting long-poll loop") bot.RunLoop(func(u telegram.Update) { tgH.ProcessUpdate(u) }) }() } } addr := fmt.Sprintf(":%d", cfg.AppPort) logger.Info("listening", "addr", addr) if err := app.Listen(addr); err != nil { logger.Error("listen failed", "err", err) os.Exit(1) } }