buhapp-backend/cmd/server/main.go
ga 2d3bdfb98f Sprint 6.1: photo upload (MinIO) + storage proxy
- POST /api/v1/me/photo (multipart, field 'photo')
- MinIO upload via storage.UploadAvatar (avatars/{userID}/{uuid}-{ts})
- /storage/* backend route reads from MinIO + serves with proper headers
- nginx unchanged: /storage/* proxied to backend like everything else
- Bumped to v0.5.0
2026-08-20 20:59:52 +00:00

255 lines
7.9 KiB
Go

package main
import (
"context"
"fmt"
"io"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/limiter"
"github.com/gofiber/fiber/v2/middleware/logger"
"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() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
ctx := context.Background()
pg, err := db.New(ctx, cfg.PostgresDSN())
if err != nil {
log.Fatalf("postgres: %v", err)
}
defer pg.Close()
log.Println("postgres: connected")
migDir := os.Getenv("MIGRATIONS_DIR")
if migDir == "" {
migDir = "./migrations"
}
if err := db.RunMigrations(ctx, pg, migDir); err != nil {
log.Fatalf("migrations: %v", err)
}
rdb, err := redis.New(ctx, cfg.RedisAddr(), cfg.RedisPassword, cfg.RedisDB)
if err != nil {
log.Printf("redis warning: %v", err)
} else {
log.Println("redis: connected")
}
st, err := storage.New(ctx, cfg.MinIOEndpoint, cfg.MinIOAccessKey, cfg.MinIOSecretKey, cfg.MinIOBucket, cfg.MinIOUseSSL)
if err != nil {
log.Printf("minio warning: %v", err)
} else {
log.Println("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)
log.Println("telegram bot: configured")
} else {
log.Println("telegram bot: NOT configured (set TELEGRAM_BOT_TOKEN)")
}
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(logger.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.5.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"})
})
// публичная раздача MinIO через бэкенд (MinIO в docker network, не на хосте)
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
log.Println("shutdown...")
_ = app.ShutdownWithTimeout(10 * time.Second)
}()
if bot != nil && tgH != nil {
webhookURL := cfg.TelegramWebhookURL
if webhookURL != "" {
if err := bot.SetWebhook(webhookURL, cfg.TelegramWebhookSecret); err != nil {
log.Printf("telegram setWebhook failed: %v (fallback to long poll)", err)
} else {
log.Printf("telegram bot: webhook set to %s", webhookURL)
}
} else {
go func() {
log.Println("telegram bot: starting long-poll loop")
bot.RunLoop(func(u telegram.Update) {
tgH.ProcessUpdate(u)
})
}()
}
}
addr := fmt.Sprintf(":%d", cfg.AppPort)
log.Printf("listening on %s", addr)
if err := app.Listen(addr); err != nil {
log.Fatalf("listen: %v", err)
}
}