306 lines
8.8 KiB
Go
306 lines
8.8 KiB
Go
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"errors"
|
||
"fmt"
|
||
"image/jpeg"
|
||
"image/png"
|
||
"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/aws/aws-sdk-go/aws/credentials"
|
||
|
||
"github.com/aws/aws-sdk-go/aws"
|
||
"github.com/aws/aws-sdk-go/aws/session"
|
||
"github.com/aws/aws-sdk-go/service/s3/s3manager"
|
||
)
|
||
|
||
// Интерфейс сервиса постов
|
||
type PostService interface {
|
||
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)
|
||
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)
|
||
}
|
||
|
||
// Реализация сервиса постов
|
||
type postService struct {
|
||
postRepo repository.PostRepository
|
||
}
|
||
|
||
// Конструктор сервиса
|
||
func NewPostService(postRepo repository.PostRepository) PostService {
|
||
return &postService{postRepo: postRepo}
|
||
}
|
||
|
||
func (s *postService) CreateWithS3Upload(ctx context.Context, authorID int, title, s3Key string) (*domain.Post, error) {
|
||
const op = "service/postService.CreateWithS3Upload"
|
||
|
||
// Валидация
|
||
if s3Key == "" {
|
||
return nil, errors.New("S3 key cannot be empty")
|
||
}
|
||
|
||
// Проверяем что ключ принадлежит пользователю и находится в правильной папке
|
||
if !isValidUserPostKey(authorID, s3Key) {
|
||
return nil, errors.New("invalid S3 key for user")
|
||
}
|
||
|
||
// Формируем URL для модерации
|
||
imageURL := fmt.Sprintf("https://s3.regru.cloud/tailly/%s", s3Key)
|
||
|
||
// Модерация изображения по 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, imageURL)
|
||
if err != nil {
|
||
// Удаляем файл при ошибке модерации
|
||
S3.DeleteFromS3(imageURL)
|
||
return nil, fmt.Errorf("%s: image moderation failed: %w", op, err)
|
||
}
|
||
if !allowed {
|
||
// Удаляем файл если не прошло модерацию
|
||
S3.DeleteFromS3(imageURL)
|
||
return nil, errors.New("image rejected by moderation service")
|
||
}
|
||
|
||
// Создаем пост с уже готовым URL
|
||
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.DeleteFromS3(imageURL)
|
||
return nil, fmt.Errorf("%s: failed to save post: %w", op, err)
|
||
}
|
||
|
||
return post, nil
|
||
}
|
||
|
||
// Новая функция проверки ключа
|
||
func isValidUserPostKey(userID int, key string) bool {
|
||
// Проверяем что ключ принадлежит пользователю и находится в папке posts
|
||
// Формат: posts/{userID}/{timestamp}_{filename}
|
||
return strings.HasPrefix(key, fmt.Sprintf("posts/%d/", userID))
|
||
}
|
||
|
||
// Создание нового поста
|
||
func (s *postService) Create(ctx context.Context, authorID int, title, content string) (*domain.Post, error) {
|
||
const op = "service/postService.Create"
|
||
// Валидация
|
||
if content == "" {
|
||
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")
|
||
}
|
||
|
||
// Декодируем 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, 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
|
||
randName := utils.GenerateId() + ".jpg"
|
||
sess := session.Must(session.NewSession(&aws.Config{
|
||
Region: aws.String("ru-central1"),
|
||
Endpoint: aws.String("https://s3.regru.cloud"),
|
||
S3ForcePathStyle: aws.Bool(true),
|
||
Credentials: credentials.NewStaticCredentials(
|
||
"TJ946G2S1Z5FEI3I7DQQ",
|
||
"C2H2aITHRDpek8H921yhnrINZwDoADsjW3F6HURl",
|
||
"",
|
||
),
|
||
}))
|
||
|
||
uploader := s3manager.NewUploader(sess)
|
||
s3Key := fmt.Sprintf("posts/%d/%s", authorID, randName)
|
||
|
||
_, err = uploader.Upload(&s3manager.UploadInput{
|
||
Bucket: aws.String("tailly"),
|
||
Key: aws.String(s3Key),
|
||
Body: bytes.NewReader(imgData),
|
||
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)
|
||
|
||
post := &domain.Post{
|
||
Title: title,
|
||
Content: imageURL,
|
||
AuthorID: authorID,
|
||
CreatedAt: time.Now(),
|
||
UpdatedAt: time.Now(),
|
||
}
|
||
|
||
if err := s.postRepo.Create(ctx, post); err != nil {
|
||
return nil, fmt.Errorf("%s: failed to save post: %w", op, err)
|
||
}
|
||
|
||
return post, nil
|
||
}
|
||
|
||
// Получение поста по ID
|
||
func (s *postService) GetByID(ctx context.Context, id int) (*domain.Post, error) {
|
||
post, err := s.postRepo.GetByID(ctx, id)
|
||
if err != nil {
|
||
if errors.Is(err, repository.ErrPostNotFound) {
|
||
return nil, errors.New("post not found")
|
||
}
|
||
return nil, err
|
||
}
|
||
return post, nil
|
||
}
|
||
|
||
// Получение всех постов
|
||
func (s *postService) GetAll(ctx context.Context) ([]*domain.Post, error) {
|
||
posts, err := s.postRepo.GetAll(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Возвращаем пустой слайс вместо nil
|
||
if posts == nil {
|
||
return []*domain.Post{}, nil
|
||
}
|
||
|
||
return posts, nil
|
||
}
|
||
|
||
// Получение постов автора
|
||
func (s *postService) GetByAuthorID(ctx context.Context, authorID int) ([]*domain.Post, error) {
|
||
posts, err := s.postRepo.GetByAuthorID(ctx, authorID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if posts == nil {
|
||
return []*domain.Post{}, nil
|
||
}
|
||
|
||
return posts, nil
|
||
}
|
||
|
||
// Обновление поста
|
||
func (s *postService) Update(ctx context.Context, id int, title, content string) (*domain.Post, error) {
|
||
// Получаем существующий пост
|
||
post, err := s.postRepo.GetByID(ctx, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Обновляем поля
|
||
post.Title = title
|
||
post.Content = content
|
||
post.UpdatedAt = time.Now()
|
||
|
||
if err := s.postRepo.Update(ctx, post); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return post, nil
|
||
}
|
||
|
||
// Удаление поста
|
||
func (s *postService) Delete(ctx context.Context, id int) error {
|
||
|
||
post, err := s.postRepo.GetByID(ctx, id)
|
||
if err != nil {
|
||
if errors.Is(err, repository.ErrPostNotFound) {
|
||
return errors.New("post not found")
|
||
}
|
||
return err
|
||
}
|
||
|
||
err = S3.DeleteFromS3(post.Content)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.postRepo.Delete(ctx, id)
|
||
}
|
||
func (s *postService) GetPaginated(ctx context.Context, limit, offset int) ([]*domain.Post, error) {
|
||
posts, err := s.postRepo.GetPaginated(ctx, limit, offset)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if posts == nil {
|
||
return []*domain.Post{}, nil
|
||
}
|
||
|
||
return posts, nil
|
||
}
|