From 2d36004ac7c9d4b159c2ccdff9cad0973e71262b Mon Sep 17 00:00:00 2001 From: AzenKain Date: Sun, 5 Apr 2026 22:25:43 +0700 Subject: [PATCH] UPDATE: Media module --- Dockerfile | 4 +- cmd/api/main.go | 9 +- cmd/api/server.go | 9 +- cmd/worker/storage/main.go | 119 +++++++ docker-compose.yml | 23 +- docs/docs.go | 387 ++++++++++++++++++++++- docs/swagger.json | 387 ++++++++++++++++++++++- docs/swagger.yaml | 250 ++++++++++++++- internal/controllers/mediaController.go | 230 ++++++++++++++ internal/controllers/userController.go | 71 ++++- internal/dtos/request/media.go | 13 +- internal/dtos/response/media.go | 9 +- internal/models/media.go | 20 ++ internal/models/token.go | 10 + internal/repositories/tokenRepository.go | 39 ++- internal/routes/mediaRoute.go | 53 ++++ internal/routes/userRoute.go | 21 +- internal/services/mediaService.go | 312 ++++++++++++++++-- internal/services/userService.go | 4 +- pkg/constants/sream.go | 6 +- pkg/constants/task.go | 5 +- pkg/constants/time.go | 2 + pkg/constants/token.go | 14 +- pkg/storage/{rustfs.go => s3.go} | 69 +++- 24 files changed, 1972 insertions(+), 94 deletions(-) create mode 100644 cmd/worker/storage/main.go create mode 100644 internal/controllers/mediaController.go create mode 100644 internal/routes/mediaRoute.go rename pkg/storage/{rustfs.go => s3.go} (67%) diff --git a/Dockerfile b/Dockerfile index 3a59168..5e879ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/cmd/api/main.go b/cmd/api/main.go index 0b3c333..012ed46 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -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) diff --git a/cmd/api/server.go b/cmd/api/server.go index 780c17e..463007c 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -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) diff --git a/cmd/worker/storage/main.go b/cmd/worker/storage/main.go new file mode 100644 index 0000000..50f984b --- /dev/null +++ b/cmd/worker/storage/main.go @@ -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() +} diff --git a/docker-compose.yml b/docker-compose.yml index 3ff6635..bc7a105 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: @@ -93,9 +96,25 @@ services: command: ["./email-worker"] 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: \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go index aff6d05..b1ab611 100644 --- a/docs/docs.go +++ b/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" ] } }, diff --git a/docs/swagger.json b/docs/swagger.json index 62a9d45..f1455d6 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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" ] } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 0752bc2..c78dd85 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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. diff --git a/internal/controllers/mediaController.go b/internal/controllers/mediaController.go new file mode 100644 index 0000000..22867ef --- /dev/null +++ b/internal/controllers/mediaController.go @@ -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) +} diff --git a/internal/controllers/userController.go b/internal/controllers/userController.go index 414ce98..441f426 100644 --- a/internal/controllers/userController.go +++ b/internal/controllers/userController.go @@ -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, diff --git a/internal/dtos/request/media.go b/internal/dtos/request/media.go index 92013ab..d133087 100644 --- a/internal/dtos/request/media.go +++ b/internal/dtos/request/media.go @@ -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"` } diff --git a/internal/dtos/response/media.go b/internal/dtos/response/media.go index 46d76c5..ec516c8 100644 --- a/internal/dtos/response/media.go +++ b/internal/dtos/response/media.go @@ -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 { diff --git a/internal/models/media.go b/internal/models/media.go index 2b16e61..a673b47 100644 --- a/internal/models/media.go +++ b/internal/models/media.go @@ -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 +} diff --git a/internal/models/token.go b/internal/models/token.go index fef3dab..4c402b1 100644 --- a/internal/models/token.go +++ b/internal/models/token.go @@ -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"` diff --git a/internal/repositories/tokenRepository.go b/internal/repositories/tokenRepository.go index e1a85c2..497c6b3 100644 --- a/internal/repositories/tokenRepository.go +++ b/internal/repositories/tokenRepository.go @@ -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 } diff --git a/internal/routes/mediaRoute.go b/internal/routes/mediaRoute.go new file mode 100644 index 0000000..b61947f --- /dev/null +++ b/internal/routes/mediaRoute.go @@ -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, + ) + +} diff --git a/internal/routes/userRoute.go b/internal/routes/userRoute.go index 26dd54b..e769f5f 100644 --- a/internal/routes/userRoute.go +++ b/internal/routes/userRoute.go @@ -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), diff --git a/internal/services/mediaService.go b/internal/services/mediaService.go index 5a1de4d..0ba577e 100644 --- a/internal/services/mediaService.go +++ b/internal/services/mediaService.go @@ -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 +} diff --git a/internal/services/userService.go b/internal/services/userService.go index aad5ef9..cbe1869 100644 --- a/internal/services/userService.go +++ b/internal/services/userService.go @@ -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), } diff --git a/pkg/constants/sream.go b/pkg/constants/sream.go index 14aebf5..6c365dd 100644 --- a/pkg/constants/sream.go +++ b/pkg/constants/sream.go @@ -1,6 +1,8 @@ package constants const ( - StreamEmailName = "stream:email_tasks" - GroupEmailName = "email_workers_group" + StreamEmailName = "stream:email_tasks" + StreamStorageName = "stream:storage_tasks" + GroupEmailName = "email_workers_group" + GroupStorageName = "storage_workers_group" ) diff --git a/pkg/constants/task.go b/pkg/constants/task.go index 4f14926..7303a96 100644 --- a/pkg/constants/task.go +++ b/pkg/constants/task.go @@ -4,8 +4,9 @@ type TaskType string const ( TaskTypeSendEmailOTP TaskType = "SEND_EMAIL_OTP" -) + TaskTypeDeleteMedia TaskType = "DELETE_MEDIA" +) func (t TaskType) String() string { return string(t) -} \ No newline at end of file +} diff --git a/pkg/constants/time.go b/pkg/constants/time.go index b248bcc..8dd156a 100644 --- a/pkg/constants/time.go +++ b/pkg/constants/time.go @@ -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 ) diff --git a/pkg/constants/token.go b/pkg/constants/token.go index 76eaa37..6aac59b 100644 --- a/pkg/constants/token.go +++ b/pkg/constants/token.go @@ -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,9 +51,9 @@ 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 } -} \ No newline at end of file +} diff --git a/pkg/storage/rustfs.go b/pkg/storage/s3.go similarity index 67% rename from pkg/storage/rustfs.go rename to pkg/storage/s3.go index de63689..e831139 100644 --- a/pkg/storage/rustfs.go +++ b/pkg/storage/s3.go @@ -2,7 +2,9 @@ package storage import ( "context" + "fmt" "io" + "net/url" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -20,18 +22,27 @@ 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 - endPoint string + client *s3.Client + ps *s3.PresignClient + bucket string + tempBucket string + endPoint string } func NewS3Storage() (Storage, error) { @@ -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, - endPoint: endpoint, + 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,