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/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 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= 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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/caarlos0/env/v8 v8.0.0 h1:POhxHhSpuxrLMIdvTGARuZqR4Jjm8AYmoi/JKlcScs0= 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 ConfirmEmail func(childComplexity int, token string) int
CreateChat func(childComplexity int, user1Id int, user2Id int) int CreateChat func(childComplexity int, user1Id int, user2Id int) int
CreateComment func(childComplexity int, postID int, content string) 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 DeletePost func(childComplexity int, id int) int
FollowUser func(childComplexity int, followingID int) int FollowUser func(childComplexity int, followingID int) int
LikePost func(childComplexity int, postID 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) Register(ctx context.Context, input domain.RegisterInput) (*domain.User, error)
Login(ctx context.Context, input domain.LoginInput) (*domain.Tokens, error) Login(ctx context.Context, input domain.LoginInput) (*domain.Tokens, error)
RefreshTokens(ctx context.Context, refreshToken string) (*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) CreateComment(ctx context.Context, postID int, content string) (*domain.Comment, error)
LikePost(ctx context.Context, postID int) (*domain.Like, error) LikePost(ctx context.Context, postID int) (*domain.Like, error)
UnlikePost(ctx context.Context, postID int) (bool, 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 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": case "Mutation.deletePost":
if e.complexity.Mutation.DeletePost == nil { if e.complexity.Mutation.DeletePost == nil {
@ -1690,7 +1690,7 @@ func (ec *executionContext) field_Mutation_createPost_args(ctx context.Context,
return nil, err return nil, err
} }
args["title"] = arg0 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 { if err != nil {
return nil, err 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) { resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children 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 { if err != nil {
ec.Error(ctx, err) ec.Error(ctx, err)
@ -14907,6 +14907,22 @@ func (ec *executionContext) marshalNUnfollowResult2ᚖtailly_back_v2ᚋinternal
return ec._UnfollowResult(ctx, sel, v) 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 { 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) return ec._User(ctx, sel, &v)
} }

View File

@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"tailly_back_v2/internal/domain" "tailly_back_v2/internal/domain"
"time" "time"
"github.com/99designs/gqlgen/graphql"
) )
type postResolver struct{ *Resolver } 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. // 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) userID, err := getUserIDFromContext(ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf("unauthorized: %w", err) return nil, fmt.Errorf("unauthorized: %w", err)

View File

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

View File

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