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:
+1
-4
@@ -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);
|
||||||
|
|||||||
+4
-4
@@ -12,10 +12,10 @@ export const runMigration = async (): Promise<void> => {
|
|||||||
// In source via bun --hot: import.meta.dir = .../src/db/
|
// In source via bun --hot: import.meta.dir = .../src/db/
|
||||||
const dir = import.meta.dir || '';
|
const dir = import.meta.dir || '';
|
||||||
const candidates = [
|
const candidates = [
|
||||||
dir + '/../../schema.sql', // from dist/
|
dir + '/../../schema.sql', // from dist/
|
||||||
dir + '/../schema.sql', // from src/ (bun --hot src/index.ts)
|
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/db/ (bun --hot src/db/migrate.ts)
|
||||||
dir + '/schema.sql', // from src/ (bun run db:migrate)
|
dir + '/schema.sql', // from src/ (bun run db:migrate)
|
||||||
];
|
];
|
||||||
|
|
||||||
let schemaSql: string | null = null;
|
let schemaSql: string | null = null;
|
||||||
|
|||||||
+376
-62
@@ -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);
|
||||||
|
|||||||
+54
-20
@@ -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.toISOString()
|
o.createdAt instanceof Date
|
||||||
: new Date(o.createdAt).toISOString(),
|
? 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
@@ -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
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user