v0.0.27 Изменена мутация загрузки изображений через скаляр Upload
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
0647b62048
commit
f86590570c
36
go.sum
36
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=
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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!
|
||||
|
||||
@ -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)")
|
||||
}
|
||||
|
||||
// Проверяем валидность изображения
|
||||
// Проверяем тип файла
|
||||
contentType := content.ContentType
|
||||
if contentType == "" {
|
||||
// Определяем ContentType по расширению файла
|
||||
ext := strings.ToLower(filepath.Ext(content.Filename))
|
||||
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)
|
||||
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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user