UPDATE: Media module
All checks were successful
Build and Release / release (push) Successful in 1m7s

This commit is contained in:
2026-04-05 22:25:43 +07:00
parent eb404b37e9
commit 2d36004ac7
24 changed files with 1972 additions and 94 deletions

View File

@@ -2,19 +2,35 @@ 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/rs/zerolog/log"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
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, mediaId string) error
GetMediaByTarget(ctx context.Context, targetType string, targetId string) ([]*response.MediaResponse, error)
DeleteMedia(ctx context.Context, claims *response.JWTClaims, mediaId string) 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)
@@ -22,56 +38,292 @@ type MediaService interface {
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,
}
}
// DeleteMedia implements [MediaService].
func (m *mediaService) DeleteMedia(ctx context.Context, mediaId string) error {
panic("unimplemented")
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
}
// GeneratePresignedURL implements [MediaService].
func (m *mediaService) GeneratePresignedURL(ctx context.Context, userId string, dto *request.PreSignedDto) (*response.PreSignedResponse, error) {
panic("unimplemented")
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
}
// GetMediaByID implements [MediaService].
func (m *mediaService) GetMediaByID(ctx context.Context, mediaId string) (*response.MediaResponse, error) {
panic("unimplemented")
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
}
// GetMediaByTarget implements [MediaService].
func (m *mediaService) GetMediaByTarget(ctx context.Context, targetType string, targetId string) ([]*response.MediaResponse, error) {
panic("unimplemented")
}
// GetMediaByUserID implements [MediaService].
func (m *mediaService) GetMediaByUserID(ctx context.Context, userId string) ([]*response.MediaResponse, error) {
panic("unimplemented")
}
// PreSignedCompleted implements [MediaService].
func (m *mediaService) PreSignedCompleted(ctx context.Context, userId string, dto *request.PreSignedCompleteDto) (*response.MediaResponse, error) {
panic("unimplemented")
}
// SearchMedia implements [MediaService].
func (m *mediaService) SearchMedia(ctx context.Context, dto *request.SearchMediaDto) (*response.PaginatedResponse, error) {
panic("unimplemented")
arg := sqlc.SearchMediasParams{
Limit: int32(dto.Limit + 1),
}
if dto.Cursor != "" {
pgID, err := convert.StringToUUID(dto.Cursor)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid cursor format")
}
arg.Cursor = pgID
}
if dto.Search != "" {
arg.SearchText = pgtype.Text{String: dto.Search, Valid: true}
}
rows, err := m.mediaRepo.Search(ctx, arg)
if err != nil {
return nil, err
}
hasMore := false
var nextCursor string
if len(rows) > dto.Limit {
hasMore = true
nextCursor = rows[dto.Limit-1].ID
rows = rows[:dto.Limit]
}
res := &response.PaginatedResponse{
Data: rows,
Status: true,
Message: "",
}
res.Pagination.HasMore = hasMore
res.Pagination.NextCursor = nextCursor
return res, nil
}
// UploadServerSide implements [MediaService].
func (m *mediaService) UploadServerSide(ctx context.Context, userId string, fileHeader *multipart.FileHeader) (*response.MediaResponse, error) {
panic("unimplemented")
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, "Failed to move file to final destination")
}
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
}