diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 7b18549..ad6b56a 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -180,7 +180,7 @@ export async function createServer() { const upload = await runtime.store.getUpload(uploadId); if (!upload || upload.objectKey !== key) return reply.code(404).send({ error: "UPLOAD_NOT_FOUND" }); const body = request.body as NodeJS.ReadableStream; - const receivedSize = await runtime.storage.writeUploadFromStream(key, body); + const receivedSize = await runtime.storage.writeUploadFromStream(key, body, upload.expectedSize); if (receivedSize !== upload.expectedSize) return reply.code(400).send({ error: "UPLOAD_SIZE_MISMATCH", expectedSize: upload.expectedSize, receivedSize }); await runtime.store.updateUpload(upload.id, { receivedSize, status: "uploaded" }); return { ok: true, receivedSize }; diff --git a/apps/api/src/storage.ts b/apps/api/src/storage.ts index 8048f1d..28a7ed7 100644 --- a/apps/api/src/storage.ts +++ b/apps/api/src/storage.ts @@ -27,7 +27,7 @@ export type UploadPlan = export interface StorageAdapter { createUploadPlan(key: string, contentType: string, sizeBytes: number): Promise; completeMultipartUpload(key: string, uploadId: string | undefined, parts: Array<{ partNumber: number; etag: string }> | undefined): Promise; - writeUploadFromStream(key: string, stream: NodeJS.ReadableStream): Promise; + writeUploadFromStream(key: string, stream: NodeJS.ReadableStream, sizeBytes?: number): Promise; writeObject(key: string, body: Buffer, contentType: string): Promise; writeStream(key: string, transform: NodeJS.ReadWriteStream, contentType: string, sizeBytes: number, source: NodeJS.ReadableStream): Promise; downloadToFile(key: string, filePath: string): Promise; @@ -66,7 +66,8 @@ export class LocalStorage implements StorageAdapter { return; } - async writeUploadFromStream(key: string, stream: NodeJS.ReadableStream) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- sizeBytes is only needed by S3. + async writeUploadFromStream(key: string, stream: NodeJS.ReadableStream, sizeBytes?: number) { const filePath = this.pathFor(key); await mkdir(dirname(filePath), { recursive: true }); await pipeline(stream, createWriteStream(filePath, { flags: "w" })); @@ -158,8 +159,11 @@ export class S3Storage implements StorageAdapter { await this.client.send(new CompleteMultipartUploadCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, UploadId: uploadId, MultipartUpload: { Parts: parts.sort((a, b) => a.partNumber - b.partNumber).map((part) => ({ PartNumber: part.partNumber, ETag: part.etag })) } })); } - async writeUploadFromStream(key: string, stream: NodeJS.ReadableStream) { - await this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: stream as any })); + async writeUploadFromStream(key: string, stream: NodeJS.ReadableStream, sizeBytes?: number) { + // ContentLength must be provided for streaming bodies, otherwise the SDK's + // flexible-checksums middleware sends `x-amz-decoded-content-length: + // undefined` and the upload fails with ERR_HTTP_INVALID_HEADER_VALUE. + await this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: stream as any, ContentLength: sizeBytes })); const metadata = await this.headObject(key); return metadata?.sizeBytes || 0; } @@ -169,7 +173,7 @@ export class S3Storage implements StorageAdapter { } async writeStream(key: string, transform: NodeJS.ReadWriteStream, contentType: string, sizeBytes: number, source: NodeJS.ReadableStream) { - const upload = this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: transform as any, ContentType: contentType, ContentLength: sizeBytes || undefined })); + const upload = this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: transform as any, ContentType: contentType, ContentLength: sizeBytes })); await pipeline(source, transform); await upload; }