architecture/upload-architecture.md

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):

  1. SDK config: S3Client với endpoint, region, credentials, forcePathStyle: true (R2 yêu cầu false, MinIO yêu cầu true — đọc từ STORAGE_FORCE_PATH_STYLE env).
  2. Error translation: AWS SDK error → UploadDomainError (ví dụ NoSuchKeyUPLOAD_NOT_FOUND).
  3. Retry: SDK built-in (3 retries, exponential backoff cho 5xx + throttling).
  4. Streaming: putObject body 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ỳ Accept header) → 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

  • key unique: 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 query WHERE referencedAt IS NULL AND createdAt < now() - 24h.
  • referencedBy JSON polymorphic: không cần FK cứng tới mỗi table. Service / Resource / Tenant / Onboarding update UploadedFile.referencedBy qua UploadService.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 } });

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}. UploadService enforce ở 3 chỗ:
    1. upload() generate key với tenantId từ request context
    2. delete() parse key → reject nếu prefix !== current tenantId
    3. linkReference() parse key → reject nếu prefix !== fileId.tenantId từ DB
  • Repository contract: mọi find* query trong UploadedFileRepository require tenantId argument (đồ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-call
  • upload_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 imageUrlimageKey (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

  1. Thêm enum value vào UploadPurpose (Prisma migration)
  2. Thêm row vào use case matrix §9 với size limit + MIME allowlist + visibility + imgproxy preset
  3. Thêm role authorization vào §10.5
  4. 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