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

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

View File

@@ -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 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 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 FROM alpine:latest
@@ -21,9 +22,10 @@ WORKDIR /app
COPY --from=builder /app/history-api . COPY --from=builder /app/history-api .
COPY --from=builder /app/email-worker . COPY --from=builder /app/email-worker .
COPY --from=builder /app/storage-worker .
COPY data ./data COPY data ./data
RUN chmod +x ./history-api ./email-worker RUN chmod +x ./history-api ./email-worker ./storage-worker
EXPOSE 3344 EXPOSE 3344

View File

@@ -10,6 +10,7 @@ import (
_ "history-api/pkg/log" _ "history-api/pkg/log"
"history-api/pkg/mbtiles" "history-api/pkg/mbtiles"
"history-api/pkg/oauth" "history-api/pkg/oauth"
"history-api/pkg/storage"
"os/signal" "os/signal"
"syscall" "syscall"
"time" "time"
@@ -72,6 +73,12 @@ func StartServer() {
panic(err) panic(err)
} }
storageClient, err := storage.NewS3Storage()
if err != nil {
log.Error().Msg(err.Error())
panic(err)
}
googleOAuthConfig, err := oauth.NewGoogleProvider() googleOAuthConfig, err := oauth.NewGoogleProvider()
if err != nil { if err != nil {
log.Error().Msg(err.Error()) log.Error().Msg(err.Error())
@@ -89,7 +96,7 @@ func StartServer() {
} }
serverHttp := NewHttpServer() serverHttp := NewHttpServer()
serverHttp.SetupServer(poolPg, sqlTile, redisClient, googleOAuthConfig) serverHttp.SetupServer(poolPg, sqlTile, redisClient, storageClient, googleOAuthConfig)
Singleton = serverHttp Singleton = serverHttp
done := make(chan bool, 1) done := make(chan bool, 1)

View File

@@ -10,6 +10,7 @@ import (
"history-api/internal/routes" "history-api/internal/routes"
"history-api/internal/services" "history-api/internal/services"
"history-api/pkg/cache" "history-api/pkg/cache"
"history-api/pkg/storage"
"os" "os"
"time" "time"
@@ -55,7 +56,7 @@ func NewHttpServer() *FiberServer {
return server 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 // Apply CORS middleware
s.App.Use(cors.New(cors.Config{ s.App.Use(cors.New(cors.Config{
AllowOrigins: []string{ AllowOrigins: []string{
@@ -75,22 +76,26 @@ func (s *FiberServer) SetupServer(sqlPg sqlc.DBTX, sqlTile *sql.DB, redis cache.
roleRepo := repositories.NewRoleRepository(sqlPg, redis) roleRepo := repositories.NewRoleRepository(sqlPg, redis)
tileRepo := repositories.NewTileRepository(sqlTile, redis) tileRepo := repositories.NewTileRepository(sqlTile, redis)
tokenRepo := repositories.NewTokenRepository(redis) tokenRepo := repositories.NewTokenRepository(redis)
mediaRepo := repositories.NewMediaRepository(sqlPg, redis)
// service setup // service setup
authService := services.NewAuthService(userRepo, roleRepo, tokenRepo, redis) authService := services.NewAuthService(userRepo, roleRepo, tokenRepo, redis)
userService := services.NewUserService(userRepo, roleRepo) userService := services.NewUserService(userRepo, roleRepo)
roleService := services.NewRoleService(roleRepo) roleService := services.NewRoleService(roleRepo)
tileService := services.NewTileService(tileRepo) tileService := services.NewTileService(tileRepo)
mediaService := services.NewMediaService(mediaRepo, tokenRepo, sclient, redis)
// controller setup // controller setup
authController := controllers.NewAuthController(authService, oauth) authController := controllers.NewAuthController(authService, oauth)
userController := controllers.NewUserController(userService) userController := controllers.NewUserController(userService, mediaService)
tileController := controllers.NewTileController(tileService) tileController := controllers.NewTileController(tileService)
roleController := controllers.NewRoleController(roleService) roleController := controllers.NewRoleController(roleService)
mediaController := controllers.NewMediaController(mediaService)
// route setup // route setup
routes.AuthRoutes(s.App, authController, userRepo) routes.AuthRoutes(s.App, authController, userRepo)
routes.UserRoutes(s.App, userController, userRepo) routes.UserRoutes(s.App, userController, userRepo)
routes.MediaRoutes(s.App, mediaController, userRepo)
routes.RoleRoutes(s.App, roleController, userRepo) routes.RoleRoutes(s.App, roleController, userRepo)
routes.TileRoutes(s.App, tileController) routes.TileRoutes(s.App, tileController)
routes.NotFoundRoute(s.App) routes.NotFoundRoute(s.App)

119
cmd/worker/storage/main.go Normal file
View 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()
}

View File

@@ -26,6 +26,9 @@ services:
image: redis:8.6.2-alpine image: redis:8.6.2-alpine
container_name: history_redis container_name: history_redis
restart: unless-stopped restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
networks: networks:
- history-api-project - history-api-project
@@ -79,9 +82,9 @@ services:
networks: networks:
- history-api-project - history-api-project
worker: email_worker:
build: . build: .
container_name: history_worker container_name: history_email_worker
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
db: db:
@@ -93,9 +96,25 @@ services:
command: ["./email-worker"] command: ["./email-worker"]
networks: networks:
- history-api-project - 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: volumes:
pg_data: pg_data:
redis_data:
networks: networks:
history-api-project: history-api-project:

View File

@@ -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": { "/roles": {
"get": { "get": {
"security": [ "security": [
@@ -601,7 +889,7 @@ const docTemplate = `{
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" "$ref": "#/definitions/history-api_internal_dtos_response.PaginatedResponse"
} }
}, },
"400": { "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}": { "/users/{id}": {
"get": { "get": {
"security": [ "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": { "/users/{id}/password": {
"patch": { "patch": {
"security": [ "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": { "history-api_pkg_constants.TokenType": {
"type": "integer", "type": "integer",
"format": "int32", "format": "int32",
@@ -1179,7 +1562,7 @@ const docTemplate = `{
"TokenPasswordReset", "TokenPasswordReset",
"TokenEmailVerify", "TokenEmailVerify",
"TokenMagicLink", "TokenMagicLink",
"TokenRefreshToken" "TokenUpload"
] ]
} }
}, },

View File

@@ -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": { "/roles": {
"get": { "get": {
"security": [ "security": [
@@ -594,7 +882,7 @@
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse" "$ref": "#/definitions/history-api_internal_dtos_response.PaginatedResponse"
} }
}, },
"400": { "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}": { "/users/{id}": {
"get": { "get": {
"security": [ "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": { "/users/{id}/password": {
"patch": { "patch": {
"security": [ "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": { "history-api_pkg_constants.TokenType": {
"type": "integer", "type": "integer",
"format": "int32", "format": "int32",
@@ -1172,7 +1555,7 @@
"TokenPasswordReset", "TokenPasswordReset",
"TokenEmailVerify", "TokenEmailVerify",
"TokenMagicLink", "TokenMagicLink",
"TokenRefreshToken" "TokenUpload"
] ]
} }
}, },

View File

@@ -150,6 +150,21 @@ definitions:
status: status:
type: boolean type: boolean
type: object 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: history-api_pkg_constants.TokenType:
enum: enum:
- 1 - 1
@@ -162,7 +177,7 @@ definitions:
- TokenPasswordReset - TokenPasswordReset
- TokenEmailVerify - TokenEmailVerify
- TokenMagicLink - TokenMagicLink
- TokenRefreshToken - TokenUpload
info: info:
contact: contact:
email: support@swagger.io email: support@swagger.io
@@ -398,6 +413,191 @@ paths:
summary: Verify a security token summary: Verify a security token
tags: tags:
- Auth - 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: /roles:
get: get:
consumes: consumes:
@@ -551,7 +751,7 @@ paths:
"200": "200":
description: OK description: OK
schema: schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse' $ref: '#/definitions/history-api_internal_dtos_response.PaginatedResponse'
"400": "400":
description: Bad Request description: Bad Request
schema: schema:
@@ -654,6 +854,31 @@ paths:
summary: Update user profile summary: Update user profile
tags: tags:
- Users - 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: /users/{id}/password:
patch: patch:
consumes: consumes:
@@ -777,6 +1002,27 @@ paths:
summary: Get current user profile summary: Get current user profile
tags: tags:
- Users - 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: securityDefinitions:
BearerAuth: BearerAuth:
description: Type "Bearer " followed by a space and JWT token. description: Type "Bearer " followed by a space and JWT token.

View File

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

View File

@@ -12,11 +12,15 @@ import (
) )
type UserController struct { type UserController struct {
service services.UserService service services.UserService
mediaService services.MediaService
} }
func NewUserController(svc services.UserService) *UserController { func NewUserController(svc services.UserService, mediaSvc services.MediaService) *UserController {
return &UserController{service: svc} return &UserController{
service: svc,
mediaService: mediaSvc,
}
} }
// GetUserCurrent godoc // 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 // UpdateProfile godoc
// @Summary Update user profile // @Summary Update user profile
// @Description Update the profile details of the currently authenticated user // @Description Update the profile details of the currently authenticated user
@@ -250,11 +309,11 @@ func (h *UserController) GetUserById(c fiber.Ctx) error {
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
// @Param query query request.SearchUserDto false "Search Query" // @Param query query request.SearchUserDto false "Search Query"
// @Success 200 {object} response.CommonResponse // @Success 200 {object} response.PaginatedResponse
// @Failure 400 {object} response.CommonResponse // @Failure 400 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse // @Failure 500 {object} response.CommonResponse
// @Router /users [get] // @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) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
@@ -265,7 +324,7 @@ func (h *UserController) Search(c fiber.Ctx) error {
Message: err.Error(), Message: err.Error(),
}) })
} }
res, err := h.service.Search(ctx, dto) res, err := h.service.SearchUser(ctx, dto)
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{
Status: false, Status: false,

View File

@@ -3,19 +3,14 @@ package request
type PreSignedDto struct { type PreSignedDto struct {
FileName string `json:"fileName" validate:"required"` FileName string `json:"fileName" validate:"required"`
ContentType string `json:"contentType" validate:"required"` ContentType string `json:"contentType" validate:"required"`
Size int64 `json:"size" validate:"required"`
} }
type PreSignedCompleteDto struct { type PreSignedCompleteDto struct {
FileName string `json:"fileName" validate:"required"` TokenID string `json:"token_id" validate:"required"`
MediaId string `json:"mediaId" validate:"required"`
PublicUrl string `json:"publicUrl" validate:"required"`
} }
type SearchMediaDto struct { type SearchMediaDto struct {
MediaId string `query:"media_id" validate:"omitempty"` CursorPaginationDto
FileName string `query:"file_name" validate:"omitempty"` Search string `json:"search" query:"search" validate:"omitempty,min=2,max=200"`
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"`
} }

View File

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

View File

@@ -17,6 +17,18 @@ type MediaEntity struct {
UpdatedAt *time.Time `json:"updated_at"` 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 { func (e *MediaEntity) ToResponse() *response.MediaResponse {
return &response.MediaResponse{ return &response.MediaResponse{
ID: e.ID, ID: e.ID,
@@ -30,3 +42,11 @@ func (e *MediaEntity) ToResponse() *response.MediaResponse {
UpdatedAt: e.UpdatedAt, UpdatedAt: e.UpdatedAt,
} }
} }
func MediaEntitiesToResponse(entities []*MediaEntity) []*response.MediaResponse {
responses := make([]*response.MediaResponse, len(entities))
for i, entity := range entities {
responses[i] = entity.ToResponse()
}
return responses
}

View File

@@ -8,6 +8,16 @@ type TokenEntity struct {
TokenType constants.TokenType `json:"token_type"` 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 { type OAuthState struct {
State string `json:"state"` State string `json:"state"`
RedirectURL string `json:"redirect"` RedirectURL string `json:"redirect"`

View File

@@ -13,9 +13,14 @@ type TokenRepository interface {
Get(ctx context.Context, email string, tokenType constants.TokenType) (*models.TokenEntity, error) Get(ctx context.Context, email string, tokenType constants.TokenType) (*models.TokenEntity, error)
Create(ctx context.Context, token *models.TokenEntity) error Create(ctx context.Context, token *models.TokenEntity) error
Delete(ctx context.Context, email string, tokenType constants.TokenType) error Delete(ctx context.Context, email string, tokenType constants.TokenType) error
CheckVerified(ctx context.Context, email string, tokenType constants.TokenType, id string) (bool, 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 CreateVerified(ctx context.Context, email string, tokenType constants.TokenType, id string) error
DeleteVerified(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 { 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) cacheKey := fmt.Sprintf("token:verified:%d:%s:%s", tokenType.Value(), email, id)
return t.c.Del(ctx, cacheKey) return t.c.Del(ctx, cacheKey)
} }
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) 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) exists, err := t.c.Exists(ctx, cacheKey)
return exists, err return exists, err
} }
func (t *tokenRepository) CheckVerified(ctx context.Context, email string, tokenType constants.TokenType, id string) (bool, error) { func (t *tokenRepository) CreateUploadToken(ctx context.Context, userId string, token *models.TokenUploadEntity) error {
cacheKey := fmt.Sprintf("token:verified:%d:%s:%s", tokenType.Value(), email, id) 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) exists, err := t.c.Exists(ctx, cacheKey)
return exists, err return exists, err
} }

View File

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

View File

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

View File

@@ -2,19 +2,35 @@ package services
import ( import (
"context" "context"
"encoding/json"
"fmt"
"history-api/internal/dtos/request" "history-api/internal/dtos/request"
"history-api/internal/dtos/response" "history-api/internal/dtos/response"
"history-api/internal/gen/sqlc"
"history-api/internal/models"
"history-api/internal/repositories" "history-api/internal/repositories"
"history-api/pkg/cache"
"history-api/pkg/constants"
"history-api/pkg/convert"
"history-api/pkg/storage" "history-api/pkg/storage"
"io"
"mime/multipart" "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 { type MediaService interface {
GetMediaByID(ctx context.Context, mediaId string) (*response.MediaResponse, error) GetMediaByID(ctx context.Context, mediaId string) (*response.MediaResponse, error)
GetMediaByUserID(ctx context.Context, userId string) ([]*response.MediaResponse, error) GetMediaByUserID(ctx context.Context, userId string) ([]*response.MediaResponse, error)
SearchMedia(ctx context.Context, dto *request.SearchMediaDto) (*response.PaginatedResponse, error) SearchMedia(ctx context.Context, dto *request.SearchMediaDto) (*response.PaginatedResponse, error)
DeleteMedia(ctx context.Context, mediaId string) error DeleteMedia(ctx context.Context, claims *response.JWTClaims, mediaId string) error
GetMediaByTarget(ctx context.Context, targetType string, targetId string) ([]*response.MediaResponse, error)
UploadServerSide(ctx context.Context, userId string, fileHeader *multipart.FileHeader) (*response.MediaResponse, 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) GeneratePresignedURL(ctx context.Context, userId string, dto *request.PreSignedDto) (*response.PreSignedResponse, error)
PreSignedCompleted(ctx context.Context, userId string, dto *request.PreSignedCompleteDto) (*response.MediaResponse, error) PreSignedCompleted(ctx context.Context, userId string, dto *request.PreSignedCompleteDto) (*response.MediaResponse, error)
@@ -22,56 +38,292 @@ type MediaService interface {
type mediaService struct { type mediaService struct {
mediaRepo repositories.MediaRepository mediaRepo repositories.MediaRepository
tokenRepo repositories.TokenRepository
s storage.Storage s storage.Storage
c cache.Cache
} }
func NewMediaService( func NewMediaService(
mediaRepo repositories.MediaRepository, mediaRepo repositories.MediaRepository,
tokenRepo repositories.TokenRepository,
s storage.Storage, s storage.Storage,
c cache.Cache,
) MediaService { ) MediaService {
return &mediaService{ return &mediaService{
mediaRepo: mediaRepo, mediaRepo: mediaRepo,
tokenRepo: tokenRepo,
s: s, s: s,
c: c,
} }
} }
// DeleteMedia implements [MediaService]. func (m *mediaService) DeleteMedia(ctx context.Context, claims *response.JWTClaims, mediaId string) error {
func (m *mediaService) DeleteMedia(ctx context.Context, mediaId string) error { mediaIdUUID, err := convert.StringToUUID(mediaId)
panic("unimplemented") 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) GetMediaByID(ctx context.Context, id string) (*response.MediaResponse, error) {
func (m *mediaService) GeneratePresignedURL(ctx context.Context, userId string, dto *request.PreSignedDto) (*response.PreSignedResponse, error) { mediaId, err := convert.StringToUUID(id)
panic("unimplemented") 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) GetMediaByUserID(ctx context.Context, id string) ([]*response.MediaResponse, error) {
func (m *mediaService) GetMediaByID(ctx context.Context, mediaId string) (*response.MediaResponse, error) { userId, err := convert.StringToUUID(id)
panic("unimplemented") 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) { 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) { func (m *mediaService) UploadServerSide(ctx context.Context, userId string, fileHeader *multipart.FileHeader) (*response.MediaResponse, error) {
panic("unimplemented") userIdUUID, err := convert.StringToUUID(userId)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
file, err := fileHeader.Open()
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Cannot open file")
}
defer file.Close()
var reader io.Reader = file
fileExt := filepath.Ext(fileHeader.Filename)
contentType := fileHeader.Header.Get("Content-Type")
mid, err := uuid.NewV7()
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate media ID")
}
newFileName := mid.String() + fileExt
originalName := fileHeader.Filename
encodedName := url.QueryEscape(originalName)
dispositionType := "attachment"
if strings.HasPrefix(contentType, "image/") || contentType == "application/pdf" {
dispositionType = "inline"
}
contentDisposition := fmt.Sprintf("%s; filename=\"%s\"; filename*=UTF-8''%s",
dispositionType,
"file"+fileExt,
encodedName,
)
metadata := map[string]string{
"original-name": encodedName,
}
mdByte, err := json.Marshal(metadata)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to encode metadata")
}
err = m.s.Upload(ctx, newFileName, reader, fileHeader.Size, storage.UploadOptions{
ContentType: contentType,
ContentDisposition: contentDisposition,
Metadata: metadata,
})
if err != nil {
log.Err(err).Msg("Failed to upload file to storage")
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to upload file")
}
media, err := m.mediaRepo.Create(ctx, sqlc.CreateMediaParams{
UserID: userIdUUID,
StorageKey: newFileName,
OriginalName: originalName,
MimeType: contentType,
Size: fileHeader.Size,
FileMetadata: mdByte,
})
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return media.ToResponse(), nil
} }
func (m *mediaService) GeneratePresignedURL(ctx context.Context, userId string, dto *request.PreSignedDto) (*response.PreSignedResponse, error) {
fileExt := filepath.Ext(dto.FileName)
mid, err := uuid.NewV7()
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate media ID")
}
newFileName := mid.String() + fileExt
encodedName := url.QueryEscape(dto.FileName)
dispositionType := "attachment"
if dto.ContentType == "application/pdf" || (len(dto.ContentType) > 6 && dto.ContentType[:6] == "image/") {
dispositionType = "inline"
}
contentDisposition := fmt.Sprintf("%s; filename=\"%s\"; filename*=UTF-8''%s",
dispositionType, "file"+fileExt, encodedName)
metadata := map[string]string{
"original-name": encodedName,
}
mdByte, err := json.Marshal(metadata)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to encode metadata")
}
presignedURL, err := m.s.PresignUpload(ctx, newFileName, constants.PreSignedURLDuration, storage.UploadOptions{
ContentType: dto.ContentType,
ContentDisposition: contentDisposition,
Metadata: metadata,
})
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate presigned URL")
}
tokenId := uuid.New().String()
err = m.tokenRepo.CreateUploadToken(
ctx,
userId,
&models.TokenUploadEntity{
ID: tokenId,
UserID: userId,
StorageKey: newFileName,
OriginalName: dto.FileName,
MimeType: dto.ContentType,
Size: dto.Size,
FileMetadata: mdByte,
},
)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Internal Server Error")
}
return &response.PreSignedResponse{
TokenID: tokenId,
UploadUrl: presignedURL,
StorageKey: newFileName,
SignedHeaders: map[string]string{
"x-amz-meta-original-name": encodedName,
"Content-Disposition": contentDisposition,
},
}, nil
}
func (m *mediaService) PreSignedCompleted(ctx context.Context, userId string, dto *request.PreSignedCompleteDto) (*response.MediaResponse, error) {
token, err := m.tokenRepo.GetUploadToken(ctx, userId, dto.TokenID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get upload token")
}
if token == nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid or expired token")
}
userIdUUID, err := convert.StringToUUID(userId)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
err = m.s.Move(
ctx,
&storage.MoveOptions{
Bucket: m.s.GetTempBucket(),
Key: token.StorageKey,
},
&storage.MoveOptions{
Bucket: m.s.GetMainBucket(),
Key: token.StorageKey,
},
)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to move file to final destination")
}
media, err := m.mediaRepo.Create(ctx, sqlc.CreateMediaParams{
UserID: userIdUUID,
StorageKey: token.StorageKey,
OriginalName: token.OriginalName,
MimeType: token.MimeType,
Size: token.Size,
FileMetadata: token.FileMetadata,
})
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create media record")
}
_ = m.tokenRepo.DeleteUploadToken(ctx, userId, dto.TokenID)
return media.ToResponse(), nil
}

View File

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

View File

@@ -1,6 +1,8 @@
package constants package constants
const ( const (
StreamEmailName = "stream:email_tasks" StreamEmailName = "stream:email_tasks"
GroupEmailName = "email_workers_group" StreamStorageName = "stream:storage_tasks"
GroupEmailName = "email_workers_group"
GroupStorageName = "storage_workers_group"
) )

View File

@@ -4,8 +4,9 @@ type TaskType string
const ( const (
TaskTypeSendEmailOTP TaskType = "SEND_EMAIL_OTP" TaskTypeSendEmailOTP TaskType = "SEND_EMAIL_OTP"
) TaskTypeDeleteMedia TaskType = "DELETE_MEDIA"
)
func (t TaskType) String() string { func (t TaskType) String() string {
return string(t) return string(t)
} }

View File

@@ -10,4 +10,6 @@ const (
AccessTokenDuration = 15 * time.Minute AccessTokenDuration = 15 * time.Minute
RefreshTokenDuration = 7 * 24 * time.Hour RefreshTokenDuration = 7 * 24 * time.Hour
TokenVerifiedDuration = 10 * time.Minute TokenVerifiedDuration = 10 * time.Minute
PreSignedURLDuration = 15 * time.Minute
TokenUploadDuration = 1 * time.Hour
) )

View File

@@ -6,7 +6,7 @@ const (
TokenPasswordReset TokenType = 1 TokenPasswordReset TokenType = 1
TokenEmailVerify TokenType = 2 TokenEmailVerify TokenType = 2
TokenMagicLink TokenType = 3 TokenMagicLink TokenType = 3
TokenRefreshToken TokenType = 4 TokenUpload TokenType = 4
) )
func (t TokenType) String() string { func (t TokenType) String() string {
@@ -17,8 +17,8 @@ func (t TokenType) String() string {
return "EMAIL_VERIFY" return "EMAIL_VERIFY"
case TokenMagicLink: case TokenMagicLink:
return "LOGIN_MAGIC_LINK" return "LOGIN_MAGIC_LINK"
case TokenRefreshToken: case TokenUpload:
return "REFRESH_TOKEN" return "UPLOAD"
default: default:
return "UNKNOWN" return "UNKNOWN"
} }
@@ -37,7 +37,7 @@ func ParseTokenType(v int16) TokenType {
case 3: case 3:
return TokenMagicLink return TokenMagicLink
case 4: case 4:
return TokenRefreshToken return TokenUpload
default: default:
return 0 return 0
} }
@@ -51,9 +51,9 @@ func ParseTokenTypeFromString(s string) TokenType {
return TokenEmailVerify return TokenEmailVerify
case "LOGIN_MAGIC_LINK": case "LOGIN_MAGIC_LINK":
return TokenMagicLink return TokenMagicLink
case "REFRESH_TOKEN": case "UPLOAD":
return TokenRefreshToken return TokenUpload
default: default:
return 0 return 0
} }
} }

View File

@@ -2,7 +2,9 @@ package storage
import ( import (
"context" "context"
"fmt"
"io" "io"
"net/url"
"time" "time"
"github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws"
@@ -20,18 +22,27 @@ type UploadOptions struct {
Metadata map[string]string Metadata map[string]string
} }
type MoveOptions struct {
Bucket string
Key string
}
type Storage interface { type Storage interface {
Upload(ctx context.Context, key string, body io.Reader, size int64, opts UploadOptions) error 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) PresignUpload(ctx context.Context, key string, expire time.Duration, opts UploadOptions) (string, error)
GetURL(ctx context.Context, key string, expire time.Duration) (string, error) GetURL(ctx context.Context, key string, expire time.Duration) (string, error)
Delete(ctx context.Context, key string) error Delete(ctx context.Context, key string) error
GetMainBucket() string
GetTempBucket() string
} }
type s3Storage struct { type s3Storage struct {
client *s3.Client client *s3.Client
ps *s3.PresignClient ps *s3.PresignClient
bucket string bucket string
endPoint string tempBucket string
endPoint string
} }
func NewS3Storage() (Storage, error) { func NewS3Storage() (Storage, error) {
@@ -49,16 +60,26 @@ func NewS3Storage() (Storage, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
tempBucketName, err := ffconfig.GetConfig("STORAGE_BUCKET_TEMP_NAME")
if err != nil {
return nil, err
}
endpoint, err := ffconfig.GetConfig("STORAGE_ENDPOINT") endpoint, err := ffconfig.GetConfig("STORAGE_ENDPOINT")
if err != nil { if err != nil {
return nil, err return nil, err
} }
region, err := ffconfig.GetConfig("STORAGE_REGION")
if err != nil {
return nil, err
}
cfg, err := config.LoadDefaultConfig(context.TODO(), cfg, err := config.LoadDefaultConfig(context.TODO(),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretAccessKey, "")), config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretAccessKey, "")),
config.WithRegion("auto"), config.WithRegion(region),
) )
if err != nil { if err != nil {
log.Error().Msgf("unable to load AWS SDK config, %v", err) log.Error().Msgf("unable to load AWS SDK config, %v", err)
return nil, err return nil, err
@@ -66,15 +87,42 @@ func NewS3Storage() (Storage, error) {
client := s3.NewFromConfig(cfg, func(o *s3.Options) { client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint) o.BaseEndpoint = aws.String(endpoint)
o.UsePathStyle = true
}) })
return &s3Storage{ return &s3Storage{
client: client, client: client,
ps: s3.NewPresignClient(client), ps: s3.NewPresignClient(client),
bucket: bucketName, bucket: bucketName,
endPoint: endpoint, tempBucket: tempBucketName,
endPoint: endpoint,
}, nil }, 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 { func (s *s3Storage) Upload(ctx context.Context, key string, body io.Reader, size int64, opts UploadOptions) error {
input := &s3.PutObjectInput{ 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) { func (s *s3Storage) PresignUpload(ctx context.Context, key string, expire time.Duration, opts UploadOptions) (string, error) {
input := &s3.PutObjectInput{ input := &s3.PutObjectInput{
Bucket: &s.bucket, Bucket: &s.tempBucket,
Key: &key, Key: &key,
} }
@@ -119,6 +167,7 @@ func (s *s3Storage) PresignUpload(ctx context.Context, key string, expire time.D
} }
return req.URL, nil return req.URL, nil
} }
func (s *s3Storage) GetURL(ctx context.Context, key string, expire time.Duration) (string, error) { func (s *s3Storage) GetURL(ctx context.Context, key string, expire time.Duration) (string, error) {
req, err := s.ps.PresignGetObject(ctx, &s3.GetObjectInput{ req, err := s.ps.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: &s.bucket, Bucket: &s.bucket,