Files
History_Api/internal/services/mediaService.go
AzenKain acecd1738e
All checks were successful
Build and Release / release (push) Successful in 1m34s
UPDATE: fix bug
2026-04-14 17:06:09 +07:00

408 lines
12 KiB
Go

package services
import (
"context"
"encoding/json"
"fmt"
"history-api/internal/dtos/request"
"history-api/internal/dtos/response"
"history-api/internal/gen/sqlc"
"history-api/internal/models"
"history-api/internal/repositories"
"history-api/pkg/cache"
"history-api/pkg/constants"
"history-api/pkg/convert"
"history-api/pkg/storage"
"io"
"mime/multipart"
"net/url"
"path/filepath"
"slices"
"strings"
"github.com/gofiber/fiber/v3"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/rs/zerolog/log"
"golang.org/x/sync/errgroup"
)
type MediaService interface {
GetMediaByID(ctx context.Context, mediaId string) (*response.MediaResponse, error)
GetMediaByUserID(ctx context.Context, userId string) ([]*response.MediaResponse, error)
SearchMedia(ctx context.Context, dto *request.SearchMediaDto) (*response.PaginatedResponse, error)
DeleteMedia(ctx context.Context, claims *response.JWTClaims, mediaId string) error
BulkDeleteMedia(ctx context.Context, claims *response.JWTClaims, dto *request.MediaBulkDeleteDto) error
UploadServerSide(ctx context.Context, userId string, fileHeader *multipart.FileHeader) (*response.MediaResponse, error)
GeneratePresignedURL(ctx context.Context, userId string, dto *request.PreSignedDto) (*response.PreSignedResponse, error)
PreSignedCompleted(ctx context.Context, userId string, dto *request.PreSignedCompleteDto) (*response.MediaResponse, error)
}
type mediaService struct {
mediaRepo repositories.MediaRepository
tokenRepo repositories.TokenRepository
s storage.Storage
c cache.Cache
}
func NewMediaService(
mediaRepo repositories.MediaRepository,
tokenRepo repositories.TokenRepository,
s storage.Storage,
c cache.Cache,
) MediaService {
return &mediaService{
mediaRepo: mediaRepo,
tokenRepo: tokenRepo,
s: s,
c: c,
}
}
func (m *mediaService) DeleteMedia(ctx context.Context, claims *response.JWTClaims, mediaId string) error {
mediaIdUUID, err := convert.StringToUUID(mediaId)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
media, err := m.mediaRepo.GetByID(ctx, mediaIdUUID)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
shoudDelete := false
if slices.Contains(claims.Roles, constants.ADMIN) || slices.Contains(claims.Roles, constants.MOD) || media.UserID == claims.UId {
shoudDelete = true
}
if !shoudDelete {
return fiber.NewError(fiber.StatusForbidden, "You don't have permission to delete this media")
}
err = m.mediaRepo.Delete(ctx, mediaIdUUID)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
m.c.PublishTask(ctx, constants.StreamStorageName, constants.TaskTypeDeleteMedia, media.ToStorageEntity())
return nil
}
func (m *mediaService) BulkDeleteMedia(ctx context.Context, claims *response.JWTClaims, dto *request.MediaBulkDeleteDto) error {
listMedia, err := m.mediaRepo.GetByIDs(ctx, dto.MediaIDs)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
shoudDelete := false
if slices.Contains(claims.Roles, constants.ADMIN) || slices.Contains(claims.Roles, constants.MOD) {
shoudDelete = true
}
listMediaIds := make([]pgtype.UUID, len(listMedia))
listMediaStorageEntities := make([]*models.MediaStorageEntity, len(listMedia))
for _, media := range listMedia {
if media.UserID != claims.UId && !shoudDelete {
return fiber.NewError(fiber.StatusForbidden, "You don't have permission to delete this media")
}
id, err := convert.StringToUUID(media.ID)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
listMediaIds = append(listMediaIds, id)
listMediaStorageEntities = append(listMediaStorageEntities, media.ToStorageEntity())
}
err = m.mediaRepo.BulkDelete(ctx, listMediaIds)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
m.c.PublishTask(ctx, constants.StreamStorageName, constants.TaskTypeBulkDeleteMedia, listMediaStorageEntities)
return nil
}
func (m *mediaService) GetMediaByID(ctx context.Context, id string) (*response.MediaResponse, error) {
mediaId, err := convert.StringToUUID(id)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
media, err := m.mediaRepo.GetByID(ctx, mediaId)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return media.ToResponse(), nil
}
func (m *mediaService) GetMediaByUserID(ctx context.Context, id string) ([]*response.MediaResponse, error) {
userId, err := convert.StringToUUID(id)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
medias, err := m.mediaRepo.GetByUserID(ctx, userId)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return models.MediaEntitiesToResponse(medias), nil
}
func (m *mediaService) fillSearchArgs(arg *sqlc.SearchMediasParams, dto *request.SearchMediaDto) {
if dto.Sort != "" {
arg.Sort = pgtype.Text{String: dto.Sort, Valid: true}
} else {
arg.Sort = pgtype.Text{String: "id", Valid: true}
}
arg.Order = pgtype.Text{String: "asc", Valid: true}
if dto.Order == "desc" {
arg.Order = pgtype.Text{String: "desc", Valid: true}
}
if dto.MimeType != "" {
arg.MimeType = pgtype.Text{String: dto.MimeType, Valid: true}
}
if dto.MaxSize != nil {
arg.MaxSize = pgtype.Int8{Int64: *dto.MaxSize, Valid: true}
}
if dto.MinSize != nil {
arg.MinSize = pgtype.Int8{Int64: *dto.MinSize, Valid: true}
}
if len(dto.UserIDs) > 0 {
for _, id := range dto.UserIDs {
if u, err := convert.StringToUUID(id); err == nil {
arg.UserIds = append(arg.UserIds, u)
}
}
}
if dto.Search != "" {
arg.SearchText = pgtype.Text{String: dto.Search, Valid: true}
}
}
func (m *mediaService) SearchMedia(ctx context.Context, dto *request.SearchMediaDto) (*response.PaginatedResponse, error) {
if dto.Page < 1 {
dto.Page = 1
}
if dto.Limit == 0 {
dto.Limit = 20
}
offset := (dto.Page - 1) * dto.Limit
arg := sqlc.SearchMediasParams{
Limit: int32(dto.Limit),
Offset: int32(offset),
}
m.fillSearchArgs(&arg, dto)
var rows []*models.MediaEntity
var totalRecords int64
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
rows, err = m.mediaRepo.Search(gCtx, arg)
return err
})
g.Go(func() error {
countArg := sqlc.CountMediasParams{
UserIds: arg.UserIds,
MimeType: arg.MimeType,
MinSize: arg.MinSize,
MaxSize: arg.MaxSize,
SearchText: arg.SearchText,
}
var err error
totalRecords, err = m.mediaRepo.Count(gCtx, countArg)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
media := models.MediaEntitiesToResponse(rows)
return response.BuildPaginatedResponse(media, totalRecords, dto.Page, dto.Limit), nil
}
func (m *mediaService) UploadServerSide(ctx context.Context, userId string, fileHeader *multipart.FileHeader) (*response.MediaResponse, error) {
userIdUUID, err := convert.StringToUUID(userId)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
file, err := fileHeader.Open()
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Cannot open file")
}
defer file.Close()
var reader io.Reader = file
fileExt := filepath.Ext(fileHeader.Filename)
contentType := fileHeader.Header.Get("Content-Type")
mid, err := uuid.NewV7()
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate media ID")
}
newFileName := mid.String() + fileExt
originalName := fileHeader.Filename
encodedName := url.QueryEscape(originalName)
dispositionType := "attachment"
if strings.HasPrefix(contentType, "image/") || contentType == "application/pdf" {
dispositionType = "inline"
}
contentDisposition := fmt.Sprintf("%s; filename=\"%s\"; filename*=UTF-8''%s",
dispositionType,
"file"+fileExt,
encodedName,
)
metadata := map[string]string{
"original-name": encodedName,
}
mdByte, err := json.Marshal(metadata)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to encode metadata")
}
err = m.s.Upload(ctx, newFileName, reader, fileHeader.Size, storage.UploadOptions{
ContentType: contentType,
ContentDisposition: contentDisposition,
Metadata: metadata,
})
if err != nil {
log.Err(err).Msg("Failed to upload file to storage")
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to upload file")
}
media, err := m.mediaRepo.Create(ctx, sqlc.CreateMediaParams{
UserID: userIdUUID,
StorageKey: newFileName,
OriginalName: originalName,
MimeType: contentType,
Size: fileHeader.Size,
FileMetadata: mdByte,
})
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return media.ToResponse(), nil
}
func (m *mediaService) GeneratePresignedURL(ctx context.Context, userId string, dto *request.PreSignedDto) (*response.PreSignedResponse, error) {
fileExt := filepath.Ext(dto.FileName)
mid, err := uuid.NewV7()
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate media ID")
}
newFileName := mid.String() + fileExt
encodedName := url.QueryEscape(dto.FileName)
dispositionType := "attachment"
if dto.ContentType == "application/pdf" || (len(dto.ContentType) > 6 && dto.ContentType[:6] == "image/") {
dispositionType = "inline"
}
contentDisposition := fmt.Sprintf("%s; filename=\"%s\"; filename*=UTF-8''%s",
dispositionType, "file"+fileExt, encodedName)
metadata := map[string]string{
"original-name": encodedName,
}
mdByte, err := json.Marshal(metadata)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to encode metadata")
}
presignedURL, err := m.s.PresignUpload(ctx, newFileName, constants.PreSignedURLDuration, storage.UploadOptions{
ContentType: dto.ContentType,
ContentDisposition: contentDisposition,
Metadata: metadata,
})
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate presigned URL")
}
tokenId := uuid.New().String()
err = m.tokenRepo.CreateUploadToken(
ctx,
userId,
&models.TokenUploadEntity{
ID: tokenId,
UserID: userId,
StorageKey: newFileName,
OriginalName: dto.FileName,
MimeType: dto.ContentType,
Size: dto.Size,
FileMetadata: mdByte,
},
)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Internal Server Error")
}
return &response.PreSignedResponse{
TokenID: tokenId,
UploadUrl: presignedURL,
StorageKey: newFileName,
SignedHeaders: map[string]string{
"x-amz-meta-original-name": encodedName,
"Content-Disposition": contentDisposition,
},
}, nil
}
func (m *mediaService) PreSignedCompleted(ctx context.Context, userId string, dto *request.PreSignedCompleteDto) (*response.MediaResponse, error) {
token, err := m.tokenRepo.GetUploadToken(ctx, userId, dto.TokenID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get upload token")
}
if token == nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid or expired token")
}
userIdUUID, err := convert.StringToUUID(userId)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
err = m.s.Move(
ctx,
&storage.MoveOptions{
Bucket: m.s.GetTempBucket(),
Key: token.StorageKey,
},
&storage.MoveOptions{
Bucket: m.s.GetMainBucket(),
Key: token.StorageKey,
},
)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
media, err := m.mediaRepo.Create(ctx, sqlc.CreateMediaParams{
UserID: userIdUUID,
StorageKey: token.StorageKey,
OriginalName: token.OriginalName,
MimeType: token.MimeType,
Size: token.Size,
FileMetadata: token.FileMetadata,
})
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create media record")
}
_ = m.tokenRepo.DeleteUploadToken(ctx, userId, dto.TokenID)
return media.ToResponse(), nil
}