assets: go 15GB limit

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-08-01 12:07:39 +08:00
parent aad298bc54
commit 2bff5c030a
4 changed files with 347 additions and 185 deletions
+147 -3
View File
@@ -22,7 +22,10 @@
// whenever S3_ENDPOINT isn't reachable from this machine —
// see resolvePublicHost() below)
const { S3Client, DeleteObjectCommand, GetObjectCommand, HeadBucketCommand } = require("@aws-sdk/client-s3");
const {
S3Client, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, HeadBucketCommand, PutObjectCommand,
CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand,
} = require("@aws-sdk/client-s3");
const { Upload } = require("@aws-sdk/lib-storage");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
const crypto = require("crypto");
@@ -44,7 +47,14 @@ const credentials = {
// WHEN_REQUIRED skips validation unless the operation mandates it (GetObject
// never does) — safe here since Garage isn't computing/verifying per AWS spec
// anyway.
const CHECKSUM_CONFIG = { responseChecksumValidation: "WHEN_REQUIRED" };
//
// requestChecksumCalculation: "WHEN_REQUIRED" — same fix, request side. SDK
// v3's default "WHEN_SUPPORTED" makes presignUpload()'s PutObjectCommand carry
// a signed x-amz-checksum-crc32 computed over an empty/phantom body (there's
// no real body yet at presign time), so Garage rejects the browser's actual
// PUT once real bytes arrive with "InvalidDigest ... expected Crc32([0,0,0,0])".
// PutObject doesn't require a checksum, so WHEN_REQUIRED just omits it.
const CHECKSUM_CONFIG = { responseChecksumValidation: "WHEN_REQUIRED", requestChecksumCalculation: "WHEN_REQUIRED" };
// Internal client — uploads, deletes, direct streams from the server itself.
// Always targets S3_ENDPOINT. When this backend is co-located with Garage,
@@ -271,4 +281,138 @@ async function ping() {
await client.send(new HeadBucketCommand({ Bucket: DEFAULT_BUCKET }));
}
module.exports = { uploadFile, deleteFile, getSignedDownloadUrl, getPublicUrl, getObjectStream, ping };
// S3's own hard ceiling for a single (non-multipart) PUT — files above this
// need real multipart upload instead (see presignUpload() below).
const MULTIPART_THRESHOLD = 5 * 1024 ** 3; // 5GB
// Fixed part size for multipart uploads — comfortably above S3's 5MB-per-part
// minimum, and keeps the part count reasonable even at the largest files this
// app expects (15GB / 50MB = 300 parts, well under S3's 10,000-part ceiling).
// No adaptive sizing needed at this scale.
const PART_SIZE = 50 * 1024 ** 2; // 50MB
// ─── presignUpload ────────────────────────────────────────────────────────────
//
// Presigned upload URL(s) for browser-direct uploads — the browser PUTs the
// file's bytes straight to storage; this backend never buffers them. Signed
// against the same public-facing host as getPublicUrl() (browsers can't
// reach S3_ENDPOINT when that's an internal-only address), and deliberately
// does NOT pin Content-Type on any signed command — the browser's actual
// Content-Type header (from the File object) would have to match whatever
// was signed exactly, and leaving it unsigned avoids that fragility.
//
// input: originalname, ownerType — same as uploadFile(), used to build the
// same {prefix}/{uuid}.{ext} key convention.
// size — total file size in bytes, decides single-PUT vs multipart.
// output: { key, uploadUrl } (size <= 5GB)
// or { key, multipart: true, uploadId, partSize, parts } (size > 5GB)
// parts: [{ partNumber, uploadUrl }, ...], one presigned UploadPart
// URL per part, all generated upfront in this one call.
//
async function presignUpload(originalname, ownerType = "image", size = 0) {
const key = buildKey(originalname, ownerType);
const client = getPublicClient();
if (size <= MULTIPART_THRESHOLD) {
// Generous expiry — even a file near the 5GB ceiling can take a long
// time to PUT on a slow connection, and the signature must still be
// valid when the browser actually gets around to sending it.
const uploadUrl = await getSignedUrl(
client,
new PutObjectCommand({ Bucket: DEFAULT_BUCKET, Key: key }),
{ expiresIn: 4 * 60 * 60 } // 4h
);
return { key, uploadUrl };
}
// Above the single-PUT ceiling — the backend initiates the multipart
// session itself with real credentials (a lightweight control-plane call,
// no file bytes involved); only the individual part uploads, which do
// carry real bytes, get presigned for the browser.
const { UploadId: uploadId } = await s3.send(new CreateMultipartUploadCommand({
Bucket: DEFAULT_BUCKET,
Key: key,
}));
const partCount = Math.ceil(size / PART_SIZE);
const parts = [];
for (let partNumber = 1; partNumber <= partCount; partNumber++) {
const uploadUrl = await getSignedUrl(
client,
new UploadPartCommand({ Bucket: DEFAULT_BUCKET, Key: key, UploadId: uploadId, PartNumber: partNumber }),
{ expiresIn: 24 * 60 * 60 } // 24h — a 15GB upload can genuinely take a while on a slow connection
);
parts.push({ partNumber, uploadUrl });
}
return { key, multipart: true, uploadId, partSize: PART_SIZE, parts };
}
// ─── completeMultipartUpload ──────────────────────────────────────────────────
//
// Finishes a multipart upload once the browser has PUT every part directly
// (see presignUpload() above). Parts must carry the ETag each part's own PUT
// response returned — order doesn't matter here, they're sorted by
// PartNumber before submitting. Real credentials, not presigned: this is a
// small control-plane call, no file bytes involved.
//
// input: key, uploadId, parts — [{ partNumber, etag }, ...]
//
async function completeMultipartUpload(key, uploadId, parts) {
await s3.send(new CompleteMultipartUploadCommand({
Bucket: DEFAULT_BUCKET,
Key: key,
UploadId: uploadId,
MultipartUpload: {
Parts: parts
.slice()
.sort((a, b) => a.partNumber - b.partNumber)
.map(({ partNumber, etag }) => ({ PartNumber: partNumber, ETag: etag })),
},
}));
}
// ─── abortMultipartUpload ──────────────────────────────────────────────────────
//
// Cancels an in-progress multipart upload (a part permanently failed after
// retries, or the browser gave up) so it doesn't linger as orphaned storage
// forever. Real credentials, called server-side — this only ever happens on
// failure, no need for browser-direct access to it.
//
async function abortMultipartUpload(key, uploadId) {
await s3.send(new AbortMultipartUploadCommand({
Bucket: DEFAULT_BUCKET,
Key: key,
UploadId: uploadId,
}));
}
// ─── getFileMetadata ──────────────────────────────────────────────────────────
//
// Reads back what the browser actually uploaded via presignUpload() — used by
// the finalize step in place of multer's req.file (size, mimetype), since no
// buffer ever passes through this backend to read those from directly. ETag
// doubles as the object's checksum: for a single (non-multipart) unencrypted
// PutObjectCommand, S3-compatible ETag is exactly the MD5 of the body.
//
// input: key
// output: { size, mimetype, checksum } — checksum is the ETag with its
// surrounding quotes stripped, or null if the object has no ETag.
//
async function getFileMetadata(key) {
const result = await s3.send(new HeadObjectCommand({
Bucket: DEFAULT_BUCKET,
Key: key,
}));
return {
size: result.ContentLength ?? null,
mimetype: result.ContentType ?? null,
checksum: result.ETag ? result.ETag.replace(/"/g, "") : null,
};
}
module.exports = {
uploadFile, deleteFile, getSignedDownloadUrl, getPublicUrl, getObjectStream,
presignUpload, completeMultipartUpload, abortMultipartUpload,
getFileMetadata, buildPublicUrl, ping,
};