From 2d3bdfb98fe9458b4f75cd6e49bc7c15b029b27a Mon Sep 17 00:00:00 2001 From: ga Date: Thu, 20 Aug 2026 20:59:52 +0000 Subject: [PATCH] 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 --- cmd/server/main.go | 39 ++++++++++++++----- internal/handlers/photo.go | 76 ++++++++++++++++++++++++++++++++++++++ internal/storage/minio.go | 17 +++++++++ 3 files changed, 122 insertions(+), 10 deletions(-) create mode 100644 internal/handlers/photo.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 4cc1535..f7a9ec7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "io" "log" "os" "os/signal" @@ -14,6 +15,7 @@ import ( "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" @@ -80,7 +82,6 @@ func main() { authSvc := auth.NewService(cfg.JWTSecret, cfg.JwTAccessTTL(), cfg.JwtRefreshTTL()) hub := ws.NewHub() - // Telegram bot (опционально) var bot *telegram.Bot var tgH *handlers.TGHandler if cfg.TelegramBotToken != "" { @@ -96,9 +97,9 @@ func main() { chatH := handlers.NewChatHandlers(chatRepo, auditRepo, hub) reviewH := handlers.NewReviewHandlers(reviewRepo, chatRepo, auditRepo) consentH := handlers.NewConsentHandlers(consentRepo, auditRepo) + photoH := handlers.NewPhotoHandlers(usersRepo, st, auditRepo) _ = rdb - _ = st app := fiber.New(fiber.Config{ AppName: "buhapp-api", @@ -113,7 +114,6 @@ func main() { AllowMethods: "GET, POST, PUT, DELETE, OPTIONS", })) - // Rate limiters — защита от брутфорса login и register loginLimiter := limiter.New(limiter.Config{ Max: 10, Expiration: 1 * time.Minute, @@ -135,7 +135,7 @@ func main() { return c.JSON(fiber.Map{ "status": "ok", "time": time.Now().UTC().Format(time.RFC3339), - "version": "0.4.0", + "version": "0.5.0", "db": pg.Pool != nil, "redis": rdb != nil, "storage": st != nil, @@ -152,9 +152,32 @@ func main() { 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") - // Telegram WebApp auth (публичный) if tgH != nil { api.Post("/auth/telegram", tgH.TGAuth) api.Post("/telegram/webhook", tgH.BotWebhook) @@ -170,10 +193,10 @@ func main() { 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) - // Chat protected.Post("/chats", chatH.EnsureChat) protected.Get("/chats", chatH.ListChats) protected.Get("/chats/:id/messages", chatH.ListMessages) @@ -188,16 +211,13 @@ func main() { protected.Post("/reports", chatH.Report) - // Reviews protected.Post("/reviews", reviewH.Create) protected.Get("/users/:id/reviews", reviewH.ListForUser) protected.Get("/users/:id/stats", reviewH.Stats) - // Consents (повторное принятие) protected.Post("/auth/consents", consentH.Accept) protected.Get("/auth/consents", consentH.Status) - // WebSocket — авторизация по ?token= app.Get("/ws", ws.AuthMiddleware(authSvc), ws.Upgrade, ws.Handler(hub)) go func() { @@ -208,7 +228,6 @@ func main() { _ = app.ShutdownWithTimeout(10 * time.Second) }() - // Запуск Telegram-бота — webhook если доступен, иначе long polling if bot != nil && tgH != nil { webhookURL := cfg.TelegramWebhookURL if webhookURL != "" { diff --git a/internal/handlers/photo.go b/internal/handlers/photo.go new file mode 100644 index 0000000..bf1003f --- /dev/null +++ b/internal/handlers/photo.go @@ -0,0 +1,76 @@ +package handlers + +import ( + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + + "github.com/buhapp/backend/internal/audit" + "github.com/buhapp/backend/internal/auth" + "github.com/buhapp/backend/internal/storage" + "github.com/buhapp/backend/internal/users" +) + +type PhotoHandlers struct { + Users *users.Repo + Storage *storage.Storage + Audit *audit.Repo +} + +func NewPhotoHandlers(u *users.Repo, s *storage.Storage, au *audit.Repo) *PhotoHandlers { + return &PhotoHandlers{Users: u, Storage: s, Audit: au} +} + +const maxPhotoSize = 8 * 1024 * 1024 // 8 MB + +var allowedPhotoTypes = map[string]bool{ + "image/jpeg": true, + "image/jpg": true, + "image/png": true, + "image/webp": true, +} + +// UploadPhoto — POST /me/photo (multipart, поле "photo") +func (h *PhotoHandlers) UploadPhoto(c *fiber.Ctx) error { + uid, err := auth.UserID(c) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"}) + } + uuidUser, err := uuid.Parse(uid) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "bad user id"}) + } + if h.Storage == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "storage not configured"}) + } + file, err := c.FormFile("photo") + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "photo field required"}) + } + if file.Size > maxPhotoSize { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "file too large (max 8MB)"}) + } + contentType := file.Header.Get("Content-Type") + if !allowedPhotoTypes[contentType] { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid image type"}) + } + src, err := file.Open() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "open failed"}) + } + defer src.Close() + + url, err := h.Storage.UploadAvatar(c.UserContext(), uuidUser, contentType, src, file.Size) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "upload failed: " + err.Error()}) + } + // обновим photo_url в users + sql := `UPDATE users SET photo_url=$1, updated_at=NOW() WHERE id=$2` + if _, err := h.Users.UpdateRaw(c.UserContext(), sql, url, uuidUser); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db update failed"}) + } + _ = h.Audit.Log(c.UserContext(), &audit.Event{ + UserID: &uuidUser, Action: "user.photo_upload", + IP: c.IP(), UserAgent: c.Get("User-Agent"), + }) + return c.JSON(fiber.Map{"ok": true, "photo_url": url}) +} diff --git a/internal/storage/minio.go b/internal/storage/minio.go index 9f94864..9ac48e2 100644 --- a/internal/storage/minio.go +++ b/internal/storage/minio.go @@ -3,7 +3,10 @@ package storage import ( "context" "fmt" + "io" + "time" + "github.com/google/uuid" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" ) @@ -34,3 +37,17 @@ func New(ctx context.Context, endpoint, accessKey, secretKey, bucket string, use return &Storage{Client: client, Bucket: bucket}, nil } + +// UploadAvatar — загружает аватар в MinIO и возвращает публичный URL +// (для MinIO нужен bucket policy или presigned URL — пока делаем простой PUT с публичным доступом через наш backend). +func (s *Storage) UploadAvatar(ctx context.Context, userID uuid.UUID, contentType string, data io.Reader, size int64) (string, error) { + key := fmt.Sprintf("avatars/%s/%s-%d", userID.String(), uuid.NewString(), time.Now().Unix()) + _, err := s.Client.PutObject(ctx, s.Bucket, key, data, size, minio.PutObjectOptions{ + ContentType: contentType, + }) + if err != nil { + return "", err + } + // возвращаем относительный URL — бэкенд сам раздаёт через nginx (proxy_pass /storage → MinIO) + return "/storage/" + key, nil +}