From f86590570c7ee358177e90789d0a60d9fdf1e328 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 28 Aug 2025 20:09:29 +0300 Subject: [PATCH] =?UTF-8?q?v0.0.27=20=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BC=D1=83=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20=D1=81=D0=BA=D0=B0=D0=BB=D1=8F=D1=80?= =?UTF-8?q?=20Upload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.sum | 36 ------- internal/http/graph/generated.go | 26 ++++- internal/http/graph/post_resolvers.go | 4 +- internal/http/graph/schema.graphql | 4 +- internal/service/post_service.go | 137 ++++++++++++++------------ 5 files changed, 100 insertions(+), 107 deletions(-) diff --git a/go.sum b/go.sum index 5cc5221..b7879e8 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/http/graph/generated.go b/internal/http/graph/generated.go index 799118d..91a77e4 100644 --- a/internal/http/graph/generated.go +++ b/internal/http/graph/generated.go @@ -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) } diff --git a/internal/http/graph/post_resolvers.go b/internal/http/graph/post_resolvers.go index 79a2734..a01cdec 100644 --- a/internal/http/graph/post_resolvers.go +++ b/internal/http/graph/post_resolvers.go @@ -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) diff --git a/internal/http/graph/schema.graphql b/internal/http/graph/schema.graphql index 2828a13..b149d57 100644 --- a/internal/http/graph/schema.graphql +++ b/internal/http/graph/schema.graphql @@ -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! diff --git a/internal/service/post_service.go b/internal/service/post_service.go index d9f0e12..eee678b 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -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)