From ef9bb2c4841dc17ed84efdb5259573fc6637541c Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 28 Aug 2025 09:55:17 +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=20=D0=BF=D1=80=D0=B8=D0=BD=D1=86=D0=B8=D0=BF=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20s3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/http/graph/generated.go | 389 +++++++++++++++++++++++++- internal/http/graph/models_gen.go | 11 + internal/http/graph/post_resolvers.go | 24 +- internal/http/graph/schema.graphql | 22 +- internal/service/post_service.go | 75 +++++ pkg/S3/s3.go | 91 +++++- pkg/moderation/moderation.go | 13 +- 7 files changed, 603 insertions(+), 22 deletions(-) diff --git a/internal/http/graph/generated.go b/internal/http/graph/generated.go index 799118d..66ef022 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, input CreatePostInput) int DeletePost func(childComplexity int, id int) int FollowUser func(childComplexity int, followingID int) int LikePost func(childComplexity int, postID int) int @@ -180,6 +180,7 @@ type ComplexityRoot struct { Query struct { Comments func(childComplexity int, postID int) int + GenerateUploadURL func(childComplexity int, filename string) int GetChat func(childComplexity int, user1Id int, user2Id int) int GetChatMessages func(childComplexity int, chatID int, limit int, offset int) int GetFollowers func(childComplexity int, userID int, limit *int, offset *int) int @@ -234,6 +235,12 @@ type ComplexityRoot struct { Success func(childComplexity int) int } + UploadURL struct { + ExpiresAt func(childComplexity int) int + Key func(childComplexity int) int + URL func(childComplexity int) int + } + User struct { Avatar func(childComplexity int) int CreatedAt func(childComplexity int) int @@ -277,7 +284,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, input CreatePostInput) (*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) @@ -310,6 +317,7 @@ type QueryResolver interface { Post(ctx context.Context, id int) (*domain.Post, error) Posts(ctx context.Context) ([]*domain.Post, error) PostsPaginated(ctx context.Context, limit int, offset int) ([]*domain.Post, error) + GenerateUploadURL(ctx context.Context, filename string) (*UploadURL, error) GetUserPosts(ctx context.Context, userID int) ([]*domain.Post, error) User(ctx context.Context, id int) (*domain.User, error) Users(ctx context.Context) ([]*domain.User, error) @@ -733,7 +741,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["input"].(CreatePostInput)), true case "Mutation.deletePost": if e.complexity.Mutation.DeletePost == nil { @@ -1020,6 +1028,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Query.Comments(childComplexity, args["postID"].(int)), true + case "Query.generateUploadURL": + if e.complexity.Query.GenerateUploadURL == nil { + break + } + + args, err := ec.field_Query_generateUploadURL_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.GenerateUploadURL(childComplexity, args["filename"].(string)), true + case "Query.getChat": if e.complexity.Query.GetChat == nil { break @@ -1349,6 +1369,27 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.UnfollowResult.Success(childComplexity), true + case "UploadURL.expiresAt": + if e.complexity.UploadURL.ExpiresAt == nil { + break + } + + return e.complexity.UploadURL.ExpiresAt(childComplexity), true + + case "UploadURL.key": + if e.complexity.UploadURL.Key == nil { + break + } + + return e.complexity.UploadURL.Key(childComplexity), true + + case "UploadURL.url": + if e.complexity.UploadURL.URL == nil { + break + } + + return e.complexity.UploadURL.URL(childComplexity), true + case "User.avatar": if e.complexity.User.Avatar == nil { break @@ -1468,6 +1509,7 @@ 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, ) @@ -1685,16 +1727,11 @@ 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, "title", ec.unmarshalNString2string) + arg0, err := processArgField(ctx, rawArgs, "input", ec.unmarshalNCreatePostInput2tailly_back_v2ᚋinternalᚋhttpᚋgraphᚐCreatePostInput) if err != nil { return nil, err } - args["title"] = arg0 - arg1, err := processArgField(ctx, rawArgs, "content", ec.unmarshalNString2string) - if err != nil { - return nil, err - } - args["content"] = arg1 + args["input"] = arg0 return args, nil } @@ -1899,6 +1936,17 @@ func (ec *executionContext) field_Query_comments_args(ctx context.Context, rawAr return args, nil } +func (ec *executionContext) field_Query_generateUploadURL_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := processArgField(ctx, rawArgs, "filename", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["filename"] = arg0 + return args, nil +} + func (ec *executionContext) field_Query_getChatMessages_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -4519,7 +4567,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["input"].(CreatePostInput)) }) if err != nil { ec.Error(ctx, err) @@ -6541,6 +6589,69 @@ func (ec *executionContext) fieldContext_Query_postsPaginated(ctx context.Contex return fc, nil } +func (ec *executionContext) _Query_generateUploadURL(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_generateUploadURL(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().GenerateUploadURL(rctx, fc.Args["filename"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*UploadURL) + fc.Result = res + return ec.marshalNUploadURL2ᚖtailly_back_v2ᚋinternalᚋhttpᚋgraphᚐUploadURL(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Query_generateUploadURL(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "url": + return ec.fieldContext_UploadURL_url(ctx, field) + case "key": + return ec.fieldContext_UploadURL_key(ctx, field) + case "expiresAt": + return ec.fieldContext_UploadURL_expiresAt(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type UploadURL", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_generateUploadURL_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Query_getUserPosts(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Query_getUserPosts(ctx, field) if err != nil { @@ -8521,6 +8632,138 @@ func (ec *executionContext) fieldContext_UnfollowResult_message(_ context.Contex return fc, nil } +func (ec *executionContext) _UploadURL_url(ctx context.Context, field graphql.CollectedField, obj *UploadURL) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_UploadURL_url(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.URL, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_UploadURL_url(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "UploadURL", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _UploadURL_key(ctx context.Context, field graphql.CollectedField, obj *UploadURL) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_UploadURL_key(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Key, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_UploadURL_key(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "UploadURL", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _UploadURL_expiresAt(ctx context.Context, field graphql.CollectedField, obj *UploadURL) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_UploadURL_expiresAt(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.ExpiresAt, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_UploadURL_expiresAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "UploadURL", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _User_id(ctx context.Context, field graphql.CollectedField, obj *domain.User) (ret graphql.Marshaler) { fc, err := ec.fieldContext_User_id(ctx, field) if err != nil { @@ -11105,6 +11348,40 @@ 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", "s3TempKey"} + 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 "s3TempKey": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("s3TempKey")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.S3TempKey = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputLoginInput(ctx context.Context, obj any) (domain.LoginInput, error) { var it domain.LoginInput asMap := map[string]any{} @@ -12796,6 +13073,28 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "generateUploadURL": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_generateUploadURL(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "getUserPosts": field := field @@ -13511,6 +13810,55 @@ func (ec *executionContext) _UnfollowResult(ctx context.Context, sel ast.Selecti return out } +var uploadURLImplementors = []string{"UploadURL"} + +func (ec *executionContext) _UploadURL(ctx context.Context, sel ast.SelectionSet, obj *UploadURL) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, uploadURLImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("UploadURL") + case "url": + out.Values[i] = ec._UploadURL_url(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "key": + out.Values[i] = ec._UploadURL_key(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "expiresAt": + out.Values[i] = ec._UploadURL_expiresAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var userImplementors = []string{"User"} func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj *domain.User) graphql.Marshaler { @@ -14353,6 +14701,11 @@ 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) } @@ -14907,6 +15260,20 @@ func (ec *executionContext) marshalNUnfollowResult2ᚖtailly_back_v2ᚋinternal return ec._UnfollowResult(ctx, sel, v) } +func (ec *executionContext) marshalNUploadURL2tailly_back_v2ᚋinternalᚋhttpᚋgraphᚐUploadURL(ctx context.Context, sel ast.SelectionSet, v UploadURL) graphql.Marshaler { + return ec._UploadURL(ctx, sel, &v) +} + +func (ec *executionContext) marshalNUploadURL2ᚖtailly_back_v2ᚋinternalᚋhttpᚋgraphᚐUploadURL(ctx context.Context, sel ast.SelectionSet, v *UploadURL) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._UploadURL(ctx, sel, v) +} + 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 03e4f88..b3e174a 100644 --- a/internal/http/graph/models_gen.go +++ b/internal/http/graph/models_gen.go @@ -9,6 +9,11 @@ import ( "strconv" ) +type CreatePostInput struct { + Title string `json:"title"` + S3TempKey string `json:"s3TempKey"` +} + type FollowResult struct { Success bool `json:"success"` Message string `json:"message"` @@ -73,6 +78,12 @@ type UnfollowResult struct { Message string `json:"message"` } +type UploadURL struct { + URL string `json:"url"` + Key string `json:"key"` + ExpiresAt string `json:"expiresAt"` +} + type MessageStatus string const ( diff --git a/internal/http/graph/post_resolvers.go b/internal/http/graph/post_resolvers.go index 79a2734..e280966 100644 --- a/internal/http/graph/post_resolvers.go +++ b/internal/http/graph/post_resolvers.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "tailly_back_v2/internal/domain" + "tailly_back_v2/pkg/S3" "time" ) @@ -124,19 +125,38 @@ 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, input CreatePostInput) (*domain.Post, error) { userID, err := getUserIDFromContext(ctx) if err != nil { return nil, fmt.Errorf("unauthorized: %w", err) } - post, err := r.Services.Post.Create(ctx, userID, title, content) + post, err := r.Services.Post.CreateWithS3Upload(ctx, userID, input.Title, input.S3TempKey) if err != nil { return nil, fmt.Errorf("failed to create post: %w", err) } return post, nil } +// GenerateUploadURL is the resolver for the generateUploadURL field. +func (r *queryResolver) GenerateUploadURL(ctx context.Context, filename string) (*UploadURL, error) { + userID, err := getUserIDFromContext(ctx) + if err != nil { + return nil, fmt.Errorf("unauthorized: %w", err) + } + + url, key, err := S3.GeneratePresignedUploadURL(userID, filename) + if err != nil { + return nil, fmt.Errorf("failed to generate upload URL: %w", err) + } + + return &UploadURL{ + URL: url, + Key: key, + ExpiresAt: time.Now().Add(15 * time.Minute).Format(time.RFC3339), + }, nil +} + // DeletePost is the resolver for the deletePost field. func (r *mutationResolver) DeletePost(ctx context.Context, id int) (bool, error) { userID, err := getUserIDFromContext(ctx) diff --git a/internal/http/graph/schema.graphql b/internal/http/graph/schema.graphql index 2828a13..6cb7a6e 100644 --- a/internal/http/graph/schema.graphql +++ b/internal/http/graph/schema.graphql @@ -170,12 +170,26 @@ type MarkNotificationReadResult { message: String! } +scalar Upload + +input CreatePostInput { + title: String! + s3TempKey: String! +} + +type UploadURL { + url: String! + key: String! + expiresAt: String! +} + # Запросы (получение данных) type Query { me: User! # Получить текущего пользователя post(id: Int!): Post! # Получить пост по ID posts: [Post!]! # Получить все посты postsPaginated(limit: Int!, offset: Int!): [Post!]! + generateUploadURL(filename: String!): UploadURL! getUserPosts(userId: Int!): [Post!]! user(id: Int!): User! # Получить пользователя по ID users: [User!]! @@ -186,19 +200,14 @@ type Query { comments(postID: Int!): [Comment!]! # Получить количество подписчиков getFollowersCount(userId: Int!): Int! - # Получить количество подписок getFollowingCount(userId: Int!): Int! - # Проверить подписку isFollowing(followingId: Int!): Boolean! - # Получить список подписчиков getFollowers(userId: Int!, limit: Int = 20, offset: Int = 0): FollowersResponse! - # Получить список подписок getFollowing(userId: Int!, limit: Int = 20, offset: Int = 0): FollowingResponse! - # Получить уведомления о подписках getSubscriptionNotifications( unreadOnly: Boolean = false @@ -219,8 +228,7 @@ type Mutation { refreshTokens(refreshToken: String!): Tokens! # Создание поста - createPost(title: String!, content: String!): Post! - + createPost(input: CreatePostInput!): Post! # Создание комментария createComment(postId: Int!, content: String!): Comment! diff --git a/internal/service/post_service.go b/internal/service/post_service.go index d9f0e12..d0cd42a 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -32,6 +32,7 @@ type PostService interface { Update(ctx context.Context, id int, title, content string) (*domain.Post, error) Delete(ctx context.Context, id int) error GetPaginated(ctx context.Context, limit, offset int) ([]*domain.Post, error) + CreateWithS3Upload(ctx context.Context, authorID int, title, s3TempKey string) (*domain.Post, error) } // Реализация сервиса постов @@ -44,6 +45,80 @@ func NewPostService(postRepo repository.PostRepository) PostService { return &postService{postRepo: postRepo} } +func (s *postService) CreateWithS3Upload(ctx context.Context, authorID int, title, s3TempKey string) (*domain.Post, error) { + const op = "service/postService.CreateWithS3Upload" + + // Валидация + if s3TempKey == "" { + return nil, errors.New("S3 temp key cannot be empty") + } + + // Проверяем что ключ принадлежит пользователю (дополнительная валидация) + if !isValidUserTempKey(authorID, s3TempKey) { + return nil, errors.New("invalid temp key for user") + } + + // Генерируем временный URL для модерации + tempURL := fmt.Sprintf("https://s3.regru.cloud/tailly/%s", s3TempKey) + + // Модерация изображения по URL + 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.CheckImageURL(ctx, tempURL) + if err != nil { + // Удаляем временный файл при ошибке модерации + S3.DeleteTempFile(s3TempKey) + return nil, fmt.Errorf("%s: image moderation failed: %w", op, err) + } + if !allowed { + // Удаляем временный файл если не прошло модерацию + S3.DeleteTempFile(s3TempKey) + return nil, errors.New("image rejected by moderation service") + } + + // Перемещаем файл из временной папки в постоянную + permanentKey := fmt.Sprintf("posts/%d/%d_%s", authorID, time.Now().UnixNano(), getFilenameFromKey(s3TempKey)) + + err = S3.MoveFile(s3TempKey, permanentKey) + if err != nil { + S3.DeleteTempFile(s3TempKey) // Удаляем временный файл при ошибке + return nil, fmt.Errorf("%s: failed to move file to permanent location: %w", op, err) + } + + imageURL := fmt.Sprintf("https://s3.regru.cloud/tailly/%s", permanentKey) + + post := &domain.Post{ + Title: title, + Content: imageURL, + AuthorID: authorID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := s.postRepo.Create(ctx, post); err != nil { + // Если не удалось сохранить пост, удаляем файл из S3 + S3.DeleteTempFile(permanentKey) + return nil, fmt.Errorf("%s: failed to save post: %w", op, err) + } + + return post, nil +} + +func isValidUserTempKey(userID int, key string) bool { + // Проверяем что ключ принадлежит пользователю + // Формат: temp/{userID}/{timestamp}_{filename} + return strings.Contains(key, fmt.Sprintf("temp/%d/", userID)) +} + +func getFilenameFromKey(key string) string { + parts := strings.Split(key, "/") + return parts[len(parts)-1] +} + // Создание нового поста func (s *postService) Create(ctx context.Context, authorID int, title, content string) (*domain.Post, error) { const op = "service/postService.Create" diff --git a/pkg/S3/s3.go b/pkg/S3/s3.go index 6887c44..6520150 100644 --- a/pkg/S3/s3.go +++ b/pkg/S3/s3.go @@ -2,11 +2,13 @@ package S3 import ( "fmt" + "strings" + "time" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/s3" - "strings" ) func DeleteFromS3(imageURL string) error { @@ -50,3 +52,90 @@ func DeleteFromS3(imageURL string) error { return nil } +func GeneratePresignedUploadURL(userID int, filename string) (string, string, error) { + const op = "s3.GeneratePresignedUploadURL" + + sess := session.Must(session.NewSession(&aws.Config{ + Region: aws.String("ru-central1"), + Endpoint: aws.String("https://s3.regru.cloud"), + Credentials: credentials.NewStaticCredentials( + "TJ946G2S1Z5FEI3I7DQQ", + "C2H2aITHRDpek8H921yhnrINZwDoADsjW3F6HURl", + "", + ), + })) + + svc := s3.New(sess) + + // Генерируем уникальный ключ с userID для безопасности + uniqueKey := fmt.Sprintf("temp/%d/%d_%s", userID, time.Now().UnixNano(), filename) + + req, _ := svc.PutObjectRequest(&s3.PutObjectInput{ + Bucket: aws.String("tailly"), + Key: aws.String(uniqueKey), + ContentLength: aws.Int64(10 * 1024 * 1024), // Макс 10MB + }) + + // URL действителен 15 минут + url, err := req.Presign(15 * time.Minute) + if err != nil { + return "", "", fmt.Errorf("%s: failed to generate presigned URL: %w", op, err) + } + + return url, uniqueKey, nil +} + +func MoveFile(sourceKey, destinationKey string) error { + const op = "s3.MoveFile" + + sess := session.Must(session.NewSession(&aws.Config{ + Region: aws.String("ru-central1"), + Endpoint: aws.String("https://s3.regru.cloud"), + Credentials: credentials.NewStaticCredentials( + "TJ946G2S1Z5FEI3I7DQQ", + "C2H2aITHRDpek8H921yhnrINZwDoADsjW3F6HURl", + "", + ), + })) + + svc := s3.New(sess) + + // Копируем файл + _, err := svc.CopyObject(&s3.CopyObjectInput{ + Bucket: aws.String("tailly"), + CopySource: aws.String(fmt.Sprintf("tailly/%s", sourceKey)), + Key: aws.String(destinationKey), + }) + if err != nil { + return fmt.Errorf("%s: failed to copy file: %w", op, err) + } + + // Удаляем исходный файл + return DeleteTempFile(sourceKey) +} + +func DeleteTempFile(key string) error { + const op = "s3.DeleteTempFile" + + sess := session.Must(session.NewSession(&aws.Config{ + Region: aws.String("ru-central1"), + Endpoint: aws.String("https://s3.regru.cloud"), + Credentials: credentials.NewStaticCredentials( + "TJ946G2S1Z5FEI3I7DQQ", + "C2H2aITHRDpek8H921yhnrINZwDoADsjW3F6HURl", + "", + ), + })) + + svc := s3.New(sess) + + _, err := svc.DeleteObject(&s3.DeleteObjectInput{ + Bucket: aws.String("tailly"), + Key: aws.String(key), + }) + if err != nil { + return fmt.Errorf("%s: failed to delete temp file: %w", op, err) + } + + return nil +} diff --git a/pkg/moderation/moderation.go b/pkg/moderation/moderation.go index 30c22f8..e5b3f6f 100644 --- a/pkg/moderation/moderation.go +++ b/pkg/moderation/moderation.go @@ -3,8 +3,9 @@ package moderation import ( "context" - "google.golang.org/grpc" pb "tailly_back_v2/pkg/moderation/proto" + + "google.golang.org/grpc" ) type ModerationClient struct { @@ -35,6 +36,16 @@ func (c *ModerationClient) CheckImage(ctx context.Context, imageData []byte) (bo return resp.OverallDecision == "allowed", nil } +func (c *ModerationClient) CheckImageURL(ctx context.Context, imageURL string) (bool, error) { + resp, err := c.client.CheckImage(ctx, &pb.ImageRequest{ + ImageUrl: imageURL, // ← Передаем URL вместо данных! + }) + if err != nil { + return false, err + } + return resp.OverallDecision == "allowed", nil +} + func (c *ModerationClient) Close() { c.conn.Close() }