From 1a7251976da329c4454077ee11e1c88ef9b8eec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 8 May 2026 15:59:03 +0300 Subject: [PATCH] add media service --- cmd/main.go | 135 ++++++++++++++++++++++++++++++++++++ go.mod | 27 ++++++++ go.sum | 45 ++++++++++++ internal/processor/image.go | 48 +++++++++++++ internal/storage/minio.go | 67 ++++++++++++++++++ 5 files changed, 322 insertions(+) create mode 100644 cmd/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/processor/image.go create mode 100644 internal/storage/minio.go diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..eaf4087 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,135 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "log/slog" + "net/http" + "path/filepath" + "time" + + "lendry-erp/media/internal/processor" + "lendry-erp/media/internal/storage" +) + +// InternalAuthMiddleware защищает Go-сервис. +// Он пропускает только те запросы, в которых API Gateway заботливо положил X-User-Id +func InternalAuthMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userId := r.Header.Get("X-User-Id") + isModerator := r.Header.Get("X-Is-Admin") == "true" + + if userId == "" { + slog.Warn("Блокировка несанкционированного доступа (отсутствует X-User-Id)", "ip", r.RemoteAddr) + http.Error(w, `{"error": "Unauthorized: API Gateway only"}`, http.StatusUnauthorized) + return + } + + slog.Debug("Запрос авторизован Gateway", "user_id", userId, "moderator", isModerator) + next.ServeHTTP(w, r) + } +} + +func main() { + // Подключение к MinIO + minioStore, err := storage.NewMinioStorage("minio:9000", "admin", "admin12345", "erp-media") + if err != nil { + panic(err) + } + + // === Эндпоинт 1: ЗАГРУЗКА ПРИВАТНЫХ ФАЙЛОВ === + http.HandleFunc("/upload", InternalAuthMiddleware(func(w http.ResponseWriter, r *http.Request) { + r.ParseMultipartForm(50 << 20) // Лимит 50 МБ + + file, header, err := r.FormFile("file") + if err != nil { + http.Error(w, "Файл не найден", http.StatusBadRequest) + return + } + defer file.Close() + + mode := r.FormValue("mode") + if mode == "" { + mode = "chat" + } + + fileBytes, err := io.ReadAll(file) + if err != nil { + http.Error(w, "Ошибка чтения файла", http.StatusInternalServerError) + return + } + + finalBytes, contentType, err := processor.ProcessImage(fileBytes, mode) + if err != nil { + http.Error(w, "Ошибка обработки изображения", http.StatusInternalServerError) + return + } + + ext := filepath.Ext(header.Filename) + if ext == "" { + ext = ".jpg" + } + + objectName := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext) + + fileName, err := minioStore.Upload(r.Context(), objectName, bytes.NewReader(finalBytes), int64(len(finalBytes)), contentType) + if err != nil { + http.Error(w, "Ошибка загрузки в MinIO", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"success": true, "fileName": "%s", "size": %d}`, fileName, len(finalBytes)) + })) + + // === Эндпоинт 2: ПОЛУЧЕНИЕ ПРИВАТНОЙ ССЫЛКИ === + http.HandleFunc("/media/url", InternalAuthMiddleware(func(w http.ResponseWriter, r *http.Request) { + fileName := r.URL.Query().Get("file") + if fileName == "" { + http.Error(w, "Укажите имя файла", http.StatusBadRequest) + return + } + + // ВАЖНО: Раз запрос дошел сюда, значит API Gateway УЖЕ проверил + // JWT токен и принадлежность пользователя к чату (через gRPC). + // Никакие проверки БД в Go больше не нужны. Мы доверяем Gateway! + + expiry := time.Minute * 2 // Приватные ссылки живут 2 минуты + presignedURL, err := minioStore.GeneratePresignedURL(r.Context(), fileName, expiry) + if err != nil { + http.Error(w, "Ошибка генерации ссылки", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"url": "%s"}`, presignedURL) + })) + + // === Эндпоинт 3: ПОЛУЧЕНИЕ ПУБЛИЧНОЙ ССЫЛКИ (Аватарки, Баннеры) === + // Обрати внимание: этот эндпоинт НЕ обернут в InternalAuthMiddleware + http.HandleFunc("/media/public/url", func(w http.ResponseWriter, r *http.Request) { + fileName := r.URL.Query().Get("file") + if fileName == "" { + http.Error(w, "Укажите имя файла", http.StatusBadRequest) + return + } + + // Публичные ссылки можно делать более долгоживущими (например, на 24 часа), + // чтобы браузер мог их эффективно кэшировать + expiry := time.Hour * 24 + presignedURL, err := minioStore.GeneratePresignedURL(r.Context(), fileName, expiry) + if err != nil { + http.Error(w, "Ошибка генерации ссылки", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"url": "%s"}`, presignedURL) + }) + + slog.Info("Media Service запущен на внутреннем порту :8081") + if err := http.ListenAndServe(":8081", nil); err != nil { + slog.Error("Ошибка работы HTTP сервера", "err", err) + } +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..96c3683 --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module lendry-erp/media + +go 1.26.1 + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/disintegration/imaging v1.6.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/minio/minio-go/v7 v7.1.0 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..131b053 --- /dev/null +++ b/go.sum @@ -0,0 +1,45 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8= +github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/processor/image.go b/internal/processor/image.go new file mode 100644 index 0000000..97577a5 --- /dev/null +++ b/internal/processor/image.go @@ -0,0 +1,48 @@ +package processor + +import ( + "bytes" + "image" + "image/jpeg" + _ "image/png" // Для поддержки декодирования PNG + + "github.com/disintegration/imaging" +) + +// ProcessImage обрабатывает картинку в зависимости от типа загрузки +// mode может быть: "avatar", "chat", "raw" +func ProcessImage(fileBytes []byte, mode string) ([]byte, string, error) { + // Если пользователь отправил "как файл" (без сжатия) + if mode == "raw" { + return fileBytes, "image/jpeg", nil // В идеале тут нужно определять mime-type по байтам + } + + // Декодируем исходную картинку + img, _, err := image.Decode(bytes.NewReader(fileBytes)) + if err != nil { + return nil, "", err + } + + var processedImg image.Image + + switch mode { + case "avatar": + // Telegram делает аватарки квадратными (например, 500x500) + processedImg = imaging.Fill(img, 500, 500, imaging.Center, imaging.Lanczos) + case "chat": + // Ограничиваем максимальный размер для чата (например, 1280px по большей стороне), + // сохраняя пропорции + processedImg = imaging.Fit(img, 1280, 1280, imaging.Lanczos) + default: + processedImg = img + } + + // Кодируем результат в сжатый JPEG (качество 80 - отличный баланс размера и качества) + buf := new(bytes.Buffer) + err = jpeg.Encode(buf, processedImg, &jpeg.Options{Quality: 80}) + if err != nil { + return nil, "", err + } + + return buf.Bytes(), "image/jpeg", nil +} \ No newline at end of file diff --git a/internal/storage/minio.go b/internal/storage/minio.go new file mode 100644 index 0000000..a19d5fb --- /dev/null +++ b/internal/storage/minio.go @@ -0,0 +1,67 @@ +package storage + +import ( + "context" + "io" + "log/slog" + "net/url" + "time" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +type MinioStorage struct { + client *minio.Client + bucket string +} + +func NewMinioStorage(endpoint, accessKey, secretKey, bucket string) (*MinioStorage, error) { + client, err := minio.New(endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(accessKey, secretKey, ""), + Secure: false, // Для локальной разработки (без HTTPS) + }) + if err != nil { + return nil, err + } + + ctx := context.Background() + exists, err := client.BucketExists(ctx, bucket) + if err == nil && !exists { + err = client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}) + if err != nil { + return nil, err + } + slog.Info("Бакет создан", "bucket", bucket) + // Бакет остается ПРИВАТНЫМ (нет публичного BucketPolicy). + } + + return &MinioStorage{ + client: client, + bucket: bucket, + }, nil +} + +func (s *MinioStorage) Upload(ctx context.Context, objectName string, reader io.Reader, size int64, contentType string) (string, error) { + info, err := s.client.PutObject(ctx, s.bucket, objectName, reader, size, minio.PutObjectOptions{ + ContentType: contentType, + }) + if err != nil { + return "", err + } + + // Возвращаем ТОЛЬКО имя файла (info.Key). + // Полный путь клиенту не нужен, он сам не сможет по нему перейти. + return info.Key, nil +} + +func (s *MinioStorage) GeneratePresignedURL(ctx context.Context, objectName string, expiry time.Duration) (string, error) { + reqParams := make(url.Values) + + presignedURL, err := s.client.PresignedGetObject(ctx, s.bucket, objectName, expiry, reqParams) + if err != nil { + return "", err + } + + return presignedURL.String(), nil +} \ No newline at end of file