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
This commit is contained in:
parent
c30faf02ef
commit
2d3bdfb98f
@ -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 != "" {
|
||||
|
||||
76
internal/handlers/photo.go
Normal file
76
internal/handlers/photo.go
Normal file
@ -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})
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user