add media service

This commit is contained in:
Дмитрий
2026-05-08 15:59:03 +03:00
commit 1a7251976d
5 changed files with 322 additions and 0 deletions

135
cmd/main.go Normal file
View File

@@ -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)
}
}

27
go.mod Normal file
View File

@@ -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
)

45
go.sum Normal file
View File

@@ -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=

View File

@@ -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
}

67
internal/storage/minio.go Normal file
View File

@@ -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
}