diff --git a/go.mod b/go.mod index 8a99f96..73e5171 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/prometheus/common v0.65.0 // indirect github.com/prometheus/procfs v0.17.0 // indirect github.com/sosodev/duration v1.3.1 // indirect + github.com/stretchr/objx v0.5.2 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sys v0.34.0 // indirect golang.org/x/text v0.27.0 // indirect diff --git a/go.sum b/go.sum index 1b0701b..ec5ccb1 100644 --- a/go.sum +++ b/go.sum @@ -79,6 +79,7 @@ github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NF github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/vektah/gqlparser/v2 v2.5.30 h1:EqLwGAFLIzt1wpx1IPpY67DwUujF1OfzgEyDsLrN6kE= diff --git a/internal/http/graph/generated.go b/internal/http/graph/generated.go index 24fd37a..176c26d 100644 --- a/internal/http/graph/generated.go +++ b/internal/http/graph/generated.go @@ -106,7 +106,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, input CreatePostInput) int + CreatePost func(childComplexity int, title string, content string) int DeletePost func(childComplexity int, id int) int LikePost func(childComplexity int, postID int) int Login func(childComplexity int, input domain.LoginInput) int @@ -206,7 +206,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, input CreatePostInput) (*domain.Post, error) + CreatePost(ctx context.Context, title string, content string) (*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) @@ -534,7 +534,7 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return 0, false } - return e.complexity.Mutation.CreatePost(childComplexity, args["input"].(CreatePostInput)), true + return e.complexity.Mutation.CreatePost(childComplexity, args["title"].(string), args["content"].(string)), true case "Mutation.deletePost": if e.complexity.Mutation.DeletePost == nil { @@ -1003,7 +1003,6 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { opCtx := graphql.GetOperationContext(ctx) ec := executionContext{opCtx, e, 0, 0, make(chan graphql.DeferredResult)} inputUnmarshalMap := graphql.BuildUnmarshalerMap( - ec.unmarshalInputCreatePostInput, ec.unmarshalInputLoginInput, ec.unmarshalInputRegisterInput, ) @@ -1221,11 +1220,16 @@ func (ec *executionContext) field_Mutation_createComment_args(ctx context.Contex func (ec *executionContext) field_Mutation_createPost_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} - arg0, err := processArgField(ctx, rawArgs, "input", ec.unmarshalNCreatePostInput2tailly_back_v2ᚋinternalᚋhttpᚋgraphᚐCreatePostInput) + arg0, err := processArgField(ctx, rawArgs, "title", ec.unmarshalNString2string) if err != nil { return nil, err } - args["input"] = arg0 + args["title"] = arg0 + arg1, err := processArgField(ctx, rawArgs, "content", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["content"] = arg1 return args, nil } @@ -3081,7 +3085,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["input"].(CreatePostInput)) + return ec.resolvers.Mutation().CreatePost(rctx, fc.Args["title"].(string), fc.Args["content"].(string)) }) if err != nil { ec.Error(ctx, err) @@ -8125,40 +8129,6 @@ func (ec *executionContext) fieldContext___Type_isOneOf(_ context.Context, field // region **************************** input.gotpl ***************************** -func (ec *executionContext) unmarshalInputCreatePostInput(ctx context.Context, obj any) (CreatePostInput, error) { - var it CreatePostInput - asMap := map[string]any{} - for k, v := range obj.(map[string]any) { - asMap[k] = v - } - - fieldsInOrder := [...]string{"title", "image"} - for _, k := range fieldsInOrder { - v, ok := asMap[k] - if !ok { - continue - } - switch k { - case "title": - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("title")) - data, err := ec.unmarshalNString2string(ctx, v) - if err != nil { - return it, err - } - it.Title = data - case "image": - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("image")) - data, err := ec.unmarshalNUpload2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚐUpload(ctx, v) - if err != nil { - return it, err - } - it.Image = data - } - } - - return it, nil -} - func (ec *executionContext) unmarshalInputLoginInput(ctx context.Context, obj any) (domain.LoginInput, error) { var it domain.LoginInput asMap := map[string]any{} @@ -10570,11 +10540,6 @@ func (ec *executionContext) marshalNComment2ᚖtailly_back_v2ᚋinternalᚋdomai return ec._Comment(ctx, sel, v) } -func (ec *executionContext) unmarshalNCreatePostInput2tailly_back_v2ᚋinternalᚋhttpᚋgraphᚐCreatePostInput(ctx context.Context, v any) (CreatePostInput, error) { - res, err := ec.unmarshalInputCreatePostInput(ctx, v) - return res, graphql.ErrorOnPath(ctx, err) -} - func (ec *executionContext) marshalNDevice2tailly_back_v2ᚋinternalᚋdomainᚐDevice(ctx context.Context, sel ast.SelectionSet, v domain.Device) graphql.Marshaler { return ec._Device(ctx, sel, &v) } @@ -10883,22 +10848,6 @@ func (ec *executionContext) marshalNTokens2ᚖtailly_back_v2ᚋinternalᚋdomain return ec._Tokens(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/models_gen.go b/internal/http/graph/models_gen.go index b8a2b22..02e8ed7 100644 --- a/internal/http/graph/models_gen.go +++ b/internal/http/graph/models_gen.go @@ -7,15 +7,8 @@ import ( "fmt" "io" "strconv" - - "github.com/99designs/gqlgen/graphql" ) -type CreatePostInput struct { - Title string `json:"title"` - Image graphql.Upload `json:"image"` -} - type Mutation struct { } diff --git a/internal/http/graph/post_resolvers.go b/internal/http/graph/post_resolvers.go index b07466a..fddb8b3 100644 --- a/internal/http/graph/post_resolvers.go +++ b/internal/http/graph/post_resolvers.go @@ -3,7 +3,6 @@ package graph import ( "context" "fmt" - "io" "tailly_back_v2/internal/domain" "time" ) @@ -125,35 +124,13 @@ 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, input CreatePostInput) (*domain.Post, error) { +func (r *mutationResolver) CreatePost(ctx context.Context, title string, content string) (*domain.Post, error) { userID, err := getUserIDFromContext(ctx) if err != nil { return nil, fmt.Errorf("unauthorized: %w", err) } - contentType := input.Image.ContentType - size := input.Image.Size - if size > 10_000_000 { - return nil, fmt.Errorf("image too large (max 10MB), got %d bytes", size) - } - - // Проверяем поддерживаемые форматы - supportedTypes := map[string]bool{ - "image/jpeg": true, - "image/png": true, - "image/webp": true, - } - if !supportedTypes[contentType] { - return nil, fmt.Errorf("unsupported image format: %s", contentType) - } - - // Читаем бинарные данные - imageData, err := io.ReadAll(input.Image.File) - if err != nil { - return nil, fmt.Errorf("failed to read file: %w", err) - } - - post, err := r.Services.Post.Create(ctx, userID, input.Title, imageData, contentType, size) + post, err := r.Services.Post.Create(ctx, userID, title, content) if err != nil { return nil, fmt.Errorf("failed to create post: %w", err) } diff --git a/internal/http/graph/schema.graphql b/internal/http/graph/schema.graphql index c70aa48..93224ed 100644 --- a/internal/http/graph/schema.graphql +++ b/internal/http/graph/schema.graphql @@ -119,14 +119,6 @@ type Query { comments(postID: Int!): [Comment!]! } -scalar Upload - -input CreatePostInput { - title: String! - image: Upload! -} - - # Мутации (изменение данных) type Mutation { # Регистрация нового пользователя @@ -139,7 +131,7 @@ type Mutation { refreshTokens(refreshToken: String!): Tokens! # Создание поста - createPost(input: CreatePostInput!): Post! + createPost(title: String!, content: String!): Post! # Создание комментария createComment(postId: Int!, content: String!): Comment! diff --git a/internal/service/post_service.go b/internal/service/post_service.go index cec3bba..f1e5247 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -3,9 +3,12 @@ package service import ( "bytes" "context" + "encoding/base64" "errors" "fmt" "github.com/aws/aws-sdk-go/aws/credentials" + "image/jpeg" + "image/png" "strings" "tailly_back_v2/internal/domain" "tailly_back_v2/internal/repository" @@ -21,7 +24,7 @@ import ( // Интерфейс сервиса постов type PostService interface { - Create(ctx context.Context, authorID int, title string, imageData []byte, contentType string, size int64) (*domain.Post, error) + Create(ctx context.Context, authorID int, title, content string) (*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) @@ -40,25 +43,63 @@ func NewPostService(postRepo repository.PostRepository) PostService { } // Создание нового поста -func (s *postService) Create(ctx context.Context, authorID int, title string, imageData []byte, contentType string, size int64) (*domain.Post, error) { +func (s *postService) Create(ctx context.Context, authorID int, title, content string) (*domain.Post, error) { const op = "service/postService.Create" - // Валидация - if len(imageData) == 0 { - return nil, errors.New("image data cannot be empty") + if content == "" { + return nil, errors.New("post content cannot be empty") } - if len(imageData) > 10_000_000 { + + // Определяем формат и извлекаем данные + 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") + } + + // Декодируем 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) + } + } + + // Модерация изображения 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, imageData) // ← Прямая передача bytes + allowed, err := modClient.CheckImage(ctx, imgData) if err != nil { return nil, fmt.Errorf("%s: image moderation failed: %w", op, err) } @@ -85,7 +126,7 @@ func (s *postService) Create(ctx context.Context, authorID int, title string, im _, err = uploader.Upload(&s3manager.UploadInput{ Bucket: aws.String("tailly"), Key: aws.String(s3Key), - Body: bytes.NewReader(imageData), // ← Бинарные данные + Body: bytes.NewReader(imgData), ContentType: aws.String(contentType), }) if err != nil { @@ -109,46 +150,6 @@ func (s *postService) Create(ctx context.Context, authorID int, title string, im return post, nil } -func getContentTypeAndExtension(filename string) (string, string) { - switch { - case strings.HasSuffix(strings.ToLower(filename), ".jpg"), strings.HasSuffix(strings.ToLower(filename), ".jpeg"): - return "image/jpeg", "jpg" - case strings.HasSuffix(strings.ToLower(filename), ".png"): - return "image/png", "png" - case strings.HasSuffix(strings.ToLower(filename), ".webp"): - return "image/webp", "webp" - default: - return "", "" - } -} - -func isValidImage(data []byte, ext string) bool { - if len(data) < 4 { - return false - } - - switch ext { - case "jpg", "jpeg": - // JPEG: FF D8 FF - return data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF - case "png": - // PNG: 89 50 4E 47 - return data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 - case "webp": - // WebP: RIFF....WEBP - return len(data) > 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP" - default: - return false - } -} - -func getFileExtension(filename string) string { - if idx := strings.LastIndex(filename, "."); idx != -1 { - return filename[idx:] - } - return ".jpg" -} - // Получение поста по ID func (s *postService) GetByID(ctx context.Context, id int) (*domain.Post, error) { post, err := s.postRepo.GetByID(ctx, id)