Chuyển đến nội dung

API file tải lên

Đường dẫn import: @sdcorejs/nestjs/features

Feature uploaded-file lưu metadata trong PostgreSQL và byte trên ổ đĩa local hoặc S3. Mỗi thao tác thông thường phân giải scope tenant/owner tin cậy, áp dụng authorization policy và dùng lỗi không tiết lộ thông tin định danh cho resource không hợp lệ, bị thiếu, bị từ chối hoặc khác scope.

Các export

ExportLoạiMục đích
UploadedFile<TExtraData>entity classMetadata cùng trạng thái upload/deletion bền vững
UploadedFileServiceclassAPI upload, lookup, download, attach và deletion có cấp quyền
UploadedFileModuleclassModule feature global; forRoot(config)
UploadedFileControllerclassBề mặt HTTP upload/download có xác thực, tùy chọn
UploadedFileConfiginterfaceCấu hình driver, validation, scope, authorization và cleanup
UploadedFileContexttypeRequest context tin cậy được các thao tác service chấp nhận
UploadedFileOperationtypegồm create, read, complete, abort và thao tác legacy
UploadedFileAccessDecisiontype'owner' | 'tenant' | 'deny'
UploadedFileScopeinterfaceTenant, department tùy chọn và owner ID
UploadedFileAuthorizationRequestinterfaceĐầu vào policy với context tin cậy và resource có giới hạn
UploadedFileAuthorizationPolicytypeCallback decision sync/async có giới hạn
UploadedFileAttachmentinterfaceOwnership attachment chính xác theo module/entity/entityId
UploadedFileAttachedReadRequestinterfaceScope tin cậy và attachment cho thao tác đọc
UploadedFileAttachedReadPolicytypeCallback đọc attachment mặc định từ chối
UploadedFileMetainterfaceNguồn gốc module/entity/entityId/type tùy chọn
UploadedFileUploadOptionsinterfaceMIME khai báo và giới hạn validation theo lời gọi
UploadedFileTemporaryUploadOptionstypeOption temporary managed; cấm visibility/TTL
InitiateUploadedFileInputinterfaceTên gốc, content type khai báo, size chính xác, checksum
InitiateUploadedFileResultinterfaceUpload instruction một object, trung lập provider
UploadedFileResultinterfaceKết quả service có URL; giữ key/CDN legacy ở lớp internal
UploadedFileHttpResulttypeKết quả HTTP đã loại keycdn persist
UploadedFileVisibilitytype'public' | 'private'
UploadedFilePublicAccessModetypeChính sách object public 'object-acl' | 'external'
UploadedFileStatustype'pending' | 'completing' | 'ready' | 'failed'
UploadedFileDispositiontype'inline' | 'attachment'
UploadedFileDirectLifecycle1785744000000migration classMigration PostgreSQL additive cho lifecycle
UploadedFileRemoteCloneConfigtypeĐiều khiển bật/timeout/kích thước/redirect/host
UPLOADED_FILE_BATCH_LIMITvalue100 ID/reference mỗi batch công khai
UPLOADED_FILE_REFERENCE_MAX_LENGTHvalue1024 ký tự cho mỗi reference chính xác
UPLOADED_FILE_CONFIGvalueDI token cấu hình feature đã phân giải

Raw storage driver, helper storage-key và provider bảo trì chủ ý không được export, nhờ đó mã ứng dụng không thể bypass authorization của service bằng object key tùy ý.

Đăng ký entity và thiết lập module

ts
TypeOrmModule.forRoot({
  type: 'postgres',
  // ...
  entities: [UploadedFile],
});

UploadedFileModule.forRoot({
  driver: 'local',
  localRoot: './var/uploads',
  host: 'https://api.example.com',
  cleanupAfterDays: 7,
  resolveScope: (ctx) => ({
    tenantCode: ctx.tenant,
    departmentCode: typeof ctx.custom?.departmentCode === 'string' ? ctx.custom.departmentCode : undefined,
    userId: ctx.userId,
  }),
  authorizationPolicy: ({ operation, context }) => {
    if (operation === 'read' && context.roles?.includes('tenant-file-reader')) return 'tenant';
    return 'owner';
  },
  attachedReadPolicy: ({ context, attachment }) =>
    attachment.module === 'cms' &&
    attachment.entity === 'asset' &&
    context.permissions?.includes('cms.asset.view'),
});

Thêm UploadedFile vào datasource. Entity dùng UUID, jsonb, timestamptz, cột soft-delete và có key/cdn unique. Direct lifecycle thêm sizeBytes chính xác, contentType, pendingKey, visibility, status, isTemporary, timestamp upload/complete/expire, disposition, checksum, etag và index cho pending/temporary/owner-status.

Cấu hình

OptionMặc định / constraint
driverLocal trừ khi cặp accessId + accessKey đầy đủ, rõ ràng tự chọn S3; 's3' rõ ràng dùng chuỗi credential AWS
accessId, accessKeyTùy chọn nhưng phải được cung cấp cùng nhau và không rỗng
regionChuỗi provider AWS SDK khi bỏ qua
endpointS3-compatible origin tùy chọn, ví dụ Spaces endpoint
forcePathStylefalse; dành cho compatible origin yêu cầu path-style addressing
bucketBắt buộc và không rỗng với S3
folder'core'; được chuẩn hóa, từ chối segment traversal
localRoot<cwd>/upload; đường dẫn tuyệt đối chuẩn
hostRỗng; base cho URL private có xác thực
cdnBaseUrlRỗng; base public read ổn định, không bao giờ là presigned PUT origin
defaultVisibility'private'; mặc định cho managed internal upload
allowPublicUploadsfalse; bắt buộc trước khi internal caller yêu cầu public
publicAccessMode'external'; 'object-acl' gửi public-read, external không gửi ACL
publicFilesInput tương thích đã deprecated; true chuẩn hóa permanent, không áp dụng temporary
uploadUrlTtlSeconds600; số nguyên dương, tối đa một giờ
privateDownloadUrlTtlSeconds900; không vượt maximum cấu hình/trần tuyệt đối một giờ
maxPrivateDownloadUrlTtlSeconds3600; không thể vượt một giờ
pendingCleanupInterval'0 3 * * *'; sweep direct pending/staging/outbox/legacy age
temporaryCleanupInterval'*/15 * * * *'; sweep temporary ready đã hết hạn
cleanupBatchSize100; số nguyên dương, tối đa 100
downloadPath'uploaded-file'
maxFileSizeBytesMặc định 10 MiB; giới hạn theo trần tuyệt đối 25 MiB
allowedMimeTypesJSON, PDF, ZIP, DOCX, XLSX, PPTX, GIF, JPEG, PNG, WebP, CSV và plain text
validateMagicBytestrue
remoteCloneTắt; mặc định 5s, tối đa bằng service size, 3 redirect (tối đa tuyệt đối 5)
resolveScopeFallback về ctx.tenant, ctx.userId, rồi các trường ctx.custom tương ứng
authorizationPolicyChỉ owner khi vắng mặt
attachedReadPolicyTừ chối đọc attachment khi vắng mặt; chỉ giá trị true chính xác mới cho phép
cleanupAfterDaysChỉ age purge row legacy chưa dùng; không điều khiển TTL temporary cố định 24 giờ

allowedHosts từ xa là allowlist hostname chính xác tùy chọn. Khi bật clone, mọi URL và redirect đều được validation lại, DNS phải phân giải thành địa chỉ public, kết nối được pin vào địa chỉ đã validation, response size bị giới hạn và byte tải xuống vẫn phải qua validation upload thông thường.

Authorization policy

ts
type UploadedFileAccessDecision = 'owner' | 'tenant' | 'deny';

Service luôn thêm tenantCode; khi scope chứa departmentCode, nó cũng thêm predicate đó. Decision owner bổ sung userId; tenant chỉ bỏ predicate owner và không thể bỏ boundary tenant/department. Exception policy và decision không xác định trở thành deny.

Policy nhận UUID resource đã validation và reference storage chính xác, không bao giờ nhận raw SQL. ID và reference được giới hạn trước khi callback chạy.

Các method service

Thành viênSignature / kết quả
getContent(fileName?) => { ContentType?, ContentDisposition? }; ánh xạ extension allowlist thuần
upload<T> legacy(buffer, fileName?, meta?, extraData?, options?) => Promise<UploadedFile<T>>
upload managed(source, originalName, context?, ownerId?, options?) => Promise<UploadedFileResult>
uploadTemporary legacy(buffer, fileName?, options?) => Promise<{ key, cdn }>
uploadTemporary managed(source, originalName, context?, ownerId?, options?) => Promise<UploadedFileResult>
initiateUpload / initiateTemporaryUpload(input) => Promise<InitiateUploadedFileResult>
completeUpload / abortUpload(id) => Promise<UploadedFileResult | void>
resolveUrl(id) => Promise<{ url, urlExpiredAt }>
find(id) => Promise<UploadedFileResult>
deleteById(id) => Promise<void>; abort pending hoặc delete ready qua durable cleanup
putUploadContent(id, buffer) => Promise<UploadedFileResult>; chỉ local target đã xác thực
cloneFromUrl<T>(url, fileName?, meta?, extraData?) => Promise<UploadedFile<T>>
download(id) => Promise<{ stream, fileName }>
downloadAttached(id, attachment) => Promise<{ stream, fileName }>; đọc attachment chính xác đã được duyệt
findById<T>(id) => Promise<UploadedFile<T>>
setExtraData<T>(id, extraData: Partial<T>) => Promise<void>
markUsed(ids, meta?, manager?) => Promise<void>; transaction của caller là tùy chọn
useFiles(references, entity?, entityId?) => Promise<void>
delete(references) => Promise<void>; xóa object bền vững
changeFiles(olds, news, entity?, entityId?) => Promise<void>
unsafeSystemRetryPendingDeletions(limit = 100) => Promise<number>; chỉ bảo trì cross-tenant
unsafeSystemRetireUploadTombstone(id) => Promise<boolean>; chỉ upload bị bỏ quên do operator xác nhận
unsafeSystemPurgeUnusedBefore(cutoff) => Promise<number>; chỉ bảo trì cross-tenant
cleanupPendingUploads(limit?) => Promise<number>; sweep direct pending/staging và deletion retry
cleanupExpiredTemporaryFiles(now?, limit?) => Promise<number>; cleanup CAS theo expiry chính xác
unsafeSystemCleanupCompletedStaging(limit?) => Promise<number>; cleanup S3 staging còn giữ
ts
const file = await uploads.upload(
  buffer,
  'invoice.pdf',
  { module: 'billing', entity: 'invoice', entityId: invoiceId, type: 'attachment' },
  { checksum: sha256 },
  { contentType: 'application/pdf' },
);

await uploads.markUsed([file.id], {
  module: 'billing',
  entity: 'invoice',
  entityId: invoiceId,
  type: 'attachment',
});

module, entity, entityIdtype chỉ là nguồn gốc; chúng không phải decision phân quyền domain-resource. Trước khi gọi markUsed, useFiles hoặc changeFiles, host phải tải và cấp quyền resource domain đích trong mô hình tenant/permission của chính mình. Lookup tích hợp theo entityId không tự cấp quyền độc lập cho resource đích đó.

Đường upload validation buffer không rỗng có giới hạn, tên đã làm sạch, allowlist MIME, sự phù hợp extension/content và signature/UTF-8 thực tế khi bật. Storage key chứa UUID server và namespace tenant đã encode; filename của caller không phải boundary unique. Mỗi lời gọi upload có thể thu hẹp allowedMimeTypesmaxFileSizeBytes; các giá trị này được lấy giao hoặc chặn theo cấu hình module và không thể mở rộng cấu hình.

DOCX, XLSX và PPTX còn được kiểm tra cấu trúc ZIP có giới hạn: tối đa 2.048 entry, tổng dữ liệu uncompressed khai báo 100 MiB, 50 MiB mỗi entry và tỷ lệ nén 100:1 mỗi entry. Package mã hóa, ZIP64/multi-disk, path không an toàn hoặc trùng, offset directory sai, thiếu [Content_Types].xml hoặc main part tương ứng đều bị từ chối. Validation này không phải quét malware.

Khi consumer truyền EntityManager, markUsed xác minh và cập nhật qua manager đó mà không mở transaction lồng; nếu bỏ qua, thư viện mở đúng một transaction riêng. downloadAttached trước tiên yêu cầu attachedReadPolicy trả về chính xác true, sau đó vẫn áp dụng tenant tin cậy và metadata attachment active, đã dùng, khớp chính xác. Domain entityId UUIDv7 được hỗ trợ. Uploader ban đầu chủ ý không nằm trong lookup attachment chính xác này.

Vòng đời pending bền vững

Direct initiate persist status: 'pending', staging key private, final key sinh riêng, expected size/content type chính xác, upload expiry và in-flight cleanup lease trước khi trả target. Complete verify staging version, CAS-claim pending → completing, promote mà không download qua backend, verify final metadata rồi CAS-finalize ready. Completion lease ngăn hai instance promote độc lập; lease hết hạn có thể recovery. S3 staging được giữ để retry rồi xóa idempotent; local promotion dùng hard-link/move không overwrite và xóa pendingKey.

File public ready dùng URL ổn định với expiry null. Private S3/Spaces dùng presigned GET mới, tối đa một giờ; signed URL không persist hay log. Temporary mới luôn private và complete thành công ghi expiredAt = completedAt + 24 elapsed hours. Read/detail từ chối tại now >= expiredAt độc lập với cleanup và clamp signed URL cuối theo boundary đó.

Upload trước tiên được persist thành một hàng ẩn có uploadPendingAt là activation lease trong tương lai 15 phút; deletionPendingAt vẫn null. Sau khi ghi byte, activation dùng compare-and-swap (CAS): chỉ xóa chính xác upload marker đó khi deletion marker vẫn null. Các thao tác đọc và mutation thông thường yêu cầu cả hai trường pending đều null, vì vậy hàng đang xử lý và thất bại không hiển thị.

Nếu việc ghi hoặc activation thất bại, settled cleanup xóa nguyên tử chính xác upload marker và đặt deletion claim mới có thời điểm tương lai trước khi chạm storage. Late activation vẫn mong đợi marker gốc vì vậy tác động 0 hàng và không thể hồi sinh metadata trong lúc byte đang bị xóa. Nếu activation thực sự đã commit nhưng response bị mất, việc xác nhận active-row ngăn cleanup. Nếu maintenance gặp upload đã hết hạn trong lúc storage write của nó vẫn chậm, writer sau đó CAS-settle chính xác trạng thái hiện tại—khôi phục nguyên tử một hàng soft-delete về trạng thái pending ẩn khi cần—trước khi xóa key đã tạo. Vì vậy durable deletion claim tồn tại trước terminal storage I/O. Lỗi storage hoặc finalize sẽ CAS-release claim đang sở hữu sang retry marker trong tương lai một phút. Backoff này ngăn một batch đầy object liên tục thất bại chiếm mọi sweep có giới hạn; claim bị bỏ quên đủ điều kiện trở lại khi lease hoặc thời gian retry hết hạn.

Tương tự, delete() claim hàng thành pending trước khi xóa byte và soft-delete metadata. unsafeSystemRetryPendingDeletions() ưu tiên deletion claim đã settle, rồi dùng phần capacity batch còn lại cho upload tombstone hết hạn. Nó CAS-claim chính xác upload marker, deletion marker và trạng thái soft-delete trước storage I/O. Worker cũ bị mất CAS sẽ bỏ qua deletion. Settled deletion thành công xóa claim của nó. Upload chưa được giải quyết giữ cả tombstone uploadPendingAt và deletion claim trong tương lai sau soft-delete, vì vậy producer-process chết không thể xóa tín hiệu retry; sweep sau sẽ restore, delete và lên lịch lại. Cron drain tối đa mười batch 100 hàng mỗi lần chạy. Hàng thất bại rời khỏi cửa sổ đủ điều kiện trước query tiếp theo, cho phép các hàng sau tiến qua queue có giới hạn.

Tombstone chưa giải quyết chủ ý bền vững cho tới khi writer settle. Sau khi dừng và drain mọi producer process, đồng thời xác minh object được tạo không thể tiếp tục được ghi, operator có thể gọi unsafeSystemRetireUploadTombstone(id). Method này chuyển nguyên tử chính xác tombstone hết hạn thành trạng thái deletion-retry đã trôi qua thông thường và từ chối retire upload lease đang sống hoặc chiếm deletion lease đang sống.

UploadedFileModule đăng ký hai cron pending và temporary cấu hình được. Import ScheduleModule.forRoot() để kích hoạt. Pending/staging/deletion được retry kể cả khi cleanupAfterDays bị tắt; age purge row legacy chưa dùng chỉ chạy khi retention dương. Temporary expiry luôn 24 giờ và dùng interval riêng. Khi có JobSchedulerService, mỗi cron dùng distributed lock; row CAS/outbox cũng bảo vệ nhiều instance chạy đồng thời.

API bảo trì không an toàn

Mọi method unsafeSystem* chủ ý bypass policy tenant/owner của request trên tất cả tenant. Chỉ gọi chúng từ mã bảo trì nền tin cậy; không bao giờ công khai trực tiếp trong HTTP controller.

Controller tùy chọn

UploadedFileController không được tự động đăng ký. Thêm nó vào controllers của module ứng dụng:

RouteHành vi
POST /uploaded-file/initiateMetadata private pending và direct target trung lập provider
POST /uploaded-file/temporary/initiateTemporary direct target private; không nhận TTL/visibility
POST /uploaded-file/:id/completeVerify/promote/finalize; body rỗng
PUT /uploaded-file/:id/contentRaw binary target cho local driver
GET /uploaded-file/:idDetail có URL đã phân quyền
DELETE /uploaded-file/:idAbort pending hoặc delete ready
POST /uploaded-fileRoute multipart tương thích đã deprecated
GET /uploaded-file/:id/downloadStream tương thích với type allowlist, nosniff, attachment disposition

Mọi route dùng AuthGuard; policy service vẫn thực thi tenant/owner. HTTP response direct không bao giờ serialize storage key hay reference private/CDN đã persist. Giới hạn multipart cho phép một file và thực thi trần tuyệt đối 25 MiB; service có thể thực thi giới hạn cấu hình thấp hơn. downloadPath mặc định khớp controller này. Nếu tùy chỉnh nó, hãy cung cấp hành vi routing/proxy tương ứng hoặc controller tùy chỉnh.

Hành vi lỗi

Lỗi định danh resource, authorization và existence chủ ý dùng chung core.file.not-found. Lỗi validation/vòng đời đã cấu hình dùng các code ổn định gồm core.file.invalid-upload, core.file.invalid-meta, core.file.remote-disabled, core.file.remote-fetch-failed, core.file.upload-expired, core.file.upload-completing, core.file.upload-verification-failed, core.file.public-upload-disabled, core.file.expired, core.file.upload-failed, core.file.delete-failedcore.file.cleanup-failed.

Không chuyển lỗi service không tiết lộ thông tin định danh thành chẩn đoán storage hoặc policy chi tiết tại HTTP boundary.

Phát hành theo giấy phép MIT.