refactor: full DDD + Clean Architecture refactor
Deploy FileDrop / deploy (push) Successful in 48s

- Hapus src/utils/ (17 files) + src/db/ (8 files) dead code
- Absorb 7 re-export stubs → real impl di lokasi DDD
- Buat src/infrastructure/di.ts (DI container)
- Rewrite 5 controllers pakai repository/DI
- Fix shared/utils imports, env.ts, routes, index.ts
- Update package.json build path migrate
- Lint clean, build clean
This commit is contained in:
Claude
2026-07-29 15:41:50 +07:00
parent 3580001b8a
commit 4c216b1d9f
44 changed files with 1223 additions and 3364 deletions
@@ -11,7 +11,7 @@ import {
createSessionCookie,
getAuthSession,
isAuthEnabled,
} from '../../../utils/auth';
} from '../middleware/auth';
/**
* Helper that builds a JSON Response with optional extra headers.
@@ -2,11 +2,11 @@ import { createReadStream } from 'node:fs';
import { nanoid } from 'nanoid';
import type { TelegramFileInfo } from '../../../domain/ports/telegram-service';
import { fileInfoCache } from '../../../infrastructure/cache/index';
import { chunkedStorage } from '../../../infrastructure/di';
import { botPool } from '../../../infrastructure/telegram/bot-pool';
import logger from '../../../shared/logger/index';
import { cleanupTempFile, formatCreatedAt, getErrorMessage } from '../../../shared/utils/file';
import { createChunkedObjectResponse } from '../../../utils/chunked-storage';
import { locateZipEntry } from '../../../utils/zip';
import { locateZipEntry } from '../../../shared/utils/zip';
/**
* Extended Request type that includes route parameter access.
@@ -115,7 +115,7 @@ export const handleFileRedirect = async (req: RequestWithParams): Promise<Respon
return fail(501, 'Archive entry extraction is not supported for chunked files');
}
const range = { type: 'none' as const };
return createChunkedObjectResponse({ file, range, reqId: '' });
return chunkedStorage.createChunkedObjectResponse({ file, range, reqId: '' });
}
const archiveEntryName = file.archiveEntryName;
@@ -1,36 +1,22 @@
import { createReadStream } from 'node:fs';
import { nanoid } from 'nanoid';
import { config } from '../../../config/index';
import { createBucket, deleteBucket, findBucketByName, listBuckets } from '../../../db/buckets';
import {
countBucketObjects,
findFileByBucketAndKey,
listObjectsByPrefix,
softDeleteFile,
} from '../../../db/files-ext';
import { db, files as fileSchema } from '../../../db/index';
import {
abortMultipartUpload,
completeMultipartUpload,
createMultipartUpload,
findMultipartUpload,
insertMultipartPart,
listMultipartParts,
listMultipartUploadsByBucket,
} from '../../../db/multipart';
import type { File } from '../../../db/schema';
import type { File as FileEntity } from '../../../domain/entities/file';
import type { ForwardResult } from '../../../domain/ports/telegram-service';
import {
bucketRepository,
chunkedStorage,
fileRepository,
multipartRepository,
} from '../../../infrastructure/di';
import { db, files as fileSchema } from '../../../infrastructure/persistence/drizzle/index';
import { botPool } from '../../../infrastructure/telegram/bot-pool';
import logger from '../../../shared/logger/index';
import { cleanupTempFile, ensureExtension, getErrorMessage } from '../../../shared/utils/file';
import {
createChunkedObjectResponse,
storeFileInTelegramChunks,
} from '../../../utils/chunked-storage';
import { verifyBodyHash, verifyPresignedUrl, verifySignature } from '../../../utils/s3/auth';
import { S3_CORS_HEADERS, s3Headers } from '../../../utils/s3/headers';
import { createGetObjectResponse, type ObjectPartSource } from '../../../utils/s3/object-stream';
import { parseRangeHeader, unsatisfiedContentRange } from '../../../utils/s3/range';
import { verifyBodyHash, verifyPresignedUrl, verifySignature } from '../../s3/auth';
import { S3_CORS_HEADERS, s3Headers } from '../../s3/headers';
import { createGetObjectResponse, type ObjectPartSource } from '../../s3/object-stream';
import { parseRangeHeader, unsatisfiedContentRange } from '../../s3/range';
import {
bucketVersioningConfigurationXml,
completeMultipartUploadXml,
@@ -45,7 +31,7 @@ import {
parseCompleteMultipartBody,
parseDeleteObjectsBody,
s3ErrorResponse,
} from '../../../utils/s3/xml';
} from '../../s3/xml';
/**
* The default S3 region returned when no region is explicitly configured.
@@ -309,7 +295,7 @@ export const handleS3Request = async (
* @returns An S3 XML response with the bucket list.
*/
const handleListBuckets = async (reqId: string): Promise<Response> => {
const buckets = await listBuckets();
const buckets = await bucketRepository.list();
const xml = listBucketsXml(buckets, reqId);
return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' });
};
@@ -339,7 +325,7 @@ const handleCreateBucket = async (bucketName: string, reqId: string): Promise<Re
reqId,
);
}
const existing = await findBucketByName(bucketName);
const existing = await bucketRepository.findByName(bucketName);
if (existing) {
return s3ErrorResponse(
'BucketAlreadyExists',
@@ -349,7 +335,7 @@ const handleCreateBucket = async (bucketName: string, reqId: string): Promise<Re
reqId,
);
}
await createBucket(bucketName);
await bucketRepository.create(bucketName);
return s3Response(null, 200, reqId);
};
@@ -361,7 +347,7 @@ const handleCreateBucket = async (bucketName: string, reqId: string): Promise<Re
* @returns A 200 response when the bucket exists, or an S3 XML error.
*/
const handleHeadBucket = async (bucketName: string, reqId: string): Promise<Response> => {
const bucket = await findBucketByName(bucketName);
const bucket = await bucketRepository.findByName(bucketName);
if (!bucket) {
return s3ErrorResponse(
'NoSuchBucket',
@@ -384,7 +370,7 @@ const handleHeadBucket = async (bucketName: string, reqId: string): Promise<Resp
* @returns A 204 response on success, or an S3 XML error.
*/
const handleDeleteBucket = async (bucketName: string, reqId: string): Promise<Response> => {
const bucket = await findBucketByName(bucketName);
const bucket = await bucketRepository.findByName(bucketName);
if (!bucket) {
return s3ErrorResponse(
'NoSuchBucket',
@@ -394,7 +380,7 @@ const handleDeleteBucket = async (bucketName: string, reqId: string): Promise<Re
reqId,
);
}
const objCount = await countBucketObjects(bucket.id);
const objCount = await fileRepository.countByBucket(bucket.id);
if (objCount > 0) {
return s3ErrorResponse(
'BucketNotEmpty',
@@ -404,7 +390,7 @@ const handleDeleteBucket = async (bucketName: string, reqId: string): Promise<Re
reqId,
);
}
await deleteBucket(bucketName);
await bucketRepository.delete(bucketName);
return s3Response(null, 204, reqId);
};
@@ -417,7 +403,7 @@ const handleDeleteBucket = async (bucketName: string, reqId: string): Promise<Re
* @returns An S3 XML response with the versioning configuration.
*/
const handleGetBucketVersioning = async (bucketName: string, reqId: string): Promise<Response> => {
const bucket = await findBucketByName(bucketName);
const bucket = await bucketRepository.findByName(bucketName);
if (!bucket) {
return s3ErrorResponse(
'NoSuchBucket',
@@ -456,7 +442,7 @@ const handleGetObject = async (
headers: Record<string, string>,
reqId: string,
): Promise<Response> => {
const bucketRecord = await findBucketByName(bucket);
const bucketRecord = await bucketRepository.findByName(bucket);
if (!bucketRecord)
return s3ErrorResponse(
'NoSuchBucket',
@@ -466,7 +452,7 @@ const handleGetObject = async (
reqId,
);
const file = await findFileByBucketAndKey(bucketRecord.id, key);
const file = await fileRepository.findByBucketAndKey(bucketRecord.id, key);
if (!file)
return s3ErrorResponse(
'NoSuchKey',
@@ -551,7 +537,7 @@ const handleGetObject = async (
);
}
try {
return await createChunkedObjectResponse({ file, range, reqId });
return await chunkedStorage.createChunkedObjectResponse({ file, range, reqId });
} catch (error) {
logger.warn('Chunked object content fetch failed', { key, error: getErrorMessage(error) });
return s3ErrorResponse(
@@ -638,14 +624,14 @@ const handleGetObject = async (
* @returns An S3 response streaming the assembled object content.
*/
const handleGetMultipartObject = async (
file: File,
file: FileEntity,
bucket: string,
key: string,
headers: Record<string, string>,
reqId: string,
): Promise<Response> => {
const uploadId = file.multipartUploadId!;
const parts = await listMultipartParts(uploadId);
const parts = await multipartRepository.listParts(uploadId);
if (parts.length === 0) {
return s3ErrorResponse(
@@ -724,7 +710,7 @@ const handleHeadObject = async (
headers: Record<string, string>,
reqId: string,
): Promise<Response> => {
const bucketRecord = await findBucketByName(bucket);
const bucketRecord = await bucketRepository.findByName(bucket);
if (!bucketRecord)
return s3ErrorResponse(
'NoSuchBucket',
@@ -734,7 +720,7 @@ const handleHeadObject = async (
reqId,
);
const file = await findFileByBucketAndKey(bucketRecord.id, key);
const file = await fileRepository.findByBucketAndKey(bucketRecord.id, key);
if (!file)
return s3ErrorResponse(
'NoSuchKey',
@@ -930,7 +916,7 @@ const handlePutObject = async (
req: Request,
reqId: string,
): Promise<Response> => {
const bucketRecord = await findBucketByName(bucket);
const bucketRecord = await bucketRepository.findByName(bucket);
if (!bucketRecord)
return s3ErrorResponse(
'NoSuchBucket',
@@ -1011,7 +997,7 @@ const handlePutObject = async (
// Idempotent PUT: if the object already exists, skip upload
try {
const existing = await findFileByBucketAndKey(bucketRecord.id, key);
const existing = await fileRepository.findByBucketAndKey(bucketRecord.id, key);
if (existing) {
await cleanupTempFile(streamed.tempPath);
return s3Response(null, 200, reqId, { etag: `"${streamed.fileHash}"` });
@@ -1057,7 +1043,7 @@ const storeFileFromTemp = async (
const partFileNamePrefix = `s3-${bucketRecord.name}-${key.replace(/\//g, '_')}`;
if (streamed.sizeBytes > config.telegramChunkSizeBytes) {
const file = await storeFileInTelegramChunks({
const file = await chunkedStorage.storeFileInTelegramChunks({
tempPath: streamed.tempPath,
partFileNamePrefix,
fileName: finalFileName,
@@ -1138,7 +1124,7 @@ const handleCopyObject = async (
const sourceBucket = parts[0];
const sourceKey = parts.slice(1).join('/');
const sourceBucketRecord = await findBucketByName(sourceBucket);
const sourceBucketRecord = await bucketRepository.findByName(sourceBucket);
if (!sourceBucketRecord)
return s3ErrorResponse(
'NoSuchBucket',
@@ -1148,7 +1134,7 @@ const handleCopyObject = async (
reqId,
);
const sourceFile = await findFileByBucketAndKey(sourceBucketRecord.id, sourceKey);
const sourceFile = await fileRepository.findByBucketAndKey(sourceBucketRecord.id, sourceKey);
if (!sourceFile)
return s3ErrorResponse(
'NoSuchKey',
@@ -1232,7 +1218,7 @@ const handleDeleteObject = async (
key: string,
reqId: string,
): Promise<Response> => {
const bucketRecord = await findBucketByName(bucket);
const bucketRecord = await bucketRepository.findByName(bucket);
if (!bucketRecord)
return s3ErrorResponse(
'NoSuchBucket',
@@ -1242,7 +1228,7 @@ const handleDeleteObject = async (
reqId,
);
await softDeleteFile(bucketRecord.id, key);
await fileRepository.softDelete(bucketRecord.id, key);
return s3Response(null, 204, reqId);
};
@@ -1262,7 +1248,7 @@ const handleDeleteObjects = async (
body: string,
reqId: string,
): Promise<Response> => {
const bucketRecord = await findBucketByName(bucket);
const bucketRecord = await bucketRepository.findByName(bucket);
if (!bucketRecord)
return s3ErrorResponse(
'NoSuchBucket',
@@ -1288,7 +1274,7 @@ const handleDeleteObjects = async (
const deletedKeys: string[] = [];
const errors: Array<{ key: string; code: string; message: string }> = [];
for (const key of keys) {
const ok = await softDeleteFile(bucketRecord.id, key);
const ok = await fileRepository.softDelete(bucketRecord.id, key);
if (ok) {
deletedKeys.push(key);
} else {
@@ -1316,7 +1302,7 @@ const handleListObjectsV1 = async (
searchParams: URLSearchParams,
reqId: string,
): Promise<Response> => {
const bucketRecord = await findBucketByName(bucket);
const bucketRecord = await bucketRepository.findByName(bucket);
if (!bucketRecord)
return s3ErrorResponse(
'NoSuchBucket',
@@ -1335,7 +1321,7 @@ const handleListObjectsV1 = async (
const marker = searchParams.get('marker') || null;
const encodingType = searchParams.get('encoding-type') || null;
const { objects, prefixes: commonPrefixes } = await listObjectsByPrefix(
const { objects, prefixes: commonPrefixes } = await fileRepository.listByPrefix(
bucketRecord.id,
prefix,
delimiter,
@@ -1386,7 +1372,7 @@ const handleListObjectsV2 = async (
searchParams: URLSearchParams,
reqId: string,
): Promise<Response> => {
const bucketRecord = await findBucketByName(bucket);
const bucketRecord = await bucketRepository.findByName(bucket);
if (!bucketRecord)
return s3ErrorResponse(
'NoSuchBucket',
@@ -1403,7 +1389,7 @@ const handleListObjectsV2 = async (
const startAfter = searchParams.get('start-after') || null;
const encodingType = searchParams.get('encoding-type') || null;
const { objects, prefixes: commonPrefixes } = await listObjectsByPrefix(
const { objects, prefixes: commonPrefixes } = await fileRepository.listByPrefix(
bucketRecord.id,
prefix,
delimiter,
@@ -1459,7 +1445,7 @@ const handleCreateMultipartUpload = async (
headers: Record<string, string>,
reqId: string,
): Promise<Response> => {
const bucketRecord = await findBucketByName(bucket);
const bucketRecord = await bucketRepository.findByName(bucket);
if (!bucketRecord)
return s3ErrorResponse(
'NoSuchBucket',
@@ -1470,7 +1456,7 @@ const handleCreateMultipartUpload = async (
);
const contentType = headers['content-type'] || null;
const uploadId = await createMultipartUpload(bucketRecord.id, key, 's3', contentType);
const uploadId = await multipartRepository.create(bucketRecord.id, key, 's3', contentType);
const xml = initiateMultipartUploadXml(bucket, key, uploadId);
return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' });
@@ -1514,7 +1500,7 @@ const handleUploadPart = async (
);
}
const multipart = await findMultipartUpload(uploadId);
const multipart = await multipartRepository.findById(uploadId);
if (!multipart || multipart.s3Key !== key) {
return s3ErrorResponse(
'NoSuchUpload',
@@ -1583,7 +1569,7 @@ const handleUploadPart = async (
await cleanupTempFile(tempPath);
const etag = hasher.digest('hex');
await insertMultipartPart({
await multipartRepository.insertPart({
uploadId,
partNumber,
telegramFileId: forwardResult.telegramFileId,
@@ -1617,7 +1603,7 @@ const handleCompleteMultipartUpload = async (
reqId: string,
): Promise<Response> => {
const uploadId = searchParams.get('uploadId')!;
const multipart = await findMultipartUpload(uploadId);
const multipart = await multipartRepository.findById(uploadId);
// H5: Verify both upload exists AND key matches (consistent with handleUploadPart)
if (!multipart || multipart.s3Key !== key) {
return s3ErrorResponse(
@@ -1630,7 +1616,7 @@ const handleCompleteMultipartUpload = async (
}
const parts = parseCompleteMultipartBody(body);
const storedParts = await listMultipartParts(uploadId);
const storedParts = await multipartRepository.listParts(uploadId);
// Validate ascending part order
const partNumbers = parts.map((p) => p.partNumber);
@@ -1702,7 +1688,7 @@ const handleCompleteMultipartUpload = async (
updatedAt: new Date(),
});
await completeMultipartUpload(uploadId);
await multipartRepository.complete(uploadId);
const location = `${config.baseUrl}/${bucket}/${key}`;
const xml = completeMultipartUploadXml(bucket, key, combinedEtag, location);
@@ -1723,7 +1709,7 @@ const handleListMultipartUploads = async (
searchParams: URLSearchParams,
reqId: string,
): Promise<Response> => {
const bucketRecord = await findBucketByName(bucket);
const bucketRecord = await bucketRepository.findByName(bucket);
if (!bucketRecord)
return s3ErrorResponse(
'NoSuchBucket',
@@ -1735,7 +1721,7 @@ const handleListMultipartUploads = async (
const maxUploads = Math.min(Number.parseInt(searchParams.get('max-uploads') || '1000', 10), 1000);
const keyMarker = searchParams.get('key-marker') || null;
const { uploads, isTruncated, nextKeyMarker } = await listMultipartUploadsByBucket(
const { uploads, isTruncated, nextKeyMarker } = await multipartRepository.listByBucket(
bucketRecord.id,
maxUploads,
keyMarker,
@@ -1774,7 +1760,7 @@ const handleAbortMultipartUpload = async (
reqId: string,
): Promise<Response> => {
const uploadId = searchParams.get('uploadId')!;
const multipart = await findMultipartUpload(uploadId);
const multipart = await multipartRepository.findById(uploadId);
if (!multipart) {
return s3ErrorResponse(
'NoSuchUpload',
@@ -1785,7 +1771,7 @@ const handleAbortMultipartUpload = async (
);
}
await abortMultipartUpload(uploadId);
await multipartRepository.abort(uploadId);
return s3Response(null, 204, reqId);
};
@@ -1807,7 +1793,7 @@ const handleListParts = async (
reqId: string,
): Promise<Response> => {
const uploadId = searchParams.get('uploadId')!;
const multipart = await findMultipartUpload(uploadId);
const multipart = await multipartRepository.findById(uploadId);
if (!multipart) {
return s3ErrorResponse(
'NoSuchUpload',
@@ -1818,7 +1804,7 @@ const handleListParts = async (
);
}
const parts = await listMultipartParts(uploadId);
const parts = await multipartRepository.listParts(uploadId);
const maxParts = Math.min(Number.parseInt(searchParams.get('max-parts') || '1000', 10), 1000);
const xml = listPartsXml(
@@ -1,7 +1,8 @@
import { createWriteStream } from 'node:fs';
import { nanoid } from 'nanoid';
import { config } from '../../../config/index';
import { findFileByHash } from '../../../db/files';
import { chunkedStorage, fileRepository, uploadBatcher } from '../../../infrastructure/di';
import type { PreparedUpload } from '../../../infrastructure/telegram/upload-batcher';
import logger from '../../../shared/logger/index';
import { metricsCollector } from '../../../shared/metrics/index';
import {
@@ -14,8 +15,6 @@ import {
getErrorMessage,
getFileType,
} from '../../../shared/utils/file';
import { storeFileInTelegramChunks } from '../../../utils/chunked-storage';
import { enqueuePreparedUpload, type PreparedUpload } from '../../../utils/uploadBatcher';
/**
* Maximum allowed size (in bytes) for a base64 JSON upload.
@@ -223,7 +222,7 @@ const handleMultipartUpload = async (req: Request): Promise<Response> => {
const prepared = await streamFileToTemp(file, config.maxRequestBodyBytes);
const existingFile = await findFileByHash(prepared.fileHash);
const existingFile = await fileRepository.findByHash(prepared.fileHash);
if (existingFile) {
await cleanupTempFile(prepared.tempPath);
return Response.json(buildUploadResponse(existingFile, config.baseUrl), { status: 200 });
@@ -243,7 +242,7 @@ const handleMultipartUpload = async (req: Request): Promise<Response> => {
}
if (prepared.sizeBytes > config.telegramChunkSizeBytes) {
const uploadedFile = await storeFileInTelegramChunks({
const uploadedFile = await chunkedStorage.storeFileInTelegramChunks({
tempPath: prepared.tempPath,
partFileNamePrefix: `direct-${prepared.fileHash?.slice(0, 16) || 'upload'}`,
fileName: finalFileName,
@@ -256,7 +255,7 @@ const handleMultipartUpload = async (req: Request): Promise<Response> => {
return Response.json(buildUploadResponse(uploadedFile, config.baseUrl), { status: 200 });
}
const uploaded = await enqueuePreparedUpload({
const uploaded = await uploadBatcher.enqueuePreparedUpload({
prepared,
fileName: finalFileName,
mimeType,
@@ -317,7 +316,7 @@ const handleJSONUpload = async (req: Request): Promise<Response> => {
const fileBytes = Buffer.from(base64Data, 'base64');
const hash = computeHash(fileBytes);
const existingFile = await findFileByHash(hash);
const existingFile = await fileRepository.findByHash(hash);
if (existingFile) {
return Response.json(buildUploadResponse(existingFile, config.baseUrl), { status: 200 });
}
@@ -334,7 +333,7 @@ const handleJSONUpload = async (req: Request): Promise<Response> => {
const prepared = await writeBufferToTemp(fileBytes, hash);
if (prepared.sizeBytes > config.telegramChunkSizeBytes) {
const uploadedFile = await storeFileInTelegramChunks({
const uploadedFile = await chunkedStorage.storeFileInTelegramChunks({
tempPath: prepared.tempPath,
partFileNamePrefix: `direct-${prepared.fileHash?.slice(0, 16) || 'json'}`,
fileName: finalFileName,
@@ -347,7 +346,7 @@ const handleJSONUpload = async (req: Request): Promise<Response> => {
return Response.json(buildUploadResponse(uploadedFile, config.baseUrl), { status: 200 });
}
const uploaded = await enqueuePreparedUpload({
const uploaded = await uploadBatcher.enqueuePreparedUpload({
prepared,
fileName: finalFileName,
mimeType,
@@ -1,21 +1,11 @@
import { createReadStream } from 'node:fs';
import { nanoid } from 'nanoid';
import { config } from '../../../config/index';
import { createBucket, deleteBucket, findBucketByName, listBuckets } from '../../../db/buckets';
import {
countBucketObjects,
findFileByBucketAndKey,
listObjectsByPrefix,
softDeleteFile,
} from '../../../db/files-ext';
import { db, files as fileSchema } from '../../../db/index';
import { bucketRepository, chunkedStorage, fileRepository } from '../../../infrastructure/di';
import { db, files as fileSchema } from '../../../infrastructure/persistence/drizzle/index';
import { botPool } from '../../../infrastructure/telegram/bot-pool';
import logger from '../../../shared/logger/index';
import { cleanupTempFile, ensureExtension, getErrorMessage } from '../../../shared/utils/file';
import {
createChunkedObjectResponse,
storeFileInTelegramChunks,
} from '../../../utils/chunked-storage';
/**
* Route parameters extracted from the URL path.
@@ -48,13 +38,13 @@ const jsonError = (error: string, status: number): Response => Response.json({ e
* @returns A JSON response with the bucket list.
*/
export const handleListBucketsV1 = async (): Promise<Response> => {
const buckets = await listBuckets();
const buckets = await bucketRepository.list();
const result = await Promise.all(
buckets.map(async (b) => ({
id: b.id,
name: b.name,
createdAt: b.createdAt.toISOString(),
objectCount: await countBucketObjects(b.id),
objectCount: await fileRepository.countByBucket(b.id),
})),
);
return json({ buckets: result });
@@ -73,9 +63,9 @@ export const handleCreateBucketV1 = async (req: Request): Promise<Response> => {
if (!body.name || !/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(body.name)) {
return jsonError('Invalid bucket name. Use lowercase, 3-63 chars, no underscore', 400);
}
const existing = await findBucketByName(body.name);
const existing = await bucketRepository.findByName(body.name);
if (existing) return jsonError('Bucket already exists', 409);
const bucket = await createBucket(body.name);
const bucket = await bucketRepository.create(body.name);
return json({ id: bucket.id, name: bucket.name }, 201);
};
@@ -92,11 +82,11 @@ export const handleDeleteBucketV1 = async (
_req: Request,
params: RouteParams,
): Promise<Response> => {
const bucket = await findBucketByName(params.bucket!);
const bucket = await bucketRepository.findByName(params.bucket!);
if (!bucket) return jsonError('Bucket not found', 404);
const count = await countBucketObjects(bucket.id);
const count = await fileRepository.countByBucket(bucket.id);
if (count > 0) return jsonError('Bucket is not empty', 409);
await deleteBucket(params.bucket!);
await bucketRepository.delete(params.bucket!);
return json({ success: true });
};
@@ -110,7 +100,7 @@ export const handleDeleteBucketV1 = async (
* @returns A JSON response with the object list.
*/
export const handleListObjectsV1 = async (req: Request, params: RouteParams): Promise<Response> => {
const bucket = await findBucketByName(params.bucket!);
const bucket = await bucketRepository.findByName(params.bucket!);
if (!bucket) return jsonError('Bucket not found', 404);
const url = new URL(req.url);
@@ -119,7 +109,7 @@ export const handleListObjectsV1 = async (req: Request, params: RouteParams): Pr
const maxKeys = Number.parseInt(url.searchParams.get('max-keys') || '1000', 10);
const continuationToken = url.searchParams.get('continuation-token') || null;
const { objects, prefixes } = await listObjectsByPrefix(
const { objects, prefixes } = await fileRepository.listByPrefix(
bucket.id,
prefix,
delimiter,
@@ -162,7 +152,7 @@ export const handleUploadObjectV1 = async (
req: Request,
params: RouteParams,
): Promise<Response> => {
const bucket = await findBucketByName(params.bucket!);
const bucket = await bucketRepository.findByName(params.bucket!);
if (!bucket) return jsonError('Bucket not found', 404);
const formData = await req.formData();
@@ -217,7 +207,7 @@ export const handleUploadObjectV1 = async (
const partFileNamePrefix = `s3-${bucket.name}-${key.replace(/\//g, '_')}`;
if (sizeBytes > config.telegramChunkSizeBytes) {
const uploadedFile = await storeFileInTelegramChunks({
const uploadedFile = await chunkedStorage.storeFileInTelegramChunks({
tempPath,
partFileNamePrefix,
fileName: finalFileName,
@@ -287,9 +277,9 @@ export const handleDeleteObjectV1 = async (
_req: Request,
params: RouteParams,
): Promise<Response> => {
const bucket = await findBucketByName(params.bucket!);
const bucket = await bucketRepository.findByName(params.bucket!);
if (!bucket) return jsonError('Bucket not found', 404);
await softDeleteFile(bucket.id, params.key!);
await fileRepository.softDelete(bucket.id, params.key!);
return json({ success: true });
};
@@ -307,15 +297,15 @@ export const handleDownloadObjectV1 = async (
_req: Request,
params: RouteParams,
): Promise<Response> => {
const bucket = await findBucketByName(params.bucket!);
const bucket = await bucketRepository.findByName(params.bucket!);
if (!bucket) return jsonError('Bucket not found', 404);
const file = await findFileByBucketAndKey(bucket.id, params.key!);
const file = await fileRepository.findByBucketAndKey(bucket.id, params.key!);
if (!file) return jsonError('Object not found', 404);
if (file.storageBackend === 'chunked') {
const range = { type: 'none' as const };
return createChunkedObjectResponse({ file, range, reqId: '' });
return chunkedStorage.createChunkedObjectResponse({ file, range, reqId: '' });
}
const fileInfo = await botPool.getFileInfo(file.telegramFileId);
@@ -348,12 +338,12 @@ export const handleCopyObjectV1 = async (req: Request, params: RouteParams): Pro
}
const destBucketName = body.destBucket || params.bucket!;
const sourceBucket = await findBucketByName(params.bucket!);
const destBucket = await findBucketByName(destBucketName);
const sourceBucket = await bucketRepository.findByName(params.bucket!);
const destBucket = await bucketRepository.findByName(destBucketName);
if (!sourceBucket || !destBucket) return jsonError('Bucket not found', 404);
const sourceFile = await findFileByBucketAndKey(sourceBucket.id, body.sourceKey);
const sourceFile = await fileRepository.findByBucketAndKey(sourceBucket.id, body.sourceKey);
if (!sourceFile) return jsonError('Source object not found', 404);
if (sourceFile.storageBackend === 'chunked') {
+89 -7
View File
@@ -1,7 +1,89 @@
export {
checkRateLimit,
cleanupRateLimitCache,
clearRateLimitCache,
getRateLimitStats,
withRateLimit,
} from '../../../utils/rateLimit';
import { config } from '../../../config/index';
import logger from '../../../shared/logger/index';
import { extractClientIp } from '../../../shared/utils/ip';
interface RateLimitEntry {
count: number;
resetTime: number;
}
const rateLimitStore = new Map<string, RateLimitEntry>();
const MAX_STORE_ENTRIES = 50000;
const evictExpiredEntries = (now = Date.now()): number => {
let cleaned = 0;
for (const [key, entry] of rateLimitStore.entries()) {
if (now > entry.resetTime) {
rateLimitStore.delete(key);
cleaned++;
}
}
return cleaned;
};
const ensureStoreCapacity = (now: number): void => {
if (rateLimitStore.size < MAX_STORE_ENTRIES) return;
evictExpiredEntries(now);
while (rateLimitStore.size >= MAX_STORE_ENTRIES) {
const oldestKey = rateLimitStore.keys().next().value;
if (!oldestKey) break;
rateLimitStore.delete(oldestKey);
}
};
export const checkRateLimit = (key: string): boolean => {
const now = Date.now();
const entry = rateLimitStore.get(key);
if (!entry || now > entry.resetTime) {
ensureStoreCapacity(now);
rateLimitStore.set(key, {
count: 1,
resetTime: now + config.rateLimitWindowMs,
});
return true;
}
if (entry.count >= config.rateLimitMaxRequests) {
logger.warn('Rate limit exceeded', { key, count: entry.count });
return false;
}
entry.count++;
return true;
};
export const withRateLimit = <T extends Request>(
handler: (req: T) => Promise<Response>,
): ((req: T) => Promise<Response>) => {
return async (req: T): Promise<Response> => {
const ip = extractClientIp(req);
if (!checkRateLimit(ip)) {
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 });
}
return handler(req);
};
};
export const cleanupRateLimitCache = (): void => {
const cleaned = evictExpiredEntries();
if (cleaned > 0) {
logger.debug('Rate limit cache cleanup', { cleaned, remaining: rateLimitStore.size });
}
};
export const getRateLimitStats = () => ({
trackedIPs: rateLimitStore.size,
windowSize: config.rateLimitWindowMs,
maxRequests: config.rateLimitMaxRequests,
maxTrackedIPs: MAX_STORE_ENTRIES,
});
export const clearRateLimitCache = (): void => {
rateLimitStore.clear();
};
+1 -1
View File
@@ -1,7 +1,7 @@
import { config } from '../../../config/index';
import { handleSwaggerHtml, handleSwaggerJson } from '../../../routes/swagger';
import { extractS3BucketFromHost } from '../../../utils/s3/virtual-host';
import { isS3Request } from '../../s3/auth';
import { extractS3BucketFromHost } from '../../s3/virtual-host';
import { handleLogin, handleLogout, handleMe } from '../controllers/auth-controller';
import { handleFileInfo, handleFileRedirect } from '../controllers/file-controller';
import { handleHealth } from '../controllers/health-controller';
+463 -7
View File
@@ -1,7 +1,463 @@
export type { SigV4Result, VerifyPresignedUrlInput } from '../../utils/s3/auth';
export {
buildCanonicalQueryString,
isS3Request,
verifyPresignedUrl,
verifySignature,
} from '../../utils/s3/auth';
import { timingSafeEqual } from 'node:crypto';
/**
* Timing-safe string comparison that prevents timing attacks.
*
* Uses `crypto.timingSafeEqual` which runs in constant time regardless of
* where the strings differ. Returns false for mismatched-length inputs
* to avoid leaking length information via early return.
*
* @param left - The first string to compare.
* @param right - The second string to compare.
* @returns True if both strings are equal.
*/
const timingSafeCompare = (left: string, right: string): boolean => {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
if (leftBuffer.length !== rightBuffer.length) {
return false;
}
return timingSafeEqual(leftBuffer, rightBuffer);
};
export interface SigV4Result {
isValid: boolean;
credential: {
accessKey: string;
date: string;
region: string;
service: string;
} | null;
errorCode?: string;
}
export interface VerifyPresignedUrlInput {
url: string;
method: string;
headers: Record<string, string>;
s3AccessKey: string;
s3SecretKey: string;
region: string;
now?: Date;
}
const SERVICE = 's3';
const TERMINATION = 'aws4_request';
/**
* Maximum acceptable clock skew between client and server for header-based
* SigV4 authentication. AWS allows 15 minutes.
*/
const MAX_CLOCK_SKEW_MS = 15 * 60 * 1000;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const buf = (data: string | ArrayBuffer | Uint8Array): Uint8Array => {
if (data instanceof Uint8Array) return data;
if (data instanceof ArrayBuffer) return new Uint8Array(data);
return new TextEncoder().encode(data);
};
const sha256Hex = async (data: string | Uint8Array | ArrayBuffer): Promise<string> => {
const hashBuffer = await crypto.subtle.digest('SHA-256', buf(data) as never);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
};
const hmacSha256 = async (key: Uint8Array, message: string): Promise<Uint8Array> => {
const cryptoKey = await crypto.subtle.importKey(
'raw',
key as never,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const result = await crypto.subtle.sign('HMAC', cryptoKey, buf(message) as never);
return new Uint8Array(result);
};
const getSigningKey = async (
secretKey: string,
dateStamp: string,
region: string,
): Promise<Uint8Array> => {
let key = await hmacSha256(buf(`AWS4${secretKey}`), dateStamp);
key = await hmacSha256(key, region);
key = await hmacSha256(key, SERVICE);
return await hmacSha256(key, TERMINATION);
};
const hmacHex = async (key: Uint8Array, message: string): Promise<string> => {
const result = await hmacSha256(key, message);
return Array.from(result)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
};
const parseAuthorizationHeader = (authHeader: string) => {
const credentialMatch = authHeader.match(/Credential=([^,]+)/);
const signedHeadersMatch = authHeader.match(/SignedHeaders=([^,]+)/);
const signatureMatch = authHeader.match(/Signature=([^,]+)/);
if (!credentialMatch || !signedHeadersMatch || !signatureMatch) return null;
const credentialParts = credentialMatch[1].split('/');
if (credentialParts.length !== 5) return null;
return {
accessKey: credentialParts[0],
date: credentialParts[1],
region: credentialParts[2],
service: credentialParts[3],
termination: credentialParts[4],
signedHeaders: signedHeadersMatch[1],
signature: signatureMatch[1],
};
};
const buildCanonicalRequest = (
method: string,
canonicalUri: string,
canonicalQueryString: string,
signedHeaders: string,
headers: Record<string, string>,
hashedPayload: string,
): string => {
const canonicalHeaders = signedHeaders
.split(';')
.map((h) => {
const value = headers[h.toLowerCase()] || '';
return `${h.toLowerCase()}:${value.trim()}\n`;
})
.join('');
return `${method}\n${canonicalUri}\n${canonicalQueryString}\n${canonicalHeaders}\n${signedHeaders}\n${hashedPayload}`;
};
/**
* Normalizes a URI per AWS SigV4 requirements plus RFC 3986:
*
* 1. Decode percent-encoded characters
* 2. Remove dot-segments (`.` and `..`) per RFC 3986 section 5.2.4
*
* @param uri - The raw URI path to normalize.
* @returns The normalized URI path.
*/
const normalizeUri = (uri: string): string => {
if (!uri || uri === '') return '/';
// AWS SigV4 requires URI-decoded paths in the canonical request
// Only `.` and `..` segments are removed per RFC 3986 section 5.2.4
// Empty segments (from `//` or trailing `/`) are preserved — they are
// part of the URI and the SDK signs them.
const decoded = decodeURIComponent(uri);
const segments = decoded.split('/');
const result: string[] = [];
for (const segment of segments) {
if (segment === '.') continue;
if (segment === '..') {
result.pop();
continue;
}
result.push(segment);
}
// Join preserves empty first segment (from leading /) automatically
return result.join('/') || '/';
};
const awsEncode = (value: string): string =>
encodeURIComponent(value).replace(
/[!'()*]/g,
(ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`,
);
export const buildCanonicalQueryString = (
searchParams: URLSearchParams,
excludeKeys: Set<string> = new Set(),
): string => {
const pairs: Array<[string, string]> = [];
for (const [key, value] of searchParams.entries()) {
if (!excludeKeys.has(key)) pairs.push([key, value]);
}
// AWS SigV4 requires UTF-8 byte-order (code point) comparison, NOT localeCompare
pairs.sort(([ak, av], [bk, bv]) => {
const a = `${awsEncode(ak)}=${awsEncode(av)}`;
const b = `${awsEncode(bk)}=${awsEncode(bv)}`;
if (a < b) return -1;
if (a > b) return 1;
return 0;
});
return pairs.map(([key, value]) => `${awsEncode(key)}=${awsEncode(value)}`).join('&');
};
const getHashedPayload = async (body: string | null): Promise<string> => {
if (!body || body.length === 0) return await sha256Hex('');
return await sha256Hex(body);
};
/**
* Parses an AWS SigV4 `x-amz-date` value (e.g. `20260707T120000Z`) into a Date.
*
* @param amzDate - The date string in `YYYYMMDDTHHmmssZ` format.
* @returns The parsed Date, or null if the format is invalid.
*/
const parseAmzDateUtc = (amzDate: string): Date | null => {
const match = amzDate.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/);
if (!match) return null;
const [, year, month, day, hour, minute, second] = match;
return new Date(
Date.UTC(
Number.parseInt(year, 10),
Number.parseInt(month, 10) - 1,
Number.parseInt(day, 10),
Number.parseInt(hour, 10),
Number.parseInt(minute, 10),
Number.parseInt(second, 10),
),
);
};
/**
* Validates that `host` is included in the signed headers list.
*
* AWS SigV4 mandates that `host` is always signed. Reject requests that
* omit it to prevent header injection / replay variants.
*
* @param signedHeaders - The semicolon-separated signed headers string.
* @returns True if `host` is present.
*/
const validateSignedHeaders = (signedHeaders: string): boolean => {
return signedHeaders.split(';').some((h) => h.toLowerCase() === 'host');
};
export const verifySignature = async (
method: string,
url: string,
headers: Record<string, string>,
body: string | null,
s3AccessKey: string,
s3SecretKey: string,
region: string,
): Promise<SigV4Result> => {
const authHeader = headers.authorization;
if (!authHeader?.startsWith('AWS4-HMAC-SHA256')) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
const parsed = parseAuthorizationHeader(authHeader);
if (!parsed) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
if (!timingSafeCompare(parsed.accessKey, s3AccessKey)) {
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
}
if (!timingSafeCompare(parsed.region, region)) {
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
}
// Validate service and termination in credential scope (M2)
if (parsed.service !== SERVICE || parsed.termination !== TERMINATION) {
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
}
// Validate host is in signed headers (LOW/host)
if (!validateSignedHeaders(parsed.signedHeaders)) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
const parsedUrl = new URL(url, 'http://localhost');
const canonicalUri = normalizeUri(parsedUrl.pathname);
const canonicalQueryString = buildCanonicalQueryString(parsedUrl.searchParams);
const contentSha256 = headers['x-amz-content-sha256'] || null;
if (contentSha256?.startsWith('STREAMING-')) {
return { isValid: false, credential: null, errorCode: 'NotImplemented' };
}
// CRITICAL: Use the x-amz-content-sha256 header value in the canonical
// request because that's what the client signed. The actual body hash is
// verified by verifyBodyHash() after streaming, ensuring integrity without
// breaking SigV4.
const hashedPayload = contentSha256 || (await getHashedPayload(body));
const canonicalRequest = buildCanonicalRequest(
method,
canonicalUri,
canonicalQueryString,
parsed.signedHeaders,
headers,
hashedPayload,
);
const hashedCanonicalRequest = await sha256Hex(canonicalRequest);
// M1: Fall back to Date header if x-amz-date is missing
const amzDate = headers['x-amz-date'] || headers.date || '';
// H5: Validate request freshness (clock skew / replay protection)
if (amzDate) {
const requestDate = parseAmzDateUtc(amzDate);
if (requestDate) {
const now = Date.now();
const skew = Math.abs(now - requestDate.getTime());
if (skew > MAX_CLOCK_SKEW_MS) {
return { isValid: false, credential: null, errorCode: 'RequestExpired' };
}
}
}
const dateStamp = parsed.date;
// M3: Ensure date in credential scope matches x-amz-date
if (amzDate) {
const amzDateStamp = amzDate.slice(0, 8); // "YYYYMMDD"
if (amzDateStamp !== dateStamp) {
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
}
}
const credentialScope = `${dateStamp}/${region}/${parsed.service}/${parsed.termination}`;
const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${hashedCanonicalRequest}`;
const signingKey = await getSigningKey(s3SecretKey, dateStamp, region);
const expectedSignature = await hmacHex(signingKey, stringToSign);
if (!timingSafeCompare(expectedSignature, parsed.signature)) {
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
}
return {
isValid: true,
credential: {
accessKey: parsed.accessKey,
date: parsed.date,
region: parsed.region,
service: parsed.service,
},
};
};
export const verifyPresignedUrl = async ({
url,
method,
headers,
s3AccessKey,
s3SecretKey,
region,
now = new Date(),
}: VerifyPresignedUrlInput): Promise<SigV4Result> => {
const parsedUrl = new URL(url);
const searchParams = parsedUrl.searchParams;
const algorithm = searchParams.get('X-Amz-Algorithm');
const credential = searchParams.get('X-Amz-Credential');
const signedHeaders = searchParams.get('X-Amz-SignedHeaders');
const signature = searchParams.get('X-Amz-Signature');
const expiresText = searchParams.get('X-Amz-Expires');
const amzDate = searchParams.get('X-Amz-Date');
if (
algorithm !== 'AWS4-HMAC-SHA256' ||
!credential ||
!signedHeaders ||
!signature ||
!expiresText ||
!amzDate
) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
const expires = Number.parseInt(expiresText, 10);
const signedAt = parseAmzDateUtc(amzDate);
if (!Number.isFinite(expires) || expires <= 0 || !signedAt) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
// AWS S3 spec limits presigned URLs to 7 days (604800 seconds)
const MAX_PRESIGNED_EXPIRY_SECONDS = 604800;
if (
now.getTime() > signedAt.getTime() + expires * 1000 ||
expires > MAX_PRESIGNED_EXPIRY_SECONDS
) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
const credParts = credential.split('/');
if (credParts.length !== 5) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
const [accessKey, dateStamp, credentialRegion, service, termination] = credParts;
if (
!timingSafeCompare(accessKey, s3AccessKey) ||
!timingSafeCompare(credentialRegion, region) ||
service !== SERVICE ||
termination !== TERMINATION
) {
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
}
// Validate host is in signed headers for presigned URLs too
if (!validateSignedHeaders(signedHeaders)) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
const signedHeaderList = signedHeaders.split(';').filter(Boolean);
const canonicalHeaders = signedHeaderList
.map((headerName) => {
const lower = headerName.toLowerCase();
const value = lower === 'host' ? headers.host || parsedUrl.host : headers[lower] || '';
return `${lower}:${value.trim()}\n`;
})
.join('');
const canonicalRequest = `${method}\n${normalizeUri(parsedUrl.pathname)}\n${buildCanonicalQueryString(searchParams, new Set(['X-Amz-Signature']))}\n${canonicalHeaders}\n${signedHeaders}\nUNSIGNED-PAYLOAD`;
const hashedCanonicalRequest = await sha256Hex(canonicalRequest);
const credentialScope = `${dateStamp}/${region}/${SERVICE}/${TERMINATION}`;
const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${hashedCanonicalRequest}`;
const expectedSignature = await hmacHex(
await getSigningKey(s3SecretKey, dateStamp, region),
stringToSign,
);
if (!timingSafeCompare(expectedSignature, signature)) {
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
}
return { isValid: true, credential: { accessKey, date: dateStamp, region, service } };
};
export const isS3Request = (headers: Record<string, string>): boolean => {
const auth = headers.authorization || '';
return auth.startsWith('AWS4-HMAC-SHA256');
};
/**
* Verifies that the actual body SHA-256 matches the `x-amz-content-sha256`
* header from the original request.
*
* This MUST be called AFTER the body has been fully streamed and hashed,
* as a second pass after `verifySignature` (which cannot hash a streaming
* body without consuming it).
*
* @param bodySha256 - The SHA-256 hex digest of the actual body content.
* @param headers - The original request headers.
* @returns An error result on mismatch, or null if the check passes.
*/
export const verifyBodyHash = (
bodySha256: string,
headers: Record<string, string>,
): SigV4Result | null => {
const claimedHash = headers['x-amz-content-sha256'];
// If the client sent UNSIGNED-PAYLOAD, skip verification
if (!claimedHash || claimedHash === 'UNSIGNED-PAYLOAD' || claimedHash.startsWith('STREAMING-')) {
return null;
}
if (claimedHash !== bodySha256) {
return { isValid: false, credential: null, errorCode: 'BadDigest' };
}
return null;
};
+52 -10
View File
@@ -1,10 +1,52 @@
/**
* Re-export from the canonical headers implementation.
*
* @module
*/
export {
applyS3Headers,
S3_CORS_HEADERS,
s3Headers,
} from '../../utils/s3/headers';
import { nanoid } from 'nanoid';
export const S3_CORS_HEADERS: Record<string, string> = {
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET, PUT, HEAD, DELETE, POST, OPTIONS',
'access-control-allow-headers': [
'Authorization',
'Content-Type',
'Content-MD5',
'Range',
'If-Match',
'If-None-Match',
'If-Modified-Since',
'If-Unmodified-Since',
'X-Amz-*',
'x-amz-*',
].join(', '),
'access-control-expose-headers': [
'Accept-Ranges',
'Content-Length',
'Content-Range',
'Content-Type',
'ETag',
'Last-Modified',
'x-amz-id-2',
'x-amz-request-id',
].join(', '),
'access-control-max-age': '86400',
};
export const s3Headers = (
requestId: string,
extraHeaders: Record<string, string> = {},
): Record<string, string> => ({
...S3_CORS_HEADERS,
server: 'AmazonS3',
...(requestId
? {
'x-amz-request-id': requestId,
'x-amz-id-2': `${requestId}+${nanoid(16)}`,
}
: {}),
...extraHeaders,
});
export const applyS3Headers = (headers: Headers, requestId: string): Headers => {
const result = new Headers(headers);
for (const [key, value] of Object.entries(s3Headers(requestId))) {
result.set(key, value);
}
return result;
};
+27 -1
View File
@@ -1 +1,27 @@
export { extractS3BucketFromHost } from '../../utils/s3/virtual-host';
const stripPort = (host: string): string => {
// Handle IPv6: [::1]:8080 -> [::1]
if (host.startsWith('[')) {
const closeBracket = host.indexOf(']');
return host.slice(0, closeBracket + 1).toLowerCase();
}
return host.split(':')[0].toLowerCase().replace(/\.$/, '');
};
const isValidBucketLabel = (bucket: string): boolean =>
/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket) &&
!bucket.includes('..') &&
!bucket.includes('.-') &&
!bucket.includes('-.');
export const extractS3BucketFromHost = (host: string, domains: string[]): string | null => {
const normalizedHost = stripPort(host);
for (const domain of domains) {
const normalizedDomain = stripPort(domain);
if (!normalizedDomain || normalizedHost === normalizedDomain) continue;
if (!normalizedHost.endsWith(`.${normalizedDomain}`)) continue;
const bucket = normalizedHost.slice(0, -(normalizedDomain.length + 1));
return isValidBucketLabel(bucket) ? bucket : null;
}
return null;
};
+311 -22
View File
@@ -1,22 +1,311 @@
/**
* Re-export from the canonical XML implementation.
*
* @module
*/
export {
bucketVersioningConfigurationXml,
type CompletePart,
completeMultipartUploadXml,
copyObjectResultXml,
deleteResultXml,
initiateMultipartUploadXml,
listBucketResultXml,
listBucketsXml,
listBucketV2ResultXml,
listMultipartUploadsXml,
listPartsXml,
parseCompleteMultipartBody,
parseDeleteObjectsBody,
s3ErrorResponse,
s3ErrorXml,
} from '../../utils/s3/xml';
import { s3Headers } from './headers';
const escapeXml = (str: string): string =>
str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
const isoDate = (d: Date): string => d.toISOString().replace(/\.\d{3}Z$/, 'Z');
const encodeKey = (value: string, encodingType: string | null = null): string =>
encodingType === 'url' ? encodeURIComponent(value) : escapeXml(value);
// ─────── Bucket operations ───────
export const listBucketsXml = (
buckets: { name: string; createdAt: Date }[],
_requestId: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Buckets>
${buckets
.map(
(b) => `<Bucket>
<Name>${escapeXml(b.name)}</Name>
<CreationDate>${isoDate(b.createdAt)}</CreationDate>
</Bucket>`,
)
.join('')}
</Buckets>
</ListAllMyBucketsResult>`;
export const bucketVersioningConfigurationXml =
(): string => `<?xml version="1.0" encoding="UTF-8"?>
<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"/>`;
// ─────── Object listing ───────
export const listBucketResultXml = (
bucketName: string,
objects: { key: string; sizeBytes: number; etag: string; lastModified: Date; mimeType: string }[],
prefixes: string[],
isTruncated: boolean,
marker: string | null,
maxKeys: number,
prefix: string,
delimiter: string | null,
nextMarker: string | null,
_requestId: string,
encodingType: string | null = null,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>${escapeXml(bucketName)}</Name>
<Prefix>${encodeKey(prefix, encodingType)}</Prefix>
<Marker>${encodeKey(marker || '', encodingType)}</Marker>
<MaxKeys>${maxKeys}</MaxKeys>
<Delimiter>${encodeKey(delimiter || '', encodingType)}</Delimiter>
${encodingType ? `<EncodingType>${escapeXml(encodingType)}</EncodingType>` : ''}
<IsTruncated>${isTruncated}</IsTruncated>
${objects
.map(
(o) => `<Contents>
<Key>${encodeKey(o.key, encodingType)}</Key>
<LastModified>${isoDate(o.lastModified)}</LastModified>
<ETag>"${o.etag}"</ETag>
<Size>${o.sizeBytes}</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>`,
)
.join('')}
${prefixes
.map(
(p) => `<CommonPrefixes>
<Prefix>${encodeKey(p, encodingType)}</Prefix>
</CommonPrefixes>`,
)
.join('')}
${nextMarker ? `<NextMarker>${encodeKey(nextMarker, encodingType)}</NextMarker>` : ''}
</ListBucketResult>`;
export const listBucketV2ResultXml = (
bucketName: string,
objects: { key: string; sizeBytes: number; etag: string; lastModified: Date; mimeType: string }[],
prefixes: string[],
isTruncated: boolean,
maxKeys: number,
prefix: string,
delimiter: string | null,
continuationToken: string | null,
nextContinuationToken: string | null,
keyCount: number,
_requestId: string,
encodingType: string | null = null,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResultV2 xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>${escapeXml(bucketName)}</Name>
<Prefix>${encodeKey(prefix, encodingType)}</Prefix>
<MaxKeys>${maxKeys}</MaxKeys>
<KeyCount>${keyCount}</KeyCount>
${delimiter ? `<Delimiter>${encodeKey(delimiter, encodingType)}</Delimiter>` : ''}
${encodingType ? `<EncodingType>${escapeXml(encodingType)}</EncodingType>` : ''}
${continuationToken ? `<ContinuationToken>${encodeKey(continuationToken, encodingType)}</ContinuationToken>` : ''}
<IsTruncated>${isTruncated}</IsTruncated>
${objects
.map(
(o) => `<Contents>
<Key>${encodeKey(o.key, encodingType)}</Key>
<LastModified>${isoDate(o.lastModified)}</LastModified>
<ETag>"${o.etag}"</ETag>
<Size>${o.sizeBytes}</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>`,
)
.join('')}
${prefixes
.map(
(p) => `<CommonPrefixes>
<Prefix>${encodeKey(p, encodingType)}</Prefix>
</CommonPrefixes>`,
)
.join('')}
${nextContinuationToken ? `<NextContinuationToken>${encodeKey(nextContinuationToken, encodingType)}</NextContinuationToken>` : ''}
</ListBucketResultV2>`;
// ─────── Multipart ───────
export const initiateMultipartUploadXml = (
bucketName: string,
key: string,
uploadId: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<InitiateMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Bucket>${escapeXml(bucketName)}</Bucket>
<Key>${escapeXml(key)}</Key>
<UploadId>${uploadId}</UploadId>
</InitiateMultipartUploadResult>`;
export const listPartsXml = (
bucketName: string,
key: string,
uploadId: string,
parts: { partNumber: number; etag: string; sizeBytes: number; createdAt: Date }[],
maxParts: number,
isTruncated: boolean,
_requestId: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<ListPartsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Bucket>${escapeXml(bucketName)}</Bucket>
<Key>${escapeXml(key)}</Key>
<UploadId>${uploadId}</UploadId>
<MaxParts>${maxParts}</MaxParts>
<IsTruncated>${isTruncated}</IsTruncated>
${parts
.map(
(p) => `<Part>
<PartNumber>${p.partNumber}</PartNumber>
<LastModified>${isoDate(p.createdAt)}</LastModified>
<ETag>"${p.etag}"</ETag>
<Size>${p.sizeBytes}</Size>
</Part>`,
)
.join('')}
</ListPartsResult>`;
export const listMultipartUploadsXml = (
bucketName: string,
uploads: { key: string; uploadId: string; initiatedAt: Date; initiatedBy: string }[],
maxUploads: number,
isTruncated: boolean,
nextKeyMarker: string | null,
_requestId: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<ListMultipartUploadsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Bucket>${escapeXml(bucketName)}</Bucket>
<KeyMarker></KeyMarker>
<UploadIdMarker></UploadIdMarker>
${nextKeyMarker ? `<NextKeyMarker>${escapeXml(nextKeyMarker)}</NextKeyMarker>` : ''}
<MaxUploads>${maxUploads}</MaxUploads>
<IsTruncated>${isTruncated}</IsTruncated>
${uploads
.map(
(u) => `<Upload>
<Key>${escapeXml(u.key)}</Key>
<UploadId>${u.uploadId}</UploadId>
<Initiator><ID>${escapeXml(u.initiatedBy || 's3')}</ID><DisplayName>${escapeXml(u.initiatedBy || 's3')}</DisplayName></Initiator>
<Owner><ID>${escapeXml(u.initiatedBy || 's3')}</ID><DisplayName>${escapeXml(u.initiatedBy || 's3')}</DisplayName></Owner>
<StorageClass>STANDARD</StorageClass>
<Initiated>${isoDate(u.initiatedAt)}</Initiated>
</Upload>`,
)
.join('')}
</ListMultipartUploadsResult>`;
export const completeMultipartUploadXml = (
bucketName: string,
key: string,
etag: string,
location: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<CompleteMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Location>${escapeXml(location)}</Location>
<Bucket>${escapeXml(bucketName)}</Bucket>
<Key>${escapeXml(key)}</Key>
<ETag>"${etag}"</ETag>
</CompleteMultipartUploadResult>`;
// ─────── Delete result ───────
export const deleteResultXml = (
deleted: string[],
errors: { key: string; code: string; message: string }[],
): string => `<?xml version="1.0" encoding="UTF-8"?>
<DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
${deleted
.map(
(key) => `<Deleted>
<Key>${escapeXml(key)}</Key>
</Deleted>`,
)
.join('')}
${errors
.map(
(e) => `<Error>
<Key>${escapeXml(e.key)}</Key>
<Code>${e.code}</Code>
<Message>${escapeXml(e.message)}</Message>
</Error>`,
)
.join('')}
</DeleteResult>`;
// ─────── Copy ───────
export const copyObjectResultXml = (
etag: string,
lastModified: Date,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<CopyObjectResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<ETag>"${etag}"</ETag>
<LastModified>${isoDate(lastModified)}</LastModified>
</CopyObjectResult>`;
// ─────── Error ───────
export const s3ErrorXml = (
code: string,
message: string,
resource: string,
requestId: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>${code}</Code>
<Message>${escapeXml(message)}</Message>
<Resource>${escapeXml(resource)}</Resource>
<RequestId>${requestId}</RequestId>
<HostId>${requestId}</HostId>
</Error>`;
export const s3ErrorResponse = (
code: string,
message: string,
resource: string,
status: number,
requestId: string = '',
extraHeaders: Record<string, string> = {},
): Response =>
new Response(s3ErrorXml(code, message, resource, requestId), {
status,
headers: s3Headers(requestId, {
'content-type': 'application/xml',
...extraHeaders,
}),
});
// ─────── DeleteObjects XML parser ───────
export const parseDeleteObjectsBody = (body: string): { keys: string[]; quiet: boolean } => {
// H9: Use non-greedy match to handle keys containing < character
const keys = Array.from(body.matchAll(/<Key>([\s\S]*?)<\/Key>/g), (match) => match[1]);
// Handle whitespace inside <Quiet> element + namespace prefix support
const quiet = /<\w*:?Quiet\w*>\s*true\s*<\/\w*:?Quiet\w*>/i.test(body);
return { keys, quiet };
};
// ─────── CompleteMultipartUpload XML parser ───────
export interface CompletePart {
partNumber: number;
etag: string;
}
export const parseCompleteMultipartBody = (body: string): CompletePart[] => {
const parts: CompletePart[] = [];
const partRegex = /<Part>[\s\S]*?<\/Part>/g;
const partMatch = body.match(partRegex) || [];
for (const partXml of partMatch) {
const numMatch = partXml.match(/<PartNumber>(\d+)<\/PartNumber>/);
const etagMatch = partXml.match(/<ETag>"?([^"<\s]+)"?<\/ETag>/);
if (numMatch && etagMatch) {
parts.push({
partNumber: Number.parseInt(numMatch[1], 10),
etag: etagMatch[1].replace(/^"/, '').replace(/"$/, ''),
});
}
}
return parts;
};