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.
This commit is contained in:
asepharyana
2026-07-06 22:36:15 +07:00
parent 2d3d7891a0
commit 245138e23a
8 changed files with 568 additions and 123 deletions
+1 -4
View File
@@ -111,10 +111,7 @@ export const softDeleteFile = async (bucketId: string, s3Key: string): Promise<b
return result.rows.length > 0; return result.rows.length > 0;
}; };
export const softDeleteFilesBatch = async ( export const softDeleteFilesBatch = async (bucketId: string, keys: string[]): Promise<number> => {
bucketId: string,
keys: string[],
): Promise<number> => {
let deleted = 0; let deleted = 0;
for (const key of keys) { for (const key of keys) {
const ok = await softDeleteFile(bucketId, key); const ok = await softDeleteFile(bucketId, key);
+376 -62
View File
@@ -1,16 +1,31 @@
import { verifySignature, verifyPresignedUrl } from '../utils/s3/auth'; import { verifySignature, verifyPresignedUrl } from '../utils/s3/auth';
import { import {
listBucketsXml, s3ErrorResponse, listBucketResultXml, listBucketV2ResultXml, listBucketsXml,
initiateMultipartUploadXml, listPartsXml, completeMultipartUploadXml, deleteResultXml, s3ErrorResponse,
copyObjectResultXml, parseDeleteObjectsBody, parseCompleteMultipartBody, listBucketResultXml,
listBucketV2ResultXml,
initiateMultipartUploadXml,
listPartsXml,
completeMultipartUploadXml,
deleteResultXml,
copyObjectResultXml,
parseDeleteObjectsBody,
parseCompleteMultipartBody,
} from '../utils/s3/xml'; } from '../utils/s3/xml';
import { createBucket, findBucketByName, listBuckets, deleteBucket } from '../db/buckets'; import { createBucket, findBucketByName, listBuckets, deleteBucket } from '../db/buckets';
import { import {
createMultipartUpload, findMultipartUpload, completeMultipartUpload, abortMultipartUpload, createMultipartUpload,
insertMultipartPart, listMultipartParts, findMultipartUpload,
completeMultipartUpload,
abortMultipartUpload,
insertMultipartPart,
listMultipartParts,
} from '../db/multipart'; } from '../db/multipart';
import { import {
findFileByBucketAndKey, listObjectsByPrefix, softDeleteFile, countBucketObjects, findFileByBucketAndKey,
listObjectsByPrefix,
softDeleteFile,
countBucketObjects,
} from '../db/files-ext'; } from '../db/files-ext';
import type { File } from '../db/schema'; import type { File } from '../db/schema';
import { config } from '../env'; import { config } from '../env';
@@ -52,12 +67,32 @@ export const handleS3Request = async (req: Request): Promise<Response> => {
// Presigned URLs: only supported for GET (verified in handleGetObject) // Presigned URLs: only supported for GET (verified in handleGetObject)
if (searchParams.has('X-Amz-Signature')) { if (searchParams.has('X-Amz-Signature')) {
if (method !== 'GET') { 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 { } 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) { 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<Response> => {
if (method === 'GET') { if (method === 'GET') {
return handleListBuckets(reqId); 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 // Bucket-level operations
@@ -91,7 +132,13 @@ export const handleS3Request = async (req: Request): Promise<Response> => {
return new Response(null, { status: 204 }); 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 // Object-level: multipart checks
@@ -118,10 +165,22 @@ export const handleS3Request = async (req: Request): Promise<Response> => {
if (method === 'PUT') return handlePutObject(bucket, key, searchParams, headers, req, reqId); if (method === 'PUT') return handlePutObject(bucket, key, searchParams, headers, req, reqId);
if (method === 'DELETE') return handleDeleteObject(bucket, key, 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) { } catch (error: unknown) {
logger.error('S3 operation error', { bucket, key, error: getErrorMessage(error) }); 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<Response> => {
const handleCreateBucket = async (bucketName: string, reqId: string): Promise<Response> => { const handleCreateBucket = async (bucketName: string, reqId: string): Promise<Response> => {
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucketName)) { 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); const existing = await findBucketByName(bucketName);
if (existing) { 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); await createBucket(bucketName);
return new Response(null, { status: 200, headers: { 'x-amz-request-id': reqId } }); return new Response(null, { status: 200, headers: { 'x-amz-request-id': reqId } });
@@ -151,7 +222,13 @@ const handleCreateBucket = async (bucketName: string, reqId: string): Promise<Re
const handleHeadBucket = async (bucketName: string, reqId: string): Promise<Response> => { const handleHeadBucket = async (bucketName: string, reqId: string): Promise<Response> => {
const bucket = await findBucketByName(bucketName); const bucket = await findBucketByName(bucketName);
if (!bucket) { 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 } }); return new Response(null, { status: 200, headers: { 'x-amz-request-id': reqId } });
}; };
@@ -159,11 +236,23 @@ const handleHeadBucket = async (bucketName: string, reqId: string): Promise<Resp
const handleDeleteBucket = async (bucketName: string, reqId: string): Promise<Response> => { const handleDeleteBucket = async (bucketName: string, reqId: string): Promise<Response> => {
const bucket = await findBucketByName(bucketName); const bucket = await findBucketByName(bucketName);
if (!bucket) { 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); const objCount = await countBucketObjects(bucket.id);
if (objCount > 0) { 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); await deleteBucket(bucketName);
return new Response(null, { status: 204, headers: { 'x-amz-request-id': reqId } }); return new Response(null, { status: 204, headers: { 'x-amz-request-id': reqId } });
@@ -171,20 +260,51 @@ const handleDeleteBucket = async (bucketName: string, reqId: string): Promise<Re
// ─────── Object Operations ─────── // ─────── Object Operations ───────
const handleGetObject = async (bucket: string, key: string, searchParams: URLSearchParams, reqId: string): Promise<Response> => { const handleGetObject = async (
bucket: string,
key: string,
searchParams: URLSearchParams,
reqId: string,
): Promise<Response> => {
if (searchParams.has('X-Amz-Signature')) { if (searchParams.has('X-Amz-Signature')) {
const fullUrl = `http://localhost/${bucket}/${key}?${searchParams.toString()}`; 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) { 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); 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); 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) { if (file.multipartUploadId) {
return handleGetMultipartObject(file, bucket, key, reqId); 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<Response> => { const handleGetMultipartObject = async (
file: File,
bucket: string,
key: string,
reqId: string,
): Promise<Response> => {
const uploadId = file.multipartUploadId!; const uploadId = file.multipartUploadId!;
const parts = await listMultipartParts(uploadId); const parts = await listMultipartParts(uploadId);
if (parts.length === 0) { 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); 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<Response> => { const handleHeadObject = async (bucket: string, key: string, reqId: string): Promise<Response> => {
const bucketRecord = await findBucketByName(bucket); 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); 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, { return new Response(null, {
status: 200, status: 200,
headers: { headers: {
'content-type': file.mimeType, 'content-type': file.mimeType,
'content-length': String(file.sizeBytes), 'content-length': String(file.sizeBytes),
'etag': `"${file.fileHash || nanoid(16)}"`, etag: `"${file.fileHash || nanoid(16)}"`,
'last-modified': file.createdAt instanceof Date ? file.createdAt.toUTCString() : new Date().toUTCString(), 'last-modified':
file.createdAt instanceof Date ? file.createdAt.toUTCString() : new Date().toUTCString(),
'x-amz-request-id': reqId, 'x-amz-request-id': reqId,
}, },
}); });
}; };
const handlePutObject = async (bucket: string, key: string, searchParams: URLSearchParams, headers: Record<string, string>, req: Request, reqId: string): Promise<Response> => { const handlePutObject = async (
bucket: string,
key: string,
searchParams: URLSearchParams,
headers: Record<string, string>,
req: Request,
reqId: string,
): Promise<Response> => {
const bucketRecord = await findBucketByName(bucket); 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')) { if (searchParams.has('tagging')) {
return new Response(null, { status: 204 }); 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); const existing = await findFileByBucketAndKey(bucketRecord.id, key);
if (existing) { 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); 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<Response> => { const storeFileToTelegram = async (
buffer: Buffer,
hash: string,
key: string,
bucketRecord: { id: string; name: string },
contentType: string,
reqId: string,
): Promise<Response> => {
const tempPath = `/tmp/teleuploader-s3-${nanoid()}`; const tempPath = `/tmp/teleuploader-s3-${nanoid()}`;
await Bun.write(tempPath, buffer); await Bun.write(tempPath, buffer);
const signatureBuffer = buffer.subarray(0, 16); const signatureBuffer = buffer.subarray(0, 16);
const fileName = key.split('/').pop() || 'file'; 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( const forwardResult = await forwardToStorage(
createReadStream(tempPath), createReadStream(tempPath),
@@ -309,21 +483,41 @@ const storeFileToTelegram = async (buffer: Buffer, hash: string, key: string, bu
return new Response(null, { return new Response(null, {
status: 200, 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<Response> => { const handleCopyObject = async (
_destBucket: string,
destKey: string,
copySource: string,
destBucketId: string,
reqId: string,
): Promise<Response> => {
const sourcePath = copySource.startsWith('/') ? copySource.slice(1) : copySource; const sourcePath = copySource.startsWith('/') ? copySource.slice(1) : copySource;
const parts = sourcePath.split('/'); const parts = sourcePath.split('/');
const sourceBucket = parts[0]; const sourceBucket = parts[0];
const sourceKey = parts.slice(1).join('/'); const sourceKey = parts.slice(1).join('/');
const sourceBucketRecord = await findBucketByName(sourceBucket); 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); 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 publicId = nanoid();
const { db, files: fileSchema } = await import('../db/index'); 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<Response> => { const handleDeleteObject = async (
bucket: string,
key: string,
reqId: string,
): Promise<Response> => {
const bucketRecord = await findBucketByName(bucket); 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); await softDeleteFile(bucketRecord.id, key);
return new Response(null, { status: 204, headers: { 'x-amz-request-id': reqId } }); return new Response(null, { status: 204, headers: { 'x-amz-request-id': reqId } });
}; };
const handleDeleteObjects = async (bucket: string, body: string, reqId: string): Promise<Response> => { const handleDeleteObjects = async (
bucket: string,
body: string,
reqId: string,
): Promise<Response> => {
const bucketRecord = await findBucketByName(bucket); 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 { keys } = parseDeleteObjectsBody(body);
const deletedKeys: string[] = []; const deletedKeys: string[] = [];
@@ -382,9 +598,20 @@ const handleDeleteObjects = async (bucket: string, body: string, reqId: string):
// ─────── Object Listing ─────── // ─────── Object Listing ───────
const handleListObjectsV1 = async (bucket: string, searchParams: URLSearchParams, reqId: string): Promise<Response> => { const handleListObjectsV1 = async (
bucket: string,
searchParams: URLSearchParams,
reqId: string,
): Promise<Response> => {
const bucketRecord = await findBucketByName(bucket); 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 prefix = searchParams.get('prefix') || '';
const delimiter = searchParams.get('delimiter') || null; const delimiter = searchParams.get('delimiter') || null;
@@ -392,12 +619,18 @@ const handleListObjectsV1 = async (bucket: string, searchParams: URLSearchParams
const marker = searchParams.get('marker') || null; const marker = searchParams.get('marker') || null;
const { objects, prefixes: commonPrefixes } = await listObjectsByPrefix( const { objects, prefixes: commonPrefixes } = await listObjectsByPrefix(
bucketRecord.id, prefix, delimiter, maxKeys, marker, bucketRecord.id,
prefix,
delimiter,
maxKeys,
marker,
); );
const isTruncated = objects.length > maxKeys; const isTruncated = objects.length > maxKeys;
const displayObjects = objects.slice(0, 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( const xml = listBucketResultXml(
bucket, bucket,
@@ -424,9 +657,20 @@ const handleListObjectsV1 = async (bucket: string, searchParams: URLSearchParams
}); });
}; };
const handleListObjectsV2 = async (bucket: string, searchParams: URLSearchParams, reqId: string): Promise<Response> => { const handleListObjectsV2 = async (
bucket: string,
searchParams: URLSearchParams,
reqId: string,
): Promise<Response> => {
const bucketRecord = await findBucketByName(bucket); 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 prefix = searchParams.get('prefix') || '';
const delimiter = searchParams.get('delimiter') || null; 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 startAfter = searchParams.get('start-after') || null;
const { objects, prefixes: commonPrefixes } = await listObjectsByPrefix( 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 isTruncated = objects.length > maxKeys;
const displayObjects = objects.slice(0, 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( const xml = listBucketV2ResultXml(
bucket, bucket,
@@ -470,9 +720,21 @@ const handleListObjectsV2 = async (bucket: string, searchParams: URLSearchParams
// ─────── Multipart Upload ─────── // ─────── Multipart Upload ───────
const handleCreateMultipartUpload = async (bucket: string, key: string, _searchParams: URLSearchParams, reqId: string): Promise<Response> => { const handleCreateMultipartUpload = async (
bucket: string,
key: string,
_searchParams: URLSearchParams,
reqId: string,
): Promise<Response> => {
const bucketRecord = await findBucketByName(bucket); 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'); 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<Response> => { const handleUploadPart = async (
bucket: string,
key: string,
searchParams: URLSearchParams,
req: Request,
reqId: string,
): Promise<Response> => {
const uploadId = searchParams.get('uploadId')!; const uploadId = searchParams.get('uploadId')!;
const partNumber = parseInt(searchParams.get('partNumber')!, 10); const partNumber = parseInt(searchParams.get('partNumber')!, 10);
const multipart = await findMultipartUpload(uploadId); const multipart = await findMultipartUpload(uploadId);
if (!multipart || multipart.s3Key !== key) { 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(); const body = await req.arrayBuffer();
@@ -519,22 +793,40 @@ const handleUploadPart = async (bucket: string, key: string, searchParams: URLSe
return new Response(null, { return new Response(null, {
status: 200, 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<Response> => { const handleCompleteMultipartUpload = async (
bucket: string,
key: string,
searchParams: URLSearchParams,
body: string,
reqId: string,
): Promise<Response> => {
const uploadId = searchParams.get('uploadId')!; const uploadId = searchParams.get('uploadId')!;
const multipart = await findMultipartUpload(uploadId); const multipart = await findMultipartUpload(uploadId);
if (!multipart) { 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 parts = parseCompleteMultipartBody(body);
const storedParts = await listMultipartParts(uploadId); const storedParts = await listMultipartParts(uploadId);
if (parts.length !== storedParts.length) { 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); 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<Response> => { const handleAbortMultipartUpload = async (
bucket: string,
key: string,
searchParams: URLSearchParams,
reqId: string,
): Promise<Response> => {
const uploadId = searchParams.get('uploadId')!; const uploadId = searchParams.get('uploadId')!;
const multipart = await findMultipartUpload(uploadId); const multipart = await findMultipartUpload(uploadId);
if (!multipart) { 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); await abortMultipartUpload(uploadId);
return new Response(null, { status: 204, headers: { 'x-amz-request-id': reqId } }); return new Response(null, { status: 204, headers: { 'x-amz-request-id': reqId } });
}; };
const handleListParts = async (bucket: string, key: string, searchParams: URLSearchParams, reqId: string): Promise<Response> => { const handleListParts = async (
bucket: string,
key: string,
searchParams: URLSearchParams,
reqId: string,
): Promise<Response> => {
const uploadId = searchParams.get('uploadId')!; const uploadId = searchParams.get('uploadId')!;
const multipart = await findMultipartUpload(uploadId); const multipart = await findMultipartUpload(uploadId);
if (!multipart) { 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); const parts = await listMultipartParts(uploadId);
+52 -18
View File
@@ -1,6 +1,9 @@
import { createBucket, findBucketByName, listBuckets, deleteBucket } from '../db/buckets'; import { createBucket, findBucketByName, listBuckets, deleteBucket } from '../db/buckets';
import { import {
findFileByBucketAndKey, listObjectsByPrefix, softDeleteFile, countBucketObjects, findFileByBucketAndKey,
listObjectsByPrefix,
softDeleteFile,
countBucketObjects,
} from '../db/files-ext'; } from '../db/files-ext';
import { createReadStream } from 'node:fs'; import { createReadStream } from 'node:fs';
import { config } from '../env'; import { config } from '../env';
@@ -11,11 +14,9 @@ import logger from '../utils/logger';
type RouteParams = { bucket?: string; key?: string }; type RouteParams = { bucket?: string; key?: string };
const json = (data: unknown, status = 200) => const json = (data: unknown, status = 200) => Response.json(data, { status });
Response.json(data, { status });
const jsonError = (error: string, status: number) => const jsonError = (error: string, status: number) => Response.json({ error }, { status });
Response.json({ error }, { status });
// ─────── Bucket endpoints ─────── // ─────── Bucket endpoints ───────
@@ -43,7 +44,10 @@ export const handleCreateBucketV1 = async (req: Request): Promise<Response> => {
return json({ id: bucket.id, name: bucket.name }, 201); return json({ id: bucket.id, name: bucket.name }, 201);
}; };
export const handleDeleteBucketV1 = async (_req: Request, params: RouteParams): Promise<Response> => { export const handleDeleteBucketV1 = async (
_req: Request,
params: RouteParams,
): Promise<Response> => {
const bucket = await findBucketByName(params.bucket!); const bucket = await findBucketByName(params.bucket!);
if (!bucket) return jsonError('Bucket not found', 404); if (!bucket) return jsonError('Bucket not found', 404);
const count = await countBucketObjects(bucket.id); 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 continuationToken = url.searchParams.get('continuation-token') || null;
const { objects, prefixes } = await listObjectsByPrefix( const { objects, prefixes } = await listObjectsByPrefix(
bucket.id, prefix, delimiter, maxKeys, continuationToken, bucket.id,
prefix,
delimiter,
maxKeys,
continuationToken,
); );
const isTruncated = objects.length > maxKeys; const isTruncated = objects.length > maxKeys;
const displayObjects = objects.slice(0, maxKeys); const displayObjects = objects.slice(0, maxKeys);
@@ -78,20 +86,22 @@ export const handleListObjectsV1 = async (req: Request, params: RouteParams): Pr
sizeBytes: o.sizeBytes, sizeBytes: o.sizeBytes,
fileType: o.fileType, fileType: o.fileType,
etag: o.fileHash, etag: o.fileHash,
lastModified: o.createdAt instanceof Date lastModified:
o.createdAt instanceof Date
? o.createdAt.toISOString() ? o.createdAt.toISOString()
: new Date(o.createdAt).toISOString(), : new Date(o.createdAt).toISOString(),
downloadUrl: `${config.baseUrl}/f/${o.publicId}`, downloadUrl: `${config.baseUrl}/f/${o.publicId}`,
})), })),
prefixes, prefixes,
isTruncated, isTruncated,
nextContinuationToken: isTruncated nextContinuationToken: isTruncated ? displayObjects[displayObjects.length - 1]?.s3Key : null,
? displayObjects[displayObjects.length - 1]?.s3Key
: null,
}); });
}; };
export const handleUploadObjectV1 = async (req: Request, params: RouteParams): Promise<Response> => { export const handleUploadObjectV1 = async (
req: Request,
params: RouteParams,
): Promise<Response> => {
const bucket = await findBucketByName(params.bucket!); const bucket = await findBucketByName(params.bucket!);
if (!bucket) return jsonError('Bucket not found', 404); if (!bucket) return jsonError('Bucket not found', 404);
@@ -147,17 +157,26 @@ export const handleUploadObjectV1 = async (req: Request, params: RouteParams): P
await cleanupTempFile(tempPath); 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<Response> => { export const handleDeleteObjectV1 = async (
_req: Request,
params: RouteParams,
): Promise<Response> => {
const bucket = await findBucketByName(params.bucket!); const bucket = await findBucketByName(params.bucket!);
if (!bucket) return jsonError('Bucket not found', 404); if (!bucket) return jsonError('Bucket not found', 404);
await softDeleteFile(bucket.id, params.key!); await softDeleteFile(bucket.id, params.key!);
return json({ success: true }); return json({ success: true });
}; };
export const handleDownloadObjectV1 = async (_req: Request, params: RouteParams): Promise<Response> => { export const handleDownloadObjectV1 = async (
_req: Request,
params: RouteParams,
): Promise<Response> => {
const bucket = await findBucketByName(params.bucket!); const bucket = await findBucketByName(params.bucket!);
if (!bucket) return jsonError('Bucket not found', 404); if (!bucket) return jsonError('Bucket not found', 404);
@@ -241,12 +260,22 @@ export const handleWebApiV1 = async (req: Request): Promise<Response> => {
} }
// GET /api/v1/buckets/{name}/objects // 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] }); return await handleListObjectsV1(req, { bucket: parts[1] });
} }
// POST /api/v1/buckets/{name}/upload // 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] }); return await handleUploadObjectV1(req, { bucket: parts[1] });
} }
@@ -263,7 +292,12 @@ export const handleWebApiV1 = async (req: Request): Promise<Response> => {
} }
// GET /api/v1/buckets/{name}/download/{key+} // 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 bucket = parts[1];
const key = parts.slice(3).join('/'); const key = parts.slice(3).join('/');
return await handleDownloadObjectV1(req, { bucket, key }); return await handleDownloadObjectV1(req, { bucket, key });
+48 -16
View File
@@ -16,10 +16,14 @@ export const listBucketsXml = (
): string => `<?xml version="1.0" encoding="UTF-8"?> ): string => `<?xml version="1.0" encoding="UTF-8"?>
<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> <ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Buckets> <Buckets>
${buckets.map((b) => `<Bucket> ${buckets
.map(
(b) => `<Bucket>
<Name>${escapeXml(b.name)}</Name> <Name>${escapeXml(b.name)}</Name>
<CreationDate>${isoDate(b.createdAt)}</CreationDate> <CreationDate>${isoDate(b.createdAt)}</CreationDate>
</Bucket>`).join('')} </Bucket>`,
)
.join('')}
</Buckets> </Buckets>
</ListAllMyBucketsResult>`; </ListAllMyBucketsResult>`;
@@ -44,16 +48,24 @@ export const listBucketResultXml = (
<MaxKeys>${maxKeys}</MaxKeys> <MaxKeys>${maxKeys}</MaxKeys>
<Delimiter>${escapeXml(delimiter || '')}</Delimiter> <Delimiter>${escapeXml(delimiter || '')}</Delimiter>
<IsTruncated>${isTruncated}</IsTruncated> <IsTruncated>${isTruncated}</IsTruncated>
${objects.map((o) => `<Contents> ${objects
.map(
(o) => `<Contents>
<Key>${escapeXml(o.key)}</Key> <Key>${escapeXml(o.key)}</Key>
<LastModified>${isoDate(o.lastModified)}</LastModified> <LastModified>${isoDate(o.lastModified)}</LastModified>
<ETag>"${o.etag}"</ETag> <ETag>"${o.etag}"</ETag>
<Size>${o.sizeBytes}</Size> <Size>${o.sizeBytes}</Size>
<StorageClass>STANDARD</StorageClass> <StorageClass>STANDARD</StorageClass>
</Contents>`).join('')} </Contents>`,
${prefixes.map((p) => `<CommonPrefixes> )
.join('')}
${prefixes
.map(
(p) => `<CommonPrefixes>
<Prefix>${escapeXml(p)}</Prefix> <Prefix>${escapeXml(p)}</Prefix>
</CommonPrefixes>`).join('')} </CommonPrefixes>`,
)
.join('')}
${nextMarker ? `<NextMarker>${escapeXml(nextMarker)}</NextMarker>` : ''} ${nextMarker ? `<NextMarker>${escapeXml(nextMarker)}</NextMarker>` : ''}
</ListBucketResult>`; </ListBucketResult>`;
@@ -78,16 +90,24 @@ export const listBucketV2ResultXml = (
${delimiter ? `<Delimiter>${escapeXml(delimiter)}</Delimiter>` : ''} ${delimiter ? `<Delimiter>${escapeXml(delimiter)}</Delimiter>` : ''}
${continuationToken ? `<ContinuationToken>${escapeXml(continuationToken)}</ContinuationToken>` : ''} ${continuationToken ? `<ContinuationToken>${escapeXml(continuationToken)}</ContinuationToken>` : ''}
<IsTruncated>${isTruncated}</IsTruncated> <IsTruncated>${isTruncated}</IsTruncated>
${objects.map((o) => `<Contents> ${objects
.map(
(o) => `<Contents>
<Key>${escapeXml(o.key)}</Key> <Key>${escapeXml(o.key)}</Key>
<LastModified>${isoDate(o.lastModified)}</LastModified> <LastModified>${isoDate(o.lastModified)}</LastModified>
<ETag>"${o.etag}"</ETag> <ETag>"${o.etag}"</ETag>
<Size>${o.sizeBytes}</Size> <Size>${o.sizeBytes}</Size>
<StorageClass>STANDARD</StorageClass> <StorageClass>STANDARD</StorageClass>
</Contents>`).join('')} </Contents>`,
${prefixes.map((p) => `<CommonPrefixes> )
.join('')}
${prefixes
.map(
(p) => `<CommonPrefixes>
<Prefix>${escapeXml(p)}</Prefix> <Prefix>${escapeXml(p)}</Prefix>
</CommonPrefixes>`).join('')} </CommonPrefixes>`,
)
.join('')}
${nextContinuationToken ? `<NextContinuationToken>${escapeXml(nextContinuationToken)}</NextContinuationToken>` : ''} ${nextContinuationToken ? `<NextContinuationToken>${escapeXml(nextContinuationToken)}</NextContinuationToken>` : ''}
</ListBucketResultV2>`; </ListBucketResultV2>`;
@@ -119,12 +139,16 @@ export const listPartsXml = (
<UploadId>${uploadId}</UploadId> <UploadId>${uploadId}</UploadId>
<MaxParts>${maxParts}</MaxParts> <MaxParts>${maxParts}</MaxParts>
<IsTruncated>${isTruncated}</IsTruncated> <IsTruncated>${isTruncated}</IsTruncated>
${parts.map((p) => `<Part> ${parts
.map(
(p) => `<Part>
<PartNumber>${p.partNumber}</PartNumber> <PartNumber>${p.partNumber}</PartNumber>
<LastModified>${isoDate(p.createdAt)}</LastModified> <LastModified>${isoDate(p.createdAt)}</LastModified>
<ETag>"${p.etag}"</ETag> <ETag>"${p.etag}"</ETag>
<Size>${p.sizeBytes}</Size> <Size>${p.sizeBytes}</Size>
</Part>`).join('')} </Part>`,
)
.join('')}
</ListPartsResult>`; </ListPartsResult>`;
export const completeMultipartUploadXml = ( export const completeMultipartUploadXml = (
@@ -147,14 +171,22 @@ export const deleteResultXml = (
errors: { key: string; code: string; message: string }[], errors: { key: string; code: string; message: string }[],
): string => `<?xml version="1.0" encoding="UTF-8"?> ): string => `<?xml version="1.0" encoding="UTF-8"?>
<DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> <DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
${deleted.map((key) => `<Deleted> ${deleted
.map(
(key) => `<Deleted>
<Key>${escapeXml(key)}</Key> <Key>${escapeXml(key)}</Key>
</Deleted>`).join('')} </Deleted>`,
${errors.map((e) => `<Error> )
.join('')}
${errors
.map(
(e) => `<Error>
<Key>${escapeXml(e.key)}</Key> <Key>${escapeXml(e.key)}</Key>
<Code>${e.code}</Code> <Code>${e.code}</Code>
<Message>${escapeXml(e.message)}</Message> <Message>${escapeXml(e.message)}</Message>
</Error>`).join('')} </Error>`,
)
.join('')}
</DeleteResult>`; </DeleteResult>`;
// ─────── Copy ─────── // ─────── Copy ───────
+38 -6
View File
@@ -19,35 +19,67 @@ describe('S3 Auth (SigV4)', () => {
}); });
it('rejects missing Authorization header', async () => { 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.isValid).toBe(false);
expect(result.errorCode).toBe('AccessDenied'); expect(result.errorCode).toBe('AccessDenied');
}); });
it('rejects wrong access key before signature calculation succeeds', async () => { it('rejects wrong access key before signature calculation succeeds', async () => {
const headers = { 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', 'x-amz-date': '20260706T120000Z',
host: 'localhost', 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.isValid).toBe(false);
expect(result.errorCode).toBe('SignatureDoesNotMatch'); expect(result.errorCode).toBe('SignatureDoesNotMatch');
}); });
it('rejects region mismatch in Authorization credential scope', async () => { it('rejects region mismatch in Authorization credential scope', async () => {
const headers = { 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', 'x-amz-date': '20260706T120000Z',
host: 'localhost', 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.isValid).toBe(false);
expect(result.errorCode).toBe('SignatureDoesNotMatch'); expect(result.errorCode).toBe('SignatureDoesNotMatch');
}); });
it('rejects malformed presigned URLs', async () => { 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.isValid).toBe(false);
expect(result.errorCode).toBe('AccessDenied'); expect(result.errorCode).toBe('AccessDenied');
}); });
+37 -8
View File
@@ -17,7 +17,15 @@ describe('S3 XML Builders', () => {
const xml = await import('../src/utils/s3/xml'); const xml = await import('../src/utils/s3/xml');
const result = xml.listBucketResultXml( const result = xml.listBucketResultXml(
'my-bucket', '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/'], ['photos/'],
false, false,
null, null,
@@ -37,7 +45,15 @@ describe('S3 XML Builders', () => {
const xml = await import('../src/utils/s3/xml'); const xml = await import('../src/utils/s3/xml');
const result = xml.listBucketV2ResultXml( const result = xml.listBucketV2ResultXml(
'my-bucket', '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, false,
1000, 1000,
@@ -55,14 +71,25 @@ describe('S3 XML Builders', () => {
it('builds multipart and copy XML responses', async () => { it('builds multipart and copy XML responses', async () => {
const xml = await import('../src/utils/s3/xml'); const xml = await import('../src/utils/s3/xml');
expect(xml.initiateMultipartUploadXml('bucket', 'key', 'upload-123')).toContain('<UploadId>upload-123</UploadId>'); expect(xml.initiateMultipartUploadXml('bucket', 'key', 'upload-123')).toContain(
expect(xml.completeMultipartUploadXml('bucket', 'key', 'etag-abc', 'http://localhost/bucket/key')).toContain('<CompleteMultipartUploadResult'); '<UploadId>upload-123</UploadId>',
expect(xml.copyObjectResultXml('etag-abc', new Date('2026-01-01T00:00:00Z'))).toContain('<CopyObjectResult'); );
expect(
xml.completeMultipartUploadXml('bucket', 'key', 'etag-abc', 'http://localhost/bucket/key'),
).toContain('<CompleteMultipartUploadResult');
expect(xml.copyObjectResultXml('etag-abc', new Date('2026-01-01T00:00:00Z'))).toContain(
'<CopyObjectResult',
);
}); });
it('builds error XML and error Response', async () => { it('builds error XML and error Response', async () => {
const xml = await import('../src/utils/s3/xml'); 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('<Code>NoSuchBucket</Code>'); expect(result).toContain('<Code>NoSuchBucket</Code>');
expect(result).toContain('<RequestId>req-1</RequestId>'); expect(result).toContain('<RequestId>req-1</RequestId>');
@@ -73,7 +100,8 @@ describe('S3 XML Builders', () => {
it('parses DeleteObjects body', async () => { it('parses DeleteObjects body', async () => {
const xml = await import('../src/utils/s3/xml'); const xml = await import('../src/utils/s3/xml');
const body = '<Delete><Object><Key>file1.txt</Key></Object><Object><Key>file2.txt</Key></Object><Quiet>true</Quiet></Delete>'; const body =
'<Delete><Object><Key>file1.txt</Key></Object><Object><Key>file2.txt</Key></Object><Quiet>true</Quiet></Delete>';
const { keys, quiet } = xml.parseDeleteObjectsBody(body); const { keys, quiet } = xml.parseDeleteObjectsBody(body);
expect(keys).toEqual(['file1.txt', 'file2.txt']); expect(keys).toEqual(['file1.txt', 'file2.txt']);
expect(quiet).toBe(true); expect(quiet).toBe(true);
@@ -81,7 +109,8 @@ describe('S3 XML Builders', () => {
it('parses CompleteMultipartUpload body', async () => { it('parses CompleteMultipartUpload body', async () => {
const xml = await import('../src/utils/s3/xml'); const xml = await import('../src/utils/s3/xml');
const body = '<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>"abc"</ETag></Part><Part><PartNumber>2</PartNumber><ETag>"def"</ETag></Part></CompleteMultipartUpload>'; const body =
'<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>"abc"</ETag></Part><Part><PartNumber>2</PartNumber><ETag>"def"</ETag></Part></CompleteMultipartUpload>';
const parts = xml.parseCompleteMultipartBody(body); const parts = xml.parseCompleteMultipartBody(body);
expect(parts).toEqual([ expect(parts).toEqual([
{ partNumber: 1, etag: 'abc' }, { partNumber: 1, etag: 'abc' },
+10 -3
View File
@@ -1,13 +1,20 @@
import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test'; import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test';
const mockBuckets = [ 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', () => ({ mock.module('../src/db/buckets', () => ({
listBuckets: () => Promise.resolve(mockBuckets), listBuckets: () => Promise.resolve(mockBuckets),
findBucketByName: (name: string) => Promise.resolve(mockBuckets.find((b) => b.name === name) || null), findBucketByName: (name: string) =>
createBucket: (name: string) => Promise.resolve({ id: 'new-uuid', name, createdAt: new Date(), updatedAt: new Date() }), 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), deleteBucket: () => Promise.resolve(true),
bucketExists: () => Promise.resolve(false), bucketExists: () => Promise.resolve(false),
})); }));