v0.0.27 Изменена мутация загрузки изображений через скаляр Upload
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
admin 2025-08-28 20:09:29 +03:00
parent 0647b62048
commit f86590570c
5 changed files with 100 additions and 107 deletions

36
go.sum
View File

@ -12,42 +12,6 @@ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/aws/aws-sdk-go-v2 v1.38.2 h1:QUkLO1aTW0yqW95pVzZS0LGFanL71hJ0a49w4TJLMyM=
github.com/aws/aws-sdk-go-v2 v1.38.2/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00=
github.com/aws/aws-sdk-go-v2/config v1.31.4 h1:aY2IstXOfjdLtr1lDvxFBk5DpBnHgS5GS3jgR/0BmPw=
github.com/aws/aws-sdk-go-v2/config v1.31.4/go.mod h1:1IAykiegrTp6n+CbZoCpW6kks1I74fEDgl2BPQSkLSU=
github.com/aws/aws-sdk-go-v2/credentials v1.18.8 h1:0FfdP0I9gs/f1rwtEdkcEdsclTEkPB8o6zWUG2Z8+IM=
github.com/aws/aws-sdk-go-v2/credentials v1.18.8/go.mod h1:9UReQ1UmGooX93JKzHyr7PRF3F+p3r+PmRwR7+qHJYA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.5 h1:ul7hICbZ5Z/Pp9VnLVGUVe7rqYLXCyIiPU7hQ0sRkow=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.5/go.mod h1:5cIWJ0N6Gjj+72Q6l46DeaNtcxXHV42w/Uq3fIfeUl4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.5 h1:d45S2DqHZOkHu0uLUW92VdBoT5v0hh3EyR+DzMEh3ag=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.5/go.mod h1:G6e/dR2c2huh6JmIo9SXysjuLuDDGWMeYGibfW2ZrXg=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.5 h1:ENhnQOV3SxWHplOqNN1f+uuCNf9n4Y/PKpl6b1WRP0Q=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.5/go.mod h1:csQLMI+odbC0/J+UecSTztG70Dc4aTCOu4GyPNDNpVo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.5 h1:ovHE1XM53pMGOwINf8Mas4FMl5XRRMAihNokV1YViZ8=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.5/go.mod h1:Cmu/DOSYwcr0xYTFk7sA9NJ5HF3ND0EqNUBdoK16nPI=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.5 h1:gC3YW8AojITDXfI5avcKZst5iOg6v5aQEU4HIcxwAss=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.5/go.mod h1:z5OdVolKifM0NpEel6wLkM/TQ0eodWB2dmDFoj3WCbw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.5 h1:Cx1M/UUgYu9UCQnIMKaOhkVaFvLy1HneD6T4sS/DlKg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.5/go.mod h1:fTRNLgrTvPpEzGqc9QkeO4hu/3ng+mdtUbL8shUwXz4=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.5 h1:IM2yO5Dd9bzCmYEvLU6Di5kduRKh4O93TjrZ47hxLhQ=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.5/go.mod h1:0nXagJIQFWms6GJ1jvPJLwr8r3hN6f+kTwt17Q2NrPQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.2 h1:HNAbIp6VXmtKR+JuDmywGcRc3kYoIGT9y4a2Zg9bSTQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.2/go.mod h1:6VSEglrPCTx7gi7Z7l/CtqSgbnFr1N6UJ6+Ik+vjuEo=
github.com/aws/aws-sdk-go-v2/service/sso v1.28.3 h1:z6lajFT/qGlLRB/I8V5CCklqSuWZKUkdwRAn9leIkiQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.28.3/go.mod h1:BnyjuIX0l+KXJVl2o9Ki3Zf0M4pA2hQYopFCRUj9ADU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.1 h1:8yI3jK5JZ310S8RpgdZdzwvlvBu3QbG8DP7Be/xJ6yo=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.1/go.mod h1:HPzXfFgrLd02lYpcFYdDz5xZs94LOb+lWlvbAGaeMsk=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.1 h1:3kWmIg5iiWPMBJyq/I55Fki5fyfoMtrn/SkUIpxPwHQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.1/go.mod h1:yi0b3Qez6YamRVJ+Rbi19IgvjfjPODgVRhkWA6RTMUM=
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/caarlos0/env/v8 v8.0.0 h1:POhxHhSpuxrLMIdvTGARuZqR4Jjm8AYmoi/JKlcScs0=

View File

@ -140,7 +140,7 @@ type ComplexityRoot struct {
ConfirmEmail func(childComplexity int, token string) int
CreateChat func(childComplexity int, user1Id int, user2Id int) int
CreateComment func(childComplexity int, postID int, content string) int
CreatePost func(childComplexity int, title string, content string) int
CreatePost func(childComplexity int, title string, content graphql.Upload) int
DeletePost func(childComplexity int, id int) int
FollowUser func(childComplexity int, followingID int) int
LikePost func(childComplexity int, postID int) int
@ -277,7 +277,7 @@ type MutationResolver interface {
Register(ctx context.Context, input domain.RegisterInput) (*domain.User, error)
Login(ctx context.Context, input domain.LoginInput) (*domain.Tokens, error)
RefreshTokens(ctx context.Context, refreshToken string) (*domain.Tokens, error)
CreatePost(ctx context.Context, title string, content string) (*domain.Post, error)
CreatePost(ctx context.Context, title string, content graphql.Upload) (*domain.Post, error)
CreateComment(ctx context.Context, postID int, content string) (*domain.Comment, error)
LikePost(ctx context.Context, postID int) (*domain.Like, error)
UnlikePost(ctx context.Context, postID int) (bool, error)
@ -733,7 +733,7 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return 0, false
}
return e.complexity.Mutation.CreatePost(childComplexity, args["title"].(string), args["content"].(string)), true
return e.complexity.Mutation.CreatePost(childComplexity, args["title"].(string), args["content"].(graphql.Upload)), true
case "Mutation.deletePost":
if e.complexity.Mutation.DeletePost == nil {
@ -1690,7 +1690,7 @@ func (ec *executionContext) field_Mutation_createPost_args(ctx context.Context,
return nil, err
}
args["title"] = arg0
arg1, err := processArgField(ctx, rawArgs, "content", ec.unmarshalNString2string)
arg1, err := processArgField(ctx, rawArgs, "content", ec.unmarshalNUpload2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚐUpload)
if err != nil {
return nil, err
}
@ -4519,7 +4519,7 @@ func (ec *executionContext) _Mutation_createPost(ctx context.Context, field grap
}()
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.Mutation().CreatePost(rctx, fc.Args["title"].(string), fc.Args["content"].(string))
return ec.resolvers.Mutation().CreatePost(rctx, fc.Args["title"].(string), fc.Args["content"].(graphql.Upload))
})
if err != nil {
ec.Error(ctx, err)
@ -14907,6 +14907,22 @@ func (ec *executionContext) marshalNUnfollowResult2ᚖtailly_back_v2ᚋinternal
return ec._UnfollowResult(ctx, sel, v)
}
func (ec *executionContext) unmarshalNUpload2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚐUpload(ctx context.Context, v any) (graphql.Upload, error) {
res, err := graphql.UnmarshalUpload(v)
return res, graphql.ErrorOnPath(ctx, err)
}
func (ec *executionContext) marshalNUpload2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚐUpload(ctx context.Context, sel ast.SelectionSet, v graphql.Upload) graphql.Marshaler {
_ = sel
res := graphql.MarshalUpload(v)
if res == graphql.Null {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
ec.Errorf(ctx, "the requested element is null which the schema does not allow")
}
}
return res
}
func (ec *executionContext) marshalNUser2tailly_back_v2ᚋinternalᚋdomainᚐUser(ctx context.Context, sel ast.SelectionSet, v domain.User) graphql.Marshaler {
return ec._User(ctx, sel, &v)
}

View File

@ -5,6 +5,8 @@ import (
"fmt"
"tailly_back_v2/internal/domain"
"time"
"github.com/99designs/gqlgen/graphql"
)
type postResolver struct{ *Resolver }
@ -124,7 +126,7 @@ func (r *mutationResolver) UnlikePost(ctx context.Context, postID int) (bool, er
}
// CreatePost is the resolver for the createPost field.
func (r *mutationResolver) CreatePost(ctx context.Context, title string, content string) (*domain.Post, error) {
func (r *mutationResolver) CreatePost(ctx context.Context, title string, content graphql.Upload) (*domain.Post, error) {
userID, err := getUserIDFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("unauthorized: %w", err)

View File

@ -169,7 +169,7 @@ type MarkNotificationReadResult {
success: Boolean!
message: String!
}
scalar Upload
# Запросы (получение данных)
type Query {
me: User! # Получить текущего пользователя
@ -219,7 +219,7 @@ type Mutation {
refreshTokens(refreshToken: String!): Tokens!
# Создание поста
createPost(title: String!, content: String!): Post!
createPost(title: String!, content: Upload!): Post!
# Создание комментария
createComment(postId: Int!, content: String!): Comment!

View File

@ -1,21 +1,19 @@
package service
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"image/jpeg"
"image/png"
"io"
"path/filepath"
"strings"
"tailly_back_v2/internal/domain"
"tailly_back_v2/internal/repository"
"tailly_back_v2/internal/utils"
"tailly_back_v2/pkg/S3"
"tailly_back_v2/pkg/moderation"
"time"
"github.com/99designs/gqlgen/graphql"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws"
@ -25,7 +23,7 @@ import (
// Интерфейс сервиса постов
type PostService interface {
Create(ctx context.Context, authorID int, title, content string) (*domain.Post, error)
Create(ctx context.Context, authorID int, title string, content graphql.Upload) (*domain.Post, error)
GetByID(ctx context.Context, id int) (*domain.Post, error)
GetAll(ctx context.Context) ([]*domain.Post, error)
GetByAuthorID(ctx context.Context, authorID int) ([]*domain.Post, error)
@ -44,73 +42,46 @@ func NewPostService(postRepo repository.PostRepository) PostService {
return &postService{postRepo: postRepo}
}
// Создание нового поста
func (s *postService) Create(ctx context.Context, authorID int, title, content string) (*domain.Post, error) {
func (s *postService) Create(ctx context.Context, authorID int, title string, content graphql.Upload) (*domain.Post, error) {
const op = "service/postService.Create"
// Валидация
if content == "" {
if content.Filename == "" {
return nil, errors.New("post content cannot be empty")
}
// Определяем формат и извлекаем данные
var base64Data, ext, contentType string
switch {
case strings.HasPrefix(content, "data:image/jpeg;base64,"):
base64Data = strings.TrimPrefix(content, "data:image/jpeg;base64,")
ext = "jpg"
contentType = "image/jpeg"
case strings.HasPrefix(content, "data:image/png;base64,"):
base64Data = strings.TrimPrefix(content, "data:image/png;base64,")
ext = "png"
contentType = "image/png"
case strings.HasPrefix(content, "data:image/webp;base64,"):
base64Data = strings.TrimPrefix(content, "data:image/webp;base64,")
ext = "webp"
contentType = "image/webp"
default:
return nil, errors.New("invalid image format, expected JPEG, PNG or WebP")
// Сбрасываем позицию чтения на начало
if seeker, ok := content.File.(io.Seeker); ok {
seeker.Seek(0, io.SeekStart)
}
// Декодируем base64
imgData, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return nil, fmt.Errorf("%s: failed to decode base64: %w", op, err)
}
// Проверяем размер
if len(imgData) > 10_000_000 {
return nil, errors.New("image too large (max 10MB)")
}
// Проверяем валидность изображения
switch ext {
case "jpg":
if _, err := jpeg.Decode(bytes.NewReader(imgData)); err != nil {
return nil, fmt.Errorf("%s: invalid JPEG image: %w", op, err)
}
case "png":
if _, err := png.Decode(bytes.NewReader(imgData)); err != nil {
return nil, fmt.Errorf("%s: invalid PNG image: %w", op, err)
// Проверяем тип файла
contentType := content.ContentType
if contentType == "" {
// Определяем ContentType по расширению файла
ext := strings.ToLower(filepath.Ext(content.Filename))
switch ext {
case ".jpg", ".jpeg":
contentType = "image/jpeg"
case ".png":
contentType = "image/png"
case ".webp":
contentType = "image/webp"
case ".heic", ".heif":
contentType = "image/heic"
default:
return nil, errors.New("invalid image format, expected JPEG, PNG, WebP, HEIC")
}
}
// Модерация изображения
modClient, err := moderation.NewModerationClient("tailly_censor:50051")
if err != nil {
return nil, fmt.Errorf("%s: failed to create moderation client: %w", op, err)
}
defer modClient.Close()
allowed, err := modClient.CheckImage(ctx, imgData)
if err != nil {
return nil, fmt.Errorf("%s: image moderation failed: %w", op, err)
}
if !allowed {
return nil, errors.New("image rejected by moderation service")
// Проверяем поддерживаемые форматы
if !isValidImageType(contentType) {
return nil, fmt.Errorf("invalid image format: %s, expected JPEG, PNG, WebP, HEIC", contentType)
}
// Загрузка в S3
randName := utils.GenerateId() + ".jpg"
fileExt := getFileExtension(contentType, content.Filename)
randName := utils.GenerateId() + fileExt
sess := session.Must(session.NewSession(&aws.Config{
Region: aws.String("ru-central1"),
Endpoint: aws.String("https://s3.regru.cloud"),
@ -125,17 +96,18 @@ func (s *postService) Create(ctx context.Context, authorID int, title, content s
uploader := s3manager.NewUploader(sess)
s3Key := fmt.Sprintf("posts/%d/%s", authorID, randName)
_, err = uploader.Upload(&s3manager.UploadInput{
// Загружаем напрямую из io.ReadSeeker
_, err := uploader.Upload(&s3manager.UploadInput{
Bucket: aws.String("tailly"),
Key: aws.String(s3Key),
Body: bytes.NewReader(imgData),
Body: content.File, // используем оригинальный ReadSeeker
ContentType: aws.String(contentType),
})
if err != nil {
return nil, fmt.Errorf("%s: failed to upload to S3: %w", op, err)
}
imageURL := fmt.Sprintf("https://s3.regru.cloud/%s/%s", "tailly", s3Key)
imageURL := fmt.Sprintf("https://s3.regru.cloud/tailly/%s", s3Key)
post := &domain.Post{
Title: title,
@ -146,12 +118,51 @@ func (s *postService) Create(ctx context.Context, authorID int, title, content s
}
if err := s.postRepo.Create(ctx, post); err != nil {
// Если не удалось сохранить пост, удаляем файл из S3
S3.DeleteFromS3(imageURL)
return nil, fmt.Errorf("%s: failed to save post: %w", op, err)
}
return post, nil
}
func isValidImageType(contentType string) bool {
validTypes := []string{
"image/jpeg",
"image/png",
"image/webp",
"image/heic",
"image/heif",
}
for _, t := range validTypes {
if contentType == t {
return true
}
}
return false
}
func getFileExtension(contentType, filename string) string {
// Пытаемся получить расширение из оригинального имени файла
if ext := filepath.Ext(filename); ext != "" {
return ext
}
// Если не получилось, определяем по ContentType
switch contentType {
case "image/jpeg":
return ".jpg"
case "image/png":
return ".png"
case "image/webp":
return ".webp"
case "image/heic", "image/heif":
return ".heic"
default:
return ".jpg" // fallback
}
}
// Получение поста по ID
func (s *postService) GetByID(ctx context.Context, id int) (*domain.Post, error) {
post, err := s.postRepo.GetByID(ctx, id)