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

@@ -0,0 +1,230 @@
package controllers
import (
"context"
"history-api/internal/dtos/request"
"history-api/internal/dtos/response"
"history-api/internal/services"
"history-api/pkg/validator"
"time"
"github.com/gofiber/fiber/v3"
)
type MediaController struct {
service services.MediaService
}
func NewMediaController(svc services.MediaService) *MediaController {
return &MediaController{service: svc}
}
// GetMediaByID godoc
// @Summary Get media by ID
// @Description Retrieve a media file by its ID
// @Tags Media
// @Accept json
// @Produce json
// @Param id path string true "Media ID"
// @Success 200 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
// @Router /media/{id} [get]
func (m *MediaController) GetMediaByID(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
mediaId := c.Params("id")
res, err := m.service.GetMediaByID(ctx, mediaId)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(response.CommonResponse{
Status: true,
Data: res,
})
}
// SearchMedia godoc
// @Summary Search media
// @Description Search media with filters, pagination
// @Tags Media
// @Accept json
// @Produce json
// @Param page query int false "Page number"
// @Param limit query int false "Items per page"
// @Param keyword query string false "Search keyword"
// @Success 200 {object} response.PaginatedResponse
// @Failure 400 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
// @Router /media [get]
func (m *MediaController) SearchMedia(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
dto := &request.SearchMediaDto{}
if err := validator.ValidateQueryDto(c, dto); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
res, err := m.service.SearchMedia(ctx, dto)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(res)
}
// DeleteMedia godoc
// @Summary Delete media
// @Description Delete a media file by ID
// @Tags Media
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Media ID"
// @Success 200 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
// @Router /media/{id} [delete]
func (m *MediaController) DeleteMedia(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
claimsVal := c.Locals("user_claims")
if claimsVal == nil {
return c.Status(fiber.StatusUnauthorized).JSON(response.CommonResponse{
Status: false,
Message: "Unauthorized",
})
}
claims, ok := claimsVal.(*response.JWTClaims)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(response.CommonResponse{
Status: false,
Message: "Invalid user claims",
})
}
mediaId := c.Params("id")
err := m.service.DeleteMedia(ctx, claims, mediaId)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(response.CommonResponse{
Status: true,
Message: "Media deleted successfully",
})
}
// UploadServerSide godoc
// @Summary Upload media (server-side)
// @Description Upload media file through server
// @Tags Media
// @Accept multipart/form-data
// @Produce json
// @Security BearerAuth
// @Param file formData file true "Upload file"
// @Success 200 {object} response.CommonResponse
// @Failure 400 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
// @Router /media/upload [post]
func (m *MediaController) UploadServerSide(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
fileHeader, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{
Status: false,
Message: "File is required",
})
}
url, err := m.service.UploadServerSide(ctx, c.Locals("uid").(string), fileHeader)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(response.CommonResponse{
Status: true,
Data: url,
Message: "Media uploaded successfully",
})
}
// GeneratePresignedURL godoc
// @Summary Generate presigned URL
// @Description Generate a presigned URL for direct upload to storage
// @Tags Media
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param filename query string true "File name"
// @Param contentType query string true "Content type"
// @Success 200 {object} response.CommonResponse
// @Failure 400 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
// @Router /media/presigned [get]
func (m *MediaController) GeneratePresignedURL(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
dto := &request.PreSignedDto{}
if err := validator.ValidateQueryDto(c, dto); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
res, err := m.service.GeneratePresignedURL(ctx, c.Locals("uid").(string), dto)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(res)
}
// PreSignedCompleted godoc
// @Summary Confirm presigned upload
// @Description Confirm that upload via presigned URL is completed
// @Tags Media
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param key query string true "Storage key"
// @Success 200 {object} response.CommonResponse
// @Failure 400 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
// @Router /media/presigned/complete [post]
func (m *MediaController) PreSignedCompleted(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
dto := &request.PreSignedCompleteDto{}
if err := validator.ValidateBodyDto(c, dto); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
res, err := m.service.PreSignedCompleted(ctx, c.Locals("uid").(string), dto)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(res)
}

View File

@@ -12,11 +12,15 @@ import (
)
type UserController struct {
service services.UserService
service services.UserService
mediaService services.MediaService
}
func NewUserController(svc services.UserService) *UserController {
return &UserController{service: svc}
func NewUserController(svc services.UserService, mediaSvc services.MediaService) *UserController {
return &UserController{
service: svc,
mediaService: mediaSvc,
}
}
// GetUserCurrent godoc
@@ -47,6 +51,61 @@ func (h *UserController) GetUserCurrent(c fiber.Ctx) error {
})
}
// GetUserMedia godoc
// @Summary Get current user's media
// @Description Retrieve media list of the currently authenticated user
// @Tags Users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
// @Router /users/current/media [get]
func (h *UserController) GetUserMedia(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
res, err := h.mediaService.GetMediaByUserID(ctx, c.Locals("uid").(string))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(response.CommonResponse{
Status: true,
Data: res,
})
}
// GetMediaByUserID godoc
// @Summary Get user's media by user ID
// @Description Retrieve media list by specific user ID
// @Tags Users
// @Accept json
// @Produce json
// @Param id path string true "User ID"
// @Success 200 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
// @Router /users/{id}/media [get]
func (h *UserController) GetMediaByUserID(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
userId := c.Params("id")
res, err := h.mediaService.GetMediaByUserID(ctx, userId)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
Status: false,
Message: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(response.CommonResponse{
Status: true,
Data: res,
})
}
// UpdateProfile godoc
// @Summary Update user profile
// @Description Update the profile details of the currently authenticated user
@@ -250,11 +309,11 @@ func (h *UserController) GetUserById(c fiber.Ctx) error {
// @Produce json
// @Security BearerAuth
// @Param query query request.SearchUserDto false "Search Query"
// @Success 200 {object} response.CommonResponse
// @Success 200 {object} response.PaginatedResponse
// @Failure 400 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
// @Router /users [get]
func (h *UserController) Search(c fiber.Ctx) error {
func (h *UserController) SearchUser(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -265,7 +324,7 @@ func (h *UserController) Search(c fiber.Ctx) error {
Message: err.Error(),
})
}
res, err := h.service.Search(ctx, dto)
res, err := h.service.SearchUser(ctx, dto)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
Status: false,

View File

@@ -3,19 +3,14 @@ package request
type PreSignedDto struct {
FileName string `json:"fileName" validate:"required"`
ContentType string `json:"contentType" validate:"required"`
Size int64 `json:"size" validate:"required"`
}
type PreSignedCompleteDto struct {
FileName string `json:"fileName" validate:"required"`
MediaId string `json:"mediaId" validate:"required"`
PublicUrl string `json:"publicUrl" validate:"required"`
TokenID string `json:"token_id" validate:"required"`
}
type SearchMediaDto struct {
MediaId string `query:"media_id" validate:"omitempty"`
FileName string `query:"file_name" validate:"omitempty"`
SortBy string `query:"sort_by" default:"created_at" validate:"oneof=created_at updated_at"`
Order string `query:"order" default:"desc" validate:"oneof=asc desc"`
Page int `query:"page" default:"1" validate:"min=1"`
Limit int `query:"limit" default:"10" validate:"min=1,max=100"`
CursorPaginationDto
Search string `json:"search" query:"search" validate:"omitempty,min=2,max=200"`
}

View File

@@ -3,11 +3,10 @@ package response
import "time"
type PreSignedResponse struct {
UploadUrl string `json:"uploadUrl"`
PublicUrl string `json:"publicUrl"`
FileName string `json:"fileName"`
MediaId string `json:"mediaId"`
SignedHeaders map[string]string `json:"signedHeaders"`
TokenID string `json:"token_id"`
UploadUrl string `json:"upload_url"`
StorageKey string `json:"storage_key"`
SignedHeaders map[string]string `json:"signed_headers"`
}
type MediaResponse struct {

View File

@@ -17,6 +17,18 @@ type MediaEntity struct {
UpdatedAt *time.Time `json:"updated_at"`
}
type MediaStorageEntity struct {
ID string `json:"id"`
StorageKey string `json:"storage_key"`
}
func (e * MediaEntity) ToStorageEntity() *MediaStorageEntity {
return &MediaStorageEntity{
ID: e.ID,
StorageKey: e.StorageKey,
}
}
func (e *MediaEntity) ToResponse() *response.MediaResponse {
return &response.MediaResponse{
ID: e.ID,
@@ -30,3 +42,11 @@ func (e *MediaEntity) ToResponse() *response.MediaResponse {
UpdatedAt: e.UpdatedAt,
}
}
func MediaEntitiesToResponse(entities []*MediaEntity) []*response.MediaResponse {
responses := make([]*response.MediaResponse, len(entities))
for i, entity := range entities {
responses[i] = entity.ToResponse()
}
return responses
}

View File

@@ -8,6 +8,16 @@ type TokenEntity struct {
TokenType constants.TokenType `json:"token_type"`
}
type TokenUploadEntity struct {
ID string `json:"id"`
UserID string `json:"user_id"`
StorageKey string `json:"storage_key"`
OriginalName string `json:"original_name"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
FileMetadata []byte `json:"file_metadata"`
}
type OAuthState struct {
State string `json:"state"`
RedirectURL string `json:"redirect"`

View File

@@ -13,9 +13,14 @@ type TokenRepository interface {
Get(ctx context.Context, email string, tokenType constants.TokenType) (*models.TokenEntity, error)
Create(ctx context.Context, token *models.TokenEntity) error
Delete(ctx context.Context, email string, tokenType constants.TokenType) error
CheckVerified(ctx context.Context, email string, tokenType constants.TokenType, id string) (bool, error)
CreateVerified(ctx context.Context, email string, tokenType constants.TokenType, id string) error
DeleteVerified(ctx context.Context, email string, tokenType constants.TokenType, id string) error
CreateUploadToken(ctx context.Context, userId string, token *models.TokenUploadEntity) error
GetUploadToken(ctx context.Context, userId string, id string) (*models.TokenUploadEntity, error)
DeleteUploadToken(ctx context.Context, userId string, id string) error
}
type tokenRepository struct {
@@ -37,16 +42,38 @@ func (t *tokenRepository) DeleteVerified(ctx context.Context, email string, toke
cacheKey := fmt.Sprintf("token:verified:%d:%s:%s", tokenType.Value(), email, id)
return t.c.Del(ctx, cacheKey)
}
func (t *tokenRepository) CheckCooldown(ctx context.Context, email string, tokenType constants.TokenType) (bool, error) {
cacheKey := fmt.Sprintf("token:cooldown:%d:%s", tokenType.Value(), email)
func (t *tokenRepository) CheckVerified(ctx context.Context, email string, tokenType constants.TokenType, id string) (bool, error) {
cacheKey := fmt.Sprintf("token:verified:%d:%s:%s", tokenType.Value(), email, id)
exists, err := t.c.Exists(ctx, cacheKey)
return exists, err
}
func (t *tokenRepository) CheckVerified(ctx context.Context, email string, tokenType constants.TokenType, id string) (bool, error) {
cacheKey := fmt.Sprintf("token:verified:%d:%s:%s", tokenType.Value(), email, id)
func (t *tokenRepository) CreateUploadToken(ctx context.Context, userId string, token *models.TokenUploadEntity) error {
cacheKey := fmt.Sprintf("token:%d:%s:%s", constants.TokenUpload.Value(), userId, token.ID)
err := t.c.Set(ctx, cacheKey, token, constants.TokenUploadDuration)
if err != nil {
return err
}
return nil
}
func (t *tokenRepository) GetUploadToken(ctx context.Context, userId string, id string) (*models.TokenUploadEntity, error) {
cacheKey := fmt.Sprintf("token:%d:%s:%s", constants.TokenUpload.Value(), userId, id)
var token models.TokenUploadEntity
err := t.c.Get(ctx, cacheKey, &token)
if err != nil {
return nil, err
}
return &token, err
}
func (t *tokenRepository) DeleteUploadToken(ctx context.Context, userId string, id string) error {
cacheKey := fmt.Sprintf("token:%d:%s:%s", constants.TokenUpload.Value(), userId, id)
return t.c.Del(ctx, cacheKey)
}
func (t *tokenRepository) CheckCooldown(ctx context.Context, email string, tokenType constants.TokenType) (bool, error) {
cacheKey := fmt.Sprintf("token:cooldown:%d:%s", tokenType.Value(), email)
exists, err := t.c.Exists(ctx, cacheKey)
return exists, err
}

View File

@@ -0,0 +1,53 @@
package routes
import (
"history-api/internal/controllers"
"history-api/internal/middlewares"
"history-api/internal/repositories"
"history-api/pkg/constants"
"github.com/gofiber/fiber/v3"
)
func MediaRoutes(app *fiber.App, controller *controllers.MediaController, userRepo repositories.UserRepository) {
route := app.Group("/media")
route.Get(
"/",
middlewares.JwtAccess(userRepo),
middlewares.RequireAnyRole(constants.ADMIN, constants.MOD),
controller.SearchMedia,
)
route.Post(
"/upload",
middlewares.JwtAccess(userRepo),
middlewares.RequireAnyRole(constants.ADMIN, constants.MOD),
controller.UploadServerSide,
)
route.Get(
"/presigned",
middlewares.JwtAccess(userRepo),
controller.GeneratePresignedURL,
)
route.Post(
"/presigned/complete",
middlewares.JwtAccess(userRepo),
controller.GeneratePresignedURL,
)
route.Get(
"/:id",
middlewares.JwtAccess(userRepo),
middlewares.RequireAnyRole(constants.ADMIN, constants.MOD),
controller.GetMediaByID,
)
route.Delete(
"/:id",
middlewares.JwtAccess(userRepo),
controller.DeleteMedia,
)
}

View File

@@ -16,19 +16,26 @@ func UserRoutes(app *fiber.App, controller *controllers.UserController, userRepo
"/",
middlewares.JwtAccess(userRepo),
middlewares.RequireAnyRole(constants.ADMIN, constants.MOD),
controller.Search,
controller.SearchUser,
)
route.Get(
"/current",
middlewares.JwtAccess(userRepo),
controller.GetUserCurrent,
)
route.Get(
"/current/media",
middlewares.JwtAccess(userRepo),
controller.GetUserMedia,
)
route.Get(
"/:id",
middlewares.JwtAccess(userRepo),
middlewares.RequireAnyRole(constants.ADMIN, constants.MOD),
controller.Search,
controller.SearchUser,
)
route.Put(
@@ -43,6 +50,14 @@ func UserRoutes(app *fiber.App, controller *controllers.UserController, userRepo
middlewares.RequireAnyRole(constants.ADMIN, constants.MOD),
controller.DeleteUser,
)
route.Get(
"/:id/media",
middlewares.JwtAccess(userRepo),
middlewares.RequireAnyRole(constants.ADMIN, constants.MOD),
controller.GetMediaByUserID,
)
route.Patch(
"/:id/restore",
middlewares.JwtAccess(userRepo),

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
}

View File

@@ -25,7 +25,7 @@ type UserService interface {
ChangeRoleUser(ctx context.Context, dto *request.ChangeRoleDto) (*response.UserResponse, error)
RestoreUser(ctx context.Context, userId string) (*response.UserResponse, error)
GetUserByID(ctx context.Context, userId string) (*response.UserResponse, error)
Search(ctx context.Context, dto *request.SearchUserDto) (*response.PaginatedResponse, error)
SearchUser(ctx context.Context, dto *request.SearchUserDto) (*response.PaginatedResponse, error)
}
type userService struct {
@@ -208,7 +208,7 @@ func (u *userService) RestoreUser(ctx context.Context, userId string) (*response
return user.ToResponse(), nil
}
func (u *userService) Search(ctx context.Context, dto *request.SearchUserDto) (*response.PaginatedResponse, error) {
func (u *userService) SearchUser(ctx context.Context, dto *request.SearchUserDto) (*response.PaginatedResponse, error) {
arg := sqlc.SearchUsersParams{
Limit: int32(dto.Limit + 1),
}