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:
105
infra/servers/minio.md
Normal file
105
infra/servers/minio.md
Normal 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
64
infra/servers/mssql.md
Normal 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
47
infra/servers/psql.md
Normal 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
54
infra/servers/redis.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` và `/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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user