Kiến trúc Upload
BẮT BUỘC đọc trước khi viết code liên quan upload, image rendering, file storage, hoặc tích hợp với feature có ảnh / tài liệu.
Upload là supporting service (không phải bounded context riêng như Payment). Mục tiêu: provider-agnostic (S3-compatible bất kỳ — Cloudflare R2, Hetzner Object Storage, Backblaze B2, DigitalOcean Spaces, Scaleway, …), tenant-isolated, secure-by-default, image transform on-the-fly qua imgproxy.
1. Phạm vi
Trong phạm vi (in scope)
- Public images (qua imgproxy resize/format on-the-fly):
- Tenant logo + cover (branding)
- Service image
- Staff/Resource avatar
- Portfolio gallery (before/after photos)
- Private files (presigned GET, không qua imgproxy):
- Onboarding documents (business license, ID — KYC)
- Invoice / payment attachments
- Server-side validation: magic-bytes, MIME, size, dimensions
- Tenant isolation: key prefix
${tenantId}/..., cross-tenant access blocked at service layer - File lifecycle: track uploads in DB, cleanup orphan sau 24h
- Provider portability: chuẩn S3 API qua
@aws-sdk/client-s3, swap provider qua env
Ngoài phạm vi (cho đến có yêu cầu cụ thể)
- Customer avatar custom (dùng Google avatar từ OAuth)
- Video upload (MVP chỉ image + PDF)
- Antivirus scan (defer cho post-MVP, có thể wire ClamAV adapter sau)
- Image moderation (NSFW detect, brand watermark)
- Multi-region replication (provider tự lo nếu enabled)
- CDN cache invalidation API (rely on signed URL TTL)
- File versioning / history
- Direct browser upload qua presigned PUT cho public images (chỉ áp dụng cho private files lớn)
2. Vị trí trong hệ thống
flowchart LR
subgraph C [Client — web / mobile]
FE[Frontend uploads file<br/>multipart/form-data]
IMG[<ProxyImage> component<br/>requests signed imgproxy URL]
end
subgraph API [booking-api]
UC[UploadController<br/>POST /upload<br/>DELETE /upload]
US[UploadService<br/>validate + persist]
SP[StoragePort<br/>abstraction]
IP[ImageProxyService<br/>URL signing]
DB[(UploadedFile<br/>tracking table)]
end
subgraph EXT [External infra]
S3[(S3-compatible storage<br/>R2 / Hetzner / B2 / Spaces)]
Proxy[imgproxy<br/>resize / format / strip EXIF]
end
FE -->|multipart| UC
UC --> US
US --> SP
US --> DB
SP --> S3
IMG -->|signed URL| Proxy
Proxy -.->|fetch private| S3
Integration pattern: Upload là transactional infrastructure — feature module (Tenant, Service, Resource, Portfolio, Invoice, Onboarding) gọi UploadService.upload() hoặc nhận về key từ FE, tự lưu vào field domain (Service.imageUrl, Tenant.logoKey, …). Upload không phát domain events, không listen events. Cleanup orphan files chạy độc lập theo UploadedFile table.
3. Nhật ký quyết định (Decision Log)
| # | Quyết định | Lý do |
|---|---|---|
| D1 | Provider-agnostic S3 SDK (@aws-sdk/client-s3 + @aws-sdk/s3-request-presigner) — không lock vào provider cụ thể |
Code identical cho mọi S3-compatible provider. Swap MinIO → R2 / Hetzner / B2 / Spaces / Scaleway chỉ đổi 5 env (endpoint, region, key, secret, bucket). Không phụ thuộc lib provider-specific (minio package, cloudflare-r2-sdk, …). |
| D2 | imgproxy serve-time resize thay vì backend pipeline (sharp resize lúc upload) |
1 source of truth (original) — không generate thumb/medium/full variants. Storage cost thấp hơn 4×. FE chỉ định size/format qua URL params. Format auto (WebP/AVIF) miễn phí. Thay đổi size strategy không phải re-process backlog. |
| D3 | 1 bucket private duy nhất (booking-assets) với 2 prefix (/images/, /docs/) |
Đơn giản: 1 set credentials, 1 bucket policy, 1 backup target. Phân loại qua prefix + DB metadata. Bucket private hoàn toàn — không public-read; mọi access qua imgproxy (images) hoặc presigned GET (docs). |
| D4 | DB tracking qua UploadedFile table |
Orphan cleanup deterministic (file uploaded > 24h, không reference) — tránh storage bill leak. Audit "ai upload cái gì khi nào". Dễ implement quota per tenant sau này. |
| D5 | Presigned PUT cho file private lớn (onboarding doc, invoice attachment), multipart qua API cho image (≤5 MB) | Image nhỏ → multipart đơn giản, validate magic-bytes server-side trước khi push storage. File private có thể tới 20 MB (PDF KYC) → presigned PUT trực tiếp client → storage, đỡ bottleneck API server. |
| D6 | Magic-bytes validation (file-type package) bắt buộc cho mọi multipart upload |
Content-Type header + extension đều client-controlled, không tin được. Magic-bytes (4-8 byte đầu file) phát hiện file giả mạo (PDF gắn extension .png, polyglot file). |
| D7 | Bỏ SVG khỏi allowlist | SVG cho phép embedded <script> → XSS khi browser render. Beauty cluster MVP không có nhu cầu SVG (logo upload dưới dạng PNG/WebP transparent là đủ). Nếu cần sau: phải sanitize qua DOMPurify-svg ở edge. |
| D8 | Strip EXIF lúc upload (qua sharp rotate-and-strip) |
Privacy: ảnh từ smartphone chứa GPS coordinates → tenant chụp tại salon → leak địa chỉ. Reduce file size 5-15%. Lưu ý: imgproxy đã strip EXIF khi serve, nhưng original trên bucket vẫn có → strip lúc upload là defense-in-depth. |
| D9 | Tenant isolation qua key prefix ${tenantId}/... |
Deterministic, không cần extra column trong storage. DELETE service parse key → reject nếu prefix mismatch tenantId. List/cleanup operations scope qua Prefix=${tenantId}/. |
| D10 | Error codes prefix UPLOAD_* + i18n bắt buộc |
Đồng bộ convention với phần còn lại codebase (xem error-codes.md sau khi tách). Stable codes giúp FE dịch + retry logic. |
| D11 | Bucket private + imgproxy có S3 credentials thay vì bucket public-read | Original không expose. Mọi image serve qua imgproxy URL có signature → tenant không thể bypass resize/transform. Đồng thời chống hot-link abuse. |
| D12 | UploadedFile.referencedBy nullable polymorphic ({type, id} JSON, không FK cứng) |
File có thể reference từ Service / Resource / Tenant / TenantOnboardingDoc / PortfolioItem / PaymentAttachment. FK cứng tới mỗi table sẽ làm schema rối. Nullable JSON đủ cho cleanup logic, không cần JOIN. |
4. Kiến trúc layered
flowchart TB
subgraph IL [Interface Layer — HTTP / NestJS]
UC[UploadController<br/>POST /upload — multipart<br/>POST /upload/presigned-put<br/>GET /upload/presigned-get/:id<br/>DELETE /upload/:id]
IPC[ImageProxyController<br/>GET /image-proxy/sign?key=...&w=...&h=...]
end
subgraph SL [Service Layer]
US[UploadService<br/>validate + persist + track]
IPS[ImageProxyService<br/>HMAC-SHA256 URL sign]
OW[OrphanCleanupWorker<br/>cron 1h]
end
subgraph DOM [Domain Layer — pure]
Port[StoragePort<br/>interface]
Errors[UploadDomainError<br/>typed error codes]
Validators[FileValidator<br/>magic-bytes + size + dim]
end
subgraph INF [Infrastructure Layer — adapters]
S3A[S3StorageAdapter<br/>@aws-sdk/client-s3]
Sharp[SharpProcessor<br/>EXIF strip + rotate]
Repo[Prisma UploadedFileRepo]
end
IL --> SL
SL --> DOM
INF -.->|implements| Port
SL --> INF
Dependency rule: StoragePort là interface trong domain layer. S3StorageAdapter infrastructure implements port. Service depend on port abstract → swap adapter (mock cho test, S3 thật cho prod) zero refactor.
5. Storage Port (Hexagonal boundary)
// src/core/upload/domain/ports/storage.port.ts
export const STORAGE_PORT = Symbol('STORAGE_PORT');
export interface StoragePort {
/** Upload buffer trực tiếp (multipart route). */
putObject(input: PutObjectInput): Promise<PutObjectResult>;
/** Lấy presigned URL để client PUT trực tiếp lên storage (file lớn / private). */
getPresignedPutUrl(input: PresignedPutInput): Promise<PresignedPutResult>;
/** Lấy presigned URL để client GET file private (bypass imgproxy). */
getPresignedGetUrl(input: PresignedGetInput): Promise<PresignedGetResult>;
/** Xóa object — caller phải verify tenant ownership trước. */
deleteObject(key: string): Promise<void>;
/** HEAD — kiểm tra tồn tại + metadata (dùng cho post-presigned-PUT verify). */
headObject(key: string): Promise<HeadObjectResult | null>;
}
export interface PutObjectInput {
key: string;
body: Buffer;
contentType: string;
contentLength: number;
metadata?: Record<string, string>; // strip-able x-amz-meta-*
}
export interface PresignedPutInput {
key: string;
contentType: string;
contentLength: number; // enforce server-side via Content-Length-Range condition
expiresInSeconds: number; // mặc định 300 (5 phút)
}
export interface PresignedGetInput {
key: string;
expiresInSeconds: number; // mặc định 3600 (1 giờ)
contentDisposition?: string; // 'attachment; filename="..."' cho download
}
Adapter responsibilities
S3StorageAdapter (chỉ adapter MVP):
- SDK config:
S3Clientvớiendpoint,region,credentials,forcePathStyle: true(R2 yêu cầu false, MinIO yêu cầu true — đọc từSTORAGE_FORCE_PATH_STYLEenv). - Error translation: AWS SDK error →
UploadDomainError(ví dụNoSuchKey→UPLOAD_NOT_FOUND). - Retry: SDK built-in (3 retries, exponential backoff cho 5xx + throttling).
- Streaming:
putObjectbody buffer cho file ≤ 5 MB; future multipart upload cho file lớn (post-MVP).
6. ImageProxy Integration
6.1 Tại sao imgproxy
- On-the-fly resize/format/quality dựa theo URL params → 1 source of truth (original)
- Format
auto(WebP/AVIF tuỳAcceptheader) → bandwidth giảm 25-40% - Strip EXIF khi serve → privacy
- Smart crop (extend) cho avatar / cover ratio
- Built-in HMAC URL signing → chống abuse (random URL không serve được)
- Built-in S3 source mode (đọc trực tiếp từ private bucket với credentials)
6.2 Triển khai
Self-host trong Docker (docker-compose.yml):
imgproxy:
image: ghcr.io/imgproxy/imgproxy:latest
container_name: booking-imgproxy
restart: unless-stopped
environment:
IMGPROXY_KEY: ${IMGPROXY_KEY} # 32-byte hex
IMGPROXY_SALT: ${IMGPROXY_SALT} # 32-byte hex
IMGPROXY_S3_ENDPOINT: ${STORAGE_ENDPOINT} # cùng env với UploadService
IMGPROXY_USE_S3: "true"
AWS_ACCESS_KEY_ID: ${STORAGE_ACCESS_KEY}
AWS_SECRET_ACCESS_KEY: ${STORAGE_SECRET_KEY}
AWS_REGION: ${STORAGE_REGION}
IMGPROXY_ALLOWED_SOURCES: "s3://${STORAGE_BUCKET}/" # whitelist source
IMGPROXY_MAX_SRC_RESOLUTION: "50" # 50 megapixels max
IMGPROXY_MAX_SRC_FILE_SIZE: 10485760 # 10 MB max
IMGPROXY_STRIP_METADATA: "true"
IMGPROXY_AUTO_ROTATE: "true"
IMGPROXY_ENABLE_AVIF_DETECTION: "true"
IMGPROXY_ENABLE_WEBP_DETECTION: "true"
IMGPROXY_ENFORCE_THUMBNAIL: "true"
ports:
- "8080:8080"
depends_on:
- postgres # imgproxy không phụ thuộc DB nhưng đảm bảo network up
Production: deploy riêng container behind Nginx (TLS termination + cache headers).
6.3 URL signing
Server-side (ImageProxyService trong booking-api):
// src/core/upload/services/image-proxy.service.ts
@Injectable()
export class ImageProxyService {
private readonly key: Buffer;
private readonly salt: Buffer;
private readonly publicUrl: string; // https://images.<domain>
signUrl(input: SignImageUrlInput): string {
// input: { key, width, height, resizeType: 'fit'|'fill'|'crop', quality?, gravity? }
const path = this.buildProcessingOptionsPath(input); // /rs:fill:400:400:0/g:sm/q:85/plain/s3://booking-assets/<key>@webp
const signature = createHmac('sha256', this.key)
.update(this.salt)
.update(path)
.digest('base64url')
.substring(0, 32);
return `${this.publicUrl}/${signature}${path}`;
}
}
Client-side (<ProxyImage> React component trong booking-web):
// src/components/ui/image/ProxyImage.tsx
type Props = {
storageKey: string; // e.g., "tenant-abc/abc123.jpg"
width: number; // CSS pixels — component nhân với devicePixelRatio
height?: number;
resize?: 'fit' | 'fill';
alt: string;
className?: string;
};
export function ProxyImage({ storageKey, width, height, resize = 'fill', alt, className }: Props) {
const url = useImageProxyUrl({ storageKey, width, height, resize }); // hook gọi API ký URL
return <img src={url} alt={alt} className={className} loading="lazy" />;
}
Tradeoff: client phải gọi API ký URL (1 round-trip extra). Mitigation: cache signed URL trong React Query với key ['image-proxy', storageKey, width, height, resize], TTL 1h. Hoặc compute signature client-side (sau khi expose IMGPROXY_KEY qua trusted endpoint — rủi ro hơn, defer).
6.4 Path format
{publicUrl}/{signature}/{processing_options}/plain/s3://{bucket}/{key}@{format}
Example:
https://images.booking.no/abc123signature/rs:fill:400:400:0/g:sm/q:85/plain/s3://booking-assets/tenant-xyz/logo.jpg@webp
Processing options reference: https://docs.imgproxy.net/usage/processing
7. Persistence Schema (Prisma)
enum UploadPurpose {
TENANT_LOGO
TENANT_COVER
SERVICE_IMAGE
RESOURCE_AVATAR
PORTFOLIO_PHOTO
ONBOARDING_DOC
PAYMENT_ATTACHMENT
}
enum UploadVisibility {
PUBLIC // serve qua imgproxy
PRIVATE // serve qua presigned GET
}
model UploadedFile {
id String @id @default(uuid(7))
tenantId String @map("tenant_id")
key String @unique // {tenantId}/{uuid}.{ext}
purpose UploadPurpose
visibility UploadVisibility
mimeType String @map("mime_type")
sizeBytes Int @map("size_bytes")
originalName String? @map("original_name") // sanitized, optional
width Int?
height Int?
checksumSha256 String? @map("checksum_sha256") // optional integrity check
uploadedBy String @map("uploaded_by") // userId
referencedBy Json? @map("referenced_by") // { type: 'Service', id: '...' } | null
createdAt DateTime @default(now()) @map("created_at")
referencedAt DateTime? @map("referenced_at") // null = orphan candidate
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
uploader User @relation(fields: [uploadedBy], references: [id])
@@index([tenantId, purpose, createdAt])
@@index([referencedAt, createdAt]) // orphan cleanup query
@@index([tenantId, visibility])
@@map("uploaded_files")
}
Notes on schema
keyunique: race-safe khi 2 upload concurrent (rất hiếm — UUID v7 collision ~0)referencedAt: set khi feature module commit reference (Service.imageKey = file.key). Cleanup queryWHERE referencedAt IS NULL AND createdAt < now() - 24h.referencedByJSON polymorphic: không cần FK cứng tới mỗi table. Service / Resource / Tenant / Onboarding updateUploadedFile.referencedByquaUploadService.linkReference(fileId, type, id).checksumSha256: optional MVP, useful cho post-presigned-PUT verify (FE compute + send, BE compare với S3 ETag).- Cascade on tenant delete: tenant bị xoá → file rows xoá → cleanup worker dọn S3 objects theo prefix
${tenantId}/.
8. File Lifecycle
sequenceDiagram
participant FE as Frontend
participant API as UploadController
participant US as UploadService
participant S3 as Storage
participant DB as UploadedFile
participant Feat as Feature module<br/>(e.g., ServiceController)
Note over FE,DB: Multipart upload (image ≤5 MB)
FE->>API: POST /upload (multipart, purpose=SERVICE_IMAGE)
API->>US: upload(tenantId, userId, file, purpose)
US->>US: validate (magic-bytes, size, dim)
US->>US: sharp.rotate().withMetadata({ exif: {} })
US->>S3: putObject(key, processedBuffer)
US->>DB: INSERT UploadedFile (referencedAt=null)
US-->>API: { id, key, sizeBytes, width, height }
API-->>FE: { id, key, sizeBytes, width, height }
Note over FE,Feat: Reference link (atomic with feature update)
FE->>Feat: PATCH /services/:id { imageKey: key }
Feat->>DB: UPDATE Service SET image_key = ?
Feat->>US: linkReference(fileId, 'Service', serviceId)
US->>DB: UPDATE UploadedFile SET referenced_by, referenced_at = NOW()
Note over DB,S3: Orphan cleanup (cron 1h)
DB->>DB: SELECT WHERE referenced_at IS NULL AND created_at < NOW()-24h
DB->>S3: deleteObject(key)
DB->>DB: DELETE UploadedFile
Reference contract (feature modules)
Feature module update field image PHẢI call UploadService.linkReference() trong cùng transaction để ngăn orphan:
// CORRECT
await this.prisma.$transaction(async (tx) => {
const service = await tx.service.update({ where: { id }, data: { imageKey: dto.imageKey } });
await this.uploadService.linkReference(dto.imageKey, 'Service', service.id, tx);
});
// WRONG — race window: nếu update Service fail sau khi linkReference, file vẫn track là referenced
await this.uploadService.linkReference(dto.imageKey, 'Service', id);
await this.prisma.service.update({ where: { id }, data: { imageKey: dto.imageKey } });
Unlink contract (xoá / thay file)
Feature module xoá hoặc thay image PHẢI call UploadService.unlinkReference(oldKey) để file cũ trở lại orphan candidate:
// service.service.ts
async update(id: string, dto: UpdateServiceDto) {
const service = await this.prisma.service.findUnique({ where: { id } });
await this.prisma.$transaction(async (tx) => {
await tx.service.update({ where: { id }, data: { imageKey: dto.imageKey } });
if (service.imageKey && service.imageKey !== dto.imageKey) {
await this.uploadService.unlinkReference(service.imageKey, tx);
}
if (dto.imageKey) {
await this.uploadService.linkReference(dto.imageKey, 'Service', id, tx);
}
});
}
9. Use Case Matrix (MVP)
| Use case | Field domain | Visibility | Max size | Allowed MIME | imgproxy preset |
|---|---|---|---|---|---|
| Tenant logo | Tenant.logoKey |
PUBLIC | 2 MB | png, webp, jpeg | rs:fit:200:200/q:90 |
| Tenant cover | Tenant.coverKey |
PUBLIC | 5 MB | png, webp, jpeg | rs:fill:1920:480/g:sm/q:85 |
| Service image | Service.imageKey |
PUBLIC | 3 MB | png, webp, jpeg | rs:fill:600:400/q:85 |
| Resource avatar | Resource.avatarKey |
PUBLIC | 2 MB | png, webp, jpeg | rs:fill:200:200/g:sm/q:90 |
| Portfolio photo | PortfolioItem.imageKey |
PUBLIC | 5 MB | png, webp, jpeg | rs:fit:1200:1200/q:85 |
| Onboarding doc | TenantOnboardingDoc.fileKey |
PRIVATE | 10 MB | pdf, png, jpeg | n/a (presigned GET) |
| Payment attachment | PaymentAttachment.fileKey |
PRIVATE | 10 MB | pdf, png, jpeg | n/a (presigned GET) |
Field naming: dùng *Key (storage key) thay vì *Url để tránh hardcode URL. URL được compute lúc render qua <ProxyImage> (public) hoặc presigned GET endpoint (private).
Migration note: Service.imageUrl (đã có) sẽ rename → Service.imageKey trong Phase 4. Migration: parse URL hiện tại extract key, populate imageKey, drop imageUrl.
10. Security Model
10.1 Tenant Isolation
- Key prefix mandatory: mọi key có dạng
{tenantId}/{uuid}.{ext}.UploadServiceenforce ở 3 chỗ:upload()generate key với tenantId từ request contextdelete()parse key → reject nếu prefix !== current tenantIdlinkReference()parse key → reject nếu prefix !== fileId.tenantId từ DB
- Repository contract: mọi
find*query trongUploadedFileRepositoryrequiretenantIdargument (đồng bộ với memory rule "Tenant Scope Mandatory") - Cross-tenant test: e2e test bắt buộc cho từng endpoint (DELETE, link, unlink, presigned GET)
10.2 File Validation
| Check | Tool | Khi nào |
|---|---|---|
MIME from Content-Type header |
NestJS Multer | Reject sớm |
| Magic-bytes | file-type package |
Sau khi nhận buffer, trước putObject |
| Size limit per purpose | UploadPurposeConfig |
Reject ở Multer + double-check trong service |
| Image dimensions (max) | sharp metadata |
Sau magic-bytes check |
| Filename sanitize | path.basename + regex /[^a-zA-Z0-9._-]/ |
Lưu vào originalName (display only, key dùng UUID) |
Allowed MIME (final): image/jpeg, image/png, image/webp, application/pdf. Loại bỏ: image/svg+xml (XSS), image/gif (animation abuse — GIF bomb), image/tiff (libtiff CVE history).
10.3 Presigned URL
PUT (upload private file lớn):
// FE flow
1. POST /upload/presigned-put { purpose: 'ONBOARDING_DOC', mimeType: 'application/pdf', sizeBytes: 5242880 }
2. BE: validate purpose visibility, mimeType allowlist, sizeBytes ≤ 10MB
3. BE: tạo UploadedFile row (status=PENDING_UPLOAD), generate key
4. BE: getPresignedPutUrl({ key, contentType, contentLength, expiresInSeconds: 300 })
5. BE return { uploadUrl, fileId, key }
6. FE: PUT file trực tiếp tới uploadUrl với Content-Type + Content-Length headers
7. FE: POST /upload/confirm/:fileId ← BE call headObject(key) verify, set status=ACTIVE
8. FE: thực hiện linkReference qua feature module
Tradeoff: 3 round-trip thay vì 1, nhưng file 10MB không qua API server → giảm bottleneck. Nếu FE bỏ confirm step, orphan cleanup dọn sau 24h.
GET (download private file):
GET /upload/presigned-get/:fileId
1. BE: load UploadedFile → verify tenantId match request
2. BE: verify role permission per purpose (OWNER+ADMIN cho ONBOARDING_DOC; OWNER+STAFF cho PAYMENT_ATTACHMENT)
3. BE: getPresignedGetUrl({ key, expiresInSeconds: 3600, contentDisposition: 'attachment; filename="..."' })
4. BE return { downloadUrl }
5. FE: redirect / window.open
10.4 Rate Limiting
| Endpoint | Limit | Lý do |
|---|---|---|
POST /upload (multipart) |
30 req/min/user | Avatar/logo upload không cần burst |
POST /upload/presigned-put |
20 req/min/user | Onboarding KYC ít file |
GET /upload/presigned-get/:id |
60 req/min/user | Download view, có thể burst |
DELETE /upload/:id |
20 req/min/user | Cleanup ít |
GET /image-proxy/sign |
600 req/min/user | List page có nhiều ảnh — cao hơn |
Implement qua @nestjs/throttler (đã có trong project).
10.5 Authorization
| Action | OWNER | STAFF | ADMIN | Customer |
|---|---|---|---|---|
| Upload PUBLIC image | ✓ | ✓ | ✓ | ✗ |
| Upload PRIVATE doc | ✓ | ✗ | ✓ | ✗ |
| Delete file đã reference | ✓ | ✗ | ✓ | ✗ |
| Delete orphan file | ✓ | ✓ (own upload) | ✓ | ✗ |
| Get presigned GET (own tenant) | ✓ | ✓ (per-purpose) | ✓ | ✗ |
| imgproxy URL sign (public) | ✓ | ✓ | ✓ | ✓ |
Customer không có quyền upload trong MVP (Customer avatar đã quyết skip — dùng Google avatar).
10.6 EXIF Strip & Image Sanitize
sharp pipeline trước khi push S3:
const processed = await sharp(file.buffer)
.rotate() // auto-orient theo EXIF orientation tag
.withMetadata({ exif: {} }) // strip GPS + camera info, giữ orientation
.toBuffer();
PDF: không strip metadata (có thể chứa thông tin pháp lý quan trọng), chỉ validate magic-bytes (%PDF-).
11. Env Contract
# Storage (S3-compatible)
# Provider-agnostic: hoạt động với Cloudflare R2, Hetzner Object Storage,
# Backblaze B2, DigitalOcean Spaces, Scaleway, AWS S3, … qua endpoint config.
STORAGE_ENDPOINT=https://<account>.r2.cloudflarestorage.com
STORAGE_REGION=auto # R2="auto", Hetzner="eu-central", Spaces="fra1"
STORAGE_ACCESS_KEY=
STORAGE_SECRET_KEY=
STORAGE_BUCKET=booking-assets
STORAGE_FORCE_PATH_STYLE=false # true cho MinIO/Garage; false cho R2/Spaces/B2
STORAGE_PUBLIC_BASE= # optional: CDN URL (chỉ dùng nếu bypass imgproxy)
# ImageProxy
IMGPROXY_PUBLIC_URL=https://images.<domain>
IMGPROXY_KEY= # 32-byte hex (64 chars) — generate: openssl rand -hex 32
IMGPROXY_SALT= # 32-byte hex (64 chars)
# Limits (override defaults if needed)
UPLOAD_MAX_IMAGE_SIZE_BYTES=5242880 # 5 MB
UPLOAD_MAX_DOC_SIZE_BYTES=10485760 # 10 MB
UPLOAD_PRESIGNED_PUT_TTL_SECONDS=300
UPLOAD_PRESIGNED_GET_TTL_SECONDS=3600
UPLOAD_ORPHAN_CLEANUP_AGE_HOURS=24
Migration note: env cũ (MINIO_ENDPOINT, MINIO_PORT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY) bị deprecate ở Phase 1, xoá ở Phase 6.
12. Error Codes
Tất cả error codes prefix UPLOAD_*, throw qua UploadDomainError (extends BadRequestException / ForbiddenException / NotFoundException tùy semantic):
| Code | HTTP | Khi nào |
|---|---|---|
UPLOAD_INVALID_TYPE |
400 | MIME hoặc magic-bytes không khớp allowlist |
UPLOAD_TYPE_MISMATCH |
400 | Magic-bytes ≠ Content-Type header (giả mạo) |
UPLOAD_TOO_LARGE |
413 | Vượt size limit của purpose |
UPLOAD_DIMENSIONS_TOO_LARGE |
400 | Width × height > 50 megapixels |
UPLOAD_TOO_SMALL |
400 | File < 100 byte (suspicious empty/corrupted) |
UPLOAD_NOT_FOUND |
404 | fileId / key không tồn tại |
UPLOAD_TENANT_MISMATCH |
403 | Key prefix không khớp tenantId của caller (cross-tenant attempt) |
UPLOAD_PURPOSE_FORBIDDEN |
403 | Role không có quyền upload purpose này |
UPLOAD_ALREADY_REFERENCED |
409 | File đã link tới feature khác (delete/relink phải unlink trước) |
UPLOAD_PROVIDER_ERROR |
502 | S3 provider lỗi (5xx, timeout, throttling sau retry) |
UPLOAD_INTEGRITY_FAILED |
422 | Checksum SHA-256 mismatch sau presigned PUT |
UPLOAD_QUOTA_EXCEEDED |
429 | Tenant vượt quota tổng (defer Phase 7 — chưa enforce MVP) |
i18n: messages dịch trong booking-web/messages/{nb,en,vi}.json namespace errors.upload.*.
13. Observability
Logs (structured JSON)
Mọi upload action log:
{
"ts": "...",
"level": "info",
"context": "UploadService",
"tenantId": "tenant-abc",
"userId": "user-xyz",
"action": "upload",
"purpose": "SERVICE_IMAGE",
"fileId": "...",
"sizeBytes": 245678,
"mimeType": "image/jpeg",
"durationMs": 142
}
Errors log full + reason code (KHÔNG log file content / buffer).
Metrics (Prometheus-style)
upload_total{tenant_id, purpose, result} # counter
upload_size_bytes{purpose} # histogram
upload_duration_seconds{purpose, phase} # histogram (validate / process / s3_put / db_write)
upload_orphan_cleanup_total{result} # counter
upload_storage_quota_bytes{tenant_id} # gauge (defer)
imgproxy_sign_total{tenant_id} # counter
Alerts
upload_total{result="error"}rate > 5% → page on-callupload_orphan_cleanup_total{result="error"}> 0 trong 1h → warn- S3 provider 5xx rate > 1% → warn (provider degraded)
14. Roadmap (Phases)
| Phase | Scope | Phụ thuộc | Acceptance |
|---|---|---|---|
| 1 | Harden core: swap minio → @aws-sdk/client-s3, StoragePort abstraction, tenant DELETE fix, magic-bytes, bỏ SVG, error codes, presigned helper methods |
Provider credentials sẵn sàng | Cross-tenant attack test pass; magic-bytes test pass; tất cả env mới có trong .env.example; existing endpoint backward-compatible |
| 2 | imgproxy integration: container, ImageProxyService URL sign, <ProxyImage> FE component, replace <img> calls in admin pages |
Phase 1 done; IMGPROXY_KEY/SALT env |
Logo + cover + service + avatar render qua imgproxy; signed URL TTL hoạt động; format auto-detect WebP/AVIF |
| 3 | DB tracking: UploadedFile schema + migration, linkReference / unlinkReference API, orphan cleanup cron |
Phase 1 done | Cron xoá orphan files > 24h; tenant cascade delete xoá objects S3 |
| 4 | ✅ All 6 use cases shipped: Tenant logo/cover (4.1), Service image + rename imageUrl → imageKey (4.2 + 4.3), Resource avatar (4.1), Portfolio gallery (4.4 — model mới + drag-reorder + public lightbox), Onboarding docs (4.5 — private files + 3-step presigned PUT + admin verify endpoint), Payment attachments (4.6 — reuses 4.5 helpers, role-tiered ACL) |
Phase 2 + 3 done | ✅ Mỗi use case có CRUD + tenant isolation tests; FE form dùng <ImageUploader> / <DocumentsSection> / <PaymentAttachmentsSection> thống nhất |
| 5 | Mobile (Expo): expo-image-picker + useImageUpload hook + <ProxyImage> RN port |
Phase 2 + 4 done | Owner/Staff app upload avatar + portfolio thành công; iOS/Android tested |
| 6 | Production deploy: Nginx reverse proxy /imgproxy/, TLS, CDN cache headers, backup mc mirror cron, swap MinIO → cloud provider, deprecation cleanup (xoá MINIO_* env, minio package) |
All previous phases | Production checklist hoàn thành; backup verified với restore test; provider failover playbook documented |
15. Extension Points
15.1 Thêm provider mới (S3 thuần — zero code change)
Đổi 5 env: STORAGE_ENDPOINT, STORAGE_REGION, STORAGE_ACCESS_KEY, STORAGE_SECRET_KEY, STORAGE_FORCE_PATH_STYLE. Test với provider mới qua e2e suite. Done.
15.2 Thêm provider non-S3 (Azure Blob, GCS native API)
Implement StoragePort adapter mới (AzureBlobStorageAdapter, GcsStorageAdapter). Register trong DI module qua factory provider chọn theo STORAGE_DRIVER env. Domain layer + service không sửa.
15.3 Thêm purpose mới
- Thêm enum value vào
UploadPurpose(Prisma migration) - Thêm row vào use case matrix §9 với size limit + MIME allowlist + visibility + imgproxy preset
- Thêm role authorization vào §10.5
- Wire feature module gọi
UploadService.upload()+linkReference()
15.4 Antivirus scan (post-MVP)
Wire ClamAVAdapter implements ScannerPort. UploadService.upload() gọi scanner.scan(buffer) sau magic-bytes, trước putObject. Nếu infected → reject + log + (optional) notify admin.
15.5 Image moderation (NSFW detect, brand watermark)
Wire ModerationPort gọi external service (AWS Rekognition, Sightengine). Async job sau upload — mark file quarantined=true nếu flagged → hide trong FE rendering.
15.6 Per-tenant storage quota
Thêm Tenant.storageQuotaBytes + Tenant.storageUsedBytes. UploadService.upload() check quota trước, error UPLOAD_QUOTA_EXCEEDED. Update storageUsedBytes trong cùng transaction. Cleanup worker decrement khi delete.
16. Tham chiếu
docs/architecture/payment-architecture.md— DDD + Hexagonal pattern referencedocs/architecture/api-design.md— API conventions, response envelope, error formatdocs/rules/development-rules.md— Git, testing, ESLint, PR rules- imgproxy docs — processing options, signing, S3 source mode
- AWS SDK v3 S3 Client — official reference
- S3 API compatibility matrix — providers comparison