add media service
This commit is contained in:
135
cmd/main.go
Normal file
135
cmd/main.go
Normal 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
27
go.mod
Normal 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
45
go.sum
Normal 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=
|
||||||
48
internal/processor/image.go
Normal file
48
internal/processor/image.go
Normal 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
67
internal/storage/minio.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user