1. chat.EnsureChat: ON CONFLICT DO UPDATE (race-safe) 2. chat.Send: tx.Begin/Commit (atomic INSERT message + UPDATE last_msg_at) 3. chat.MarkRead: добавил member-check (NOT a member -> 403) 4. chat.Message: +ReadAt field 5. migrations/0006: read_at column on messages 6. locations.Nearby: bounding-box prefilter + CTE (haversine только для отфильтрованных) 7. audit.Log: real metadata JSON passthrough (no more 'null' TODO) 8. main.go: slog JSON logger (был stdlib log) 9. WS: 'message_read' event от MarkRead 10. WS push: добавлены read/read_at/edited в message payload 11. Bumped v0.6.0
262 lines
8.1 KiB
Go
262 lines
8.1 KiB
Go
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)
|
|
}
|
|
}
|