2026-07-07 03:07:51 +07:00
import { createReadStream } from 'node:fs' ;
import { nanoid } from 'nanoid' ;
import { createBucket , deleteBucket , findBucketByName , listBuckets } from '../db/buckets' ;
2026-07-06 15:52:42 +07:00
import {
2026-07-07 03:07:51 +07:00
countBucketObjects ,
2026-07-06 22:36:15 +07:00
findFileByBucketAndKey ,
listObjectsByPrefix ,
softDeleteFile ,
2026-07-06 15:52:42 +07:00
} from '../db/files-ext' ;
2026-07-07 03:07:51 +07:00
import {
abortMultipartUpload ,
completeMultipartUpload ,
createMultipartUpload ,
findMultipartUpload ,
insertMultipartPart ,
listMultipartParts ,
2026-07-07 05:39:23 +07:00
listMultipartUploadsByBucket ,
2026-07-07 03:07:51 +07:00
} from '../db/multipart' ;
2026-07-06 15:52:42 +07:00
import type { File } from '../db/schema' ;
import { config } from '../env' ;
2026-07-07 08:08:22 +07:00
import { createChunkedObjectResponse , storeFileInTelegramChunks } from '../utils/chunked-storage' ;
2026-07-07 03:07:51 +07:00
import { cleanupTempFile , computeHash , ensureExtension , getErrorMessage } from '../utils/file' ;
2026-07-06 15:52:42 +07:00
import logger from '../utils/logger' ;
2026-07-07 03:07:51 +07:00
import { verifyPresignedUrl , verifySignature } from '../utils/s3/auth' ;
2026-07-07 05:39:23 +07:00
import { S3_CORS_HEADERS , s3Headers } from '../utils/s3/headers' ;
2026-07-07 04:29:24 +07:00
import { createGetObjectResponse , type ObjectPartSource } from '../utils/s3/object-stream' ;
import { parseRangeHeader , unsatisfiedContentRange } from '../utils/s3/range' ;
2026-07-07 03:07:51 +07:00
import {
completeMultipartUploadXml ,
copyObjectResultXml ,
deleteResultXml ,
initiateMultipartUploadXml ,
listBucketResultXml ,
listBucketsXml ,
listBucketV2ResultXml ,
2026-07-07 05:39:23 +07:00
listMultipartUploadsXml ,
2026-07-07 03:07:51 +07:00
listPartsXml ,
parseCompleteMultipartBody ,
parseDeleteObjectsBody ,
s3ErrorResponse ,
} from '../utils/s3/xml' ;
import { forwardToStorage , getFileInfo } from '../utils/telegram' ;
2026-07-06 15:52:42 +07:00
const REGION = config . s3DefaultRegion || 'us-east-1' ;
const REQUEST_ID = () => nanoid ( 16 );
2026-07-07 05:39:23 +07:00
const s3Response = (
body : string | null ,
status : number ,
reqId : string ,
extraHeaders : Record < string , string > = {},
) : Response => new Response ( body , { status , headers : s3Headers ( reqId , extraHeaders ) });
const s3OptionsResponse = () : Response =>
new Response ( null , { status : 204 , headers : S3_CORS_HEADERS });
2026-07-06 15:52:42 +07:00
const parseS3Path = ( pathname : string ) : { bucket : string | null ; key : string | null } => {
const parts = pathname . split ( '/' ). filter ( Boolean );
if ( parts . length === 0 ) return { bucket : null , key : null };
if ( parts . length === 1 ) return { bucket : parts [ 0 ], key : null };
return { bucket : parts [ 0 ], key : parts.slice ( 1 ). join ( '/' ) };
};
const headersToRecord = ( req : Request ) : Record < string , string > => {
const record : Record < string , string > = {};
for ( const [ key , value ] of req . headers . entries ()) {
record [ key . toLowerCase ()] = value ;
}
return record ;
};
// ─────── Main Dispatcher ───────
2026-07-07 05:39:23 +07:00
export const handleS3Request = async (
req : Request ,
virtualHostBucket : string | null = null ,
) : Promise < Response > => {
2026-07-06 15:52:42 +07:00
const method = req . method ;
const url = new URL ( req . url );
const pathname = url . pathname ;
2026-07-07 05:39:23 +07:00
const { bucket , key } = virtualHostBucket
? {
bucket : virtualHostBucket ,
key : pathname === '/' ? null : decodeURIComponent ( pathname . slice ( 1 )),
}
: parseS3Path ( pathname );
2026-07-06 15:52:42 +07:00
const headers = headersToRecord ( req );
const searchParams = url . searchParams ;
const reqId = REQUEST_ID ();
2026-07-07 05:39:23 +07:00
if ( method === 'OPTIONS' ) {
return s3OptionsResponse ();
}
const isPresigned = searchParams . has ( 'X-Amz-Signature' );
const authResult = isPresigned
? await verifyPresignedUrl ({
url : req.url ,
method ,
headers ,
s3AccessKey : config.s3AccessKey ,
s3SecretKey : config.s3SecretKey ,
region : REGION ,
})
: await verifySignature (
method ,
req . url ,
headers ,
null ,
config . s3AccessKey ,
config . s3SecretKey ,
REGION ,
2026-07-06 22:36:15 +07:00
);
2026-07-07 05:39:23 +07:00
if ( ! authResult . isValid ) {
const status = authResult . errorCode === 'NotImplemented' ? 501 : 403 ;
const message =
authResult . errorCode === 'NotImplemented'
? 'aws-chunked streaming payloads are not supported.'
: isPresigned
? 'Presigned URL verification failed'
: 'Authentication required' ;
return s3ErrorResponse (
authResult . errorCode || 'AccessDenied' ,
message ,
pathname ,
status ,
reqId ,
2026-07-06 22:36:15 +07:00
);
2026-07-06 15:52:42 +07:00
}
try {
// Root: ListBuckets
if ( ! bucket ) {
if ( method === 'GET' ) {
return handleListBuckets ( reqId );
}
2026-07-06 22:36:15 +07:00
return s3ErrorResponse (
'MethodNotAllowed' ,
'The specified method is not allowed against this resource.' ,
'/' ,
405 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
}
// Bucket-level operations
if ( ! key ) {
if ( method === 'GET' ) {
2026-07-07 05:39:23 +07:00
if ( searchParams . has ( 'uploads' )) {
return handleListMultipartUploads ( bucket , searchParams , reqId );
}
2026-07-06 15:52:42 +07:00
const listType = searchParams . get ( 'list-type' );
if ( listType === '2' ) {
return handleListObjectsV2 ( bucket , searchParams , reqId );
}
return handleListObjectsV1 ( bucket , searchParams , reqId );
}
if ( method === 'PUT' ) return handleCreateBucket ( bucket , reqId );
if ( method === 'HEAD' ) return handleHeadBucket ( bucket , reqId );
if ( method === 'DELETE' ) return handleDeleteBucket ( bucket , reqId );
if ( method === 'POST' ) {
if ( searchParams . has ( 'delete' )) {
const body = await req . text ();
return handleDeleteObjects ( bucket , body , reqId );
}
if ( searchParams . has ( 'tagging' )) {
2026-07-07 05:39:23 +07:00
return s3Response ( null , 204 , reqId );
2026-07-06 15:52:42 +07:00
}
}
2026-07-06 22:36:15 +07:00
return s3ErrorResponse (
'MethodNotAllowed' ,
'The specified method is not allowed against this resource.' ,
`/ ${ bucket } ` ,
405 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
}
// Object-level: multipart checks
if ( searchParams . has ( 'uploads' ) && method === 'POST' ) {
return handleCreateMultipartUpload ( bucket , key , searchParams , reqId );
}
if ( searchParams . has ( 'uploadId' ) && searchParams . has ( 'partNumber' ) && method === 'PUT' ) {
return handleUploadPart ( bucket , key , searchParams , req , reqId );
}
if ( searchParams . has ( 'uploadId' ) && method === 'POST' ) {
const body = await req . text ();
return handleCompleteMultipartUpload ( bucket , key , searchParams , body , reqId );
}
if ( searchParams . has ( 'uploadId' ) && method === 'DELETE' ) {
return handleAbortMultipartUpload ( bucket , key , searchParams , reqId );
}
if ( searchParams . has ( 'uploadId' ) && method === 'GET' ) {
return handleListParts ( bucket , key , searchParams , reqId );
}
// Standard object operations
2026-07-07 05:39:23 +07:00
if ( method === 'GET' ) return handleGetObject ( bucket , key , searchParams , headers , reqId );
2026-07-06 15:52:42 +07:00
if ( method === 'HEAD' ) return handleHeadObject ( bucket , key , reqId );
if ( method === 'PUT' ) return handlePutObject ( bucket , key , searchParams , headers , req , reqId );
if ( method === 'DELETE' ) return handleDeleteObject ( bucket , key , reqId );
2026-07-06 22:36:15 +07:00
return s3ErrorResponse (
'MethodNotAllowed' ,
'The specified method is not allowed against this resource.' ,
`/ ${ bucket } / ${ key } ` ,
405 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
} catch ( error : unknown ) {
logger . error ( 'S3 operation error' , { bucket , key , error : getErrorMessage ( error ) });
2026-07-06 22:36:15 +07:00
return s3ErrorResponse (
'InternalError' ,
'We encountered an internal error. Please try again.' ,
pathname ,
500 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
}
};
// ─────── Bucket Operations ───────
const handleListBuckets = async ( reqId : string ) : Promise < Response > => {
const buckets = await listBuckets ();
const xml = listBucketsXml ( buckets , reqId );
2026-07-07 05:39:23 +07:00
return s3Response ( xml , 200 , reqId , { 'content-type' : 'application/xml' });
2026-07-06 15:52:42 +07:00
};
const handleCreateBucket = async ( bucketName : string , reqId : string ) : Promise < Response > => {
if ( ! /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/ . test ( bucketName )) {
2026-07-06 22:36:15 +07:00
return s3ErrorResponse (
'InvalidBucketName' ,
'The specified bucket is not valid.' ,
`/ ${ bucketName } ` ,
400 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
}
const existing = await findBucketByName ( bucketName );
if ( existing ) {
2026-07-06 22:36:15 +07:00
return s3ErrorResponse (
'BucketAlreadyExists' ,
'The requested bucket name is not available.' ,
`/ ${ bucketName } ` ,
409 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
}
await createBucket ( bucketName );
2026-07-07 05:39:23 +07:00
return s3Response ( null , 200 , reqId );
2026-07-06 15:52:42 +07:00
};
const handleHeadBucket = async ( bucketName : string , reqId : string ) : Promise < Response > => {
const bucket = await findBucketByName ( bucketName );
if ( ! bucket ) {
2026-07-06 22:36:15 +07:00
return s3ErrorResponse (
'NoSuchBucket' ,
'The specified bucket does not exist.' ,
`/ ${ bucketName } ` ,
404 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
}
2026-07-07 05:39:23 +07:00
return s3Response ( null , 200 , reqId );
2026-07-06 15:52:42 +07:00
};
const handleDeleteBucket = async ( bucketName : string , reqId : string ) : Promise < Response > => {
const bucket = await findBucketByName ( bucketName );
if ( ! bucket ) {
2026-07-06 22:36:15 +07:00
return s3ErrorResponse (
'NoSuchBucket' ,
'The specified bucket does not exist.' ,
`/ ${ bucketName } ` ,
404 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
}
const objCount = await countBucketObjects ( bucket . id );
if ( objCount > 0 ) {
2026-07-06 22:36:15 +07:00
return s3ErrorResponse (
'BucketNotEmpty' ,
'The bucket you tried to delete is not empty.' ,
`/ ${ bucketName } ` ,
409 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
}
await deleteBucket ( bucketName );
2026-07-07 05:39:23 +07:00
return s3Response ( null , 204 , reqId );
2026-07-06 15:52:42 +07:00
};
// ─────── Object Operations ───────
2026-07-06 22:36:15 +07:00
const handleGetObject = async (
bucket : string ,
key : string ,
2026-07-07 05:39:23 +07:00
_searchParams : URLSearchParams ,
2026-07-07 04:17:37 +07:00
headers : Record < string , string >,
2026-07-06 22:36:15 +07:00
reqId : string ,
) : Promise < Response > => {
2026-07-06 15:52:42 +07:00
const bucketRecord = await findBucketByName ( bucket );
2026-07-06 22:36:15 +07:00
if ( ! bucketRecord )
return s3ErrorResponse (
'NoSuchBucket' ,
'The specified bucket does not exist.' ,
`/ ${ bucket } / ${ key } ` ,
404 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
const file = await findFileByBucketAndKey ( bucketRecord . id , key );
2026-07-06 22:36:15 +07:00
if ( ! file )
return s3ErrorResponse (
'NoSuchKey' ,
'The specified key does not exist.' ,
`/ ${ bucket } / ${ key } ` ,
404 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
2026-07-07 08:08:22 +07:00
if ( file . storageBackend === 'chunked' ) {
const totalSize = Number ( file . sizeBytes );
const range = parseRangeHeader ( headers . range || null , totalSize );
if ( range . type === 'invalid' ) {
return s3ErrorResponse (
'InvalidRange' ,
'The requested range is not satisfiable.' ,
`/ ${ bucket } / ${ key } ` ,
416 ,
reqId ,
{
'content-range' : unsatisfiedContentRange ( totalSize ),
},
);
}
try {
return await createChunkedObjectResponse ({ file , range , reqId });
} catch ( error ) {
logger . warn ( 'Chunked object content fetch failed' , { key , error : getErrorMessage ( error ) });
return s3ErrorResponse (
'InternalError' ,
'Failed to fetch object content from storage' ,
`/ ${ bucket } / ${ key } ` ,
502 ,
reqId ,
);
}
}
2026-07-06 15:52:42 +07:00
if ( file . multipartUploadId ) {
2026-07-07 04:37:20 +07:00
return handleGetMultipartObject ( file , bucket , key , headers , reqId );
2026-07-06 15:52:42 +07:00
}
const fileInfo = await getFileInfo ( file . telegramFileId );
const redirectUrl = `https://api.telegram.org/file/bot ${ fileInfo . bot_token } / ${ fileInfo . file_path } ` ;
2026-07-07 04:29:24 +07:00
const totalSize = file . sizeBytes ;
const range = parseRangeHeader ( headers . range || null , totalSize );
if ( range . type === 'invalid' ) {
return s3ErrorResponse (
'InvalidRange' ,
'The requested range is not satisfiable.' ,
`/ ${ bucket } / ${ key } ` ,
416 ,
reqId ,
{
'content-range' : unsatisfiedContentRange ( totalSize ),
},
);
}
2026-07-07 02:54:51 +07:00
if ( ! config . proxyS3Get ) {
// Legacy 302 redirect path (when proxy is disabled)
2026-07-07 05:39:23 +07:00
return s3Response ( null , 302 , reqId , { location : redirectUrl });
2026-07-07 02:54:51 +07:00
}
2026-07-07 04:29:24 +07:00
const part : ObjectPartSource = {
telegramFileId : file.telegramFileId ,
telegramUrl : redirectUrl ,
sizeBytes : file.sizeBytes ,
partNumber : 1 ,
};
try {
return await createGetObjectResponse ({
reqId ,
contentType : file.mimeType ,
etag : file.fileHash || '' ,
lastModified : file.createdAt instanceof Date ? file.createdAt : new Date ( file . createdAt ),
totalSize : file.sizeBytes ,
parts : [ part ],
range ,
});
} catch ( error ) {
2026-07-07 02:54:51 +07:00
logger . warn ( 'Telegram content fetch failed' , {
fileId : file.telegramFileId ,
2026-07-07 04:29:24 +07:00
error : getErrorMessage ( error ),
2026-07-07 02:54:51 +07:00
});
return s3ErrorResponse (
'InternalError' ,
'Failed to fetch object content from storage' ,
`/ ${ bucket } / ${ key } ` ,
502 ,
reqId ,
);
}
2026-07-06 15:52:42 +07:00
};
2026-07-06 22:36:15 +07:00
const handleGetMultipartObject = async (
file : File ,
bucket : string ,
key : string ,
2026-07-07 04:37:20 +07:00
headers : Record < string , string >,
2026-07-06 22:36:15 +07:00
reqId : string ,
) : Promise < Response > => {
2026-07-06 15:52:42 +07:00
const uploadId = file . multipartUploadId ! ;
const parts = await listMultipartParts ( uploadId );
if ( parts . length === 0 ) {
2026-07-06 22:36:15 +07:00
return s3ErrorResponse (
'InternalError' ,
'Multipart object has no parts.' ,
`/ ${ bucket } / ${ key } ` ,
500 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
}
2026-07-07 04:42:07 +07:00
const totalSize = parts . reduce (( sum , p ) => sum + Number ( p . sizeBytes ), 0 );
2026-07-07 04:37:20 +07:00
const range = parseRangeHeader ( headers . range || null , totalSize );
if ( range . type === 'invalid' ) {
return s3ErrorResponse (
'InvalidRange' ,
'The requested range is not satisfiable.' ,
`/ ${ bucket } / ${ key } ` ,
416 ,
reqId ,
{
'content-range' : unsatisfiedContentRange ( totalSize ),
},
);
}
const sources : ObjectPartSource [] = [];
for ( const part of parts ) {
const fileInfo = await getFileInfo ( part . telegramFileId );
sources . push ({
telegramFileId : part.telegramFileId ,
telegramUrl : `https://api.telegram.org/file/bot ${ fileInfo . bot_token } / ${ fileInfo . file_path } ` ,
sizeBytes : part.sizeBytes ,
partNumber : part.partNumber ,
});
}
2026-07-06 15:52:42 +07:00
2026-07-07 02:54:51 +07:00
if ( ! config . proxyS3Get ) {
2026-07-07 05:39:23 +07:00
return s3Response ( null , 302 , reqId , { location : sources [ 0 ]. telegramUrl });
2026-07-07 02:54:51 +07:00
}
2026-07-07 04:37:20 +07:00
try {
return await createGetObjectResponse ({
reqId ,
contentType : file.mimeType ,
etag : file.fileHash || parts . map (( p ) => p . etag ). join ( '-' ),
lastModified : file.createdAt instanceof Date ? file.createdAt : new Date ( file . createdAt ),
totalSize ,
parts : sources ,
range ,
});
} catch ( error ) {
2026-07-07 02:54:51 +07:00
logger . warn ( 'Telegram multipart content fetch failed' , {
uploadId : file.multipartUploadId ,
2026-07-07 04:37:20 +07:00
error : getErrorMessage ( error ),
2026-07-07 02:54:51 +07:00
});
return s3ErrorResponse (
'InternalError' ,
'Failed to fetch object content from storage' ,
`/ ${ bucket } / ${ key } ` ,
502 ,
reqId ,
);
}
2026-07-06 15:52:42 +07:00
};
const handleHeadObject = async ( bucket : string , key : string , reqId : string ) : Promise < Response > => {
const bucketRecord = await findBucketByName ( bucket );
2026-07-06 22:36:15 +07:00
if ( ! bucketRecord )
return s3ErrorResponse (
'NoSuchBucket' ,
'The specified bucket does not exist.' ,
`/ ${ bucket } / ${ key } ` ,
404 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
const file = await findFileByBucketAndKey ( bucketRecord . id , key );
2026-07-06 22:36:15 +07:00
if ( ! file )
return s3ErrorResponse (
'NoSuchKey' ,
'The specified key does not exist.' ,
`/ ${ bucket } / ${ key } ` ,
404 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
2026-07-07 05:39:23 +07:00
return s3Response ( null , 200 , reqId , {
'content-type' : file . mimeType ,
'content-length' : String ( file . sizeBytes ),
etag : `" ${ file . fileHash || nanoid ( 16 ) } "` ,
'last-modified' :
file . createdAt instanceof Date ? file . createdAt . toUTCString () : new Date (). toUTCString (),
'accept-ranges' : 'bytes' ,
'cache-control' : 'public, max-age=31536000' ,
2026-07-06 15:52:42 +07:00
});
};
2026-07-06 22:36:15 +07:00
const handlePutObject = async (
bucket : string ,
key : string ,
searchParams : URLSearchParams ,
headers : Record < string , string >,
req : Request ,
reqId : string ,
) : Promise < Response > => {
2026-07-06 15:52:42 +07:00
const bucketRecord = await findBucketByName ( bucket );
2026-07-06 22:36:15 +07:00
if ( ! bucketRecord )
return s3ErrorResponse (
'NoSuchBucket' ,
'The specified bucket does not exist.' ,
`/ ${ bucket } / ${ key } ` ,
404 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
if ( searchParams . has ( 'tagging' )) {
2026-07-07 05:39:23 +07:00
return s3Response ( null , 204 , reqId );
2026-07-06 15:52:42 +07:00
}
const copySource = headers [ 'x-amz-copy-source' ];
if ( copySource ) {
2026-07-07 05:39:23 +07:00
return handleCopyObject ( bucket , key , copySource , headers , bucketRecord . id , reqId );
2026-07-06 15:52:42 +07:00
}
2026-07-06 21:38:17 +07:00
// Read raw body — S3 clients send raw binary, not multipart/form-data
const body = await req . arrayBuffer ();
const fileBuffer = Buffer . from ( body );
const contentType = headers [ 'content-type' ] || 'application/octet-stream' ;
const hash = computeHash ( fileBuffer );
2026-07-06 15:52:42 +07:00
const existing = await findFileByBucketAndKey ( bucketRecord . id , key );
if ( existing ) {
2026-07-07 05:39:23 +07:00
return s3Response ( null , 200 , reqId , { etag : `" ${ hash } "` });
2026-07-06 15:52:42 +07:00
}
2026-07-06 21:38:17 +07:00
return await storeFileToTelegram ( fileBuffer , hash , key , bucketRecord , contentType , reqId );
2026-07-06 15:52:42 +07:00
};
2026-07-06 22:36:15 +07:00
const storeFileToTelegram = async (
buffer : Buffer ,
hash : string ,
key : string ,
bucketRecord : { id : string ; name : string },
contentType : string ,
reqId : string ,
) : Promise < Response > => {
2026-07-06 15:52:42 +07:00
const tempPath = `/tmp/teleuploader-s3- ${ nanoid () } ` ;
await Bun . write ( tempPath , buffer );
const signatureBuffer = buffer . subarray ( 0 , 16 );
const fileName = key . split ( '/' ). pop () || 'file' ;
2026-07-06 22:36:15 +07:00
const { fileName : finalFileName , mimeType } = ensureExtension (
fileName ,
signatureBuffer ,
contentType ,
);
2026-07-06 15:52:42 +07:00
2026-07-07 08:08:22 +07:00
const bucketId = bucketRecord . id ;
const partFileNamePrefix = `s3- ${ bucketRecord . name } - ${ key . replace ( /\//g , '_' ) } ` ;
if ( buffer . byteLength > config . telegramChunkSizeBytes ) {
const file = await storeFileInTelegramChunks ({
tempPath ,
partFileNamePrefix ,
fileName : finalFileName ,
mimeType ,
sizeBytes : buffer.byteLength ,
fileType : 'document' ,
uploaderId : 0 ,
bucketId ,
s3Key : key ,
});
await cleanupTempFile ( tempPath );
return s3Response ( null , 200 , reqId , { etag : `" ${ file . fileHash } "` });
}
2026-07-06 15:52:42 +07:00
const forwardResult = await forwardToStorage (
createReadStream ( tempPath ),
2026-07-07 08:08:22 +07:00
partFileNamePrefix ,
2026-07-06 15:52:42 +07:00
'document' ,
);
const publicId = nanoid ();
const { db , files : fileSchema } = await import ( '../db/index' );
await db . insert ( fileSchema ). values ({
publicId ,
telegramFileId : forwardResult.telegramFileId ,
telegramFileUniqueId : forwardResult.telegramFileUniqueId ,
storageChatId : config.storageChatId ,
storageMessageId : forwardResult.storageMessageId ,
fileName : finalFileName ,
mimeType ,
sizeBytes : buffer.byteLength ,
fileType : 'document' ,
uploaderId : 0 ,
fileHash : hash ,
2026-07-07 08:08:22 +07:00
bucketId ,
2026-07-06 15:52:42 +07:00
s3Key : key ,
storageBackend : 'telegram' ,
isDeleted : false ,
createdAt : new Date (),
updatedAt : new Date (),
});
await cleanupTempFile ( tempPath );
2026-07-07 05:39:23 +07:00
return s3Response ( null , 200 , reqId , { etag : `" ${ hash } "` });
2026-07-06 15:52:42 +07:00
};
2026-07-06 22:36:15 +07:00
const handleCopyObject = async (
_destBucket : string ,
destKey : string ,
2026-07-07 05:39:23 +07:00
rawCopySource : string ,
headers : Record < string , string >,
2026-07-06 22:36:15 +07:00
destBucketId : string ,
reqId : string ,
) : Promise < Response > => {
2026-07-07 05:39:23 +07:00
const copySource = decodeURIComponent ( rawCopySource );
2026-07-06 15:52:42 +07:00
const sourcePath = copySource . startsWith ( '/' ) ? copySource . slice ( 1 ) : copySource ;
const parts = sourcePath . split ( '/' );
const sourceBucket = parts [ 0 ];
const sourceKey = parts . slice ( 1 ). join ( '/' );
const sourceBucketRecord = await findBucketByName ( sourceBucket );
2026-07-06 22:36:15 +07:00
if ( ! sourceBucketRecord )
return s3ErrorResponse (
'NoSuchBucket' ,
'The specified bucket does not exist.' ,
copySource ,
404 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
const sourceFile = await findFileByBucketAndKey ( sourceBucketRecord . id , sourceKey );
2026-07-06 22:36:15 +07:00
if ( ! sourceFile )
return s3ErrorResponse (
'NoSuchKey' ,
'The specified key does not exist.' ,
copySource ,
404 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
2026-07-07 08:08:22 +07:00
if ( sourceFile . storageBackend === 'chunked' ) {
// Copying chunked objects is not yet supported.
return s3ErrorResponse (
'NotImplemented' ,
'Copying chunked objects is not yet implemented.' ,
copySource ,
501 ,
reqId ,
);
}
2026-07-07 05:39:23 +07:00
// Conditional copy: if-match / if-none-match checks
const ifMatch = headers [ 'x-amz-copy-source-if-match' ];
const ifNoneMatch = headers [ 'x-amz-copy-source-if-none-match' ];
if ( ifMatch && sourceFile . fileHash && ifMatch !== `" ${ sourceFile . fileHash } "` ) {
return s3ErrorResponse (
'PreconditionFailed' ,
'The preconditions you specified did not hold.' ,
copySource ,
412 ,
reqId ,
);
}
if ( ifNoneMatch && sourceFile . fileHash && ifNoneMatch === `" ${ sourceFile . fileHash } "` ) {
return s3ErrorResponse (
'PreconditionFailed' ,
'The preconditions you specified did not hold.' ,
copySource ,
412 ,
reqId ,
);
}
2026-07-06 15:52:42 +07:00
const publicId = nanoid ();
const { db , files : fileSchema } = await import ( '../db/index' );
await db . insert ( fileSchema ). values ({
publicId ,
telegramFileId : sourceFile.telegramFileId ,
telegramFileUniqueId : sourceFile.telegramFileUniqueId ,
storageChatId : sourceFile.storageChatId ,
storageMessageId : sourceFile.storageMessageId ,
fileName : sourceFile.fileName ,
mimeType : sourceFile.mimeType ,
sizeBytes : sourceFile.sizeBytes ,
fileType : sourceFile.fileType ,
uploaderId : 0 ,
fileHash : sourceFile.fileHash ,
bucketId : destBucketId ,
s3Key : destKey ,
storageBackend : 'telegram' ,
isDeleted : false ,
createdAt : new Date (),
updatedAt : new Date (),
});
const xml = copyObjectResultXml ( sourceFile . fileHash || nanoid ( 16 ), new Date ());
2026-07-07 05:39:23 +07:00
return s3Response ( xml , 200 , reqId , { 'content-type' : 'application/xml' });
2026-07-06 15:52:42 +07:00
};
2026-07-06 22:36:15 +07:00
const handleDeleteObject = async (
bucket : string ,
key : string ,
reqId : string ,
) : Promise < Response > => {
2026-07-06 15:52:42 +07:00
const bucketRecord = await findBucketByName ( bucket );
2026-07-06 22:36:15 +07:00
if ( ! bucketRecord )
return s3ErrorResponse (
'NoSuchBucket' ,
'The specified bucket does not exist.' ,
`/ ${ bucket } / ${ key } ` ,
404 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
await softDeleteFile ( bucketRecord . id , key );
2026-07-07 05:39:23 +07:00
return s3Response ( null , 204 , reqId );
2026-07-06 15:52:42 +07:00
};
2026-07-06 22:36:15 +07:00
const handleDeleteObjects = async (
bucket : string ,
body : string ,
reqId : string ,
) : Promise < Response > => {
2026-07-06 15:52:42 +07:00
const bucketRecord = await findBucketByName ( bucket );
2026-07-06 22:36:15 +07:00
if ( ! bucketRecord )
return s3ErrorResponse (
'NoSuchBucket' ,
'The specified bucket does not exist.' ,
`/ ${ bucket } ` ,
404 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
2026-07-07 05:39:23 +07:00
const { keys , quiet } = parseDeleteObjectsBody ( body );
2026-07-06 21:38:17 +07:00
const deletedKeys : string [] = [];
for ( const key of keys ) {
const ok = await softDeleteFile ( bucketRecord . id , key );
if ( ok ) deletedKeys . push ( key );
}
2026-07-07 05:39:23 +07:00
const xml = quiet ? deleteResultXml ([], []) : deleteResultXml ( deletedKeys , []);
return s3Response ( xml , 200 , reqId , { 'content-type' : 'application/xml' });
2026-07-06 15:52:42 +07:00
};
// ─────── Object Listing ───────
2026-07-06 22:36:15 +07:00
const handleListObjectsV1 = async (
bucket : string ,
searchParams : URLSearchParams ,
reqId : string ,
) : Promise < Response > => {
2026-07-06 15:52:42 +07:00
const bucketRecord = await findBucketByName ( bucket );
2026-07-06 22:36:15 +07:00
if ( ! bucketRecord )
return s3ErrorResponse (
'NoSuchBucket' ,
'The specified bucket does not exist.' ,
`/ ${ bucket } ` ,
404 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
const prefix = searchParams . get ( 'prefix' ) || '' ;
const delimiter = searchParams . get ( 'delimiter' ) || null ;
const maxKeys = Math . min ( parseInt ( searchParams . get ( 'max-keys' ) || '1000' , 10 ), 1000 );
const marker = searchParams . get ( 'marker' ) || null ;
2026-07-07 05:39:23 +07:00
const encodingType = searchParams . get ( 'encoding-type' ) || null ;
2026-07-06 15:52:42 +07:00
const { objects , prefixes : commonPrefixes } = await listObjectsByPrefix (
2026-07-06 22:36:15 +07:00
bucketRecord . id ,
prefix ,
delimiter ,
maxKeys ,
marker ,
2026-07-06 15:52:42 +07:00
);
const isTruncated = objects . length > maxKeys ;
const displayObjects = objects . slice ( 0 , maxKeys );
2026-07-06 22:36:15 +07:00
const nextMarker = isTruncated
? ( displayObjects [ displayObjects . length - 1 ] ? . s3Key ?? null )
: null ;
2026-07-06 15:52:42 +07:00
const xml = listBucketResultXml (
bucket ,
displayObjects . map (( o ) => ({
key : o.s3Key ?? '' ,
sizeBytes : o.sizeBytes ,
etag : o.fileHash || nanoid ( 16 ),
lastModified : o.createdAt instanceof Date ? o.createdAt : new Date (),
mimeType : o.mimeType ,
})),
commonPrefixes ,
isTruncated ,
marker ,
maxKeys ,
prefix ,
delimiter ,
nextMarker ,
reqId ,
2026-07-07 05:39:23 +07:00
encodingType ,
2026-07-06 15:52:42 +07:00
);
2026-07-07 05:39:23 +07:00
return s3Response ( xml , 200 , reqId , { 'content-type' : 'application/xml' });
2026-07-06 15:52:42 +07:00
};
2026-07-06 22:36:15 +07:00
const handleListObjectsV2 = async (
bucket : string ,
searchParams : URLSearchParams ,
reqId : string ,
) : Promise < Response > => {
2026-07-06 15:52:42 +07:00
const bucketRecord = await findBucketByName ( bucket );
2026-07-06 22:36:15 +07:00
if ( ! bucketRecord )
return s3ErrorResponse (
'NoSuchBucket' ,
'The specified bucket does not exist.' ,
`/ ${ bucket } ` ,
404 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
const prefix = searchParams . get ( 'prefix' ) || '' ;
const delimiter = searchParams . get ( 'delimiter' ) || null ;
const maxKeys = Math . min ( parseInt ( searchParams . get ( 'max-keys' ) || '1000' , 10 ), 1000 );
const continuationToken = searchParams . get ( 'continuation-token' ) || null ;
const startAfter = searchParams . get ( 'start-after' ) || null ;
2026-07-07 05:39:23 +07:00
const encodingType = searchParams . get ( 'encoding-type' ) || null ;
2026-07-06 15:52:42 +07:00
const { objects , prefixes : commonPrefixes } = await listObjectsByPrefix (
2026-07-06 22:36:15 +07:00
bucketRecord . id ,
prefix ,
delimiter ,
maxKeys ,
continuationToken || startAfter ,
2026-07-06 15:52:42 +07:00
);
const isTruncated = objects . length > maxKeys ;
const displayObjects = objects . slice ( 0 , maxKeys );
2026-07-06 22:36:15 +07:00
const nextContinuationToken = isTruncated
? ( displayObjects [ displayObjects . length - 1 ] ? . s3Key ?? null )
: null ;
2026-07-06 15:52:42 +07:00
const xml = listBucketV2ResultXml (
bucket ,
displayObjects . map (( o ) => ({
key : o.s3Key ?? '' ,
sizeBytes : o.sizeBytes ,
etag : o.fileHash || nanoid ( 16 ),
lastModified : o.createdAt instanceof Date ? o.createdAt : new Date (),
mimeType : o.mimeType ,
})),
commonPrefixes ,
isTruncated ,
maxKeys ,
prefix ,
delimiter ,
continuationToken ,
nextContinuationToken ,
displayObjects . length ,
reqId ,
2026-07-07 05:39:23 +07:00
encodingType ,
2026-07-06 15:52:42 +07:00
);
2026-07-07 05:39:23 +07:00
return s3Response ( xml , 200 , reqId , { 'content-type' : 'application/xml' });
2026-07-06 15:52:42 +07:00
};
// ─────── Multipart Upload ───────
2026-07-06 22:36:15 +07:00
const handleCreateMultipartUpload = async (
bucket : string ,
key : string ,
_searchParams : URLSearchParams ,
reqId : string ,
) : Promise < Response > => {
2026-07-06 15:52:42 +07:00
const bucketRecord = await findBucketByName ( bucket );
2026-07-06 22:36:15 +07:00
if ( ! bucketRecord )
return s3ErrorResponse (
'NoSuchBucket' ,
'The specified bucket does not exist.' ,
`/ ${ bucket } / ${ key } ` ,
404 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
const uploadId = await createMultipartUpload ( bucketRecord . id , key , 's3' );
const xml = initiateMultipartUploadXml ( bucket , key , uploadId );
2026-07-07 05:39:23 +07:00
return s3Response ( xml , 200 , reqId , { 'content-type' : 'application/xml' });
2026-07-06 15:52:42 +07:00
};
2026-07-06 22:36:15 +07:00
const handleUploadPart = async (
bucket : string ,
key : string ,
searchParams : URLSearchParams ,
req : Request ,
reqId : string ,
) : Promise < Response > => {
2026-07-06 15:52:42 +07:00
const uploadId = searchParams . get ( 'uploadId' ) ! ;
const partNumber = parseInt ( searchParams . get ( 'partNumber' ) ! , 10 );
2026-07-07 05:39:23 +07:00
if ( partNumber < 1 || partNumber > 10000 ) {
return s3ErrorResponse (
'InvalidArgument' ,
'Part number must be an integer between 1 and 10000' ,
`/ ${ bucket } / ${ key } ` ,
400 ,
reqId ,
);
}
2026-07-06 15:52:42 +07:00
const multipart = await findMultipartUpload ( uploadId );
if ( ! multipart || multipart . s3Key !== key ) {
2026-07-06 22:36:15 +07:00
return s3ErrorResponse (
'NoSuchUpload' ,
'The specified upload does not exist.' ,
`/ ${ bucket } / ${ key } ` ,
404 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
}
const body = await req . arrayBuffer ();
const buffer = Buffer . from ( body );
2026-07-07 08:08:22 +07:00
if ( buffer . byteLength > config . telegramChunkSizeBytes ) {
return s3ErrorResponse (
'EntityTooLarge' ,
`Your proposed upload part size ( ${ buffer . byteLength } bytes) exceeds the maximum allowed part size ( ${ config . telegramChunkSizeBytes } bytes) for this storage backend. Use smaller part sizes.` ,
`/ ${ bucket } / ${ key } ` ,
400 ,
reqId ,
);
}
2026-07-06 15:52:42 +07:00
const tempPath = `/tmp/teleuploader-mp- ${ nanoid () } ` ;
await Bun . write ( tempPath , buffer );
const forwardResult = await forwardToStorage (
createReadStream ( tempPath ),
`mp- ${ uploadId } -part- ${ partNumber } ` ,
'document' ,
);
await cleanupTempFile ( tempPath );
const etag = computeHash ( buffer );
await insertMultipartPart ({
uploadId ,
partNumber ,
telegramFileId : forwardResult.telegramFileId ,
telegramFileUniqueId : forwardResult.telegramFileUniqueId ,
storageMessageId : forwardResult.storageMessageId ,
sizeBytes : buffer.byteLength ,
etag ,
});
2026-07-07 05:39:23 +07:00
return s3Response ( null , 200 , reqId , { etag : `" ${ etag } "` });
2026-07-06 15:52:42 +07:00
};
2026-07-06 22:36:15 +07:00
const handleCompleteMultipartUpload = async (
bucket : string ,
key : string ,
searchParams : URLSearchParams ,
body : string ,
reqId : string ,
) : Promise < Response > => {
2026-07-06 15:52:42 +07:00
const uploadId = searchParams . get ( 'uploadId' ) ! ;
const multipart = await findMultipartUpload ( uploadId );
if ( ! multipart ) {
2026-07-06 22:36:15 +07:00
return s3ErrorResponse (
'NoSuchUpload' ,
'The specified upload does not exist.' ,
`/ ${ bucket } / ${ key } ` ,
404 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
}
const parts = parseCompleteMultipartBody ( body );
const storedParts = await listMultipartParts ( uploadId );
2026-07-07 06:11:49 +07:00
// Validate ascending part order
const partNumbers = parts . map (( p ) => p . partNumber );
if ( partNumbers . length > 1 && partNumbers . some (( n , i ) => i > 0 && n <= partNumbers [ i - 1 ])) {
return s3ErrorResponse (
'InvalidPartOrder' ,
'The list of parts was not in ascending order.' ,
`/ ${ bucket } / ${ key } ` ,
400 ,
reqId ,
);
2026-07-07 05:39:23 +07:00
}
2026-07-06 15:52:42 +07:00
if ( parts . length !== storedParts . length ) {
2026-07-06 22:36:15 +07:00
return s3ErrorResponse (
'InvalidPart' ,
'One or more specified parts could not be found.' ,
`/ ${ bucket } / ${ key } ` ,
400 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
}
const totalSize = storedParts . reduce (( sum , p ) => sum + p . sizeBytes , 0 );
const publicId = nanoid ();
const { db , files : fileSchema } = await import ( '../db/index' );
await db . insert ( fileSchema ). values ({
publicId ,
telegramFileId : storedParts [ 0 ]. telegramFileId ,
telegramFileUniqueId : storedParts [ 0 ]. telegramFileUniqueId ,
storageChatId : config.storageChatId ,
storageMessageId : storedParts [ 0 ]. storageMessageId ,
fileName : key.split ( '/' ). pop () || 'file' ,
mimeType : 'application/octet-stream' ,
sizeBytes : totalSize ,
fileType : 'document' ,
uploaderId : 0 ,
bucketId : multipart.bucketId ,
s3Key : key ,
storageBackend : 'telegram' ,
isDeleted : false ,
multipartUploadId : uploadId ,
createdAt : new Date (),
updatedAt : new Date (),
});
await completeMultipartUpload ( uploadId );
const location = ` ${ config . baseUrl } / ${ bucket } / ${ key } ` ;
const combinedEtag = storedParts . map (( p ) => p . etag ). join ( '-' );
const xml = completeMultipartUploadXml ( bucket , key , combinedEtag , location );
2026-07-07 05:39:23 +07:00
return s3Response ( xml , 200 , reqId , { 'content-type' : 'application/xml' });
};
const handleListMultipartUploads = async (
bucket : string ,
searchParams : URLSearchParams ,
reqId : string ,
) : Promise < Response > => {
const bucketRecord = await findBucketByName ( bucket );
if ( ! bucketRecord )
return s3ErrorResponse (
'NoSuchBucket' ,
'The specified bucket does not exist.' ,
`/ ${ bucket } ` ,
404 ,
reqId ,
);
const maxUploads = Math . min ( parseInt ( searchParams . get ( 'max-uploads' ) || '1000' , 10 ), 1000 );
const keyMarker = searchParams . get ( 'key-marker' ) || null ;
const { uploads , isTruncated , nextKeyMarker } = await listMultipartUploadsByBucket (
bucketRecord . id ,
maxUploads ,
keyMarker ,
);
const xml = listMultipartUploadsXml (
bucket ,
uploads . map (( u ) => ({
key : u.s3Key ,
uploadId : u.uploadId ,
initiatedAt : u.initiatedAt ,
initiatedBy : u.initiatedBy ,
})),
maxUploads ,
isTruncated ,
nextKeyMarker ,
reqId ,
);
return s3Response ( xml , 200 , reqId , { 'content-type' : 'application/xml' });
2026-07-06 15:52:42 +07:00
};
2026-07-06 22:36:15 +07:00
const handleAbortMultipartUpload = async (
bucket : string ,
key : string ,
searchParams : URLSearchParams ,
reqId : string ,
) : Promise < Response > => {
2026-07-06 15:52:42 +07:00
const uploadId = searchParams . get ( 'uploadId' ) ! ;
const multipart = await findMultipartUpload ( uploadId );
if ( ! multipart ) {
2026-07-06 22:36:15 +07:00
return s3ErrorResponse (
'NoSuchUpload' ,
'The specified upload does not exist.' ,
`/ ${ bucket } / ${ key } ` ,
404 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
}
await abortMultipartUpload ( uploadId );
2026-07-07 05:39:23 +07:00
return s3Response ( null , 204 , reqId );
2026-07-06 15:52:42 +07:00
};
2026-07-06 22:36:15 +07:00
const handleListParts = async (
bucket : string ,
key : string ,
searchParams : URLSearchParams ,
reqId : string ,
) : Promise < Response > => {
2026-07-06 15:52:42 +07:00
const uploadId = searchParams . get ( 'uploadId' ) ! ;
const multipart = await findMultipartUpload ( uploadId );
if ( ! multipart ) {
2026-07-06 22:36:15 +07:00
return s3ErrorResponse (
'NoSuchUpload' ,
'The specified upload does not exist.' ,
`/ ${ bucket } / ${ key } ` ,
404 ,
reqId ,
);
2026-07-06 15:52:42 +07:00
}
const parts = await listMultipartParts ( uploadId );
const maxParts = Math . min ( parseInt ( searchParams . get ( 'max-parts' ) || '1000' , 10 ), 1000 );
const xml = listPartsXml (
bucket ,
key ,
uploadId ,
parts . map (( p ) => ({
partNumber : p.partNumber ,
etag : p.etag ,
sizeBytes : p.sizeBytes ,
createdAt : p.createdAt ,
})),
maxParts ,
false ,
reqId ,
);
2026-07-07 05:39:23 +07:00
return s3Response ( xml , 200 , reqId , { 'content-type' : 'application/xml' });
2026-07-06 15:52:42 +07:00
};