UPDATE: Media module
All checks were successful
Build and Release / release (push) Successful in 1m7s
All checks were successful
Build and Release / release (push) Successful in 1m7s
This commit is contained in:
@@ -11,6 +11,7 @@ COPY . .
|
||||
|
||||
RUN GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o history-api ./cmd/api
|
||||
RUN GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o email-worker ./cmd/worker/email
|
||||
RUN GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o storage-worker ./cmd/worker/storage
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
@@ -21,9 +22,10 @@ WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/history-api .
|
||||
COPY --from=builder /app/email-worker .
|
||||
COPY --from=builder /app/storage-worker .
|
||||
COPY data ./data
|
||||
|
||||
RUN chmod +x ./history-api ./email-worker
|
||||
RUN chmod +x ./history-api ./email-worker ./storage-worker
|
||||
|
||||
EXPOSE 3344
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
_ "history-api/pkg/log"
|
||||
"history-api/pkg/mbtiles"
|
||||
"history-api/pkg/oauth"
|
||||
"history-api/pkg/storage"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -72,6 +73,12 @@ func StartServer() {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
storageClient, err := storage.NewS3Storage()
|
||||
if err != nil {
|
||||
log.Error().Msg(err.Error())
|
||||
panic(err)
|
||||
}
|
||||
|
||||
googleOAuthConfig, err := oauth.NewGoogleProvider()
|
||||
if err != nil {
|
||||
log.Error().Msg(err.Error())
|
||||
@@ -89,7 +96,7 @@ func StartServer() {
|
||||
}
|
||||
|
||||
serverHttp := NewHttpServer()
|
||||
serverHttp.SetupServer(poolPg, sqlTile, redisClient, googleOAuthConfig)
|
||||
serverHttp.SetupServer(poolPg, sqlTile, redisClient, storageClient, googleOAuthConfig)
|
||||
Singleton = serverHttp
|
||||
|
||||
done := make(chan bool, 1)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"history-api/internal/routes"
|
||||
"history-api/internal/services"
|
||||
"history-api/pkg/cache"
|
||||
"history-api/pkg/storage"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -55,7 +56,7 @@ func NewHttpServer() *FiberServer {
|
||||
return server
|
||||
}
|
||||
|
||||
func (s *FiberServer) SetupServer(sqlPg sqlc.DBTX, sqlTile *sql.DB, redis cache.Cache, oauth *oauth2.Config) {
|
||||
func (s *FiberServer) SetupServer(sqlPg sqlc.DBTX, sqlTile *sql.DB, redis cache.Cache, sclient storage.Storage, oauth *oauth2.Config) {
|
||||
// Apply CORS middleware
|
||||
s.App.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{
|
||||
@@ -75,22 +76,26 @@ func (s *FiberServer) SetupServer(sqlPg sqlc.DBTX, sqlTile *sql.DB, redis cache.
|
||||
roleRepo := repositories.NewRoleRepository(sqlPg, redis)
|
||||
tileRepo := repositories.NewTileRepository(sqlTile, redis)
|
||||
tokenRepo := repositories.NewTokenRepository(redis)
|
||||
mediaRepo := repositories.NewMediaRepository(sqlPg, redis)
|
||||
|
||||
// service setup
|
||||
authService := services.NewAuthService(userRepo, roleRepo, tokenRepo, redis)
|
||||
userService := services.NewUserService(userRepo, roleRepo)
|
||||
roleService := services.NewRoleService(roleRepo)
|
||||
tileService := services.NewTileService(tileRepo)
|
||||
mediaService := services.NewMediaService(mediaRepo, tokenRepo, sclient, redis)
|
||||
|
||||
// controller setup
|
||||
authController := controllers.NewAuthController(authService, oauth)
|
||||
userController := controllers.NewUserController(userService)
|
||||
userController := controllers.NewUserController(userService, mediaService)
|
||||
tileController := controllers.NewTileController(tileService)
|
||||
roleController := controllers.NewRoleController(roleService)
|
||||
mediaController := controllers.NewMediaController(mediaService)
|
||||
|
||||
// route setup
|
||||
routes.AuthRoutes(s.App, authController, userRepo)
|
||||
routes.UserRoutes(s.App, userController, userRepo)
|
||||
routes.MediaRoutes(s.App, mediaController, userRepo)
|
||||
routes.RoleRoutes(s.App, roleController, userRepo)
|
||||
routes.TileRoutes(s.App, tileController)
|
||||
routes.NotFoundRoute(s.App)
|
||||
|
||||
119
cmd/worker/storage/main.go
Normal file
119
cmd/worker/storage/main.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"history-api/internal/models"
|
||||
"history-api/pkg/cache"
|
||||
"history-api/pkg/config"
|
||||
"history-api/pkg/constants"
|
||||
_ "history-api/pkg/log"
|
||||
"history-api/pkg/storage"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func runSingleWorker(ctx context.Context, rdb *redis.Client, consumerID int, sc storage.Storage) {
|
||||
consumerName := "worker-" + strconv.Itoa(consumerID)
|
||||
|
||||
log.Info().Str("worker", consumerName).Msg("Worker started and ready")
|
||||
|
||||
for {
|
||||
entries, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
|
||||
Group: constants.GroupStorageName,
|
||||
Consumer: consumerName,
|
||||
Streams: []string{constants.StreamStorageName, ">"},
|
||||
Count: 1,
|
||||
Block: 0,
|
||||
}).Result()
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("worker", consumerName).Msg("Failed to read stream")
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, stream := range entries {
|
||||
for _, message := range stream.Messages {
|
||||
taskType := message.Values["task_type"].(string)
|
||||
payloadStr := message.Values["payload"].(string)
|
||||
|
||||
if taskType == constants.TaskTypeDeleteMedia.String() {
|
||||
var data models.MediaStorageEntity
|
||||
if err := json.Unmarshal([]byte(payloadStr), &data); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to unmarshal payload")
|
||||
continue
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("worker", consumerName).
|
||||
Str("storage_key", data.StorageKey).
|
||||
Msg("Processing delete media task")
|
||||
|
||||
errSend := sc.Delete(ctx, data.StorageKey)
|
||||
if errSend != nil {
|
||||
log.Error().Err(errSend).Str("storage_key", data.StorageKey).Msg("Failed to delete media")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
rdb.XAck(ctx, constants.StreamStorageName, constants.GroupStorageName, message.ID)
|
||||
log.Info().Str("msg_id", message.ID).Msg("Task acknowledged")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
func main() {
|
||||
|
||||
config.LoadEnv()
|
||||
|
||||
workerCountStr := config.GetConfigWithDefault("STORAGE_WORKER_COUNT", "1")
|
||||
workerCount, err := strconv.Atoi(workerCountStr)
|
||||
if err != nil || workerCount <= 0 {
|
||||
workerCount = 1
|
||||
}
|
||||
|
||||
cacheInterface, err := cache.NewRedisClient()
|
||||
if err != nil {
|
||||
log.Fatal().
|
||||
Err(err).
|
||||
Msg("Failed to connect to Redis")
|
||||
}
|
||||
|
||||
rdb := cacheInterface.GetRawClient()
|
||||
|
||||
sc, err := storage.NewS3Storage()
|
||||
if err != nil {
|
||||
log.Fatal().
|
||||
Err(err).
|
||||
Msg("Failed to create S3 storage client")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
err = rdb.XGroupCreateMkStream(ctx, constants.StreamStorageName, constants.GroupStorageName, "$").Err()
|
||||
if err != nil && err.Error() != "BUSYGROUP Consumer Group name already exists" {
|
||||
log.Fatal().
|
||||
Err(err).
|
||||
Msg("Failed to create Redis Stream Group")
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Int("worker_count", workerCount).
|
||||
Msg("Starting storage worker system")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 1; i <= workerCount; i++ {
|
||||
wg.Go(func() {
|
||||
runSingleWorker(ctx, rdb, i, sc)
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -26,6 +26,9 @@ services:
|
||||
image: redis:8.6.2-alpine
|
||||
container_name: history_redis
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- history-api-project
|
||||
|
||||
@@ -79,9 +82,9 @@ services:
|
||||
networks:
|
||||
- history-api-project
|
||||
|
||||
worker:
|
||||
email_worker:
|
||||
build: .
|
||||
container_name: history_worker
|
||||
container_name: history_email_worker
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
@@ -94,8 +97,24 @@ services:
|
||||
networks:
|
||||
- history-api-project
|
||||
|
||||
storage_worker:
|
||||
build: .
|
||||
container_name: history_storage_worker
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
cache:
|
||||
condition: service_started
|
||||
env_file:
|
||||
- ./assets/resources/.env
|
||||
command: ["./storage-worker"]
|
||||
networks:
|
||||
- history-api-project
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
redis_data:
|
||||
|
||||
networks:
|
||||
history-api-project:
|
||||
387
docs/docs.go
387
docs/docs.go
@@ -362,6 +362,294 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/media": {
|
||||
"get": {
|
||||
"description": "Search media with filters, pagination",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Media"
|
||||
],
|
||||
"summary": "Search media",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page number",
|
||||
"name": "page",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Items per page",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search keyword",
|
||||
"name": "keyword",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.PaginatedResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/media/presigned": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Generate a presigned URL for direct upload to storage",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Media"
|
||||
],
|
||||
"summary": "Generate presigned URL",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "File name",
|
||||
"name": "filename",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Content type",
|
||||
"name": "contentType",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/media/presigned/complete": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Confirm that upload via presigned URL is completed",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Media"
|
||||
],
|
||||
"summary": "Confirm presigned upload",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Storage key",
|
||||
"name": "key",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/media/upload": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Upload media file through server",
|
||||
"consumes": [
|
||||
"multipart/form-data"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Media"
|
||||
],
|
||||
"summary": "Upload media (server-side)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "file",
|
||||
"description": "Upload file",
|
||||
"name": "file",
|
||||
"in": "formData",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/media/{id}": {
|
||||
"get": {
|
||||
"description": "Retrieve a media file by its ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Media"
|
||||
],
|
||||
"summary": "Get media by ID",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Media ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Delete a media file by ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Media"
|
||||
],
|
||||
"summary": "Delete media",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Media ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/roles": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -601,7 +889,7 @@ const docTemplate = `{
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.PaginatedResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
@@ -653,6 +941,40 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/current/media": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Retrieve media list of the currently authenticated user",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Get current user's media",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -793,6 +1115,44 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{id}/media": {
|
||||
"get": {
|
||||
"description": "Retrieve media list by specific user ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Get user's media by user ID",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{id}/password": {
|
||||
"patch": {
|
||||
"security": [
|
||||
@@ -1166,6 +1526,29 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"history-api_internal_dtos_response.PaginatedResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"pagination": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"has_more": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"next_cursor": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"history-api_pkg_constants.TokenType": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
@@ -1179,7 +1562,7 @@ const docTemplate = `{
|
||||
"TokenPasswordReset",
|
||||
"TokenEmailVerify",
|
||||
"TokenMagicLink",
|
||||
"TokenRefreshToken"
|
||||
"TokenUpload"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -355,6 +355,294 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/media": {
|
||||
"get": {
|
||||
"description": "Search media with filters, pagination",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Media"
|
||||
],
|
||||
"summary": "Search media",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page number",
|
||||
"name": "page",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Items per page",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search keyword",
|
||||
"name": "keyword",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.PaginatedResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/media/presigned": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Generate a presigned URL for direct upload to storage",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Media"
|
||||
],
|
||||
"summary": "Generate presigned URL",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "File name",
|
||||
"name": "filename",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Content type",
|
||||
"name": "contentType",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/media/presigned/complete": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Confirm that upload via presigned URL is completed",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Media"
|
||||
],
|
||||
"summary": "Confirm presigned upload",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Storage key",
|
||||
"name": "key",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/media/upload": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Upload media file through server",
|
||||
"consumes": [
|
||||
"multipart/form-data"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Media"
|
||||
],
|
||||
"summary": "Upload media (server-side)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "file",
|
||||
"description": "Upload file",
|
||||
"name": "file",
|
||||
"in": "formData",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/media/{id}": {
|
||||
"get": {
|
||||
"description": "Retrieve a media file by its ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Media"
|
||||
],
|
||||
"summary": "Get media by ID",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Media ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Delete a media file by ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Media"
|
||||
],
|
||||
"summary": "Delete media",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Media ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/roles": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -594,7 +882,7 @@
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.PaginatedResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
@@ -646,6 +934,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/current/media": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Retrieve media list of the currently authenticated user",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Get current user's media",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -786,6 +1108,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{id}/media": {
|
||||
"get": {
|
||||
"description": "Retrieve media list by specific user ID",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Get user's media by user ID",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{id}/password": {
|
||||
"patch": {
|
||||
"security": [
|
||||
@@ -1159,6 +1519,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"history-api_internal_dtos_response.PaginatedResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"pagination": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"has_more": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"next_cursor": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"history-api_pkg_constants.TokenType": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
@@ -1172,7 +1555,7 @@
|
||||
"TokenPasswordReset",
|
||||
"TokenEmailVerify",
|
||||
"TokenMagicLink",
|
||||
"TokenRefreshToken"
|
||||
"TokenUpload"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -150,6 +150,21 @@ definitions:
|
||||
status:
|
||||
type: boolean
|
||||
type: object
|
||||
history-api_internal_dtos_response.PaginatedResponse:
|
||||
properties:
|
||||
data: {}
|
||||
message:
|
||||
type: string
|
||||
pagination:
|
||||
properties:
|
||||
has_more:
|
||||
type: boolean
|
||||
next_cursor:
|
||||
type: string
|
||||
type: object
|
||||
status:
|
||||
type: boolean
|
||||
type: object
|
||||
history-api_pkg_constants.TokenType:
|
||||
enum:
|
||||
- 1
|
||||
@@ -162,7 +177,7 @@ definitions:
|
||||
- TokenPasswordReset
|
||||
- TokenEmailVerify
|
||||
- TokenMagicLink
|
||||
- TokenRefreshToken
|
||||
- TokenUpload
|
||||
info:
|
||||
contact:
|
||||
email: support@swagger.io
|
||||
@@ -398,6 +413,191 @@ paths:
|
||||
summary: Verify a security token
|
||||
tags:
|
||||
- Auth
|
||||
/media:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Search media with filters, pagination
|
||||
parameters:
|
||||
- description: Page number
|
||||
in: query
|
||||
name: page
|
||||
type: integer
|
||||
- description: Items per page
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
- description: Search keyword
|
||||
in: query
|
||||
name: keyword
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.PaginatedResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
summary: Search media
|
||||
tags:
|
||||
- Media
|
||||
/media/{id}:
|
||||
delete:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Delete a media file by ID
|
||||
parameters:
|
||||
- description: Media ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Delete media
|
||||
tags:
|
||||
- Media
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Retrieve a media file by its ID
|
||||
parameters:
|
||||
- description: Media ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
summary: Get media by ID
|
||||
tags:
|
||||
- Media
|
||||
/media/presigned:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Generate a presigned URL for direct upload to storage
|
||||
parameters:
|
||||
- description: File name
|
||||
in: query
|
||||
name: filename
|
||||
required: true
|
||||
type: string
|
||||
- description: Content type
|
||||
in: query
|
||||
name: contentType
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Generate presigned URL
|
||||
tags:
|
||||
- Media
|
||||
/media/presigned/complete:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Confirm that upload via presigned URL is completed
|
||||
parameters:
|
||||
- description: Storage key
|
||||
in: query
|
||||
name: key
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Confirm presigned upload
|
||||
tags:
|
||||
- Media
|
||||
/media/upload:
|
||||
post:
|
||||
consumes:
|
||||
- multipart/form-data
|
||||
description: Upload media file through server
|
||||
parameters:
|
||||
- description: Upload file
|
||||
in: formData
|
||||
name: file
|
||||
required: true
|
||||
type: file
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Upload media (server-side)
|
||||
tags:
|
||||
- Media
|
||||
/roles:
|
||||
get:
|
||||
consumes:
|
||||
@@ -551,7 +751,7 @@ paths:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.PaginatedResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
@@ -654,6 +854,31 @@ paths:
|
||||
summary: Update user profile
|
||||
tags:
|
||||
- Users
|
||||
/users/{id}/media:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Retrieve media list by specific user ID
|
||||
parameters:
|
||||
- description: User ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
summary: Get user's media by user ID
|
||||
tags:
|
||||
- Users
|
||||
/users/{id}/password:
|
||||
patch:
|
||||
consumes:
|
||||
@@ -777,6 +1002,27 @@ paths:
|
||||
summary: Get current user profile
|
||||
tags:
|
||||
- Users
|
||||
/users/current/media:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Retrieve media list of the currently authenticated user
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Get current user's media
|
||||
tags:
|
||||
- Users
|
||||
securityDefinitions:
|
||||
BearerAuth:
|
||||
description: Type "Bearer " followed by a space and JWT token.
|
||||
|
||||
230
internal/controllers/mediaController.go
Normal file
230
internal/controllers/mediaController.go
Normal 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)
|
||||
}
|
||||
@@ -13,10 +13,14 @@ import (
|
||||
|
||||
type UserController struct {
|
||||
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,
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
53
internal/routes/mediaRoute.go
Normal file
53
internal/routes/mediaRoute.go
Normal 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,
|
||||
)
|
||||
|
||||
}
|
||||
@@ -16,7 +16,7 @@ func UserRoutes(app *fiber.App, controller *controllers.UserController, userRepo
|
||||
"/",
|
||||
middlewares.JwtAccess(userRepo),
|
||||
middlewares.RequireAnyRole(constants.ADMIN, constants.MOD),
|
||||
controller.Search,
|
||||
controller.SearchUser,
|
||||
)
|
||||
|
||||
route.Get(
|
||||
@@ -24,11 +24,18 @@ func UserRoutes(app *fiber.App, controller *controllers.UserController, userRepo
|
||||
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),
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
// GeneratePresignedURL implements [MediaService].
|
||||
func (m *mediaService) GeneratePresignedURL(ctx context.Context, userId string, dto *request.PreSignedDto) (*response.PreSignedResponse, error) {
|
||||
panic("unimplemented")
|
||||
media, err := m.mediaRepo.GetByID(ctx, mediaIdUUID)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// GetMediaByID implements [MediaService].
|
||||
func (m *mediaService) GetMediaByID(ctx context.Context, mediaId string) (*response.MediaResponse, error) {
|
||||
panic("unimplemented")
|
||||
shoudDelete := false
|
||||
if slices.Contains(claims.Roles, constants.ADMIN) || slices.Contains(claims.Roles, constants.MOD) || media.UserID == claims.UId {
|
||||
shoudDelete = true
|
||||
}
|
||||
|
||||
// GetMediaByTarget implements [MediaService].
|
||||
func (m *mediaService) GetMediaByTarget(ctx context.Context, targetType string, targetId string) ([]*response.MediaResponse, error) {
|
||||
panic("unimplemented")
|
||||
if !shoudDelete {
|
||||
return fiber.NewError(fiber.StatusForbidden, "You don't have permission to delete this media")
|
||||
}
|
||||
|
||||
// GetMediaByUserID implements [MediaService].
|
||||
func (m *mediaService) GetMediaByUserID(ctx context.Context, userId string) ([]*response.MediaResponse, error) {
|
||||
panic("unimplemented")
|
||||
err = m.mediaRepo.Delete(ctx, mediaIdUUID)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// PreSignedCompleted implements [MediaService].
|
||||
func (m *mediaService) PreSignedCompleted(ctx context.Context, userId string, dto *request.PreSignedCompleteDto) (*response.MediaResponse, error) {
|
||||
panic("unimplemented")
|
||||
m.c.PublishTask(ctx, constants.StreamStorageName, constants.TaskTypeDeleteMedia, media.ToStorageEntity())
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -2,5 +2,7 @@ package constants
|
||||
|
||||
const (
|
||||
StreamEmailName = "stream:email_tasks"
|
||||
StreamStorageName = "stream:storage_tasks"
|
||||
GroupEmailName = "email_workers_group"
|
||||
GroupStorageName = "storage_workers_group"
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ type TaskType string
|
||||
|
||||
const (
|
||||
TaskTypeSendEmailOTP TaskType = "SEND_EMAIL_OTP"
|
||||
TaskTypeDeleteMedia TaskType = "DELETE_MEDIA"
|
||||
)
|
||||
|
||||
func (t TaskType) String() string {
|
||||
|
||||
@@ -10,4 +10,6 @@ const (
|
||||
AccessTokenDuration = 15 * time.Minute
|
||||
RefreshTokenDuration = 7 * 24 * time.Hour
|
||||
TokenVerifiedDuration = 10 * time.Minute
|
||||
PreSignedURLDuration = 15 * time.Minute
|
||||
TokenUploadDuration = 1 * time.Hour
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ const (
|
||||
TokenPasswordReset TokenType = 1
|
||||
TokenEmailVerify TokenType = 2
|
||||
TokenMagicLink TokenType = 3
|
||||
TokenRefreshToken TokenType = 4
|
||||
TokenUpload TokenType = 4
|
||||
)
|
||||
|
||||
func (t TokenType) String() string {
|
||||
@@ -17,8 +17,8 @@ func (t TokenType) String() string {
|
||||
return "EMAIL_VERIFY"
|
||||
case TokenMagicLink:
|
||||
return "LOGIN_MAGIC_LINK"
|
||||
case TokenRefreshToken:
|
||||
return "REFRESH_TOKEN"
|
||||
case TokenUpload:
|
||||
return "UPLOAD"
|
||||
default:
|
||||
return "UNKNOWN"
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func ParseTokenType(v int16) TokenType {
|
||||
case 3:
|
||||
return TokenMagicLink
|
||||
case 4:
|
||||
return TokenRefreshToken
|
||||
return TokenUpload
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
@@ -51,8 +51,8 @@ func ParseTokenTypeFromString(s string) TokenType {
|
||||
return TokenEmailVerify
|
||||
case "LOGIN_MAGIC_LINK":
|
||||
return TokenMagicLink
|
||||
case "REFRESH_TOKEN":
|
||||
return TokenRefreshToken
|
||||
case "UPLOAD":
|
||||
return TokenUpload
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
@@ -20,17 +22,26 @@ type UploadOptions struct {
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
type MoveOptions struct {
|
||||
Bucket string
|
||||
Key string
|
||||
}
|
||||
|
||||
type Storage interface {
|
||||
Upload(ctx context.Context, key string, body io.Reader, size int64, opts UploadOptions) error
|
||||
Move(ctx context.Context, src *MoveOptions, dest *MoveOptions) error
|
||||
PresignUpload(ctx context.Context, key string, expire time.Duration, opts UploadOptions) (string, error)
|
||||
GetURL(ctx context.Context, key string, expire time.Duration) (string, error)
|
||||
Delete(ctx context.Context, key string) error
|
||||
GetMainBucket() string
|
||||
GetTempBucket() string
|
||||
}
|
||||
|
||||
type s3Storage struct {
|
||||
client *s3.Client
|
||||
ps *s3.PresignClient
|
||||
bucket string
|
||||
tempBucket string
|
||||
endPoint string
|
||||
}
|
||||
|
||||
@@ -49,16 +60,26 @@ func NewS3Storage() (Storage, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tempBucketName, err := ffconfig.GetConfig("STORAGE_BUCKET_TEMP_NAME")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endpoint, err := ffconfig.GetConfig("STORAGE_ENDPOINT")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
region, err := ffconfig.GetConfig("STORAGE_REGION")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg, err := config.LoadDefaultConfig(context.TODO(),
|
||||
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretAccessKey, "")),
|
||||
config.WithRegion("auto"),
|
||||
config.WithRegion(region),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Error().Msgf("unable to load AWS SDK config, %v", err)
|
||||
return nil, err
|
||||
@@ -66,15 +87,42 @@ func NewS3Storage() (Storage, error) {
|
||||
|
||||
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
|
||||
o.BaseEndpoint = aws.String(endpoint)
|
||||
o.UsePathStyle = true
|
||||
})
|
||||
|
||||
return &s3Storage{
|
||||
client: client,
|
||||
ps: s3.NewPresignClient(client),
|
||||
bucket: bucketName,
|
||||
tempBucket: tempBucketName,
|
||||
endPoint: endpoint,
|
||||
}, nil
|
||||
}
|
||||
func (s *s3Storage) GetMainBucket() string { return s.bucket }
|
||||
func (s *s3Storage) GetTempBucket() string { return s.tempBucket }
|
||||
|
||||
func (s *s3Storage) Move(ctx context.Context, src *MoveOptions, dest *MoveOptions) error {
|
||||
copySource := url.PathEscape(fmt.Sprintf("%s/%s", src.Bucket, src.Key))
|
||||
|
||||
_, err := s.client.CopyObject(ctx, &s3.CopyObjectInput{
|
||||
Bucket: aws.String(dest.Bucket),
|
||||
Key: aws.String(dest.Key),
|
||||
CopySource: aws.String(copySource),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to copy object: %w", err)
|
||||
}
|
||||
|
||||
_, err = s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(src.Bucket),
|
||||
Key: aws.String(src.Key),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to delete source object after copy")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *s3Storage) Upload(ctx context.Context, key string, body io.Reader, size int64, opts UploadOptions) error {
|
||||
input := &s3.PutObjectInput{
|
||||
@@ -99,7 +147,7 @@ func (s *s3Storage) Upload(ctx context.Context, key string, body io.Reader, size
|
||||
|
||||
func (s *s3Storage) PresignUpload(ctx context.Context, key string, expire time.Duration, opts UploadOptions) (string, error) {
|
||||
input := &s3.PutObjectInput{
|
||||
Bucket: &s.bucket,
|
||||
Bucket: &s.tempBucket,
|
||||
Key: &key,
|
||||
}
|
||||
|
||||
@@ -119,6 +167,7 @@ func (s *s3Storage) PresignUpload(ctx context.Context, key string, expire time.D
|
||||
}
|
||||
return req.URL, nil
|
||||
}
|
||||
|
||||
func (s *s3Storage) GetURL(ctx context.Context, key string, expire time.Duration) (string, error) {
|
||||
req, err := s.ps.PresignGetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: &s.bucket,
|
||||
Reference in New Issue
Block a user