From 245138e23a67720380ef2d58d8654c7acc18502f Mon Sep 17 00:00:00 2001 From: asepharyana Date: Mon, 6 Jul 2026 22:36:15 +0700 Subject: [PATCH] Refactor code for improved readability and consistency - Updated formatting in web-api.ts for better alignment and readability. - Enhanced XML builders in xml.ts for clearer structure and maintainability. - Improved test cases in s3-auth.test.ts and s3-operations.test.ts for better clarity and consistency. - Refactored mock data in web-api.test.ts for improved readability. --- src/db/files-ext.ts | 5 +- src/db/migrate.ts | 8 +- src/routes/s3.ts | 438 +++++++++++++++++++++++++++++++------ src/routes/web-api.ts | 74 +++++-- src/utils/s3/xml.ts | 64 ++++-- test/s3-auth.test.ts | 44 +++- test/s3-operations.test.ts | 45 +++- test/web-api.test.ts | 13 +- 8 files changed, 568 insertions(+), 123 deletions(-) diff --git a/src/db/files-ext.ts b/src/db/files-ext.ts index debfe86..7fe6686 100644 --- a/src/db/files-ext.ts +++ b/src/db/files-ext.ts @@ -111,10 +111,7 @@ export const softDeleteFile = async (bucketId: string, s3Key: string): Promise 0; }; -export const softDeleteFilesBatch = async ( - bucketId: string, - keys: string[], -): Promise => { +export const softDeleteFilesBatch = async (bucketId: string, keys: string[]): Promise => { let deleted = 0; for (const key of keys) { const ok = await softDeleteFile(bucketId, key); diff --git a/src/db/migrate.ts b/src/db/migrate.ts index 3a3370f..d2653c8 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -12,10 +12,10 @@ export const runMigration = async (): Promise => { // In source via bun --hot: import.meta.dir = .../src/db/ const dir = import.meta.dir || ''; const candidates = [ - dir + '/../../schema.sql', // from dist/ - dir + '/../schema.sql', // from src/ (bun --hot src/index.ts) - dir + '/../schema.sql', // from src/db/ (bun --hot src/db/migrate.ts) - dir + '/schema.sql', // from src/ (bun run db:migrate) + dir + '/../../schema.sql', // from dist/ + dir + '/../schema.sql', // from src/ (bun --hot src/index.ts) + dir + '/../schema.sql', // from src/db/ (bun --hot src/db/migrate.ts) + dir + '/schema.sql', // from src/ (bun run db:migrate) ]; let schemaSql: string | null = null; diff --git a/src/routes/s3.ts b/src/routes/s3.ts index 36f2376..8429923 100644 --- a/src/routes/s3.ts +++ b/src/routes/s3.ts @@ -1,16 +1,31 @@ import { verifySignature, verifyPresignedUrl } from '../utils/s3/auth'; import { - listBucketsXml, s3ErrorResponse, listBucketResultXml, listBucketV2ResultXml, - initiateMultipartUploadXml, listPartsXml, completeMultipartUploadXml, deleteResultXml, - copyObjectResultXml, parseDeleteObjectsBody, parseCompleteMultipartBody, + listBucketsXml, + s3ErrorResponse, + listBucketResultXml, + listBucketV2ResultXml, + initiateMultipartUploadXml, + listPartsXml, + completeMultipartUploadXml, + deleteResultXml, + copyObjectResultXml, + parseDeleteObjectsBody, + parseCompleteMultipartBody, } from '../utils/s3/xml'; import { createBucket, findBucketByName, listBuckets, deleteBucket } from '../db/buckets'; import { - createMultipartUpload, findMultipartUpload, completeMultipartUpload, abortMultipartUpload, - insertMultipartPart, listMultipartParts, + createMultipartUpload, + findMultipartUpload, + completeMultipartUpload, + abortMultipartUpload, + insertMultipartPart, + listMultipartParts, } from '../db/multipart'; import { - findFileByBucketAndKey, listObjectsByPrefix, softDeleteFile, countBucketObjects, + findFileByBucketAndKey, + listObjectsByPrefix, + softDeleteFile, + countBucketObjects, } from '../db/files-ext'; import type { File } from '../db/schema'; import { config } from '../env'; @@ -52,12 +67,32 @@ export const handleS3Request = async (req: Request): Promise => { // Presigned URLs: only supported for GET (verified in handleGetObject) if (searchParams.has('X-Amz-Signature')) { if (method !== 'GET') { - return s3ErrorResponse('AccessDenied', 'Presigned URLs only supported for GET', pathname, 403, reqId); + return s3ErrorResponse( + 'AccessDenied', + 'Presigned URLs only supported for GET', + pathname, + 403, + reqId, + ); } } else { - const authResult = await verifySignature(method, req.url, headers, null, config.s3AccessKey, config.s3SecretKey, REGION); + const authResult = await verifySignature( + method, + req.url, + headers, + null, + config.s3AccessKey, + config.s3SecretKey, + REGION, + ); if (!authResult.isValid) { - return s3ErrorResponse(authResult.errorCode || 'AccessDenied', 'Authentication required', pathname, 403, reqId); + return s3ErrorResponse( + authResult.errorCode || 'AccessDenied', + 'Authentication required', + pathname, + 403, + reqId, + ); } } @@ -67,7 +102,13 @@ export const handleS3Request = async (req: Request): Promise => { if (method === 'GET') { return handleListBuckets(reqId); } - return s3ErrorResponse('MethodNotAllowed', 'The specified method is not allowed against this resource.', '/', 405, reqId); + return s3ErrorResponse( + 'MethodNotAllowed', + 'The specified method is not allowed against this resource.', + '/', + 405, + reqId, + ); } // Bucket-level operations @@ -91,7 +132,13 @@ export const handleS3Request = async (req: Request): Promise => { return new Response(null, { status: 204 }); } } - return s3ErrorResponse('MethodNotAllowed', 'The specified method is not allowed against this resource.', `/${bucket}`, 405, reqId); + return s3ErrorResponse( + 'MethodNotAllowed', + 'The specified method is not allowed against this resource.', + `/${bucket}`, + 405, + reqId, + ); } // Object-level: multipart checks @@ -118,10 +165,22 @@ export const handleS3Request = async (req: Request): Promise => { if (method === 'PUT') return handlePutObject(bucket, key, searchParams, headers, req, reqId); if (method === 'DELETE') return handleDeleteObject(bucket, key, reqId); - return s3ErrorResponse('MethodNotAllowed', 'The specified method is not allowed against this resource.', `/${bucket}/${key}`, 405, reqId); + return s3ErrorResponse( + 'MethodNotAllowed', + 'The specified method is not allowed against this resource.', + `/${bucket}/${key}`, + 405, + reqId, + ); } catch (error: unknown) { logger.error('S3 operation error', { bucket, key, error: getErrorMessage(error) }); - return s3ErrorResponse('InternalError', 'We encountered an internal error. Please try again.', pathname, 500, reqId); + return s3ErrorResponse( + 'InternalError', + 'We encountered an internal error. Please try again.', + pathname, + 500, + reqId, + ); } }; @@ -138,11 +197,23 @@ const handleListBuckets = async (reqId: string): Promise => { const handleCreateBucket = async (bucketName: string, reqId: string): Promise => { if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucketName)) { - return s3ErrorResponse('InvalidBucketName', 'The specified bucket is not valid.', `/${bucketName}`, 400, reqId); + return s3ErrorResponse( + 'InvalidBucketName', + 'The specified bucket is not valid.', + `/${bucketName}`, + 400, + reqId, + ); } const existing = await findBucketByName(bucketName); if (existing) { - return s3ErrorResponse('BucketAlreadyExists', 'The requested bucket name is not available.', `/${bucketName}`, 409, reqId); + return s3ErrorResponse( + 'BucketAlreadyExists', + 'The requested bucket name is not available.', + `/${bucketName}`, + 409, + reqId, + ); } await createBucket(bucketName); return new Response(null, { status: 200, headers: { 'x-amz-request-id': reqId } }); @@ -151,7 +222,13 @@ const handleCreateBucket = async (bucketName: string, reqId: string): Promise => { const bucket = await findBucketByName(bucketName); if (!bucket) { - return s3ErrorResponse('NoSuchBucket', 'The specified bucket does not exist.', `/${bucketName}`, 404, reqId); + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucketName}`, + 404, + reqId, + ); } return new Response(null, { status: 200, headers: { 'x-amz-request-id': reqId } }); }; @@ -159,11 +236,23 @@ const handleHeadBucket = async (bucketName: string, reqId: string): Promise => { const bucket = await findBucketByName(bucketName); if (!bucket) { - return s3ErrorResponse('NoSuchBucket', 'The specified bucket does not exist.', `/${bucketName}`, 404, reqId); + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucketName}`, + 404, + reqId, + ); } const objCount = await countBucketObjects(bucket.id); if (objCount > 0) { - return s3ErrorResponse('BucketNotEmpty', 'The bucket you tried to delete is not empty.', `/${bucketName}`, 409, reqId); + return s3ErrorResponse( + 'BucketNotEmpty', + 'The bucket you tried to delete is not empty.', + `/${bucketName}`, + 409, + reqId, + ); } await deleteBucket(bucketName); return new Response(null, { status: 204, headers: { 'x-amz-request-id': reqId } }); @@ -171,20 +260,51 @@ const handleDeleteBucket = async (bucketName: string, reqId: string): Promise => { +const handleGetObject = async ( + bucket: string, + key: string, + searchParams: URLSearchParams, + reqId: string, +): Promise => { if (searchParams.has('X-Amz-Signature')) { const fullUrl = `http://localhost/${bucket}/${key}?${searchParams.toString()}`; - const presignedResult = await verifyPresignedUrl(fullUrl, 'GET', config.s3AccessKey, config.s3SecretKey, REGION); + const presignedResult = await verifyPresignedUrl( + fullUrl, + 'GET', + config.s3AccessKey, + config.s3SecretKey, + REGION, + ); if (!presignedResult.isValid) { - return s3ErrorResponse('AccessDenied', 'Request has expired', `/${bucket}/${key}`, 403, reqId); + return s3ErrorResponse( + 'AccessDenied', + 'Request has expired', + `/${bucket}/${key}`, + 403, + reqId, + ); } } const bucketRecord = await findBucketByName(bucket); - if (!bucketRecord) return s3ErrorResponse('NoSuchBucket', 'The specified bucket does not exist.', `/${bucket}/${key}`, 404, reqId); + if (!bucketRecord) + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); const file = await findFileByBucketAndKey(bucketRecord.id, key); - if (!file) return s3ErrorResponse('NoSuchKey', 'The specified key does not exist.', `/${bucket}/${key}`, 404, reqId); + if (!file) + return s3ErrorResponse( + 'NoSuchKey', + 'The specified key does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); if (file.multipartUploadId) { return handleGetMultipartObject(file, bucket, key, reqId); @@ -202,12 +322,23 @@ const handleGetObject = async (bucket: string, key: string, searchParams: URLSea }); }; -const handleGetMultipartObject = async (file: File, bucket: string, key: string, reqId: string): Promise => { +const handleGetMultipartObject = async ( + file: File, + bucket: string, + key: string, + reqId: string, +): Promise => { const uploadId = file.multipartUploadId!; const parts = await listMultipartParts(uploadId); if (parts.length === 0) { - return s3ErrorResponse('InternalError', 'Multipart object has no parts.', `/${bucket}/${key}`, 500, reqId); + return s3ErrorResponse( + 'InternalError', + 'Multipart object has no parts.', + `/${bucket}/${key}`, + 500, + reqId, + ); } const fileInfo = await getFileInfo(parts[0].telegramFileId); @@ -224,26 +355,55 @@ const handleGetMultipartObject = async (file: File, bucket: string, key: string, const handleHeadObject = async (bucket: string, key: string, reqId: string): Promise => { const bucketRecord = await findBucketByName(bucket); - if (!bucketRecord) return s3ErrorResponse('NoSuchBucket', 'The specified bucket does not exist.', `/${bucket}/${key}`, 404, reqId); + if (!bucketRecord) + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); const file = await findFileByBucketAndKey(bucketRecord.id, key); - if (!file) return s3ErrorResponse('NoSuchKey', 'The specified key does not exist.', `/${bucket}/${key}`, 404, reqId); + if (!file) + return s3ErrorResponse( + 'NoSuchKey', + 'The specified key does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); return new Response(null, { status: 200, headers: { 'content-type': file.mimeType, 'content-length': String(file.sizeBytes), - 'etag': `"${file.fileHash || nanoid(16)}"`, - 'last-modified': file.createdAt instanceof Date ? file.createdAt.toUTCString() : new Date().toUTCString(), + etag: `"${file.fileHash || nanoid(16)}"`, + 'last-modified': + file.createdAt instanceof Date ? file.createdAt.toUTCString() : new Date().toUTCString(), 'x-amz-request-id': reqId, }, }); }; -const handlePutObject = async (bucket: string, key: string, searchParams: URLSearchParams, headers: Record, req: Request, reqId: string): Promise => { +const handlePutObject = async ( + bucket: string, + key: string, + searchParams: URLSearchParams, + headers: Record, + req: Request, + reqId: string, +): Promise => { const bucketRecord = await findBucketByName(bucket); - if (!bucketRecord) return s3ErrorResponse('NoSuchBucket', 'The specified bucket does not exist.', `/${bucket}/${key}`, 404, reqId); + if (!bucketRecord) + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); if (searchParams.has('tagging')) { return new Response(null, { status: 204 }); @@ -262,19 +422,33 @@ const handlePutObject = async (bucket: string, key: string, searchParams: URLSea const existing = await findFileByBucketAndKey(bucketRecord.id, key); if (existing) { - return new Response(null, { status: 200, headers: { 'etag': `"${hash}"`, 'x-amz-request-id': reqId } }); + return new Response(null, { + status: 200, + headers: { etag: `"${hash}"`, 'x-amz-request-id': reqId }, + }); } return await storeFileToTelegram(fileBuffer, hash, key, bucketRecord, contentType, reqId); }; -const storeFileToTelegram = async (buffer: Buffer, hash: string, key: string, bucketRecord: { id: string; name: string }, contentType: string, reqId: string): Promise => { +const storeFileToTelegram = async ( + buffer: Buffer, + hash: string, + key: string, + bucketRecord: { id: string; name: string }, + contentType: string, + reqId: string, +): Promise => { const tempPath = `/tmp/teleuploader-s3-${nanoid()}`; await Bun.write(tempPath, buffer); const signatureBuffer = buffer.subarray(0, 16); const fileName = key.split('/').pop() || 'file'; - const { fileName: finalFileName, mimeType } = ensureExtension(fileName, signatureBuffer, contentType); + const { fileName: finalFileName, mimeType } = ensureExtension( + fileName, + signatureBuffer, + contentType, + ); const forwardResult = await forwardToStorage( createReadStream(tempPath), @@ -309,21 +483,41 @@ const storeFileToTelegram = async (buffer: Buffer, hash: string, key: string, bu return new Response(null, { status: 200, - headers: { 'etag': `"${hash}"`, 'x-amz-request-id': reqId }, + headers: { etag: `"${hash}"`, 'x-amz-request-id': reqId }, }); }; -const handleCopyObject = async (_destBucket: string, destKey: string, copySource: string, destBucketId: string, reqId: string): Promise => { +const handleCopyObject = async ( + _destBucket: string, + destKey: string, + copySource: string, + destBucketId: string, + reqId: string, +): Promise => { const sourcePath = copySource.startsWith('/') ? copySource.slice(1) : copySource; const parts = sourcePath.split('/'); const sourceBucket = parts[0]; const sourceKey = parts.slice(1).join('/'); const sourceBucketRecord = await findBucketByName(sourceBucket); - if (!sourceBucketRecord) return s3ErrorResponse('NoSuchBucket', 'The specified bucket does not exist.', copySource, 404, reqId); + if (!sourceBucketRecord) + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + copySource, + 404, + reqId, + ); const sourceFile = await findFileByBucketAndKey(sourceBucketRecord.id, sourceKey); - if (!sourceFile) return s3ErrorResponse('NoSuchKey', 'The specified key does not exist.', copySource, 404, reqId); + if (!sourceFile) + return s3ErrorResponse( + 'NoSuchKey', + 'The specified key does not exist.', + copySource, + 404, + reqId, + ); const publicId = nanoid(); const { db, files: fileSchema } = await import('../db/index'); @@ -355,17 +549,39 @@ const handleCopyObject = async (_destBucket: string, destKey: string, copySource }); }; -const handleDeleteObject = async (bucket: string, key: string, reqId: string): Promise => { +const handleDeleteObject = async ( + bucket: string, + key: string, + reqId: string, +): Promise => { const bucketRecord = await findBucketByName(bucket); - if (!bucketRecord) return s3ErrorResponse('NoSuchBucket', 'The specified bucket does not exist.', `/${bucket}/${key}`, 404, reqId); + if (!bucketRecord) + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); await softDeleteFile(bucketRecord.id, key); return new Response(null, { status: 204, headers: { 'x-amz-request-id': reqId } }); }; -const handleDeleteObjects = async (bucket: string, body: string, reqId: string): Promise => { +const handleDeleteObjects = async ( + bucket: string, + body: string, + reqId: string, +): Promise => { const bucketRecord = await findBucketByName(bucket); - if (!bucketRecord) return s3ErrorResponse('NoSuchBucket', 'The specified bucket does not exist.', `/${bucket}`, 404, reqId); + if (!bucketRecord) + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucket}`, + 404, + reqId, + ); const { keys } = parseDeleteObjectsBody(body); const deletedKeys: string[] = []; @@ -382,9 +598,20 @@ const handleDeleteObjects = async (bucket: string, body: string, reqId: string): // ─────── Object Listing ─────── -const handleListObjectsV1 = async (bucket: string, searchParams: URLSearchParams, reqId: string): Promise => { +const handleListObjectsV1 = async ( + bucket: string, + searchParams: URLSearchParams, + reqId: string, +): Promise => { const bucketRecord = await findBucketByName(bucket); - if (!bucketRecord) return s3ErrorResponse('NoSuchBucket', 'The specified bucket does not exist.', `/${bucket}`, 404, reqId); + if (!bucketRecord) + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucket}`, + 404, + reqId, + ); const prefix = searchParams.get('prefix') || ''; const delimiter = searchParams.get('delimiter') || null; @@ -392,12 +619,18 @@ const handleListObjectsV1 = async (bucket: string, searchParams: URLSearchParams const marker = searchParams.get('marker') || null; const { objects, prefixes: commonPrefixes } = await listObjectsByPrefix( - bucketRecord.id, prefix, delimiter, maxKeys, marker, + bucketRecord.id, + prefix, + delimiter, + maxKeys, + marker, ); const isTruncated = objects.length > maxKeys; const displayObjects = objects.slice(0, maxKeys); - const nextMarker = isTruncated ? (displayObjects[displayObjects.length - 1]?.s3Key ?? null) : null; + const nextMarker = isTruncated + ? (displayObjects[displayObjects.length - 1]?.s3Key ?? null) + : null; const xml = listBucketResultXml( bucket, @@ -424,9 +657,20 @@ const handleListObjectsV1 = async (bucket: string, searchParams: URLSearchParams }); }; -const handleListObjectsV2 = async (bucket: string, searchParams: URLSearchParams, reqId: string): Promise => { +const handleListObjectsV2 = async ( + bucket: string, + searchParams: URLSearchParams, + reqId: string, +): Promise => { const bucketRecord = await findBucketByName(bucket); - if (!bucketRecord) return s3ErrorResponse('NoSuchBucket', 'The specified bucket does not exist.', `/${bucket}`, 404, reqId); + if (!bucketRecord) + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucket}`, + 404, + reqId, + ); const prefix = searchParams.get('prefix') || ''; const delimiter = searchParams.get('delimiter') || null; @@ -435,12 +679,18 @@ const handleListObjectsV2 = async (bucket: string, searchParams: URLSearchParams const startAfter = searchParams.get('start-after') || null; const { objects, prefixes: commonPrefixes } = await listObjectsByPrefix( - bucketRecord.id, prefix, delimiter, maxKeys, continuationToken || startAfter, + bucketRecord.id, + prefix, + delimiter, + maxKeys, + continuationToken || startAfter, ); const isTruncated = objects.length > maxKeys; const displayObjects = objects.slice(0, maxKeys); - const nextContinuationToken = isTruncated ? (displayObjects[displayObjects.length - 1]?.s3Key ?? null) : null; + const nextContinuationToken = isTruncated + ? (displayObjects[displayObjects.length - 1]?.s3Key ?? null) + : null; const xml = listBucketV2ResultXml( bucket, @@ -470,9 +720,21 @@ const handleListObjectsV2 = async (bucket: string, searchParams: URLSearchParams // ─────── Multipart Upload ─────── -const handleCreateMultipartUpload = async (bucket: string, key: string, _searchParams: URLSearchParams, reqId: string): Promise => { +const handleCreateMultipartUpload = async ( + bucket: string, + key: string, + _searchParams: URLSearchParams, + reqId: string, +): Promise => { const bucketRecord = await findBucketByName(bucket); - if (!bucketRecord) return s3ErrorResponse('NoSuchBucket', 'The specified bucket does not exist.', `/${bucket}/${key}`, 404, reqId); + if (!bucketRecord) + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); const uploadId = await createMultipartUpload(bucketRecord.id, key, 's3'); @@ -483,13 +745,25 @@ const handleCreateMultipartUpload = async (bucket: string, key: string, _searchP }); }; -const handleUploadPart = async (bucket: string, key: string, searchParams: URLSearchParams, req: Request, reqId: string): Promise => { +const handleUploadPart = async ( + bucket: string, + key: string, + searchParams: URLSearchParams, + req: Request, + reqId: string, +): Promise => { const uploadId = searchParams.get('uploadId')!; const partNumber = parseInt(searchParams.get('partNumber')!, 10); const multipart = await findMultipartUpload(uploadId); if (!multipart || multipart.s3Key !== key) { - return s3ErrorResponse('NoSuchUpload', 'The specified upload does not exist.', `/${bucket}/${key}`, 404, reqId); + return s3ErrorResponse( + 'NoSuchUpload', + 'The specified upload does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); } const body = await req.arrayBuffer(); @@ -519,22 +793,40 @@ const handleUploadPart = async (bucket: string, key: string, searchParams: URLSe return new Response(null, { status: 200, - headers: { 'etag': `"${etag}"`, 'x-amz-request-id': reqId }, + headers: { etag: `"${etag}"`, 'x-amz-request-id': reqId }, }); }; -const handleCompleteMultipartUpload = async (bucket: string, key: string, searchParams: URLSearchParams, body: string, reqId: string): Promise => { +const handleCompleteMultipartUpload = async ( + bucket: string, + key: string, + searchParams: URLSearchParams, + body: string, + reqId: string, +): Promise => { const uploadId = searchParams.get('uploadId')!; const multipart = await findMultipartUpload(uploadId); if (!multipart) { - return s3ErrorResponse('NoSuchUpload', 'The specified upload does not exist.', `/${bucket}/${key}`, 404, reqId); + return s3ErrorResponse( + 'NoSuchUpload', + 'The specified upload does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); } const parts = parseCompleteMultipartBody(body); const storedParts = await listMultipartParts(uploadId); if (parts.length !== storedParts.length) { - return s3ErrorResponse('InvalidPart', 'One or more specified parts could not be found.', `/${bucket}/${key}`, 400, reqId); + return s3ErrorResponse( + 'InvalidPart', + 'One or more specified parts could not be found.', + `/${bucket}/${key}`, + 400, + reqId, + ); } const totalSize = storedParts.reduce((sum, p) => sum + p.sizeBytes, 0); @@ -574,22 +866,44 @@ const handleCompleteMultipartUpload = async (bucket: string, key: string, search }); }; -const handleAbortMultipartUpload = async (bucket: string, key: string, searchParams: URLSearchParams, reqId: string): Promise => { +const handleAbortMultipartUpload = async ( + bucket: string, + key: string, + searchParams: URLSearchParams, + reqId: string, +): Promise => { const uploadId = searchParams.get('uploadId')!; const multipart = await findMultipartUpload(uploadId); if (!multipart) { - return s3ErrorResponse('NoSuchUpload', 'The specified upload does not exist.', `/${bucket}/${key}`, 404, reqId); + return s3ErrorResponse( + 'NoSuchUpload', + 'The specified upload does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); } await abortMultipartUpload(uploadId); return new Response(null, { status: 204, headers: { 'x-amz-request-id': reqId } }); }; -const handleListParts = async (bucket: string, key: string, searchParams: URLSearchParams, reqId: string): Promise => { +const handleListParts = async ( + bucket: string, + key: string, + searchParams: URLSearchParams, + reqId: string, +): Promise => { const uploadId = searchParams.get('uploadId')!; const multipart = await findMultipartUpload(uploadId); if (!multipart) { - return s3ErrorResponse('NoSuchUpload', 'The specified upload does not exist.', `/${bucket}/${key}`, 404, reqId); + return s3ErrorResponse( + 'NoSuchUpload', + 'The specified upload does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); } const parts = await listMultipartParts(uploadId); diff --git a/src/routes/web-api.ts b/src/routes/web-api.ts index 9dd453e..c06dc79 100644 --- a/src/routes/web-api.ts +++ b/src/routes/web-api.ts @@ -1,6 +1,9 @@ import { createBucket, findBucketByName, listBuckets, deleteBucket } from '../db/buckets'; import { - findFileByBucketAndKey, listObjectsByPrefix, softDeleteFile, countBucketObjects, + findFileByBucketAndKey, + listObjectsByPrefix, + softDeleteFile, + countBucketObjects, } from '../db/files-ext'; import { createReadStream } from 'node:fs'; import { config } from '../env'; @@ -11,11 +14,9 @@ import logger from '../utils/logger'; type RouteParams = { bucket?: string; key?: string }; -const json = (data: unknown, status = 200) => - Response.json(data, { status }); +const json = (data: unknown, status = 200) => Response.json(data, { status }); -const jsonError = (error: string, status: number) => - Response.json({ error }, { status }); +const jsonError = (error: string, status: number) => Response.json({ error }, { status }); // ─────── Bucket endpoints ─────── @@ -43,7 +44,10 @@ export const handleCreateBucketV1 = async (req: Request): Promise => { return json({ id: bucket.id, name: bucket.name }, 201); }; -export const handleDeleteBucketV1 = async (_req: Request, params: RouteParams): Promise => { +export const handleDeleteBucketV1 = async ( + _req: Request, + params: RouteParams, +): Promise => { const bucket = await findBucketByName(params.bucket!); if (!bucket) return jsonError('Bucket not found', 404); const count = await countBucketObjects(bucket.id); @@ -65,7 +69,11 @@ export const handleListObjectsV1 = async (req: Request, params: RouteParams): Pr const continuationToken = url.searchParams.get('continuation-token') || null; const { objects, prefixes } = await listObjectsByPrefix( - bucket.id, prefix, delimiter, maxKeys, continuationToken, + bucket.id, + prefix, + delimiter, + maxKeys, + continuationToken, ); const isTruncated = objects.length > maxKeys; const displayObjects = objects.slice(0, maxKeys); @@ -78,20 +86,22 @@ export const handleListObjectsV1 = async (req: Request, params: RouteParams): Pr sizeBytes: o.sizeBytes, fileType: o.fileType, etag: o.fileHash, - lastModified: o.createdAt instanceof Date - ? o.createdAt.toISOString() - : new Date(o.createdAt).toISOString(), + lastModified: + o.createdAt instanceof Date + ? o.createdAt.toISOString() + : new Date(o.createdAt).toISOString(), downloadUrl: `${config.baseUrl}/f/${o.publicId}`, })), prefixes, isTruncated, - nextContinuationToken: isTruncated - ? displayObjects[displayObjects.length - 1]?.s3Key - : null, + nextContinuationToken: isTruncated ? displayObjects[displayObjects.length - 1]?.s3Key : null, }); }; -export const handleUploadObjectV1 = async (req: Request, params: RouteParams): Promise => { +export const handleUploadObjectV1 = async ( + req: Request, + params: RouteParams, +): Promise => { const bucket = await findBucketByName(params.bucket!); if (!bucket) return jsonError('Bucket not found', 404); @@ -147,17 +157,26 @@ export const handleUploadObjectV1 = async (req: Request, params: RouteParams): P await cleanupTempFile(tempPath); - return json({ key, size: buffer.byteLength, etag: hash, downloadUrl: `${config.baseUrl}/f/${publicId}` }, 201); + return json( + { key, size: buffer.byteLength, etag: hash, downloadUrl: `${config.baseUrl}/f/${publicId}` }, + 201, + ); }; -export const handleDeleteObjectV1 = async (_req: Request, params: RouteParams): Promise => { +export const handleDeleteObjectV1 = async ( + _req: Request, + params: RouteParams, +): Promise => { const bucket = await findBucketByName(params.bucket!); if (!bucket) return jsonError('Bucket not found', 404); await softDeleteFile(bucket.id, params.key!); return json({ success: true }); }; -export const handleDownloadObjectV1 = async (_req: Request, params: RouteParams): Promise => { +export const handleDownloadObjectV1 = async ( + _req: Request, + params: RouteParams, +): Promise => { const bucket = await findBucketByName(params.bucket!); if (!bucket) return jsonError('Bucket not found', 404); @@ -241,12 +260,22 @@ export const handleWebApiV1 = async (req: Request): Promise => { } // GET /api/v1/buckets/{name}/objects - if (parts.length === 3 && parts[0] === 'buckets' && parts[2] === 'objects' && method === 'GET') { + if ( + parts.length === 3 && + parts[0] === 'buckets' && + parts[2] === 'objects' && + method === 'GET' + ) { return await handleListObjectsV1(req, { bucket: parts[1] }); } // POST /api/v1/buckets/{name}/upload - if (parts.length === 3 && parts[0] === 'buckets' && parts[2] === 'upload' && method === 'POST') { + if ( + parts.length === 3 && + parts[0] === 'buckets' && + parts[2] === 'upload' && + method === 'POST' + ) { return await handleUploadObjectV1(req, { bucket: parts[1] }); } @@ -263,7 +292,12 @@ export const handleWebApiV1 = async (req: Request): Promise => { } // GET /api/v1/buckets/{name}/download/{key+} - if (parts.length >= 4 && parts[0] === 'buckets' && parts[2] === 'download' && method === 'GET') { + if ( + parts.length >= 4 && + parts[0] === 'buckets' && + parts[2] === 'download' && + method === 'GET' + ) { const bucket = parts[1]; const key = parts.slice(3).join('/'); return await handleDownloadObjectV1(req, { bucket, key }); diff --git a/src/utils/s3/xml.ts b/src/utils/s3/xml.ts index a37a3c5..37c300d 100644 --- a/src/utils/s3/xml.ts +++ b/src/utils/s3/xml.ts @@ -16,10 +16,14 @@ export const listBucketsXml = ( ): string => ` - ${buckets.map((b) => ` + ${buckets + .map( + (b) => ` ${escapeXml(b.name)} ${isoDate(b.createdAt)} - `).join('')} + `, + ) + .join('')} `; @@ -44,16 +48,24 @@ export const listBucketResultXml = ( ${maxKeys} ${escapeXml(delimiter || '')} ${isTruncated} - ${objects.map((o) => ` + ${objects + .map( + (o) => ` ${escapeXml(o.key)} ${isoDate(o.lastModified)} "${o.etag}" ${o.sizeBytes} STANDARD - `).join('')} - ${prefixes.map((p) => ` + `, + ) + .join('')} + ${prefixes + .map( + (p) => ` ${escapeXml(p)} - `).join('')} + `, + ) + .join('')} ${nextMarker ? `${escapeXml(nextMarker)}` : ''} `; @@ -78,16 +90,24 @@ export const listBucketV2ResultXml = ( ${delimiter ? `${escapeXml(delimiter)}` : ''} ${continuationToken ? `${escapeXml(continuationToken)}` : ''} ${isTruncated} - ${objects.map((o) => ` + ${objects + .map( + (o) => ` ${escapeXml(o.key)} ${isoDate(o.lastModified)} "${o.etag}" ${o.sizeBytes} STANDARD - `).join('')} - ${prefixes.map((p) => ` + `, + ) + .join('')} + ${prefixes + .map( + (p) => ` ${escapeXml(p)} - `).join('')} + `, + ) + .join('')} ${nextContinuationToken ? `${escapeXml(nextContinuationToken)}` : ''} `; @@ -119,12 +139,16 @@ export const listPartsXml = ( ${uploadId} ${maxParts} ${isTruncated} - ${parts.map((p) => ` + ${parts + .map( + (p) => ` ${p.partNumber} ${isoDate(p.createdAt)} "${p.etag}" ${p.sizeBytes} - `).join('')} + `, + ) + .join('')} `; export const completeMultipartUploadXml = ( @@ -147,14 +171,22 @@ export const deleteResultXml = ( errors: { key: string; code: string; message: string }[], ): string => ` - ${deleted.map((key) => ` + ${deleted + .map( + (key) => ` ${escapeXml(key)} - `).join('')} - ${errors.map((e) => ` + `, + ) + .join('')} + ${errors + .map( + (e) => ` ${escapeXml(e.key)} ${e.code} ${escapeXml(e.message)} - `).join('')} + `, + ) + .join('')} `; // ─────── Copy ─────── diff --git a/test/s3-auth.test.ts b/test/s3-auth.test.ts index 872b763..99890aa 100644 --- a/test/s3-auth.test.ts +++ b/test/s3-auth.test.ts @@ -19,35 +19,67 @@ describe('S3 Auth (SigV4)', () => { }); it('rejects missing Authorization header', async () => { - const result = await verifySignature('GET', 'http://localhost/', {}, null, 'key', 'secret', 'us-east-1'); + const result = await verifySignature( + 'GET', + 'http://localhost/', + {}, + null, + 'key', + 'secret', + 'us-east-1', + ); expect(result.isValid).toBe(false); expect(result.errorCode).toBe('AccessDenied'); }); it('rejects wrong access key before signature calculation succeeds', async () => { const headers = { - authorization: 'AWS4-HMAC-SHA256 Credential=wrongkey/20260706/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=abc123', + authorization: + 'AWS4-HMAC-SHA256 Credential=wrongkey/20260706/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=abc123', 'x-amz-date': '20260706T120000Z', host: 'localhost', }; - const result = await verifySignature('GET', 'http://localhost/', headers, null, 'correctkey', 'secret', 'us-east-1'); + const result = await verifySignature( + 'GET', + 'http://localhost/', + headers, + null, + 'correctkey', + 'secret', + 'us-east-1', + ); expect(result.isValid).toBe(false); expect(result.errorCode).toBe('SignatureDoesNotMatch'); }); it('rejects region mismatch in Authorization credential scope', async () => { const headers = { - authorization: 'AWS4-HMAC-SHA256 Credential=testkey/20260706/eu-west-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=abc123', + authorization: + 'AWS4-HMAC-SHA256 Credential=testkey/20260706/eu-west-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=abc123', 'x-amz-date': '20260706T120000Z', host: 'localhost', }; - const result = await verifySignature('GET', 'http://localhost/', headers, null, 'testkey', 'secret', 'us-east-1'); + const result = await verifySignature( + 'GET', + 'http://localhost/', + headers, + null, + 'testkey', + 'secret', + 'us-east-1', + ); expect(result.isValid).toBe(false); expect(result.errorCode).toBe('SignatureDoesNotMatch'); }); it('rejects malformed presigned URLs', async () => { - const result = await verifyPresignedUrl('http://localhost/bucket/key', 'GET', 'key', 'secret', 'us-east-1'); + const result = await verifyPresignedUrl( + 'http://localhost/bucket/key', + 'GET', + 'key', + 'secret', + 'us-east-1', + ); expect(result.isValid).toBe(false); expect(result.errorCode).toBe('AccessDenied'); }); diff --git a/test/s3-operations.test.ts b/test/s3-operations.test.ts index f96e3d4..44e40cc 100644 --- a/test/s3-operations.test.ts +++ b/test/s3-operations.test.ts @@ -17,7 +17,15 @@ describe('S3 XML Builders', () => { const xml = await import('../src/utils/s3/xml'); const result = xml.listBucketResultXml( 'my-bucket', - [{ key: 'folder/a&b.txt', sizeBytes: 100, etag: 'abc', lastModified: new Date('2026-01-01T00:00:00Z'), mimeType: 'text/plain' }], + [ + { + key: 'folder/a&b.txt', + sizeBytes: 100, + etag: 'abc', + lastModified: new Date('2026-01-01T00:00:00Z'), + mimeType: 'text/plain', + }, + ], ['photos/'], false, null, @@ -37,7 +45,15 @@ describe('S3 XML Builders', () => { const xml = await import('../src/utils/s3/xml'); const result = xml.listBucketV2ResultXml( 'my-bucket', - [{ key: 'a.txt', sizeBytes: 50, etag: 'def', lastModified: new Date('2026-01-01T00:00:00Z'), mimeType: 'text/plain' }], + [ + { + key: 'a.txt', + sizeBytes: 50, + etag: 'def', + lastModified: new Date('2026-01-01T00:00:00Z'), + mimeType: 'text/plain', + }, + ], [], false, 1000, @@ -55,14 +71,25 @@ describe('S3 XML Builders', () => { it('builds multipart and copy XML responses', async () => { const xml = await import('../src/utils/s3/xml'); - expect(xml.initiateMultipartUploadXml('bucket', 'key', 'upload-123')).toContain('upload-123'); - expect(xml.completeMultipartUploadXml('bucket', 'key', 'etag-abc', 'http://localhost/bucket/key')).toContain('upload-123', + ); + expect( + xml.completeMultipartUploadXml('bucket', 'key', 'etag-abc', 'http://localhost/bucket/key'), + ).toContain(' { const xml = await import('../src/utils/s3/xml'); - const result = xml.s3ErrorXml('NoSuchBucket', 'The specified bucket does not exist', '/bucket', 'req-1'); + const result = xml.s3ErrorXml( + 'NoSuchBucket', + 'The specified bucket does not exist', + '/bucket', + 'req-1', + ); expect(result).toContain('NoSuchBucket'); expect(result).toContain('req-1'); @@ -73,7 +100,8 @@ describe('S3 XML Builders', () => { it('parses DeleteObjects body', async () => { const xml = await import('../src/utils/s3/xml'); - const body = 'file1.txtfile2.txttrue'; + const body = + 'file1.txtfile2.txttrue'; const { keys, quiet } = xml.parseDeleteObjectsBody(body); expect(keys).toEqual(['file1.txt', 'file2.txt']); expect(quiet).toBe(true); @@ -81,7 +109,8 @@ describe('S3 XML Builders', () => { it('parses CompleteMultipartUpload body', async () => { const xml = await import('../src/utils/s3/xml'); - const body = '1"abc"2"def"'; + const body = + '1"abc"2"def"'; const parts = xml.parseCompleteMultipartBody(body); expect(parts).toEqual([ { partNumber: 1, etag: 'abc' }, diff --git a/test/web-api.test.ts b/test/web-api.test.ts index e86092a..be8892d 100644 --- a/test/web-api.test.ts +++ b/test/web-api.test.ts @@ -1,13 +1,20 @@ import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test'; const mockBuckets = [ - { id: 'uuid-1', name: 'test-bucket', createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-01') }, + { + id: 'uuid-1', + name: 'test-bucket', + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + }, ]; mock.module('../src/db/buckets', () => ({ listBuckets: () => Promise.resolve(mockBuckets), - findBucketByName: (name: string) => Promise.resolve(mockBuckets.find((b) => b.name === name) || null), - createBucket: (name: string) => Promise.resolve({ id: 'new-uuid', name, createdAt: new Date(), updatedAt: new Date() }), + findBucketByName: (name: string) => + Promise.resolve(mockBuckets.find((b) => b.name === name) || null), + createBucket: (name: string) => + Promise.resolve({ id: 'new-uuid', name, createdAt: new Date(), updatedAt: new Date() }), deleteBucket: () => Promise.resolve(true), bucketExists: () => Promise.resolve(false), }));