docs(architecture): Update documentation for direct upload architecture and API endpoints

- Enhanced the architecture documentation to recommend direct upload over legacy proxy upload for improved performance and scalability.
- Added detailed comparisons of upload patterns, including throughput, memory usage, and latency.
- Updated API endpoint documentation to reflect new direct upload methods and their benefits.
- Included examples for direct upload flow and bucket directory structure to aid developers in implementation.
This commit is contained in:
Ho Ngoc Hai
2026-01-13 21:17:55 +07:00
parent 8886585f48
commit 5c8764f63a
13 changed files with 1406 additions and 12 deletions

105
infra/servers/minio.md Normal file
View File

@@ -0,0 +1,105 @@
# MinIO Server Installation Guide
## System Information
- **OS**: Ubuntu 24.04.3 LTS (Noble Numbat)
- **Architecture**: x86_64
- **Public IP**: 167.114.174.113
## Installation Steps
### 1. Download Binary
Download the latest MinIO binary and install it to `/usr/local/bin`.
```bash
wget https://dl.min.io/server/minio/release/linux-amd64/minio
chmod +x minio
sudo mv minio /usr/local/bin/
```
### 2. User & Group
Create a dedicated system user for MinIO.
```bash
sudo useradd -r minio-user -s /sbin/nologin
```
### 3. Data Directory
Create the local storage directory and assign ownership.
```bash
sudo mkdir /data
sudo chown minio-user:minio-user /data
```
### 4. License File
Create the license file at `/opt/minio/minio.license`.
```bash
sudo mkdir -p /opt/minio
echo "eyJhbGciOiJFUzM4NCIsInR5cCI6IkpXVCJ9.eyJhaWQiOjAsImNhcCI6MCwiaWF0IjoxLjc2ODI0NDQ2MjMwMzI4ODQ3ZTksImlzcyI6InN1Ym5ldEBtaW4uaW8iLCJsaWQiOiJkYjQ5ZjU4Ny0yZmRkLTQ2NzEtYjI2Yy1kOTVlOTJkNDRhYTYiLCJvcmciOiIiLCJwbGFuIjoiRlJFRSIsInN1YiI6ImhvbmdvY2hhaTEwQGljbG91ZC5jb20iLCJ0cmlhbCI6ZmFsc2V9.zPT5238rxKwdQvMBuztjjtCkC1TKG0tkbNmTBwOVWRp7rIyJfmtZsIN3uu-se4WMl5Zk0A6t9C0LDgu3gXHjBVsBG9aZuonQdKCknWYlM7czyWQx3GSIr0rDq0zMkZOM" | sudo tee /opt/minio/minio.license
sudo chown -R minio-user:minio-user /opt/minio
```
## Configuration
### Environment File: `/etc/default/minio`
```bash
# Volume to be used for MinIO Server.
MINIO_VOLUMES="/data"
# MinIO Server options.
MINIO_OPTS="--console-address :9001"
# Root user for the server.
MINIO_ROOT_USER=minioadmin
# Root secret for the server.
MINIO_ROOT_PASSWORD=Velik@2026
# MinIO License
# MinIO License
MINIO_LICENSE="/opt/minio/minio.license"
```
### Systemd Service: `/etc/systemd/system/minio.service`
```ini
[Unit]
Description=MinIO
Documentation=https://docs.min.io
Wants=network-online.target
After=network-online.target
AssertFileIsExecutable=/usr/local/bin/minio
[Service]
WorkingDirectory=/usr/local
User=minio-user
Group=minio-user
ProtectProc=invisible
EnvironmentFile=/etc/default/minio
ExecStartPre=/bin/bash -c "if [ -z \"${MINIO_VOLUMES}\" ]; then echo \"Variable MINIO_VOLUMES not set in /etc/default/minio\"; exit 1; fi"
ExecStart=/usr/local/bin/minio server $MINIO_OPTS $MINIO_VOLUMES
Restart=always
LimitNOFILE=65536
TasksMax=infinity
TimeoutStopSec=infinity
SendSIGKILL=no
[Install]
WantedBy=multi-user.target
```
## Management Commands
- **Start Service**: `sudo systemctl start minio`
- **Stop Service**: `sudo systemctl stop minio`
- **Restart Service**: `sudo systemctl restart minio`
- **Check Status**: `sudo systemctl status minio`
- **View Logs**: `journalctl -u minio`
## Access Details
- **API URL**: `http://167.114.174.113:9000`
- **Console URL**: `http://167.114.174.113:9001`
- **Username**: `minioadmin`
- **Password**: `Velik@2026`

64
infra/servers/mssql.md Normal file
View File

@@ -0,0 +1,64 @@
# Hướng Dẫn Sử Dụng MSSQL Server 2025 (Ubuntu 24.04)
## Thông Tin Cài Đặt
- **Phiên bản**: Microsoft SQL Server 2025 (RTM) - Enterprise Developer Edition
- **Giấy phép**: Miễn phí trọn đời (Free Forever) cho môi trường **Development & Test**. Không dùng cho Production.
- **Hệ điều hành**: Ubuntu 24.04
- **Tài khoản mặc định**: `SA`
- **Mật khẩu**: `Velik@2026`
## Kết Nối Database
Sử dụng `sqlcmd` để kết nối:
```bash
/opt/mssql-tools18/bin/sqlcmd -S localhost -U SA -P 'Velik@2026' -C
```
Để chạy một câu lệnh SQL đơn giản (ví dụ: kiểm tra phiên bản):
```bash
/opt/mssql-tools18/bin/sqlcmd -S localhost -U SA -P 'Velik@2026' -C -Q "SELECT @@VERSION"
```
> **Lưu ý**: Cờ `-C` là bắt buộc để tin cậy chứng chỉ server (Trust Server Certificate) vì mặc định MSSQL tạo chứng chỉ tự ký.
## Quản Lý Service
Các lệnh systemd để quản lý MSSQL Server:
- **Kiểm tra trạng thái**:
```bash
sudo systemctl status mssql-server
```
- **Khởi động**:
```bash
sudo systemctl start mssql-server
```
- **Dừng**:
```bash
sudo systemctl stop mssql-server
```
- **Khởi động lại**:
```bash
sudo systemctl restart mssql-server
```
## Cấu Hình & Thư Mục
- **File cấu hình**: `/var/opt/mssql/mssql.conf`
- **Thư mục dữ liệu (mặc định)**: `/var/opt/mssql/data`
- **Thư mục log lỗi**: `/var/opt/mssql/log`
- **Công cụ cấu hình**: `/opt/mssql/bin/mssql-conf`
### Thay Đổi Password SA
Nếu cần đặt lại mật khẩu SA:
```bash
sudo systemctl stop mssql-server
sudo MSSQL_SA_PASSWORD='NewStrongPassword!' /opt/mssql/bin/mssql-conf -n set-sa-password
sudo systemctl start mssql-server
```
### Thay Đổi Phiên Bản (Edition)
```bash
sudo systemctl stop mssql-server
sudo /opt/mssql/bin/mssql-conf set-edition
# Chọn phiên bản mong muốn (ví dụ: Developer)
sudo systemctl start mssql-server
```

47
infra/servers/psql.md Normal file
View File

@@ -0,0 +1,47 @@
# Hướng dẫn sử dụng PostgreSQL
## Phiên bản
PostgreSQL 18.1
## Thông tin tài khoản
Dưới đây là thông tin tài khoản và mật khẩu đã được cấu hình:
| Tài khoản | Mật khẩu | Quyền hạn |
| :--- | :--- | :--- |
| `ubuntu` | `Velik@2026` | Superuser |
| `postgres` | `Velik@2026` | Superuser |
## Cách kết nối
### 1. Kết nối bằng tài khoản `ubuntu` (Mặc định)
Bạn đang đăng nhập vào server bằng user `ubuntu`, nên có thể kết nối trực tiếp:
```bash
psql
```
### 2. Kết nối bằng tài khoản `postgres`
Để kết nối với tư cách là user hệ thống `postgres`:
```bash
sudo -u postgres psql
```
Hoặc kết nối qua giao thức mạng (yêu cầu mật khẩu):
```bash
psql -U postgres -h localhost
```
### 3. Kết nối từ xa (Remote Access)
Server đã được cấu hình để cho phép kết nối từ xa từ bất kỳ IP nào.
- **Public IP**: `167.114.174.113`
- **Port**: `5432`
- **Lệnh kết nối**:
```bash
psql -h 167.114.174.113 -U postgres
```
> **Lưu ý**: Mật khẩu `Velik@2026` có chứa ký tự `@` là hoàn toàn hợp lệ.

54
infra/servers/redis.md Normal file
View File

@@ -0,0 +1,54 @@
# Redis Installation Guide for Ubuntu 24.04
This guide documents the installation of the latest Redis server using the official Redis repository.
## Installation Steps
1. **Install Prerequisites**:
```bash
sudo apt-get install -y lsb-release curl gpg
```
2. **Add GPG Key and Repository**:
```bash
curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg
sudo chmod 644 /usr/share/keyrings/redis-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list
```
3. **Install Redis Server**:
```bash
sudo apt-get update
sudo apt-get install -y redis-server
```
## Configuration
### Security and Network
- **Public IP Binding**: Enabled (Bound to `0.0.0.0`)
- **Password Protection**: Enabled
- **Password**: `Velik@2026`
- **Port**: 6379
## Verification
To verify the installation and connection:
```bash
# Check service status
sudo systemctl status redis-server
# Test connection with password
redis-cli -a Velik@2026 ping
# Expected output: PONG
# Test remote connection (replace IP with 167.114.174.113)
redis-cli -h 167.114.174.113 -a Velik@2026 ping
```
## Current Status
- **Version**: 8.4.0
- **Status**: Installed and Active
- **Connection**: Publicly accessible on port 6379

View File

@@ -87,12 +87,80 @@ Application entry point and CQRS implementation:
| Component | Purpose |
|-----------|---------|
| **FilesController** | File CRUD endpoints |
| **FilesController** | File CRUD endpoints (legacy proxy upload) |
| **SignedUrlController** | Direct upload endpoints (recommended) |
| **QuotaController** | User quota endpoints |
| **UploadFileCommand** | Handle file uploads |
| **SignUploadCommand** | Generate pre-signed upload URLs |
| **ConfirmUploadCommand** | Confirm direct uploads and save metadata |
| **UploadFileCommand** | Handle proxy file uploads (legacy) |
| **DeleteFileCommand** | Handle file deletions |
| **Query Handlers** | Handle read operations |
## Direct Upload Architecture (Recommended)
For systems with millions of users, Direct Client Upload pattern is recommended over proxy upload.
### Upload Patterns Comparison
| Aspect | Proxy Upload (Legacy) | Direct Upload (Recommended) |
|--------|----------------------|----------------------------|
| **Throughput** | ~100-500/sec | ~10,000+/sec |
| **Memory per request** | 100MB (file size) | ~10KB (metadata only) |
| **Latency (100MB file)** | 30-60s | 10-20s |
| **Backend load** | High | Minimal |
### Direct Upload Flow
```mermaid
sequenceDiagram
participant Client
participant Storage_Service as Storage Service
participant MinIO
rect rgb(200, 230, 200)
Note over Client,Storage_Service: 1. Request Upload URL (lightweight)
Client->>Storage_Service: POST /api/v1/storage/sign-upload
Storage_Service->>Storage_Service: Validate JWT, Check Quota
Storage_Service-->>Client: Pre-signed PUT URL + ObjectKey
end
rect rgb(200, 200, 230)
Note over Client,MinIO: 2. Direct Upload (bypasses backend)
Client->>MinIO: PUT file binary to Pre-signed URL
MinIO-->>Client: 200 OK
end
rect rgb(230, 230, 200)
Note over Client,Storage_Service: 3. Confirm Upload (lightweight)
Client->>Storage_Service: POST /api/v1/storage/confirm-upload
Storage_Service->>MinIO: Verify file exists
Storage_Service->>Storage_Service: Save metadata, Update quota
Storage_Service-->>Client: File metadata
end
```
### Path-based Access Control
Files are organized with access level prefixes:
```
storage-bucket/
├── public/{userId}/{date}/{fileId}_{filename} → Publicly accessible
├── private/{userId}/{date}/{fileId}_{filename} → Requires pre-signed URL
└── shared/{userId}/{date}/{fileId}_{filename} → Access controlled by rules
```
### Direct Upload Components
| Component | Purpose |
|-----------|---------|
| **SignUploadCommand** | Validate quota, generate object key with path prefix, create pre-signed PUT URL |
| **SignUploadCommandHandler** | Handle sign-upload requests |
| **ConfirmUploadCommand** | Verify file exists, save metadata, update quota |
| **ConfirmUploadCommandHandler** | Handle confirm-upload with idempotency |
| **SignedUrlController** | `/sign-upload` and `/confirm-upload` endpoints |
## Storage Provider Architecture
```mermaid
@@ -229,15 +297,30 @@ erDiagram
## API Endpoints
### Files (Legacy - Proxy Upload)
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v1/files/upload` | Upload file (max 100MB) |
| `POST` | `/api/v1/files/upload` | Upload file via backend (max 100MB) |
| `GET` | `/api/v1/files` | List user files with pagination |
| `GET` | `/api/v1/files/{id}` | Get file metadata |
| `GET` | `/api/v1/files/{id}/download-url` | Get pre-signed download URL |
| `DELETE` | `/api/v1/files/{id}` | Delete file (soft delete) |
### Direct Upload (Recommended)
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v1/storage/sign-upload` | Get pre-signed PUT URL for direct upload |
| `POST` | `/api/v1/storage/confirm-upload` | Confirm upload and save metadata |
### Quota
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/quota` | Get user storage quota |
## Health Checks
```mermaid

View File

@@ -85,23 +85,148 @@ dotnet run --project src/StorageService.API
## API Endpoints
### Files
### Files (Legacy - Proxy Upload)
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v1/files/upload` | Upload a file (max 100MB) |
| `POST` | `/api/v1/files/upload` | Upload a file via backend (max 100MB) |
| `GET` | `/api/v1/files` | List user's files with pagination |
| `GET` | `/api/v1/files/{id}` | Get file metadata by ID |
| `GET` | `/api/v1/files/{id}/download-url` | Get pre-signed download URL |
| `DELETE` | `/api/v1/files/{id}` | Delete a file (soft delete) |
### Direct Upload (Recommended for Scale)
> **Benefits of Direct Upload:**
> - Backend doesn't handle file binary → 90%+ load reduction
> - Can handle millions of concurrent uploads
> - Faster upload speed (no intermediary)
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v1/storage/sign-upload` | Get pre-signed PUT URL for direct upload |
| `POST` | `/api/v1/storage/confirm-upload` | Confirm upload and save metadata |
### Quota
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/quota` | Get current user's storage quota |
## Upload Example
## Direct Upload Example (Recommended)
### Step 1: Get Pre-signed URL
```bash
curl -X POST "http://localhost:5002/api/v1/storage/sign-upload" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"fileName": "document.pdf",
"fileSizeBytes": 1048576,
"contentType": "application/pdf",
"accessLevel": "private"
}'
```
Response:
```json
{
"success": true,
"data": {
"uploadUrl": "http://minio:9000/storage/private/user123/20260113/abc12345_document.pdf?X-Amz-...",
"objectKey": "private/user123/20260113/abc12345_document.pdf",
"expiresAt": "2026-01-13T21:49:33Z"
}
}
```
### Step 2: Upload Directly to MinIO
```bash
curl -X PUT "${uploadUrl}" \
-H "Content-Type: application/pdf" \
--data-binary @document.pdf
```
### Step 3: Confirm Upload
```bash
curl -X POST "http://localhost:5002/api/v1/storage/confirm-upload" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"objectKey": "private/user123/20260113/abc12345_document.pdf",
"fileName": "document.pdf",
"fileSizeBytes": 1048576,
"contentType": "application/pdf",
"accessLevel": "private"
}'
```
Response:
```json
{
"success": true,
"data": {
"fileId": "550e8400-e29b-41d4-a716-446655440000",
"metadata": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"fileName": "document.pdf",
"contentType": "application/pdf",
"fileSizeBytes": 1048576,
"provider": "MinIO",
"accessLevel": "Private",
"uploadedAt": "2026-01-13T20:49:33Z"
}
}
}
```
## Bucket Directory Structure
Files are organized by access level and user ID with the following format:
```
{bucket}/
├── private/{userId}/{date}/{fileId}_{filename} → Owner access only (via pre-signed URL)
├── public/{userId}/{date}/{fileId}_{filename} → Publicly accessible
└── shared/{userId}/{date}/{fileId}_{filename} → Controlled by sharing rules
```
### Object Key Format Details
| Component | Description | Example |
|-----------|-------------|---------|
| `{bucket}` | Bucket name (from config) | `goodgo` |
| `{accessLevel}` | Access level prefix | `private`, `public`, `shared` |
| `{userId}` | Uploader's user ID | `user123` |
| `{date}` | Upload date (UTC) | `20260113` |
| `{fileId}` | First 8 chars of GUID | `a1b2c3d4` |
| `{filename}` | Sanitized file name | `document.pdf` |
### Real-World Example
```
goodgo/
├── private/
│ └── user123/
│ └── 20260113/
│ ├── a1b2c3d4_document.pdf
│ └── e5f6g7h8_image.jpg
├── public/
│ └── user456/
│ └── 20260113/
│ └── i9j0k1l2_avatar.png
└── shared/
└── user789/
└── 20260113/
└── m3n4o5p6_presentation.pptx
```
> **Note**: The object key is returned in the `/sign-upload` response and must be sent back when calling `/confirm-upload`.
## Legacy Upload Example (Via Backend)
```bash
curl -X POST "http://localhost:5002/api/v1/files/upload" \
@@ -109,6 +234,7 @@ curl -X POST "http://localhost:5002/api/v1/files/upload" \
-F "file=@document.pdf"
```
## Inter-Service Communication
The service communicates with IAM Service for user validation. This section explains the configuration required for proper inter-service communication in Docker environments.

View File

@@ -87,12 +87,80 @@ Entry point ứng dụng và triển khai CQRS:
| Component | Mục đích |
|-----------|----------|
| **FilesController** | Endpoints CRUD file |
| **FilesController** | Endpoints CRUD file (proxy upload cũ) |
| **SignedUrlController** | Endpoints direct upload (khuyến nghị) |
| **QuotaController** | Endpoints quota của user |
| **UploadFileCommand** | Xử lý upload file |
| **SignUploadCommand** | Tạo pre-signed upload URLs |
| **ConfirmUploadCommand** | Xác nhận direct uploads và lưu metadata |
| **UploadFileCommand** | Xử lý proxy uploads (cũ) |
| **DeleteFileCommand** | Xử lý xóa file |
| **Query Handlers** | Xử lý các thao tác đọc |
## Kiến Trúc Direct Upload (Khuyến Nghị)
Cho hệ thống với hàng triệu users, pattern Direct Client Upload được khuyến nghị thay vì proxy upload.
### So Sánh Upload Patterns
| Khía cạnh | Proxy Upload (Cũ) | Direct Upload (Khuyến nghị) |
|-----------|------------------|----------------------------|
| **Throughput** | ~100-500/giây | ~10,000+/giây |
| **Memory mỗi request** | 100MB (kích thước file) | ~10KB (chỉ metadata) |
| **Latency (file 100MB)** | 30-60 giây | 10-20 giây |
| **Tải backend** | Cao | Tối thiểu |
### Luồng Direct Upload
```mermaid
sequenceDiagram
participant Client
participant Storage_Service as Storage Service
participant MinIO
rect rgb(200, 230, 200)
Note over Client,Storage_Service: 1. Yêu cầu Upload URL (nhẹ)
Client->>Storage_Service: POST /api/v1/storage/sign-upload
Storage_Service->>Storage_Service: Validate JWT, Kiểm tra Quota
Storage_Service-->>Client: Pre-signed PUT URL + ObjectKey
end
rect rgb(200, 200, 230)
Note over Client,MinIO: 2. Upload trực tiếp (bỏ qua backend)
Client->>MinIO: PUT file binary vào Pre-signed URL
MinIO-->>Client: 200 OK
end
rect rgb(230, 230, 200)
Note over Client,Storage_Service: 3. Xác nhận Upload (nhẹ)
Client->>Storage_Service: POST /api/v1/storage/confirm-upload
Storage_Service->>MinIO: Xác minh file tồn tại
Storage_Service->>Storage_Service: Lưu metadata, Cập nhật quota
Storage_Service-->>Client: File metadata
end
```
### Kiểm Soát Truy Cập Theo Path
Files được tổ chức với prefix theo access level:
```
storage-bucket/
├── public/{userId}/{date}/{fileId}_{filename} → Truy cập công khai
├── private/{userId}/{date}/{fileId}_{filename} → Yêu cầu pre-signed URL
└── shared/{userId}/{date}/{fileId}_{filename} → Kiểm soát bằng quy tắc
```
### Components Direct Upload
| Component | Mục đích |
|-----------|----------|
| **SignUploadCommand** | Validate quota, tạo object key với path prefix, tạo pre-signed PUT URL |
| **SignUploadCommandHandler** | Xử lý yêu cầu sign-upload |
| **ConfirmUploadCommand** | Xác minh file tồn tại, lưu metadata, cập nhật quota |
| **ConfirmUploadCommandHandler** | Xử lý confirm-upload với idempotency |
| **SignedUrlController** | Endpoints `/sign-upload``/confirm-upload` |
## Kiến Trúc Storage Provider
```mermaid
@@ -229,15 +297,30 @@ erDiagram
## API Endpoints
### Files (Proxy Upload Cũ)
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| `POST` | `/api/v1/files/upload` | Upload file (tối đa 100MB) |
| `POST` | `/api/v1/files/upload` | Upload file qua backend (tối đa 100MB) |
| `GET` | `/api/v1/files` | Danh sách file với phân trang |
| `GET` | `/api/v1/files/{id}` | Lấy metadata file |
| `GET` | `/api/v1/files/{id}/download-url` | Lấy pre-signed download URL |
| `DELETE` | `/api/v1/files/{id}` | Xóa file (soft delete) |
### Direct Upload (Khuyến Nghị)
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| `POST` | `/api/v1/storage/sign-upload` | Lấy pre-signed PUT URL để upload trực tiếp |
| `POST` | `/api/v1/storage/confirm-upload` | Xác nhận upload và lưu metadata |
### Quota
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| `GET` | `/api/v1/quota` | Lấy quota storage của user |
## Health Checks
```mermaid

View File

@@ -83,25 +83,151 @@ dotnet run --project src/StorageService.API
| `IamService__CacheDurationSeconds` | TTL cache user info | `300` |
| `IamService__HealthCheckCacheDurationSeconds` | TTL cache health check | `60` |
## API Endpoints
### Files
### Files (Legacy - Proxy Upload)
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| `POST` | `/api/v1/files/upload` | Upload file (tối đa 100MB) |
| `POST` | `/api/v1/files/upload` | Upload file qua backend (tối đa 100MB) |
| `GET` | `/api/v1/files` | Danh sách files với phân trang |
| `GET` | `/api/v1/files/{id}` | Lấy metadata file theo ID |
| `GET` | `/api/v1/files/{id}/download-url` | Lấy pre-signed download URL |
| `DELETE` | `/api/v1/files/{id}` | Xóa file (soft delete) |
### Direct Upload (Khuyến nghị cho Scale lớn)
> **Lợi ích của Direct Upload:**
> - Backend không xử lý file binary → Giảm 90%+ tải
> - Có thể xử lý hàng triệu uploads đồng thời
> - Tốc độ upload nhanh hơn (không qua trung gian)
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| `POST` | `/api/v1/storage/sign-upload` | Lấy pre-signed PUT URL để upload trực tiếp |
| `POST` | `/api/v1/storage/confirm-upload` | Xác nhận upload và lưu metadata |
### Quota
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| `GET` | `/api/v1/quota` | Lấy quota storage của user hiện tại |
## Ví Dụ Upload
## Ví Dụ Direct Upload (Khuyến nghị)
### Bước 1: Lấy Pre-signed URL
```bash
curl -X POST "http://localhost:5002/api/v1/storage/sign-upload" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"fileName": "document.pdf",
"fileSizeBytes": 1048576,
"contentType": "application/pdf",
"accessLevel": "private"
}'
```
Response:
```json
{
"success": true,
"data": {
"uploadUrl": "http://minio:9000/storage/private/user123/20260113/abc12345_document.pdf?X-Amz-...",
"objectKey": "private/user123/20260113/abc12345_document.pdf",
"expiresAt": "2026-01-13T21:49:33Z"
}
}
```
### Bước 2: Upload trực tiếp lên MinIO
```bash
curl -X PUT "${uploadUrl}" \
-H "Content-Type: application/pdf" \
--data-binary @document.pdf
```
### Bước 3: Xác nhận Upload
```bash
curl -X POST "http://localhost:5002/api/v1/storage/confirm-upload" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"objectKey": "private/user123/20260113/abc12345_document.pdf",
"fileName": "document.pdf",
"fileSizeBytes": 1048576,
"contentType": "application/pdf",
"accessLevel": "private"
}'
```
Response:
```json
{
"success": true,
"data": {
"fileId": "550e8400-e29b-41d4-a716-446655440000",
"metadata": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"fileName": "document.pdf",
"contentType": "application/pdf",
"fileSizeBytes": 1048576,
"provider": "MinIO",
"accessLevel": "Private",
"uploadedAt": "2026-01-13T20:49:33Z"
}
}
}
```
## Cấu Trúc Thư Mục Trên Bucket
Files được tổ chức theo access level và user ID với định dạng:
```
{bucket}/
├── private/{userId}/{date}/{fileId}_{filename} → Chỉ owner truy cập (qua pre-signed URL)
├── public/{userId}/{date}/{fileId}_{filename} → Truy cập công khai
└── shared/{userId}/{date}/{fileId}_{filename} → Kiểm soát bởi sharing rules
```
### Chi Tiết Object Key Format
| Thành phần | Mô tả | Ví dụ |
|------------|-------|-------|
| `{bucket}` | Tên bucket (config) | `goodgo` |
| `{accessLevel}` | Mức truy cập | `private`, `public`, `shared` |
| `{userId}` | ID của user upload | `user123` |
| `{date}` | Ngày upload (UTC) | `20260113` |
| `{fileId}` | 8 ký tự đầu của GUID | `a1b2c3d4` |
| `{filename}` | Tên file đã sanitize | `document.pdf` |
### Ví Dụ Thực Tế
```
goodgo/
├── private/
│ └── user123/
│ └── 20260113/
│ ├── a1b2c3d4_document.pdf
│ └── e5f6g7h8_image.jpg
├── public/
│ └── user456/
│ └── 20260113/
│ └── i9j0k1l2_avatar.png
└── shared/
└── user789/
└── 20260113/
└── m3n4o5p6_presentation.pptx
```
> **Lưu ý**: Object key được trả về trong response của `/sign-upload` và cần được gửi lại khi gọi `/confirm-upload`.
## Ví Dụ Legacy Upload (Qua Backend)
```bash
curl -X POST "http://localhost:5002/api/v1/files/upload" \
@@ -109,6 +235,7 @@ curl -X POST "http://localhost:5002/api/v1/files/upload" \
-F "file=@document.pdf"
```
## Giao Tiếp Inter-Service
Service giao tiếp với IAM Service để xác thực user. Phần này giải thích cấu hình cần thiết cho giao tiếp inter-service trong môi trường Docker.

View File

@@ -0,0 +1,62 @@
using MediatR;
using StorageService.API.Application.Queries;
using StorageService.Domain.AggregatesModel.FileAggregate;
namespace StorageService.API.Application.Commands;
/// <summary>
/// EN: Command to confirm a direct upload after client uploads to MinIO.
/// VI: Command để xác nhận upload trực tiếp sau khi client upload lên MinIO.
/// </summary>
/// <remarks>
/// EN: Called after client successfully uploads file using pre-signed URL.
/// This saves metadata and updates quota.
/// VI: Được gọi sau khi client upload file thành công bằng pre-signed URL.
/// Lưu metadata và cập nhật quota.
/// </remarks>
/// <param name="UserId">EN: User ID who uploaded / VI: ID người dùng đã upload</param>
/// <param name="ObjectKey">EN: Object key returned from sign-upload / VI: Object key được trả về từ sign-upload</param>
/// <param name="FileName">EN: Original file name / VI: Tên file gốc</param>
/// <param name="ContentType">EN: MIME content type / VI: Content type MIME</param>
/// <param name="FileSizeBytes">EN: File size in bytes / VI: Kích thước file (bytes)</param>
/// <param name="AccessLevel">EN: Access level / VI: Mức truy cập</param>
/// <param name="TenantId">EN: Optional tenant ID / VI: Tenant ID (tùy chọn)</param>
public record ConfirmUploadCommand(
string UserId,
string ObjectKey,
string FileName,
string ContentType,
long FileSizeBytes,
FileAccessLevel AccessLevel = FileAccessLevel.Private,
string? TenantId = null
) : IRequest<ConfirmUploadResult>;
/// <summary>
/// EN: Result of confirm upload containing file metadata.
/// VI: Kết quả xác nhận upload chứa metadata file.
/// </summary>
/// <param name="Success">EN: Whether confirmation was successful / VI: Xác nhận có thành công không</param>
/// <param name="FileId">EN: File ID in database / VI: ID file trong database</param>
/// <param name="Metadata">EN: File metadata / VI: Metadata file</param>
/// <param name="Error">EN: Error message if failed / VI: Thông báo lỗi nếu thất bại</param>
public record ConfirmUploadResult(
bool Success,
Guid? FileId,
FileDto? Metadata,
string? Error
)
{
/// <summary>
/// EN: Create a successful result.
/// VI: Tạo kết quả thành công.
/// </summary>
public static ConfirmUploadResult Ok(Guid fileId, FileDto metadata) =>
new(true, fileId, metadata, null);
/// <summary>
/// EN: Create a failed result.
/// VI: Tạo kết quả thất bại.
/// </summary>
public static ConfirmUploadResult Fail(string error) =>
new(false, null, null, error);
}

View File

@@ -0,0 +1,164 @@
using MediatR;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StorageService.API.Application.Queries;
using StorageService.Domain.AggregatesModel.FileAggregate;
using StorageService.Domain.AggregatesModel.QuotaAggregate;
using StorageService.Infrastructure.Configuration;
using StorageService.Infrastructure.Storage;
namespace StorageService.API.Application.Commands;
/// <summary>
/// EN: Handler for ConfirmUploadCommand - confirms direct upload and saves metadata.
/// VI: Handler cho ConfirmUploadCommand - xác nhận upload trực tiếp và lưu metadata.
/// </summary>
/// <remarks>
/// EN: Called after client successfully uploads file using pre-signed URL.
/// Verifies file exists in storage, saves metadata to database, and updates quota.
/// VI: Được gọi sau khi client upload file thành công bằng pre-signed URL.
/// Xác minh file tồn tại trong storage, lưu metadata vào database, và cập nhật quota.
/// </remarks>
public class ConfirmUploadCommandHandler : IRequestHandler<ConfirmUploadCommand, ConfirmUploadResult>
{
private readonly IFileRepository _fileRepository;
private readonly IQuotaRepository _quotaRepository;
private readonly IStorageProviderFactory _storageProviderFactory;
private readonly StorageSettings _settings;
private readonly ILogger<ConfirmUploadCommandHandler> _logger;
public ConfirmUploadCommandHandler(
IFileRepository fileRepository,
IQuotaRepository quotaRepository,
IStorageProviderFactory storageProviderFactory,
IOptions<StorageSettings> settings,
ILogger<ConfirmUploadCommandHandler> logger)
{
_fileRepository = fileRepository;
_quotaRepository = quotaRepository;
_storageProviderFactory = storageProviderFactory;
_settings = settings.Value;
_logger = logger;
}
public async Task<ConfirmUploadResult> Handle(ConfirmUploadCommand request, CancellationToken cancellationToken)
{
try
{
_logger.LogInformation(
"Confirm upload for user {UserId}, objectKey: {ObjectKey}",
request.UserId, request.ObjectKey);
// EN: Validate object key belongs to user
// VI: Kiểm tra object key thuộc về user
if (!ValidateObjectKeyOwnership(request.ObjectKey, request.UserId))
{
_logger.LogWarning(
"Object key {ObjectKey} does not belong to user {UserId}",
request.ObjectKey, request.UserId);
return ConfirmUploadResult.Fail("Invalid object key.");
}
var bucketName = _settings.DefaultBucket;
var provider = _storageProviderFactory.GetProvider();
// EN: Verify file exists in storage / VI: Xác minh file tồn tại trong storage
var exists = await provider.ExistsAsync(bucketName, request.ObjectKey, cancellationToken);
if (!exists)
{
_logger.LogWarning(
"File not found in storage: {Bucket}/{ObjectKey}",
bucketName, request.ObjectKey);
return ConfirmUploadResult.Fail("File not found in storage. Upload may have failed.");
}
// EN: Check if already confirmed (idempotency)
// VI: Kiểm tra đã xác nhận chưa (idempotency)
var existingFile = await _fileRepository.GetByObjectKeyAsync(request.ObjectKey, cancellationToken);
if (existingFile != null)
{
_logger.LogInformation(
"Upload already confirmed for objectKey: {ObjectKey}, fileId: {FileId}",
request.ObjectKey, existingFile.Id);
return ConfirmUploadResult.Ok(existingFile.Id, existingFile.ToDto());
}
// EN: Get or create quota / VI: Lấy hoặc tạo quota
var quota = await _quotaRepository.GetOrCreateAsync(request.UserId, cancellationToken);
// EN: Double-check quota (in case of concurrent uploads)
// VI: Kiểm tra lại quota (phòng trường hợp upload đồng thời)
if (!quota.CanUpload(request.FileSizeBytes))
{
_logger.LogWarning(
"Quota exceeded for user {UserId} during confirmation",
request.UserId);
// EN: Delete the uploaded file since quota exceeded
// VI: Xóa file đã upload vì vượt quota
await provider.DeleteAsync(bucketName, request.ObjectKey, cancellationToken);
return ConfirmUploadResult.Fail(
"Storage quota exceeded. The uploaded file has been removed.");
}
// EN: Create file metadata / VI: Tạo metadata file
var storageFile = new StorageFile(
request.FileName,
bucketName,
request.ObjectKey,
request.ContentType,
request.FileSizeBytes,
request.UserId,
provider.ProviderType,
request.AccessLevel,
request.TenantId,
checksum: null);
await _fileRepository.AddAsync(storageFile, cancellationToken);
// EN: Update quota / VI: Cập nhật quota
quota.AddUsage(request.FileSizeBytes);
_quotaRepository.Update(quota);
// EN: Save changes / VI: Lưu thay đổi
await _fileRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation(
"Upload confirmed successfully: fileId={FileId}, objectKey={ObjectKey}, user={UserId}",
storageFile.Id, request.ObjectKey, request.UserId);
return ConfirmUploadResult.Ok(storageFile.Id, storageFile.ToDto());
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error confirming upload for user {UserId}, objectKey: {ObjectKey}",
request.UserId, request.ObjectKey);
return ConfirmUploadResult.Fail("An error occurred while confirming upload.");
}
}
/// <summary>
/// EN: Validate that object key belongs to the specified user.
/// VI: Xác minh object key thuộc về user được chỉ định.
/// </summary>
/// <remarks>
/// EN: Object key format: {accessLevel}/{userId}/{date}/{fileId}_{filename}
/// VI: Định dạng object key: {accessLevel}/{userId}/{date}/{fileId}_{filename}
/// </remarks>
private static bool ValidateObjectKeyOwnership(string objectKey, string userId)
{
if (string.IsNullOrEmpty(objectKey))
return false;
var parts = objectKey.Split('/');
if (parts.Length < 3)
return false;
// EN: Format: {prefix}/{userId}/{date}/...
// VI: Định dạng: {prefix}/{userId}/{date}/...
var ownerUserId = parts[1];
return ownerUserId == userId;
}
}

View File

@@ -0,0 +1,61 @@
using MediatR;
using StorageService.Domain.AggregatesModel.FileAggregate;
namespace StorageService.API.Application.Commands;
/// <summary>
/// EN: Command to request a pre-signed URL for direct upload.
/// VI: Command để yêu cầu pre-signed URL cho upload trực tiếp.
/// </summary>
/// <remarks>
/// EN: This enables Direct Client Upload pattern where files are uploaded directly to MinIO,
/// bypassing the backend to handle millions of concurrent uploads efficiently.
/// VI: Pattern này cho phép upload trực tiếp lên MinIO, bỏ qua backend để xử lý
/// hàng triệu uploads đồng thời một cách hiệu quả.
/// </remarks>
/// <param name="UserId">EN: User ID requesting upload / VI: ID người dùng yêu cầu upload</param>
/// <param name="FileName">EN: Original file name / VI: Tên file gốc</param>
/// <param name="ContentType">EN: MIME content type / VI: Content type MIME</param>
/// <param name="FileSizeBytes">EN: File size in bytes for quota check / VI: Kích thước file (bytes) để kiểm tra quota</param>
/// <param name="AccessLevel">EN: Access level: Public, Private, or Shared / VI: Mức truy cập: Public, Private, hoặc Shared</param>
/// <param name="TenantId">EN: Optional tenant ID / VI: Tenant ID (tùy chọn)</param>
public record SignUploadCommand(
string UserId,
string FileName,
string ContentType,
long FileSizeBytes,
FileAccessLevel AccessLevel = FileAccessLevel.Private,
string? TenantId = null
) : IRequest<SignUploadResult>;
/// <summary>
/// EN: Result of sign upload request containing pre-signed URL.
/// VI: Kết quả yêu cầu sign upload chứa pre-signed URL.
/// </summary>
/// <param name="Success">EN: Whether the request was successful / VI: Yêu cầu có thành công không</param>
/// <param name="UploadUrl">EN: Pre-signed URL for PUT request / VI: Pre-signed URL cho PUT request</param>
/// <param name="ObjectKey">EN: Object key in storage / VI: Object key trong storage</param>
/// <param name="ExpiresAt">EN: URL expiration time / VI: Thời gian hết hạn URL</param>
/// <param name="Error">EN: Error message if failed / VI: Thông báo lỗi nếu thất bại</param>
public record SignUploadResult(
bool Success,
string? UploadUrl,
string? ObjectKey,
DateTime? ExpiresAt,
string? Error
)
{
/// <summary>
/// EN: Create a successful result.
/// VI: Tạo kết quả thành công.
/// </summary>
public static SignUploadResult Ok(string uploadUrl, string objectKey, DateTime expiresAt) =>
new(true, uploadUrl, objectKey, expiresAt, null);
/// <summary>
/// EN: Create a failed result.
/// VI: Tạo kết quả thất bại.
/// </summary>
public static SignUploadResult Fail(string error) =>
new(false, null, null, null, error);
}

View File

@@ -0,0 +1,152 @@
using MediatR;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StorageService.Domain.AggregatesModel.FileAggregate;
using StorageService.Domain.AggregatesModel.QuotaAggregate;
using StorageService.Infrastructure.Configuration;
using StorageService.Infrastructure.Storage;
namespace StorageService.API.Application.Commands;
/// <summary>
/// EN: Handler for SignUploadCommand - generates pre-signed URL for direct upload.
/// VI: Handler cho SignUploadCommand - tạo pre-signed URL cho upload trực tiếp.
/// </summary>
/// <remarks>
/// EN: This handler validates quota, generates object key with path prefix based on access level,
/// and returns a pre-signed PUT URL for client to upload directly to MinIO.
/// VI: Handler này kiểm tra quota, tạo object key với path prefix theo access level,
/// và trả về pre-signed PUT URL để client upload trực tiếp lên MinIO.
/// </remarks>
public class SignUploadCommandHandler : IRequestHandler<SignUploadCommand, SignUploadResult>
{
private readonly IQuotaRepository _quotaRepository;
private readonly IStorageProviderFactory _storageProviderFactory;
private readonly StorageSettings _settings;
private readonly ILogger<SignUploadCommandHandler> _logger;
public SignUploadCommandHandler(
IQuotaRepository quotaRepository,
IStorageProviderFactory storageProviderFactory,
IOptions<StorageSettings> settings,
ILogger<SignUploadCommandHandler> logger)
{
_quotaRepository = quotaRepository;
_storageProviderFactory = storageProviderFactory;
_settings = settings.Value;
_logger = logger;
}
public async Task<SignUploadResult> Handle(SignUploadCommand request, CancellationToken cancellationToken)
{
try
{
_logger.LogInformation(
"Sign upload request for user {UserId}, file: {FileName}, size: {Size} bytes",
request.UserId, request.FileName, request.FileSizeBytes);
// EN: Validate file size / VI: Kiểm tra kích thước file
if (request.FileSizeBytes > _settings.MaxFileSizeBytes)
{
_logger.LogWarning(
"File size {Size} exceeds maximum {Max} for user {UserId}",
request.FileSizeBytes, _settings.MaxFileSizeBytes, request.UserId);
return SignUploadResult.Fail(
$"File size exceeds maximum allowed ({_settings.MaxFileSizeBytes / 1024 / 1024}MB)");
}
// EN: Check user quota / VI: Kiểm tra quota của user
var quota = await _quotaRepository.GetOrCreateAsync(request.UserId, cancellationToken);
if (!quota.CanUpload(request.FileSizeBytes))
{
_logger.LogWarning(
"Quota exceeded for user {UserId}. Used: {Used}/{Max}",
request.UserId, quota.UsedStorageBytes, quota.MaxStorageBytes);
return SignUploadResult.Fail(
"Storage quota exceeded. Please upgrade your plan or delete some files.");
}
// EN: Generate object key with path prefix based on access level
// VI: Tạo object key với path prefix theo access level
var objectKey = GenerateObjectKey(request.UserId, request.FileName, request.AccessLevel);
var bucketName = _settings.DefaultBucket;
var expirationSeconds = _settings.PreSignedUrlExpirationSeconds;
// EN: Ensure bucket exists / VI: Đảm bảo bucket tồn tại
var provider = _storageProviderFactory.GetProvider();
await provider.EnsureBucketExistsAsync(bucketName, cancellationToken);
// EN: Generate pre-signed PUT URL / VI: Tạo pre-signed PUT URL
var uploadUrl = await provider.GetPreSignedUploadUrlAsync(
bucketName,
objectKey,
expirationSeconds,
cancellationToken);
var expiresAt = DateTime.UtcNow.AddSeconds(expirationSeconds);
_logger.LogInformation(
"Generated pre-signed URL for user {UserId}, objectKey: {ObjectKey}, expires: {ExpiresAt}",
request.UserId, objectKey, expiresAt);
return SignUploadResult.Ok(uploadUrl, objectKey, expiresAt);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating pre-signed URL for user {UserId}", request.UserId);
return SignUploadResult.Fail("An error occurred while generating upload URL.");
}
}
/// <summary>
/// EN: Generate object key with path prefix based on access level.
/// VI: Tạo object key với path prefix theo access level.
/// </summary>
/// <remarks>
/// EN: Path structure:
/// - public/{userId}/{date}/{fileId}_{filename} → Publicly accessible
/// - private/{userId}/{date}/{fileId}_{filename} → Requires pre-signed URL
/// - shared/{userId}/{date}/{fileId}_{filename} → Access controlled by sharing rules
/// VI: Cấu trúc path:
/// - public/{userId}/{date}/{fileId}_{filename} → Truy cập công khai
/// - private/{userId}/{date}/{fileId}_{filename} → Yêu cầu pre-signed URL
/// - shared/{userId}/{date}/{fileId}_{filename} → Kiểm soát bởi quy tắc chia sẻ
/// </remarks>
private static string GenerateObjectKey(string userId, string fileName, FileAccessLevel accessLevel)
{
var prefix = accessLevel switch
{
FileAccessLevel.Public => "public",
FileAccessLevel.Shared => "shared",
_ => "private"
};
var date = DateTime.UtcNow.ToString("yyyyMMdd");
var fileId = Guid.NewGuid().ToString("N")[..8];
var sanitizedName = SanitizeFileName(fileName);
return $"{prefix}/{userId}/{date}/{fileId}_{sanitizedName}";
}
/// <summary>
/// EN: Sanitize file name to remove invalid characters.
/// VI: Làm sạch tên file để loại bỏ ký tự không hợp lệ.
/// </summary>
private static string SanitizeFileName(string fileName)
{
var invalidChars = Path.GetInvalidFileNameChars();
var sanitized = string.Join("_", fileName.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries));
// EN: Limit file name length / VI: Giới hạn độ dài tên file
if (sanitized.Length > 100)
{
var extension = Path.GetExtension(sanitized);
var nameWithoutExt = Path.GetFileNameWithoutExtension(sanitized);
sanitized = nameWithoutExt[..Math.Min(nameWithoutExt.Length, 90)] + extension;
}
return sanitized;
}
}

View File

@@ -0,0 +1,266 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using StorageService.API.Application.Commands;
using StorageService.API.Application.Queries;
using StorageService.Domain.AggregatesModel.FileAggregate;
using Swashbuckle.AspNetCore.Annotations;
using System.Security.Claims;
using Asp.Versioning;
namespace StorageService.API.Controllers;
/// <summary>
/// EN: Controller for direct upload using pre-signed URLs.
/// VI: Controller cho upload trực tiếp sử dụng pre-signed URLs.
/// </summary>
/// <remarks>
/// EN: This controller implements the Direct Client Upload pattern where files are uploaded
/// directly to MinIO, bypassing the backend to handle millions of concurrent uploads.
///
/// Flow:
/// 1. Client calls POST /sign-upload to get pre-signed PUT URL
/// 2. Client uploads file directly to MinIO using the pre-signed URL
/// 3. Client calls POST /confirm-upload to save metadata
///
/// VI: Controller này triển khai pattern Direct Client Upload, nơi files được upload
/// trực tiếp lên MinIO, bỏ qua backend để xử lý hàng triệu uploads đồng thời.
///
/// Luồng:
/// 1. Client gọi POST /sign-upload để lấy pre-signed PUT URL
/// 2. Client upload file trực tiếp lên MinIO bằng pre-signed URL
/// 3. Client gọi POST /confirm-upload để lưu metadata
/// </remarks>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/storage")]
[SwaggerTag("Direct Upload - Pre-signed URL based upload for high scalability")]
public class SignedUrlController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<SignedUrlController> _logger;
public SignedUrlController(IMediator mediator, ILogger<SignedUrlController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Request a pre-signed URL for direct upload.
/// VI: Yêu cầu pre-signed URL để upload trực tiếp.
/// </summary>
/// <remarks>
/// EN: Returns a pre-signed PUT URL that allows the client to upload directly to MinIO.
/// The URL is valid for a limited time (default: 1 hour).
///
/// VI: Trả về pre-signed PUT URL cho phép client upload trực tiếp lên MinIO.
/// URL có hiệu lực trong thời gian giới hạn (mặc định: 1 giờ).
/// </remarks>
[HttpPost("sign-upload")]
[Authorize]
[SwaggerOperation(
Summary = "Get pre-signed upload URL",
Description = "Request a pre-signed PUT URL for direct file upload to storage")]
[SwaggerResponse(200, "Pre-signed URL generated successfully")]
[SwaggerResponse(400, "Invalid request or quota exceeded")]
[SwaggerResponse(401, "Unauthorized")]
public async Task<ActionResult<ApiResponse<SignUploadResponse>>> SignUpload(
[FromBody] SignUploadRequest request,
CancellationToken cancellationToken = default)
{
var userId = GetUserId();
if (string.IsNullOrEmpty(userId))
return Unauthorized(new ApiResponse<SignUploadResponse>
{
Success = false,
Error = "User ID not found"
});
// EN: Validate request / VI: Validate request
if (string.IsNullOrWhiteSpace(request.FileName))
return BadRequest(new ApiResponse<SignUploadResponse>
{
Success = false,
Error = "File name is required"
});
if (request.FileSizeBytes <= 0)
return BadRequest(new ApiResponse<SignUploadResponse>
{
Success = false,
Error = "File size must be greater than 0"
});
var accessLevel = ParseAccessLevel(request.AccessLevel);
var command = new SignUploadCommand(
userId,
request.FileName,
request.ContentType ?? "application/octet-stream",
request.FileSizeBytes,
accessLevel);
var result = await _mediator.Send(command, cancellationToken);
if (!result.Success)
return BadRequest(new ApiResponse<SignUploadResponse>
{
Success = false,
Error = result.Error
});
return Ok(new ApiResponse<SignUploadResponse>
{
Success = true,
Data = new SignUploadResponse(
result.UploadUrl!,
result.ObjectKey!,
result.ExpiresAt!.Value)
});
}
/// <summary>
/// EN: Confirm upload after client uploads to MinIO.
/// VI: Xác nhận upload sau khi client upload lên MinIO.
/// </summary>
/// <remarks>
/// EN: Call this endpoint after successfully uploading the file using the pre-signed URL.
/// This saves the file metadata and updates the user's quota.
///
/// VI: Gọi endpoint này sau khi upload file thành công bằng pre-signed URL.
/// Lưu metadata file và cập nhật quota của user.
/// </remarks>
[HttpPost("confirm-upload")]
[Authorize]
[SwaggerOperation(
Summary = "Confirm file upload",
Description = "Confirm upload completion and save file metadata")]
[SwaggerResponse(200, "Upload confirmed successfully")]
[SwaggerResponse(400, "File not found in storage or validation failed")]
[SwaggerResponse(401, "Unauthorized")]
public async Task<ActionResult<ApiResponse<ConfirmUploadResponse>>> ConfirmUpload(
[FromBody] ConfirmUploadRequest request,
CancellationToken cancellationToken = default)
{
var userId = GetUserId();
if (string.IsNullOrEmpty(userId))
return Unauthorized(new ApiResponse<ConfirmUploadResponse>
{
Success = false,
Error = "User ID not found"
});
// EN: Validate request / VI: Validate request
if (string.IsNullOrWhiteSpace(request.ObjectKey))
return BadRequest(new ApiResponse<ConfirmUploadResponse>
{
Success = false,
Error = "Object key is required"
});
if (string.IsNullOrWhiteSpace(request.FileName))
return BadRequest(new ApiResponse<ConfirmUploadResponse>
{
Success = false,
Error = "File name is required"
});
var accessLevel = ParseAccessLevel(request.AccessLevel);
var command = new ConfirmUploadCommand(
userId,
request.ObjectKey,
request.FileName,
request.ContentType ?? "application/octet-stream",
request.FileSizeBytes,
accessLevel);
var result = await _mediator.Send(command, cancellationToken);
if (!result.Success)
return BadRequest(new ApiResponse<ConfirmUploadResponse>
{
Success = false,
Error = result.Error
});
return Ok(new ApiResponse<ConfirmUploadResponse>
{
Success = true,
Data = new ConfirmUploadResponse(
result.FileId!.Value,
result.Metadata!)
});
}
private string? GetUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier);
private static FileAccessLevel ParseAccessLevel(string? accessLevel) =>
accessLevel?.ToLowerInvariant() switch
{
"public" => FileAccessLevel.Public,
"shared" => FileAccessLevel.Shared,
_ => FileAccessLevel.Private
};
}
#region Request/Response DTOs
/// <summary>
/// EN: Request to sign upload URL.
/// VI: Request để ký URL upload.
/// </summary>
/// <param name="FileName">EN: Original file name / VI: Tên file gốc</param>
/// <param name="FileSizeBytes">EN: File size in bytes / VI: Kích thước file (bytes)</param>
/// <param name="ContentType">EN: MIME content type / VI: Content type MIME</param>
/// <param name="AccessLevel">EN: Access level: public, private, shared / VI: Mức truy cập: public, private, shared</param>
public record SignUploadRequest(
string FileName,
long FileSizeBytes,
string? ContentType = null,
string? AccessLevel = "private"
);
/// <summary>
/// EN: Response containing pre-signed upload URL.
/// VI: Response chứa pre-signed URL upload.
/// </summary>
/// <param name="UploadUrl">EN: Pre-signed PUT URL / VI: Pre-signed PUT URL</param>
/// <param name="ObjectKey">EN: Object key to use for confirm-upload / VI: Object key để sử dụng cho confirm-upload</param>
/// <param name="ExpiresAt">EN: URL expiration time / VI: Thời gian hết hạn URL</param>
public record SignUploadResponse(
string UploadUrl,
string ObjectKey,
DateTime ExpiresAt
);
/// <summary>
/// EN: Request to confirm upload.
/// VI: Request để xác nhận upload.
/// </summary>
/// <param name="ObjectKey">EN: Object key from sign-upload response / VI: Object key từ response sign-upload</param>
/// <param name="FileName">EN: Original file name / VI: Tên file gốc</param>
/// <param name="FileSizeBytes">EN: File size in bytes / VI: Kích thước file (bytes)</param>
/// <param name="ContentType">EN: MIME content type / VI: Content type MIME</param>
/// <param name="AccessLevel">EN: Access level / VI: Mức truy cập</param>
public record ConfirmUploadRequest(
string ObjectKey,
string FileName,
long FileSizeBytes,
string? ContentType = null,
string? AccessLevel = "private"
);
/// <summary>
/// EN: Response after confirming upload.
/// VI: Response sau khi xác nhận upload.
/// </summary>
/// <param name="FileId">EN: File ID in database / VI: ID file trong database</param>
/// <param name="Metadata">EN: File metadata / VI: Metadata file</param>
public record ConfirmUploadResponse(
Guid FileId,
FileDto Metadata
);
#endregion