Files
TeleUploader/src/interfaces/http/controllers/s3/s3-common.ts
T

147 lines
5.1 KiB
TypeScript

import { nanoid } from 'nanoid';
import type { Bucket } from '../../../../domain/entities/bucket';
import type { MultipartUpload } from '../../../../domain/entities/multipart';
import type { IBucketRepository } from '../../../../domain/ports/bucket-repository';
import type { IMultipartRepository } from '../../../../domain/ports/multipart-repository';
import { config } from '../../../../env';
import { s3Headers } from '../../../s3/headers';
import { unsatisfiedContentRange } from '../../../s3/range';
import { s3ErrorResponse } from '../../../s3/xml';
/**
* The default S3 region returned when no region is explicitly configured.
*/
export const REGION = config.s3DefaultRegion || 'us-east-1';
/**
* Generates a unique request identifier for S3 responses.
*
* @returns A hex string suitable for x-amz-request-id and x-amz-id-2.
*/
export const REQUEST_ID = (): string => nanoid(16);
/**
* Builds a standard S3 response with the appropriate headers.
*
* @param body - The XML or empty response body.
* @param status - HTTP status code.
* @param reqId - The request identifier for S3 headers.
* @param extraHeaders - Optional extra response headers.
* @returns An S3-formatted Response.
*/
export const s3Response = (
body: string | null,
status: number,
reqId: string,
extraHeaders: Record<string, string> = {},
): Response => {
// Add content-type for empty 200-series responses (not 204 which has no body)
if (
body === null &&
status >= 200 &&
status < 300 &&
status !== 204 &&
!extraHeaders['content-type']
) {
extraHeaders['content-type'] = 'application/xml';
}
return new Response(body, { status, headers: s3Headers(reqId, extraHeaders) });
};
/**
* Resolves a bucket by name, returning a `NoSuchBucket` S3 error when missing.
*
* Replaces the ~10 identical `findByName` + `NoSuchBucket` blocks previously
* inlined in every bucket/object/listing/multipart handler.
*
* @param bucketRepo - The bucket repository to look up.
* @param bucket - The bucket name from the request path.
* @param path - The request path for the S3 error resource.
* @param reqId - The request identifier for S3 headers.
* @returns The bucket record, or an S3 error Response when not found.
*/
export const resolveBucketOr404 = async (
bucketRepo: IBucketRepository,
bucket: string,
path: string,
reqId: string,
): Promise<Bucket | Response> => {
const bucketRecord = await bucketRepo.findByName(bucket);
if (!bucketRecord) {
return s3ErrorResponse(
'NoSuchBucket',
'The specified bucket does not exist.',
path,
404,
reqId,
);
}
return bucketRecord;
};
/**
* Resolves an in-progress multipart upload, returning a `NoSuchUpload` S3
* error when missing.
*
* Replaces the 5 identical `findById` + `NoSuchUpload` blocks previously
* inlined in the multipart handlers. Pass `key` for the H5 key-match check
* (UploadPart / CompleteMultipartUpload); omit it for Abort / ListParts,
* which historically only checked existence.
*
* @param multipartRepo - The multipart repository to look up.
* @param uploadId - The upload identifier from `?uploadId=`.
* @param path - The request path for the S3 error resource.
* @param reqId - The request identifier for S3 headers.
* @param key - Optional object key the upload must belong to.
* @returns The upload record, or an S3 error Response when not found.
*/
export const requireUploadOr404 = async (
multipartRepo: IMultipartRepository,
uploadId: string,
path: string,
reqId: string,
key?: string,
): Promise<MultipartUpload | Response> => {
const multipart = await multipartRepo.findById(uploadId);
if (!multipart || (key !== undefined && multipart.s3Key !== key)) {
return s3ErrorResponse(
'NoSuchUpload',
'The specified upload does not exist.',
path,
404,
reqId,
);
}
return multipart;
};
/**
* Returns the stable S3 etag for a file, falling back to a random ID when
* the record has no content hash yet.
*
* Replaces the 4 inline `file.fileHash || nanoid(16)` fallbacks (conditional
* headers, HeadObject, copy result, list entries). Callers add quotes where
* the transport needs them. The copy-source check keeps its own inline
* `telegramFileId` variant (M9) with a comment at the call site.
*
* @param fileHash - The stored SHA-256 content hash (may be null).
* @returns The hash, or a random 16-char fallback.
*/
export const etagOrFallback = (fileHash: string | null): string => fileHash || nanoid(16);
/**
* Builds the shared 416 response for unsatisfiable Range requests.
*
* Replaces the 3 identical `InvalidRange` blocks (chunked GET, regular GET,
* multipart GET).
*
* @param path - The request path for the S3 error resource.
* @param totalSize - The total object size for the Content-Range header.
* @param reqId - The request identifier for S3 headers.
* @returns A 416 S3 error Response.
*/
export const invalidRangeResponse = (path: string, totalSize: number, reqId: string): Response =>
s3ErrorResponse('InvalidRange', 'The requested range is not satisfiable.', path, 416, reqId, {
'content-range': unsatisfiedContentRange(totalSize),
});