refactor: dedup dead code, shared utils, conditional headers helper
- Extract shared utils: asSafeChunkSize (validation.ts), S3 detection (s3-detection.ts) - Remove dead _handleMaybeS3Root from index.ts and routes/index.ts - Remove dead _asArray from file-controller.ts - Consolidate maybeCompressChunk into shared compress.ts - Extract checkConditionalHeaders helper, remove ~120 lines dupe in s3-controller - Remove 500+ lines dead code from s3-object.ts (unused use cases + helpers) - Delegate upload-file.ts chunked path to ChunkedStorage, remove dupe - Fix broken dynamic import in file-controller.ts → proper DI - Fix test/files.test.ts import path and mocks [skip ci]
This commit is contained in:
@@ -1,28 +1,13 @@
|
|||||||
import { gzipSync } from 'node:zlib';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import type { File } from '../../domain/entities/file';
|
import type { File } from '../../domain/entities/file';
|
||||||
import { buildNewFile } from '../../domain/entities/file-factory';
|
|
||||||
import type { NewFilePart } from '../../domain/entities/file-part';
|
|
||||||
import type { MultipartPart } from '../../domain/entities/multipart';
|
|
||||||
import type { IBucketRepository } from '../../domain/ports/bucket-repository';
|
import type { IBucketRepository } from '../../domain/ports/bucket-repository';
|
||||||
import type { IFilePartRepository } from '../../domain/ports/file-part-repository';
|
import type { IFilePartRepository } from '../../domain/ports/file-part-repository';
|
||||||
import type { IFileRepository, S3FileRecord } from '../../domain/ports/file-repository';
|
import type { IFileRepository } from '../../domain/ports/file-repository';
|
||||||
import type { IMultipartRepository } from '../../domain/ports/multipart-repository';
|
import type { IMultipartRepository } from '../../domain/ports/multipart-repository';
|
||||||
import type { ITelegramService, TelegramFileInfo } from '../../domain/ports/telegram-service';
|
import type { ITelegramService, TelegramFileInfo } from '../../domain/ports/telegram-service';
|
||||||
import {
|
import type { CompressionAlgorithm } from '../../shared/utils/compress';
|
||||||
computeHash,
|
|
||||||
DEFAULT_FILE_TYPE,
|
|
||||||
ensureExtension,
|
|
||||||
formatCreatedAt,
|
|
||||||
} from '../../shared/utils/file';
|
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Compression algorithm used for chunked object storage.
|
|
||||||
*/
|
|
||||||
type CompressionAlgorithm = 'gzip' | null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single part source for building a multi-part streaming response.
|
* A single part source for building a multi-part streaming response.
|
||||||
* Each part corresponds to a Telegram-stored file chunk.
|
* Each part corresponds to a Telegram-stored file chunk.
|
||||||
@@ -178,604 +163,3 @@ export interface S3ObjectDeps {
|
|||||||
/** Application configuration subset. */
|
/** Application configuration subset. */
|
||||||
config: S3ObjectConfig;
|
config: S3ObjectConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the configured chunk size and returns it as a safe integer.
|
|
||||||
*
|
|
||||||
* @param chunkSizeBytes - The configured chunk size in bytes.
|
|
||||||
* @returns The same value if it is a positive safe integer.
|
|
||||||
*/
|
|
||||||
const asSafeChunkSize = (chunkSizeBytes: number): number => {
|
|
||||||
if (!Number.isSafeInteger(chunkSizeBytes) || chunkSizeBytes <= 0) {
|
|
||||||
throw new Error('Invalid Telegram chunk size');
|
|
||||||
}
|
|
||||||
return chunkSizeBytes;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optionally gzip-compresses a chunk if compression is enabled and the chunk
|
|
||||||
* is large enough to benefit from it.
|
|
||||||
*
|
|
||||||
* @param chunk - The raw chunk buffer.
|
|
||||||
* @param compress - Whether compression is enabled.
|
|
||||||
* @param compressionMinSizeBytes - Minimum chunk size to attempt compression.
|
|
||||||
* @returns The (possibly compressed) buffer and the algorithm used.
|
|
||||||
*/
|
|
||||||
const maybeCompressChunk = (
|
|
||||||
chunk: Buffer,
|
|
||||||
compress: boolean,
|
|
||||||
compressionMinSizeBytes: number,
|
|
||||||
): { bytes: Buffer; compressionAlgorithm: CompressionAlgorithm } => {
|
|
||||||
if (!compress || chunk.byteLength < compressionMinSizeBytes) {
|
|
||||||
return { bytes: chunk, compressionAlgorithm: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const gzipped = gzipSync(chunk);
|
|
||||||
if (gzipped.byteLength >= chunk.byteLength) {
|
|
||||||
return { bytes: chunk, compressionAlgorithm: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { bytes: gzipped, compressionAlgorithm: 'gzip' };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Metadata for a single uploaded chunk/part during S3 put-object.
|
|
||||||
*/
|
|
||||||
interface UploadedPart {
|
|
||||||
/** 1-based part number. */
|
|
||||||
partNumber: number;
|
|
||||||
/** Telegram file identifier for this part. */
|
|
||||||
telegramFileId: string;
|
|
||||||
/** Telegram unique file identifier (stable across bot tokens). */
|
|
||||||
telegramFileUniqueId: string;
|
|
||||||
/** Message ID within the storage chat. */
|
|
||||||
storageMessageId: number;
|
|
||||||
/** Original size of the chunk in bytes before compression. */
|
|
||||||
sizeBytes: number;
|
|
||||||
/** Stored (post-compression) size in bytes. */
|
|
||||||
storedSizeBytes: number;
|
|
||||||
/** Compression algorithm applied, or null if uncompressed. */
|
|
||||||
compressionAlgorithm: CompressionAlgorithm;
|
|
||||||
/** SHA-256 hash of the original chunk content. */
|
|
||||||
etag: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Result of uploading an object in multiple Telegram chunks. */
|
|
||||||
interface ChunkedUploadResult {
|
|
||||||
/** Metadata for each uploaded part. */
|
|
||||||
parts: UploadedPart[];
|
|
||||||
/** SHA-256 hex digest of the complete object content. */
|
|
||||||
fileHash: string;
|
|
||||||
/** Total object size in bytes (sum of all original chunks). */
|
|
||||||
totalSizeBytes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads a buffer to Telegram in chunks, returning metadata for all parts.
|
|
||||||
*
|
|
||||||
* @param buffer - The full object buffer.
|
|
||||||
* @param partFileNamePrefix - Prefix used for each chunk's file name in Telegram.
|
|
||||||
* @param chunkSizeBytes - Maximum size of each chunk in bytes.
|
|
||||||
* @param compress - Whether gzip compression is enabled.
|
|
||||||
* @param compressionMinSizeBytes - Minimum chunk size to attempt compression.
|
|
||||||
* @param telegramService - The Telegram service to forward each chunk.
|
|
||||||
* @returns The aggregated chunked upload result.
|
|
||||||
*/
|
|
||||||
const uploadInChunks = async (
|
|
||||||
buffer: Buffer,
|
|
||||||
partFileNamePrefix: string,
|
|
||||||
chunkSizeBytes: number,
|
|
||||||
compress: boolean,
|
|
||||||
compressionMinSizeBytes: number,
|
|
||||||
telegramService: ITelegramService,
|
|
||||||
): Promise<ChunkedUploadResult> => {
|
|
||||||
const safeChunkSize = asSafeChunkSize(chunkSizeBytes);
|
|
||||||
const hasher = new Bun.CryptoHasher('sha256');
|
|
||||||
const parts: UploadedPart[] = [];
|
|
||||||
let totalSizeBytes = 0;
|
|
||||||
let partNumber = 0;
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
while (offset < buffer.byteLength) {
|
|
||||||
const chunk = buffer.subarray(offset, offset + safeChunkSize);
|
|
||||||
if (chunk.byteLength === 0) break;
|
|
||||||
|
|
||||||
partNumber += 1;
|
|
||||||
totalSizeBytes += chunk.byteLength;
|
|
||||||
hasher.update(chunk);
|
|
||||||
|
|
||||||
const { bytes, compressionAlgorithm } = maybeCompressChunk(
|
|
||||||
chunk,
|
|
||||||
compress,
|
|
||||||
compressionMinSizeBytes,
|
|
||||||
);
|
|
||||||
|
|
||||||
const forwardResult = await telegramService.forwardToStorage(
|
|
||||||
bytes,
|
|
||||||
`${partFileNamePrefix}.part-${partNumber}`,
|
|
||||||
'document',
|
|
||||||
);
|
|
||||||
|
|
||||||
parts.push({
|
|
||||||
partNumber,
|
|
||||||
telegramFileId: forwardResult.telegramFileId,
|
|
||||||
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
|
|
||||||
storageMessageId: forwardResult.storageMessageId,
|
|
||||||
sizeBytes: chunk.byteLength,
|
|
||||||
storedSizeBytes: bytes.byteLength,
|
|
||||||
compressionAlgorithm,
|
|
||||||
etag: computeHash(chunk),
|
|
||||||
});
|
|
||||||
|
|
||||||
offset += safeChunkSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
parts,
|
|
||||||
fileHash: hasher.digest('hex'),
|
|
||||||
totalSizeBytes,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves a list of multipart parts to their Telegram CDN URLs.
|
|
||||||
*
|
|
||||||
* @param parts - The stored multipart parts.
|
|
||||||
* @param telegramService - The Telegram service for resolving file metadata.
|
|
||||||
* @returns An array of resolved part sources.
|
|
||||||
*/
|
|
||||||
const resolveMultipartParts = async (
|
|
||||||
parts: MultipartPart[],
|
|
||||||
telegramService: ITelegramService,
|
|
||||||
): Promise<ObjectPartSource[]> => {
|
|
||||||
const sources: ObjectPartSource[] = [];
|
|
||||||
for (const part of parts) {
|
|
||||||
const fileInfo = await telegramService.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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return sources;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a `createdAt` value into an HTTP Last-Modified header value.
|
|
||||||
*
|
|
||||||
* @param date - The date to format.
|
|
||||||
* @returns The UTC string representation.
|
|
||||||
*/
|
|
||||||
const formatLastModified = (date: Date | string | number): string => {
|
|
||||||
return date instanceof Date ? date.toUTCString() : new Date(date).toUTCString();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Use Case Factories ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a use case that resolves an S3 object for retrieval (GET).
|
|
||||||
*
|
|
||||||
* Looks up the bucket and file by key, then determines the storage type:
|
|
||||||
* - **direct**: regular Telegram-stored object — resolves the Telegram CDN URL.
|
|
||||||
* - **chunked**: object stored across multiple Telegram file parts.
|
|
||||||
* - **multipart**: object assembled from a completed multipart upload — resolves
|
|
||||||
* the Telegram CDN URLs for each part.
|
|
||||||
*
|
|
||||||
* @param deps - The injected dependencies.
|
|
||||||
* @returns An async function accepting bucket name and object key, returning
|
|
||||||
* a discriminated union of possible results, or `null` when the
|
|
||||||
* bucket or file is not found.
|
|
||||||
*/
|
|
||||||
export function createGetObjectUseCase(deps: S3ObjectDeps) {
|
|
||||||
return async (bucketName: string, key: string): Promise<GetObjectResult | null> => {
|
|
||||||
const bucket = await deps.bucketRepo.findByName(bucketName);
|
|
||||||
if (!bucket) return null;
|
|
||||||
|
|
||||||
const file = await deps.fileRepo.findByBucketAndKey(bucket.id, key);
|
|
||||||
if (!file) return null;
|
|
||||||
|
|
||||||
// Chunked storage — return the entity; the caller resolves parts via
|
|
||||||
// chunked-storage helpers.
|
|
||||||
if (file.storageBackend === 'chunked') {
|
|
||||||
return { type: 'chunked', file };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multipart upload object — resolve part Telegram URLs
|
|
||||||
if (file.multipartUploadId) {
|
|
||||||
const parts = await deps.multipartRepo.listParts(file.multipartUploadId);
|
|
||||||
const resolvedParts = await resolveMultipartParts(parts, deps.telegramService);
|
|
||||||
return { type: 'multipart', file, parts: resolvedParts };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular direct object — resolve Telegram CDN URL
|
|
||||||
const fileInfo = await deps.telegramService.getFileInfo(file.telegramFileId);
|
|
||||||
const telegramUrl = `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`;
|
|
||||||
|
|
||||||
return { type: 'direct', file, telegramUrl, fileInfo };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a use case that retrieves S3 object metadata (HEAD).
|
|
||||||
*
|
|
||||||
* @param deps - The injected dependencies.
|
|
||||||
* @returns An async function accepting bucket name and object key, returning
|
|
||||||
* metadata or `null` when the bucket or file is not found.
|
|
||||||
*/
|
|
||||||
export function createHeadObjectUseCase(deps: S3ObjectDeps) {
|
|
||||||
return async (bucketName: string, key: string): Promise<HeadObjectMetadata | null> => {
|
|
||||||
const bucket = await deps.bucketRepo.findByName(bucketName);
|
|
||||||
if (!bucket) return null;
|
|
||||||
|
|
||||||
const file = await deps.fileRepo.findByBucketAndKey(bucket.id, key);
|
|
||||||
if (!file) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
contentType: file.mimeType,
|
|
||||||
contentLength: file.sizeBytes,
|
|
||||||
etag: file.fileHash || nanoid(16),
|
|
||||||
lastModified: formatLastModified(file.createdAt),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a use case that stores an S3 object (PUT).
|
|
||||||
*
|
|
||||||
* Handles both chunked (large) and single-message (small) upload paths,
|
|
||||||
* deduplicates by bucket+key (idempotent PUT), and persists the file
|
|
||||||
* record and (for chunked storage) part records.
|
|
||||||
*
|
|
||||||
* @param deps - The injected dependencies.
|
|
||||||
* @returns An async function accepting bucket name, key, body buffer, and
|
|
||||||
* content type, returning the etag of the stored object. Returns
|
|
||||||
* `null` when the bucket is not found.
|
|
||||||
*/
|
|
||||||
export function createPutObjectUseCase(deps: S3ObjectDeps) {
|
|
||||||
return async (
|
|
||||||
bucketName: string,
|
|
||||||
key: string,
|
|
||||||
body: Buffer,
|
|
||||||
contentType: string,
|
|
||||||
): Promise<PutObjectResult | null> => {
|
|
||||||
const bucket = await deps.bucketRepo.findByName(bucketName);
|
|
||||||
if (!bucket) return null;
|
|
||||||
|
|
||||||
const hash = computeHash(body);
|
|
||||||
|
|
||||||
// Idempotent PUT: if the object already exists, skip upload
|
|
||||||
const existing = await deps.fileRepo.findByBucketAndKey(bucket.id, key);
|
|
||||||
if (existing) {
|
|
||||||
return { etag: `"${hash}"` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileName = key.split('/').pop() || 'file';
|
|
||||||
const signatureBuffer = body.subarray(0, 16);
|
|
||||||
const { fileName: finalFileName, mimeType } = ensureExtension(
|
|
||||||
fileName,
|
|
||||||
signatureBuffer,
|
|
||||||
contentType,
|
|
||||||
);
|
|
||||||
|
|
||||||
const partFileNamePrefix = `s3-${bucket.name}-${key.replace(/\//g, '_')}`;
|
|
||||||
const {
|
|
||||||
telegramChunkSizeBytes,
|
|
||||||
compressChunkedUploads,
|
|
||||||
chunkCompressionMinSizeBytes,
|
|
||||||
storageChatId,
|
|
||||||
} = deps.config;
|
|
||||||
|
|
||||||
if (body.byteLength > telegramChunkSizeBytes) {
|
|
||||||
// Chunked upload path
|
|
||||||
const chunkResult = await uploadInChunks(
|
|
||||||
body,
|
|
||||||
partFileNamePrefix,
|
|
||||||
telegramChunkSizeBytes,
|
|
||||||
compressChunkedUploads,
|
|
||||||
chunkCompressionMinSizeBytes,
|
|
||||||
deps.telegramService,
|
|
||||||
);
|
|
||||||
|
|
||||||
const firstPart = chunkResult.parts[0];
|
|
||||||
if (!firstPart) {
|
|
||||||
throw new Error('Chunked upload produced no parts');
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileId = nanoid();
|
|
||||||
const publicId = nanoid();
|
|
||||||
|
|
||||||
await deps.fileRepo.create(
|
|
||||||
buildNewFile({
|
|
||||||
publicId,
|
|
||||||
telegramFileId: firstPart.telegramFileId,
|
|
||||||
telegramFileUniqueId: firstPart.telegramFileUniqueId,
|
|
||||||
storageChatId,
|
|
||||||
storageMessageId: firstPart.storageMessageId,
|
|
||||||
fileName: finalFileName,
|
|
||||||
mimeType,
|
|
||||||
sizeBytes: chunkResult.totalSizeBytes,
|
|
||||||
fileType: DEFAULT_FILE_TYPE,
|
|
||||||
fileHash: chunkResult.fileHash,
|
|
||||||
bucketId: bucket.id,
|
|
||||||
s3Key: key,
|
|
||||||
storageBackend: 'chunked',
|
|
||||||
partCount: chunkResult.parts.length,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const fileParts: NewFilePart[] = chunkResult.parts.map((part) => ({
|
|
||||||
fileId,
|
|
||||||
partNumber: part.partNumber,
|
|
||||||
telegramFileId: part.telegramFileId,
|
|
||||||
telegramFileUniqueId: part.telegramFileUniqueId,
|
|
||||||
storageChatId,
|
|
||||||
storageMessageId: part.storageMessageId,
|
|
||||||
sizeBytes: part.sizeBytes,
|
|
||||||
storedSizeBytes: part.storedSizeBytes,
|
|
||||||
compressionAlgorithm: part.compressionAlgorithm,
|
|
||||||
etag: part.etag,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await deps.filePartRepo.insert(fileParts);
|
|
||||||
|
|
||||||
return { etag: `"${chunkResult.fileHash}"` };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single-message upload path
|
|
||||||
const forwardResult = await deps.telegramService.forwardToStorage(
|
|
||||||
body,
|
|
||||||
partFileNamePrefix,
|
|
||||||
'document',
|
|
||||||
);
|
|
||||||
|
|
||||||
const publicId = nanoid();
|
|
||||||
|
|
||||||
await deps.fileRepo.create(
|
|
||||||
buildNewFile({
|
|
||||||
publicId,
|
|
||||||
telegramFileId: forwardResult.telegramFileId,
|
|
||||||
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
|
|
||||||
storageChatId,
|
|
||||||
storageMessageId: forwardResult.storageMessageId,
|
|
||||||
fileName: finalFileName,
|
|
||||||
mimeType,
|
|
||||||
sizeBytes: body.byteLength,
|
|
||||||
fileType: DEFAULT_FILE_TYPE,
|
|
||||||
fileHash: hash,
|
|
||||||
bucketId: bucket.id,
|
|
||||||
s3Key: key,
|
|
||||||
storageBackend: 'telegram',
|
|
||||||
partCount: null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return { etag: `"${hash}"` };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a use case that copies an S3 object to a new key (PUT with
|
|
||||||
* x-amz-copy-source).
|
|
||||||
*
|
|
||||||
* Creates a new file record referencing the same Telegram-stored data.
|
|
||||||
* Chunked source objects are not supported for copy.
|
|
||||||
*
|
|
||||||
* @param deps - The injected dependencies.
|
|
||||||
* @returns An async function accepting source + destination identifiers and
|
|
||||||
* optional precondition headers, returning the copy result or
|
|
||||||
* `null` when a required bucket or file is not found.
|
|
||||||
*/
|
|
||||||
export function createCopyObjectUseCase(deps: S3ObjectDeps) {
|
|
||||||
return async (input: {
|
|
||||||
/** Source bucket name. */
|
|
||||||
sourceBucket: string;
|
|
||||||
/** Source object key. */
|
|
||||||
sourceKey: string;
|
|
||||||
/** Destination bucket UUID (must already exist). */
|
|
||||||
destBucketId: string;
|
|
||||||
/** Destination object key. */
|
|
||||||
destKey: string;
|
|
||||||
/** Optional if-match precondition (raw etag value, without surrounding quotes). */
|
|
||||||
ifMatch?: string | null;
|
|
||||||
/** Optional if-none-match precondition (raw etag value, without surrounding quotes). */
|
|
||||||
ifNoneMatch?: string | null;
|
|
||||||
}): Promise<CopyObjectResult | null> => {
|
|
||||||
const sourceBucket = await deps.bucketRepo.findByName(input.sourceBucket);
|
|
||||||
if (!sourceBucket) return null;
|
|
||||||
|
|
||||||
const sourceFile = await deps.fileRepo.findByBucketAndKey(sourceBucket.id, input.sourceKey);
|
|
||||||
if (!sourceFile) return null;
|
|
||||||
|
|
||||||
if (sourceFile.storageBackend === 'chunked') {
|
|
||||||
throw new ObjectError(
|
|
||||||
'NotImplemented',
|
|
||||||
'Copying chunked objects is not yet implemented.',
|
|
||||||
501,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Conditional copy: if-match / if-none-match checks
|
|
||||||
const sourceEtag = sourceFile.fileHash;
|
|
||||||
if (input.ifMatch && sourceEtag && input.ifMatch !== sourceEtag) {
|
|
||||||
throw new ObjectError(
|
|
||||||
'PreconditionFailed',
|
|
||||||
'The preconditions you specified did not hold.',
|
|
||||||
412,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (input.ifNoneMatch && sourceEtag && input.ifNoneMatch === sourceEtag) {
|
|
||||||
throw new ObjectError(
|
|
||||||
'PreconditionFailed',
|
|
||||||
'The preconditions you specified did not hold.',
|
|
||||||
412,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicId = nanoid();
|
|
||||||
|
|
||||||
await deps.fileRepo.create(
|
|
||||||
buildNewFile({
|
|
||||||
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,
|
|
||||||
fileHash: sourceFile.fileHash,
|
|
||||||
archiveTelegramFileId: sourceFile.archiveTelegramFileId,
|
|
||||||
archiveStorageMessageId: sourceFile.archiveStorageMessageId,
|
|
||||||
archiveFileName: sourceFile.archiveFileName,
|
|
||||||
archiveEntryName: sourceFile.archiveEntryName,
|
|
||||||
archiveMimeType: sourceFile.archiveMimeType,
|
|
||||||
archiveSizeBytes: sourceFile.archiveSizeBytes,
|
|
||||||
bucketId: input.destBucketId,
|
|
||||||
s3Key: input.destKey,
|
|
||||||
storageBackend: 'telegram',
|
|
||||||
partCount: null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
etag: sourceEtag || nanoid(16),
|
|
||||||
lastModified: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error type for S3 object-level application errors.
|
|
||||||
*/
|
|
||||||
export class ObjectError extends Error {
|
|
||||||
/** S3-compatible error code. */
|
|
||||||
readonly code: string;
|
|
||||||
/** Suggested HTTP status code. */
|
|
||||||
readonly status: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param code - The S3 error code.
|
|
||||||
* @param message - Human-readable error description.
|
|
||||||
* @param status - Suggested HTTP status.
|
|
||||||
*/
|
|
||||||
constructor(code: string, message: string, status: number) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'ObjectError';
|
|
||||||
this.code = code;
|
|
||||||
this.status = status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a use case that soft-deletes an S3 object (DELETE).
|
|
||||||
*
|
|
||||||
* @param deps - The injected dependencies.
|
|
||||||
* @returns An async function accepting bucket name and object key, returning
|
|
||||||
* `true` if a row was soft-deleted. Returns `null` when the bucket
|
|
||||||
* is not found.
|
|
||||||
*/
|
|
||||||
export function createDeleteObjectUseCase(deps: S3ObjectDeps) {
|
|
||||||
return async (bucketName: string, key: string): Promise<boolean | null> => {
|
|
||||||
const bucket = await deps.bucketRepo.findByName(bucketName);
|
|
||||||
if (!bucket) return null;
|
|
||||||
return deps.fileRepo.softDelete(bucket.id, key);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a use case that batch-deletes multiple S3 objects (POST with
|
|
||||||
* ?delete).
|
|
||||||
*
|
|
||||||
* @param deps - The injected dependencies.
|
|
||||||
* @returns An async function accepting bucket name and an array of keys,
|
|
||||||
* returning the array of keys that were actually deleted. Returns
|
|
||||||
* `null` when the bucket is not found.
|
|
||||||
*/
|
|
||||||
export function createDeleteObjectsUseCase(deps: S3ObjectDeps) {
|
|
||||||
return async (bucketName: string, keys: string[]): Promise<string[] | null> => {
|
|
||||||
const bucket = await deps.bucketRepo.findByName(bucketName);
|
|
||||||
if (!bucket) return null;
|
|
||||||
|
|
||||||
const deletedKeys: string[] = [];
|
|
||||||
for (const key of keys) {
|
|
||||||
const ok = await deps.fileRepo.softDelete(bucket.id, key);
|
|
||||||
if (ok) deletedKeys.push(key);
|
|
||||||
}
|
|
||||||
return deletedKeys;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a use case that lists objects within a bucket (ListObjectsV1/V2).
|
|
||||||
*
|
|
||||||
* Supports prefix filtering, delimiter-based pseudo-directory grouping, and
|
|
||||||
* pagination via marker/startAfter.
|
|
||||||
*
|
|
||||||
* @param deps - The injected dependencies.
|
|
||||||
* @returns An async function accepting query parameters and returning the
|
|
||||||
* listing result, or `null` when the bucket is not found.
|
|
||||||
*/
|
|
||||||
export function createListObjectsUseCase(deps: S3ObjectDeps) {
|
|
||||||
return async (input: {
|
|
||||||
/** Bucket name to list from. */
|
|
||||||
bucketName: string;
|
|
||||||
/** Key prefix to filter by (empty string for no filter). */
|
|
||||||
prefix: string;
|
|
||||||
/** Delimiter character (e.g. "/") or null for flat listing. */
|
|
||||||
delimiter: string | null;
|
|
||||||
/** Maximum number of object records to return (clamped to 1000). */
|
|
||||||
maxKeys: number;
|
|
||||||
/** Return only keys strictly greater than this value, or null. */
|
|
||||||
startAfter: string | null;
|
|
||||||
}): Promise<ListObjectsResult | null> => {
|
|
||||||
const bucket = await deps.bucketRepo.findByName(input.bucketName);
|
|
||||||
if (!bucket) return null;
|
|
||||||
|
|
||||||
const clampedMaxKeys = Math.min(input.maxKeys, 1000);
|
|
||||||
|
|
||||||
const { objects, prefixes } = await deps.fileRepo.listByPrefix(
|
|
||||||
bucket.id,
|
|
||||||
input.prefix,
|
|
||||||
input.delimiter,
|
|
||||||
clampedMaxKeys,
|
|
||||||
input.startAfter,
|
|
||||||
);
|
|
||||||
|
|
||||||
const isTruncated = objects.length > clampedMaxKeys;
|
|
||||||
const displayObjects = objects.slice(0, clampedMaxKeys);
|
|
||||||
const nextMarker = isTruncated
|
|
||||||
? (displayObjects[displayObjects.length - 1]?.s3Key ?? null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
objects: displayObjects.map((o: S3FileRecord) => ({
|
|
||||||
key: o.s3Key,
|
|
||||||
sizeBytes: o.sizeBytes,
|
|
||||||
etag: o.fileHash || nanoid(16),
|
|
||||||
lastModified: formatCreatedAt(o.createdAt),
|
|
||||||
mimeType: o.mimeType,
|
|
||||||
})),
|
|
||||||
prefixes,
|
|
||||||
isTruncated,
|
|
||||||
nextMarker,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a use case that checks whether an object exists and is accessible
|
|
||||||
* within a bucket.
|
|
||||||
*
|
|
||||||
* @param deps - The injected dependencies.
|
|
||||||
* @returns An async function accepting a bucket ID and object key,
|
|
||||||
* returning the file entity or null.
|
|
||||||
*/
|
|
||||||
export function createFindObjectUseCase(deps: Pick<S3ObjectDeps, 'fileRepo'>) {
|
|
||||||
return async (bucketId: string, key: string): Promise<File | null> => {
|
|
||||||
return deps.fileRepo.findByBucketAndKey(bucketId, key);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,44 +2,12 @@ import { createReadStream } from 'node:fs';
|
|||||||
import { open } from 'node:fs/promises';
|
import { open } from 'node:fs/promises';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { buildNewFile } from '../../domain/entities/file-factory';
|
import { buildNewFile } from '../../domain/entities/file-factory';
|
||||||
import type { NewFilePart } from '../../domain/entities/file-part';
|
|
||||||
import type { IFilePartRepository } from '../../domain/ports/file-part-repository';
|
|
||||||
import type { IFileRepository } from '../../domain/ports/file-repository';
|
import type { IFileRepository } from '../../domain/ports/file-repository';
|
||||||
import type { ITelegramService } from '../../domain/ports/telegram-service';
|
import type { ITelegramService } from '../../domain/ports/telegram-service';
|
||||||
import { type CompressionAlgorithm, maybeCompressChunk } from '../../shared/utils/compress';
|
import type { ChunkedStorage } from '../../infrastructure/telegram/chunked-storage';
|
||||||
import { checkFileSize, computeHash, ensureExtension, getFileType } from '../../shared/utils/file';
|
import { checkFileSize, ensureExtension, getFileType } from '../../shared/utils/file';
|
||||||
import type { UploadInput, UploadOutput } from '../dto/upload';
|
import type { UploadInput, UploadOutput } from '../dto/upload';
|
||||||
|
|
||||||
/** Metadata for a single uploaded chunk/part. */
|
|
||||||
interface UploadedPart {
|
|
||||||
/** 1-based part number. */
|
|
||||||
partNumber: number;
|
|
||||||
/** Telegram file identifier for this part. */
|
|
||||||
telegramFileId: string;
|
|
||||||
/** Telegram unique file identifier (stable across bot tokens). */
|
|
||||||
telegramFileUniqueId: string;
|
|
||||||
/** Message ID within the storage chat. */
|
|
||||||
storageMessageId: number;
|
|
||||||
/** Original size of the chunk in bytes before compression. */
|
|
||||||
sizeBytes: number;
|
|
||||||
/** Stored (post-compression) size in bytes. */
|
|
||||||
storedSizeBytes: number;
|
|
||||||
/** Compression algorithm applied, or null if uncompressed. */
|
|
||||||
compressionAlgorithm: CompressionAlgorithm;
|
|
||||||
/** SHA-256 hash of the original chunk content. */
|
|
||||||
etag: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Result of uploading a file in multiple Telegram chunks. */
|
|
||||||
interface ChunkedUploadResult {
|
|
||||||
/** Metadata for each uploaded part. */
|
|
||||||
parts: UploadedPart[];
|
|
||||||
/** SHA-256 hex digest of the complete file content. */
|
|
||||||
fileHash: string;
|
|
||||||
/** Total file size in bytes (sum of all original chunks). */
|
|
||||||
totalSizeBytes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Subset of application configuration consumed by the upload-file use case. */
|
/** Subset of application configuration consumed by the upload-file use case. */
|
||||||
export interface UploadFileConfig {
|
export interface UploadFileConfig {
|
||||||
/** Server base URL for constructing download links. */
|
/** Server base URL for constructing download links. */
|
||||||
@@ -58,27 +26,14 @@ export interface UploadFileConfig {
|
|||||||
export interface UploadFileUseCaseDeps {
|
export interface UploadFileUseCaseDeps {
|
||||||
/** File repository for CRUD operations on file records. */
|
/** File repository for CRUD operations on file records. */
|
||||||
fileRepo: IFileRepository;
|
fileRepo: IFileRepository;
|
||||||
/** File-part repository for chunked file metadata. */
|
|
||||||
filePartRepo: IFilePartRepository;
|
|
||||||
/** Telegram service for forwarding file content to storage. */
|
/** Telegram service for forwarding file content to storage. */
|
||||||
telegramService: ITelegramService;
|
telegramService: ITelegramService;
|
||||||
|
/** Chunked storage handler for large file uploads. */
|
||||||
|
chunkedStorage: ChunkedStorage;
|
||||||
/** Application configuration subset. */
|
/** Application configuration subset. */
|
||||||
config: UploadFileConfig;
|
config: UploadFileConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the configured chunk size and returns it as a safe integer.
|
|
||||||
*
|
|
||||||
* @param chunkSizeBytes - The configured chunk size in bytes.
|
|
||||||
* @returns The same value if it is a positive safe integer.
|
|
||||||
*/
|
|
||||||
const asSafeChunkSize = (chunkSizeBytes: number): number => {
|
|
||||||
if (!Number.isSafeInteger(chunkSizeBytes) || chunkSizeBytes <= 0) {
|
|
||||||
throw new Error('Invalid Telegram chunk size');
|
|
||||||
}
|
|
||||||
return chunkSizeBytes;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the first 16 bytes from a file on disk for magic-byte detection.
|
* Reads the first 16 bytes from a file on disk for magic-byte detection.
|
||||||
*
|
*
|
||||||
@@ -96,74 +51,6 @@ const readSignatureBuffer = async (tempPath: string): Promise<Buffer> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads a file from disk in chunks, forwards each chunk to Telegram storage,
|
|
||||||
* and returns metadata for all uploaded parts together with the total file
|
|
||||||
* hash.
|
|
||||||
*
|
|
||||||
* @param tempPath - Absolute path to the temporary file on disk.
|
|
||||||
* @param partFileNamePrefix - Prefix used for each chunk's file name in Telegram.
|
|
||||||
* @param chunkSizeBytes - Maximum size of each chunk in bytes.
|
|
||||||
* @param compress - Whether gzip compression is enabled.
|
|
||||||
* @param compressionMinSizeBytes - Minimum chunk size to attempt compression.
|
|
||||||
* @param telegramService - The Telegram service to forward each chunk.
|
|
||||||
* @returns The aggregated chunked upload result.
|
|
||||||
*/
|
|
||||||
const uploadFileInTelegramChunks = async (
|
|
||||||
tempPath: string,
|
|
||||||
partFileNamePrefix: string,
|
|
||||||
chunkSizeBytes: number,
|
|
||||||
compress: boolean,
|
|
||||||
compressionMinSizeBytes: number,
|
|
||||||
telegramService: ITelegramService,
|
|
||||||
): Promise<ChunkedUploadResult> => {
|
|
||||||
const safeChunkSize = asSafeChunkSize(chunkSizeBytes);
|
|
||||||
const hasher = new Bun.CryptoHasher('sha256');
|
|
||||||
const parts: UploadedPart[] = [];
|
|
||||||
let totalSizeBytes = 0;
|
|
||||||
let partNumber = 0;
|
|
||||||
|
|
||||||
const stream = createReadStream(tempPath, { highWaterMark: safeChunkSize });
|
|
||||||
|
|
||||||
for await (const data of stream) {
|
|
||||||
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data as Uint8Array);
|
|
||||||
if (chunk.byteLength === 0) continue;
|
|
||||||
|
|
||||||
partNumber += 1;
|
|
||||||
totalSizeBytes += chunk.byteLength;
|
|
||||||
hasher.update(chunk);
|
|
||||||
|
|
||||||
const { bytes, compressionAlgorithm } = maybeCompressChunk(
|
|
||||||
chunk,
|
|
||||||
compress,
|
|
||||||
compressionMinSizeBytes,
|
|
||||||
);
|
|
||||||
|
|
||||||
const forwardResult = await telegramService.forwardToStorage(
|
|
||||||
bytes,
|
|
||||||
`${partFileNamePrefix}.part-${partNumber}`,
|
|
||||||
'document',
|
|
||||||
);
|
|
||||||
|
|
||||||
parts.push({
|
|
||||||
partNumber,
|
|
||||||
telegramFileId: forwardResult.telegramFileId,
|
|
||||||
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
|
|
||||||
storageMessageId: forwardResult.storageMessageId,
|
|
||||||
sizeBytes: chunk.byteLength,
|
|
||||||
storedSizeBytes: bytes.byteLength,
|
|
||||||
compressionAlgorithm,
|
|
||||||
etag: computeHash(chunk),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
parts,
|
|
||||||
fileHash: hasher.digest('hex'),
|
|
||||||
totalSizeBytes,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a factory function for the upload-file use case.
|
* Creates a factory function for the upload-file use case.
|
||||||
*
|
*
|
||||||
@@ -171,9 +58,9 @@ const uploadFileInTelegramChunks = async (
|
|||||||
* 1. Checks for an existing file with the same SHA-256 hash (deduplication).
|
* 1. Checks for an existing file with the same SHA-256 hash (deduplication).
|
||||||
* 2. Normalises the file name and MIME type based on magic bytes.
|
* 2. Normalises the file name and MIME type based on magic bytes.
|
||||||
* 3. Validates the file size against Telegram type-specific limits.
|
* 3. Validates the file size against Telegram type-specific limits.
|
||||||
* 4. Chooses a storage strategy — chunked (for files exceeding the chunk
|
* 4. Chooses a storage strategy — chunked (delegated to ChunkedStorage) or
|
||||||
* threshold) or single-message upload.
|
* single-message upload.
|
||||||
* 5. Persists the file record (and, for chunked uploads, part records).
|
* 5. Persists the file record.
|
||||||
* 6. Builds and returns the public `UploadOutput` DTO.
|
* 6. Builds and returns the public `UploadOutput` DTO.
|
||||||
*
|
*
|
||||||
* @param deps - The injected dependencies.
|
* @param deps - The injected dependencies.
|
||||||
@@ -212,69 +99,28 @@ export function createUploadFileUseCase(deps: UploadFileUseCaseDeps) {
|
|||||||
throw new Error(`File size exceeds ${fileType} limit`);
|
throw new Error(`File size exceeds ${fileType} limit`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Upload — chunked for files above the threshold, single otherwise
|
// 4. Upload — chunked via ChunkedStorage for files above the threshold
|
||||||
if (input.sizeBytes > deps.config.telegramChunkSizeBytes) {
|
if (input.sizeBytes > deps.config.telegramChunkSizeBytes) {
|
||||||
// Chunked upload path
|
const uploadedFile = await deps.chunkedStorage.storeFileInTelegramChunks({
|
||||||
const chunkResult = await uploadFileInTelegramChunks(
|
tempPath: input.tempPath,
|
||||||
input.tempPath,
|
partFileNamePrefix: `direct-${input.fileHash.slice(0, 16)}`,
|
||||||
`direct-${input.fileHash.slice(0, 16)}`,
|
fileName: finalFileName,
|
||||||
deps.config.telegramChunkSizeBytes,
|
mimeType,
|
||||||
deps.config.compressChunkedUploads,
|
sizeBytes: input.sizeBytes,
|
||||||
deps.config.chunkCompressionMinSizeBytes,
|
fileType,
|
||||||
deps.telegramService,
|
uploaderId: input.uploaderId ?? 0,
|
||||||
);
|
bucketId: input.bucketId,
|
||||||
|
s3Key: input.s3Key,
|
||||||
const firstPart = chunkResult.parts[0];
|
});
|
||||||
if (!firstPart) {
|
|
||||||
throw new Error('Chunked upload produced no parts');
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileId = nanoid();
|
|
||||||
const publicId = nanoid();
|
|
||||||
|
|
||||||
const newFile = await deps.fileRepo.create(
|
|
||||||
buildNewFile({
|
|
||||||
publicId,
|
|
||||||
telegramFileId: firstPart.telegramFileId,
|
|
||||||
telegramFileUniqueId: firstPart.telegramFileUniqueId,
|
|
||||||
storageChatId: deps.config.storageChatId,
|
|
||||||
storageMessageId: firstPart.storageMessageId,
|
|
||||||
fileName: finalFileName,
|
|
||||||
mimeType,
|
|
||||||
sizeBytes: chunkResult.totalSizeBytes,
|
|
||||||
fileType,
|
|
||||||
storageBackend: 'chunked',
|
|
||||||
uploaderId: input.uploaderId,
|
|
||||||
fileHash: chunkResult.fileHash,
|
|
||||||
bucketId: input.bucketId,
|
|
||||||
s3Key: input.s3Key,
|
|
||||||
partCount: chunkResult.parts.length,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const fileParts: NewFilePart[] = chunkResult.parts.map((part) => ({
|
|
||||||
fileId,
|
|
||||||
partNumber: part.partNumber,
|
|
||||||
telegramFileId: part.telegramFileId,
|
|
||||||
telegramFileUniqueId: part.telegramFileUniqueId,
|
|
||||||
storageChatId: deps.config.storageChatId,
|
|
||||||
storageMessageId: part.storageMessageId,
|
|
||||||
sizeBytes: part.sizeBytes,
|
|
||||||
storedSizeBytes: part.storedSizeBytes,
|
|
||||||
compressionAlgorithm: part.compressionAlgorithm,
|
|
||||||
etag: part.etag,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await deps.filePartRepo.insert(fileParts);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
publicId: newFile.publicId,
|
publicId: uploadedFile.publicId,
|
||||||
fileName: newFile.fileName,
|
fileName: uploadedFile.fileName,
|
||||||
mimeType: newFile.mimeType,
|
mimeType: uploadedFile.mimeType,
|
||||||
sizeBytes: newFile.sizeBytes,
|
sizeBytes: uploadedFile.sizeBytes,
|
||||||
fileType: newFile.fileType,
|
fileType: uploadedFile.fileType,
|
||||||
createdAt: newFile.createdAt,
|
createdAt: uploadedFile.createdAt,
|
||||||
downloadUrl: `${deps.config.baseUrl}/f/${newFile.publicId}`,
|
downloadUrl: `${deps.config.baseUrl}/f/${uploadedFile.publicId}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-28
@@ -5,10 +5,9 @@ import { startBot } from './interfaces/bot/handler';
|
|||||||
import { handleS3Request } from './interfaces/http/controllers/s3-controller';
|
import { handleS3Request } from './interfaces/http/controllers/s3-controller';
|
||||||
import { cleanupRateLimitCache } from './interfaces/http/middleware/rate-limit';
|
import { cleanupRateLimitCache } from './interfaces/http/middleware/rate-limit';
|
||||||
import { routes } from './interfaces/http/routes/index';
|
import { routes } from './interfaces/http/routes/index';
|
||||||
import { isS3Request } from './interfaces/s3/auth';
|
|
||||||
import { extractS3BucketFromHost } from './interfaces/s3/virtual-host';
|
|
||||||
import { logger } from './shared/logger/index';
|
import { logger } from './shared/logger/index';
|
||||||
import { metricsCollector } from './shared/metrics/index';
|
import { metricsCollector } from './shared/metrics/index';
|
||||||
|
import { getS3RouteBucket, shouldHandleS3 } from './shared/utils/s3-detection';
|
||||||
|
|
||||||
// ─── Auto-run migration at startup ──────────────────────────────────────────
|
// ─── Auto-run migration at startup ──────────────────────────────────────────
|
||||||
try {
|
try {
|
||||||
@@ -18,36 +17,10 @@ try {
|
|||||||
logger.warn('Auto-migration skipped (non-fatal)');
|
logger.warn('Auto-migration skipped (non-fatal)');
|
||||||
}
|
}
|
||||||
|
|
||||||
const getS3RouteBucket = (req: Request): string | null => {
|
|
||||||
const host = req.headers.get('host') || '';
|
|
||||||
return extractS3BucketFromHost(host, config.s3VhostDomains);
|
|
||||||
};
|
|
||||||
|
|
||||||
const shouldHandleS3 = (req: Request, headers: Record<string, string>): boolean => {
|
|
||||||
const url = new URL(req.url);
|
|
||||||
return Boolean(
|
|
||||||
getS3RouteBucket(req) || isS3Request(headers) || url.searchParams.has('X-Amz-Signature'),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const _handleMaybeS3Root = (req: Request): Response | Promise<Response> => {
|
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
return handleS3Request(req, getS3RouteBucket(req));
|
|
||||||
}
|
|
||||||
const headers = Object.fromEntries(req.headers);
|
|
||||||
if (shouldHandleS3(req, headers)) {
|
|
||||||
return handleS3Request(req, getS3RouteBucket(req));
|
|
||||||
}
|
|
||||||
return new Response('Not Allowed', { status: 405 });
|
|
||||||
};
|
|
||||||
|
|
||||||
const server = serve({
|
const server = serve({
|
||||||
port: config.port,
|
port: config.port,
|
||||||
routes,
|
routes,
|
||||||
fetch: async (req: Request) => {
|
fetch: async (req: Request) => {
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
return handleS3Request(req, getS3RouteBucket(req));
|
|
||||||
}
|
|
||||||
const headers = Object.fromEntries(req.headers);
|
const headers = Object.fromEntries(req.headers);
|
||||||
if (shouldHandleS3(req, headers)) {
|
if (shouldHandleS3(req, headers)) {
|
||||||
return handleS3Request(req, getS3RouteBucket(req));
|
return handleS3Request(req, getS3RouteBucket(req));
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { createGetObjectResponse, type ObjectPartSource } from '../../interfaces
|
|||||||
import type { RangeParseResult } from '../../interfaces/s3/range';
|
import type { RangeParseResult } from '../../interfaces/s3/range';
|
||||||
import { type CompressionAlgorithm, maybeCompressChunk } from '../../shared/utils/compress';
|
import { type CompressionAlgorithm, maybeCompressChunk } from '../../shared/utils/compress';
|
||||||
import { computeHash } from '../../shared/utils/file';
|
import { computeHash } from '../../shared/utils/file';
|
||||||
|
import { asSafeChunkSize } from '../../shared/utils/validation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Metadata about a single uploaded chunk (part) stored in Telegram.
|
* Metadata about a single uploaded chunk (part) stored in Telegram.
|
||||||
@@ -70,20 +71,6 @@ export interface ChunkedFileInput {
|
|||||||
s3Key?: string | null;
|
s3Key?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate and sanitise the Telegram chunk size.
|
|
||||||
*
|
|
||||||
* @param chunkSizeBytes - The desired chunk size in bytes.
|
|
||||||
* @returns The validated chunk size.
|
|
||||||
* @throws {Error} If the chunk size is not a safe positive integer.
|
|
||||||
*/
|
|
||||||
const asSafeChunkSize = (chunkSizeBytes: number): number => {
|
|
||||||
if (!Number.isSafeInteger(chunkSizeBytes) || chunkSizeBytes <= 0) {
|
|
||||||
throw new Error('Invalid Telegram chunk size');
|
|
||||||
}
|
|
||||||
return chunkSizeBytes;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages chunked storage of large files in Telegram.
|
* Manages chunked storage of large files in Telegram.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createReadStream } from 'node:fs';
|
|||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import type { TelegramFileInfo } from '../../../domain/ports/telegram-service';
|
import type { TelegramFileInfo } from '../../../domain/ports/telegram-service';
|
||||||
import { fileInfoCache } from '../../../infrastructure/cache/index';
|
import { fileInfoCache } from '../../../infrastructure/cache/index';
|
||||||
import { chunkedStorage } from '../../../infrastructure/di';
|
import { chunkedStorage, fileRepository } from '../../../infrastructure/di';
|
||||||
import { botPool } from '../../../infrastructure/telegram/bot-pool';
|
import { botPool } from '../../../infrastructure/telegram/bot-pool';
|
||||||
import logger from '../../../shared/logger/index';
|
import logger from '../../../shared/logger/index';
|
||||||
import { cleanupTempFile, formatCreatedAt, getErrorMessage } from '../../../shared/utils/file';
|
import { cleanupTempFile, formatCreatedAt, getErrorMessage } from '../../../shared/utils/file';
|
||||||
@@ -19,14 +19,6 @@ type RequestWithParams = Request & {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps a string into a `string | string[]` for cookie append operations.
|
|
||||||
*
|
|
||||||
* @param value - The string value to wrap.
|
|
||||||
* @returns The value as a single-element tuple.
|
|
||||||
*/
|
|
||||||
const _asArray = (value: string): string[] => [value];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves Telegram file metadata for a given file ID, using the in-memory
|
* Resolves Telegram file metadata for a given file ID, using the in-memory
|
||||||
* cache to avoid repeated API calls to Telegram.
|
* cache to avoid repeated API calls to Telegram.
|
||||||
@@ -103,8 +95,7 @@ export const handleFileRedirect = async (req: RequestWithParams): Promise<Respon
|
|||||||
return fail(400, 'Missing file id');
|
return fail(400, 'Missing file id');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { findFileByPublicId } = await import('../../../db/files');
|
const file = await fileRepository.findByPublicId(publicId);
|
||||||
const file = await findFileByPublicId(publicId);
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
logger.warn('File not found', { publicId });
|
logger.warn('File not found', { publicId });
|
||||||
return fail(404, 'File not found');
|
return fail(404, 'File not found');
|
||||||
@@ -193,8 +184,7 @@ export const handleFileInfo = async (req: RequestWithParams): Promise<Response>
|
|||||||
return fail(400, 'Missing file id');
|
return fail(400, 'Missing file id');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { findFileByPublicId } = await import('../../../db/files');
|
const file = await fileRepository.findByPublicId(publicId);
|
||||||
const file = await findFileByPublicId(publicId);
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
logger.warn('File not found', { publicId });
|
logger.warn('File not found', { publicId });
|
||||||
return fail(404, 'File not found');
|
return fail(404, 'File not found');
|
||||||
|
|||||||
@@ -424,6 +424,95 @@ const handleGetBucketVersioning = async (bucketName: string, reqId: string): Pro
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─────── Conditional Headers Helper ──────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3-compatible response for 304 Not Modified.
|
||||||
|
*/
|
||||||
|
const notModifiedResponse = (
|
||||||
|
reqId: string,
|
||||||
|
etag: string,
|
||||||
|
mimeType: string,
|
||||||
|
sizeBytes: number,
|
||||||
|
lastModified: Date,
|
||||||
|
): Response =>
|
||||||
|
new Response(null, {
|
||||||
|
status: 304,
|
||||||
|
headers: s3Headers(reqId, {
|
||||||
|
etag,
|
||||||
|
'content-type': mimeType,
|
||||||
|
'content-length': String(sizeBytes),
|
||||||
|
'last-modified': lastModified.toUTCString(),
|
||||||
|
'x-amz-version-id': 'null',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3-compatible response for 412 Precondition Failed.
|
||||||
|
*/
|
||||||
|
const preconditionFailedResponse = (path: string, reqId: string): Response =>
|
||||||
|
s3ErrorResponse(
|
||||||
|
'PreconditionFailed',
|
||||||
|
'At least one of the pre-conditions you specified did not hold.',
|
||||||
|
path,
|
||||||
|
412,
|
||||||
|
reqId,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks conditional headers (If-Match, If-None-Match, If-Modified-Since,
|
||||||
|
* If-Unmodified-Since) and returns a prepared Response if the condition
|
||||||
|
* is not satisfied, or `null` to let the request proceed.
|
||||||
|
*
|
||||||
|
* @returns A 304 / 412 Response when a condition fails, or `null` to continue.
|
||||||
|
*/
|
||||||
|
const checkConditionalHeaders = (
|
||||||
|
headers: Record<string, string>,
|
||||||
|
file: {
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
fileHash: string | null;
|
||||||
|
createdAt: Date | string | number;
|
||||||
|
},
|
||||||
|
path: string,
|
||||||
|
reqId: string,
|
||||||
|
): Response | null => {
|
||||||
|
const etag = `"${file.fileHash || nanoid(16)}"`;
|
||||||
|
const lastModified = file.createdAt instanceof Date ? file.createdAt : new Date(file.createdAt);
|
||||||
|
|
||||||
|
// If-Match
|
||||||
|
const ifMatch = headers['if-match'];
|
||||||
|
if (ifMatch && ifMatch !== '*' && ifMatch !== etag) {
|
||||||
|
return preconditionFailedResponse(path, reqId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If-None-Match
|
||||||
|
const ifNoneMatch = headers['if-none-match'];
|
||||||
|
if (ifNoneMatch && ifNoneMatch === etag) {
|
||||||
|
return notModifiedResponse(reqId, etag, file.mimeType, file.sizeBytes, lastModified);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If-Modified-Since
|
||||||
|
const ifModifiedSince = headers['if-modified-since'];
|
||||||
|
if (ifModifiedSince) {
|
||||||
|
const since = new Date(ifModifiedSince);
|
||||||
|
if (!Number.isNaN(since.getTime()) && lastModified.getTime() <= since.getTime()) {
|
||||||
|
return notModifiedResponse(reqId, etag, file.mimeType, file.sizeBytes, lastModified);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If-Unmodified-Since
|
||||||
|
const ifUnmodifiedSince = headers['if-unmodified-since'];
|
||||||
|
if (ifUnmodifiedSince) {
|
||||||
|
const since = new Date(ifUnmodifiedSince);
|
||||||
|
if (!Number.isNaN(since.getTime()) && lastModified.getTime() > since.getTime()) {
|
||||||
|
return preconditionFailedResponse(path, reqId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
// ─────── Object Operations ───────
|
// ─────── Object Operations ───────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -468,62 +557,10 @@ const handleGetObject = async (
|
|||||||
reqId,
|
reqId,
|
||||||
);
|
);
|
||||||
|
|
||||||
// H3: Conditional headers — If-Match / If-None-Match
|
// H3: Conditional headers — If-Match / If-None-Match / If-Modified-Since / If-Unmodified-Since
|
||||||
const etag = `"${file.fileHash || nanoid(16)}"`;
|
const conditionResult = checkConditionalHeaders(headers, file, `/${bucket}/${key}`, reqId);
|
||||||
const lastModified = file.createdAt instanceof Date ? file.createdAt : new Date(file.createdAt);
|
if (conditionResult) {
|
||||||
const ifMatch = headers['if-match'];
|
return conditionResult;
|
||||||
if (ifMatch && ifMatch !== '*' && ifMatch !== etag) {
|
|
||||||
return s3ErrorResponse(
|
|
||||||
'PreconditionFailed',
|
|
||||||
'At least one of the pre-conditions you specified did not hold.',
|
|
||||||
`/${bucket}/${key}`,
|
|
||||||
412,
|
|
||||||
reqId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const ifNoneMatch = headers['if-none-match'];
|
|
||||||
if (ifNoneMatch && ifNoneMatch === etag) {
|
|
||||||
return new Response(null, {
|
|
||||||
status: 304,
|
|
||||||
headers: s3Headers(reqId, {
|
|
||||||
etag,
|
|
||||||
'content-type': file.mimeType,
|
|
||||||
'content-length': String(file.sizeBytes),
|
|
||||||
'last-modified': lastModified.toUTCString(),
|
|
||||||
'x-amz-version-id': 'null',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// H3: Conditional headers — If-Modified-Since / If-Unmodified-Since
|
|
||||||
const ifModifiedSince = headers['if-modified-since'];
|
|
||||||
if (ifModifiedSince) {
|
|
||||||
const since = new Date(ifModifiedSince);
|
|
||||||
if (!Number.isNaN(since.getTime()) && lastModified.getTime() <= since.getTime()) {
|
|
||||||
return new Response(null, {
|
|
||||||
status: 304,
|
|
||||||
headers: s3Headers(reqId, {
|
|
||||||
etag,
|
|
||||||
'content-type': file.mimeType,
|
|
||||||
'content-length': String(file.sizeBytes),
|
|
||||||
'last-modified': lastModified.toUTCString(),
|
|
||||||
'x-amz-version-id': 'null',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const ifUnmodifiedSince = headers['if-unmodified-since'];
|
|
||||||
if (ifUnmodifiedSince) {
|
|
||||||
const since = new Date(ifUnmodifiedSince);
|
|
||||||
if (!Number.isNaN(since.getTime()) && lastModified.getTime() > since.getTime()) {
|
|
||||||
return s3ErrorResponse(
|
|
||||||
'PreconditionFailed',
|
|
||||||
'At least one of the pre-conditions you specified did not hold.',
|
|
||||||
`/${bucket}/${key}`,
|
|
||||||
412,
|
|
||||||
reqId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chunked storage object
|
// Chunked storage object
|
||||||
@@ -736,62 +773,10 @@ const handleHeadObject = async (
|
|||||||
reqId,
|
reqId,
|
||||||
);
|
);
|
||||||
|
|
||||||
// H3: Conditional headers for HEAD — If-Match / If-None-Match
|
// H3: Conditional headers for HEAD — If-Match / If-None-Match / If-Modified-Since / If-Unmodified-Since
|
||||||
const etag = `"${file.fileHash || nanoid(16)}"`;
|
const headConditionResult = checkConditionalHeaders(headers, file, `/${bucket}/${key}`, reqId);
|
||||||
const lastModified = file.createdAt instanceof Date ? file.createdAt : new Date(file.createdAt);
|
if (headConditionResult) {
|
||||||
const ifMatch = headers['if-match'];
|
return headConditionResult;
|
||||||
if (ifMatch && ifMatch !== '*' && ifMatch !== etag) {
|
|
||||||
return s3ErrorResponse(
|
|
||||||
'PreconditionFailed',
|
|
||||||
'At least one of the pre-conditions you specified did not hold.',
|
|
||||||
`/${bucket}/${key}`,
|
|
||||||
412,
|
|
||||||
reqId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const ifNoneMatch = headers['if-none-match'];
|
|
||||||
if (ifNoneMatch && ifNoneMatch === etag) {
|
|
||||||
return new Response(null, {
|
|
||||||
status: 304,
|
|
||||||
headers: s3Headers(reqId, {
|
|
||||||
etag,
|
|
||||||
'content-type': file.mimeType,
|
|
||||||
'content-length': String(file.sizeBytes),
|
|
||||||
'last-modified': lastModified.toUTCString(),
|
|
||||||
'x-amz-version-id': 'null',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// H3: Conditional headers for HEAD — If-Modified-Since / If-Unmodified-Since
|
|
||||||
const ifModifiedSince = headers['if-modified-since'];
|
|
||||||
if (ifModifiedSince) {
|
|
||||||
const since = new Date(ifModifiedSince);
|
|
||||||
if (!Number.isNaN(since.getTime()) && lastModified.getTime() <= since.getTime()) {
|
|
||||||
return new Response(null, {
|
|
||||||
status: 304,
|
|
||||||
headers: s3Headers(reqId, {
|
|
||||||
etag,
|
|
||||||
'content-type': file.mimeType,
|
|
||||||
'content-length': String(file.sizeBytes),
|
|
||||||
'last-modified': lastModified.toUTCString(),
|
|
||||||
'x-amz-version-id': 'null',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const ifUnmodifiedSince = headers['if-unmodified-since'];
|
|
||||||
if (ifUnmodifiedSince) {
|
|
||||||
const since = new Date(ifUnmodifiedSince);
|
|
||||||
if (!Number.isNaN(since.getTime()) && lastModified.getTime() > since.getTime()) {
|
|
||||||
return s3ErrorResponse(
|
|
||||||
'PreconditionFailed',
|
|
||||||
'At least one of the pre-conditions you specified did not hold.',
|
|
||||||
`/${bucket}/${key}`,
|
|
||||||
412,
|
|
||||||
reqId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return s3Response(null, 200, reqId, {
|
return s3Response(null, 200, reqId, {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { config } from '../../../env';
|
|
||||||
import { handleSwaggerHtml, handleSwaggerJson } from '../../../routes/swagger';
|
import { handleSwaggerHtml, handleSwaggerJson } from '../../../routes/swagger';
|
||||||
import { isS3Request } from '../../s3/auth';
|
import { getS3RouteBucket, shouldHandleS3 } from '../../../shared/utils/s3-detection';
|
||||||
import { extractS3BucketFromHost } from '../../s3/virtual-host';
|
|
||||||
import { handleLogin, handleLogout, handleMe } from '../controllers/auth-controller';
|
import { handleLogin, handleLogout, handleMe } from '../controllers/auth-controller';
|
||||||
import { handleFileInfo, handleFileRedirect } from '../controllers/file-controller';
|
import { handleFileInfo, handleFileRedirect } from '../controllers/file-controller';
|
||||||
import { handleHealth } from '../controllers/health-controller';
|
import { handleHealth } from '../controllers/health-controller';
|
||||||
@@ -12,52 +10,6 @@ import { handleWebApiV1 } from '../controllers/web-api-controller';
|
|||||||
import { requireAuth } from '../middleware/auth';
|
import { requireAuth } from '../middleware/auth';
|
||||||
import { withRateLimit } from '../middleware/rate-limit';
|
import { withRateLimit } from '../middleware/rate-limit';
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts the S3 bucket name from the request host
|
|
||||||
* if it matches a virtual-hosted-style domain.
|
|
||||||
*
|
|
||||||
* @param req - The incoming HTTP request.
|
|
||||||
* @returns The bucket name if found, or null.
|
|
||||||
*/
|
|
||||||
const getS3RouteBucket = (req: Request): string | null => {
|
|
||||||
const host = req.headers.get('host') || '';
|
|
||||||
return extractS3BucketFromHost(host, config.s3VhostDomains);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines whether the incoming request appears to be an S3 API request
|
|
||||||
* based on host headers, authorization headers, or query parameters.
|
|
||||||
*
|
|
||||||
* @param req - The incoming HTTP request.
|
|
||||||
* @param headers - A record of parsed request headers.
|
|
||||||
* @returns True if the request should be handled by the S3 handler.
|
|
||||||
*/
|
|
||||||
const shouldHandleS3 = (req: Request, headers: Record<string, string>): boolean => {
|
|
||||||
const url = new URL(req.url);
|
|
||||||
return Boolean(
|
|
||||||
getS3RouteBucket(req) || isS3Request(headers) || url.searchParams.has('X-Amz-Signature'),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles non-GET requests to the root path by dispatching to the S3 handler
|
|
||||||
* if the request matches S3 patterns (virtual-hosted bucket, S3 auth headers,
|
|
||||||
* or presigned URL signature), or returning a 405 Method Not Allowed otherwise.
|
|
||||||
*
|
|
||||||
* @param req - The incoming HTTP request.
|
|
||||||
* @returns A Response from the S3 handler or a 405 response.
|
|
||||||
*/
|
|
||||||
const _handleMaybeS3Root = (req: Request): Response | Promise<Response> => {
|
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
return handleS3Request(req, getS3RouteBucket(req));
|
|
||||||
}
|
|
||||||
const headers = Object.fromEntries(req.headers);
|
|
||||||
if (shouldHandleS3(req, headers)) {
|
|
||||||
return handleS3Request(req, getS3RouteBucket(req));
|
|
||||||
}
|
|
||||||
return new Response('Not Allowed', { status: 405 });
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatches an S3 request directly, bypassing rate limiting.
|
* Dispatches an S3 request directly, bypassing rate limiting.
|
||||||
*
|
*
|
||||||
@@ -106,20 +58,12 @@ export const routes = {
|
|||||||
},
|
},
|
||||||
'/': {
|
'/': {
|
||||||
GET: (req: Request): Promise<Response> => {
|
GET: (req: Request): Promise<Response> => {
|
||||||
const headers = Object.fromEntries(req.headers);
|
if (shouldHandleS3(req)) return handleS3Direct(req);
|
||||||
if (shouldHandleS3(req, headers)) {
|
|
||||||
return handleS3Direct(req);
|
|
||||||
}
|
|
||||||
return handleHome();
|
return handleHome();
|
||||||
},
|
},
|
||||||
PUT: (req: Request): Promise<Response> => {
|
PUT: (req: Request): Promise<Response> => {
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
return handleS3Request(req, getS3RouteBucket(req));
|
|
||||||
}
|
|
||||||
const headers = Object.fromEntries(req.headers);
|
const headers = Object.fromEntries(req.headers);
|
||||||
if (shouldHandleS3(req, headers)) {
|
if (shouldHandleS3(req, headers)) return handleS3Direct(req);
|
||||||
return handleS3Direct(req);
|
|
||||||
}
|
|
||||||
return Promise.resolve(new Response('Not Allowed', { status: 405 }));
|
return Promise.resolve(new Response('Not Allowed', { status: 405 }));
|
||||||
},
|
},
|
||||||
HEAD: handleS3Direct,
|
HEAD: handleS3Direct,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { config } from '../../env';
|
||||||
|
import { isS3Request } from '../../interfaces/s3/auth';
|
||||||
|
import { extractS3BucketFromHost } from '../../interfaces/s3/virtual-host';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the S3 bucket name from the request host
|
||||||
|
* if it matches a virtual-hosted-style domain.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request.
|
||||||
|
* @returns The bucket name if found, or null.
|
||||||
|
*/
|
||||||
|
export const getS3RouteBucket = (req: Request): string | null => {
|
||||||
|
const host = req.headers.get('host') || '';
|
||||||
|
return extractS3BucketFromHost(host, config.s3VhostDomains);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether the incoming request appears to be an S3 API request
|
||||||
|
* based on host headers, authorization headers, or query parameters.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request.
|
||||||
|
* @param headers - A record of parsed request headers (optional — derived from req if omitted).
|
||||||
|
* @returns True if the request should be handled by the S3 handler.
|
||||||
|
*/
|
||||||
|
export const shouldHandleS3 = (req: Request, headers?: Record<string, string>): boolean => {
|
||||||
|
const resolvedHeaders = headers ?? Object.fromEntries(req.headers);
|
||||||
|
const url = new URL(req.url);
|
||||||
|
return Boolean(
|
||||||
|
getS3RouteBucket(req) ||
|
||||||
|
isS3Request(resolvedHeaders) ||
|
||||||
|
url.searchParams.has('X-Amz-Signature'),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Validates a chunk size value and returns it as a safe integer.
|
||||||
|
*
|
||||||
|
* @param chunkSizeBytes - The desired chunk size in bytes.
|
||||||
|
* @returns The same value if it is a positive safe integer.
|
||||||
|
* @throws {Error} If the chunk size is not a safe positive integer.
|
||||||
|
*/
|
||||||
|
export const asSafeChunkSize = (chunkSizeBytes: number): number => {
|
||||||
|
if (!Number.isSafeInteger(chunkSizeBytes) || chunkSizeBytes <= 0) {
|
||||||
|
throw new Error('Invalid Telegram chunk size');
|
||||||
|
}
|
||||||
|
return chunkSizeBytes;
|
||||||
|
};
|
||||||
+137
-76
@@ -21,14 +21,19 @@ type FileInfoBody = {
|
|||||||
|
|
||||||
type JsonBody = ErrorBody | FileInfoBody | Record<string, unknown>;
|
type JsonBody = ErrorBody | FileInfoBody | Record<string, unknown>;
|
||||||
|
|
||||||
type MockFileRecord = Record<string, unknown>;
|
type MockFileRecord = {
|
||||||
|
publicId: string;
|
||||||
type MockSelectChain = {
|
fileName: string;
|
||||||
from: () => {
|
mimeType: string;
|
||||||
where: () => {
|
sizeBytes: number;
|
||||||
limit: () => Promise<MockFileRecord[]>;
|
fileType: string;
|
||||||
};
|
uploaderId?: number;
|
||||||
};
|
createdAt?: Date;
|
||||||
|
telegramFileId?: string;
|
||||||
|
storageBackend?: string | null;
|
||||||
|
archiveEntryName?: string | null;
|
||||||
|
fileHash?: string | null;
|
||||||
|
archiveTelegramFileId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestWithPublicId = (url: string, publicId: string): RequestWithParams => {
|
const requestWithPublicId = (url: string, publicId: string): RequestWithParams => {
|
||||||
@@ -41,25 +46,11 @@ const responseJson = async <T extends JsonBody>(res: Response): Promise<T> => {
|
|||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock database layer
|
// Mock the DI module — file-controller imports fileRepository + chunkedStorage from here
|
||||||
const emptySelectChain = (): MockSelectChain => ({
|
const mockFindByPublicId = mock(
|
||||||
from: () => ({
|
(_publicId: string): Promise<MockFileRecord | null> => Promise.resolve(null),
|
||||||
where: () => ({
|
);
|
||||||
limit: () => Promise.resolve([]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockSelect = mock(() => emptySelectChain());
|
|
||||||
|
|
||||||
mock.module('../src/db/files', () => ({
|
|
||||||
findFileByPublicId: async () => {
|
|
||||||
const chain = mockSelect();
|
|
||||||
return (await chain.from().where().limit())[0] || null;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock telegram utils
|
|
||||||
const mockGetFileInfo = mock(async (_telegramFileId: string) => ({
|
const mockGetFileInfo = mock(async (_telegramFileId: string) => ({
|
||||||
file_size: 98765,
|
file_size: 98765,
|
||||||
mime_type: 'image/jpeg',
|
mime_type: 'image/jpeg',
|
||||||
@@ -67,27 +58,87 @@ const mockGetFileInfo = mock(async (_telegramFileId: string) => ({
|
|||||||
bot_token: '123456:ABC-DEF',
|
bot_token: '123456:ABC-DEF',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module('../src/utils/telegram', () => ({
|
const mockCreateChunkedObjectResponse = mock(async () => new Response(null, { status: 200 }));
|
||||||
forwardToStorage: async () => ({
|
|
||||||
telegramFileId: 'mock-tg-id',
|
mock.module('../src/infrastructure/di', () => ({
|
||||||
telegramFileUniqueId: 'mock-tg-unique',
|
fileRepository: {
|
||||||
storageMessageId: 12345,
|
findByPublicId: mockFindByPublicId,
|
||||||
}),
|
findByHash: async () => null,
|
||||||
getFileInfo: mockGetFileInfo,
|
findByUniqueId: async () => null,
|
||||||
|
findByBucketAndKey: async () => null,
|
||||||
|
create: async (data: Record<string, unknown>) => ({
|
||||||
|
...data,
|
||||||
|
id: 'mock-id',
|
||||||
|
createdAt: new Date(),
|
||||||
|
}),
|
||||||
|
softDelete: async () => true,
|
||||||
|
softDeleteBatch: async () => 1,
|
||||||
|
countByBucket: async () => 0,
|
||||||
|
listByPrefix: async () => ({ objects: [], prefixes: [] }),
|
||||||
|
findOrphansByBucket: async () => [],
|
||||||
|
},
|
||||||
|
chunkedStorage: {
|
||||||
|
createChunkedObjectResponse: mockCreateChunkedObjectResponse,
|
||||||
|
buildChunkedObjectSources: async () => [],
|
||||||
|
uploadFileInTelegramChunks: async () => ({ parts: [], fileHash: '', totalSizeBytes: 0 }),
|
||||||
|
storeFileInTelegramChunks: async () => ({
|
||||||
|
id: 'mock-id',
|
||||||
|
publicId: 'mock-public',
|
||||||
|
telegramFileId: 'mock-tg',
|
||||||
|
telegramFileUniqueId: 'mock-tg-unique',
|
||||||
|
storageChatId: 0,
|
||||||
|
storageMessageId: 0,
|
||||||
|
fileName: 'mock',
|
||||||
|
mimeType: 'application/octet-stream',
|
||||||
|
sizeBytes: 0,
|
||||||
|
fileType: 'document',
|
||||||
|
uploaderId: 0,
|
||||||
|
fileHash: null,
|
||||||
|
archiveTelegramFileId: null,
|
||||||
|
archiveStorageMessageId: null,
|
||||||
|
archiveFileName: null,
|
||||||
|
archiveEntryName: null,
|
||||||
|
archiveMimeType: null,
|
||||||
|
archiveSizeBytes: null,
|
||||||
|
bucketId: null,
|
||||||
|
s3Key: null,
|
||||||
|
storageBackend: 'telegram',
|
||||||
|
isDeleted: false,
|
||||||
|
multipartUploadId: null,
|
||||||
|
partCount: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock botPool.getFileInfo used in file redirect
|
||||||
|
mock.module('../src/infrastructure/telegram/bot-pool', () => ({
|
||||||
|
botPool: {
|
||||||
|
getFileInfo: mockGetFileInfo,
|
||||||
|
forwardToStorage: async () => ({
|
||||||
|
telegramFileId: 'mock-tg-id',
|
||||||
|
telegramFileUniqueId: 'mock-tg-unique',
|
||||||
|
storageMessageId: 12345,
|
||||||
|
}),
|
||||||
|
size: 1,
|
||||||
|
getEffectiveConcurrency: () => 1,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('File Route Handlers', () => {
|
describe('File Route Handlers', () => {
|
||||||
let handleFileRedirect: typeof import('../src/routes/files').handleFileRedirect;
|
let handleFileRedirect: typeof import('../src/interfaces/http/controllers/file-controller').handleFileRedirect;
|
||||||
let handleFileInfo: typeof import('../src/routes/files').handleFileInfo;
|
let handleFileInfo: typeof import('../src/interfaces/http/controllers/file-controller').handleFileInfo;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
mockSelect.mockClear();
|
mockFindByPublicId.mockClear();
|
||||||
mockGetFileInfo.mockClear();
|
mockGetFileInfo.mockClear();
|
||||||
|
mockCreateChunkedObjectResponse.mockClear();
|
||||||
|
|
||||||
// Set up mock token
|
// Set up mock token
|
||||||
process.env.BOT_TOKEN = '123456:ABC-DEF';
|
process.env.BOT_TOKEN = '123456:ABC-DEF';
|
||||||
|
|
||||||
const filesRoute = await import('../src/routes/files');
|
const filesRoute = await import('../src/interfaces/http/controllers/file-controller');
|
||||||
handleFileRedirect = filesRoute.handleFileRedirect;
|
handleFileRedirect = filesRoute.handleFileRedirect;
|
||||||
handleFileInfo = filesRoute.handleFileInfo;
|
handleFileInfo = filesRoute.handleFileInfo;
|
||||||
});
|
});
|
||||||
@@ -98,13 +149,7 @@ describe('File Route Handlers', () => {
|
|||||||
|
|
||||||
describe('handleFileRedirect', () => {
|
describe('handleFileRedirect', () => {
|
||||||
it('should return 404 if file is not found in database', async () => {
|
it('should return 404 if file is not found in database', async () => {
|
||||||
mockSelect.mockImplementationOnce(() => ({
|
mockFindByPublicId.mockImplementationOnce(async () => null);
|
||||||
from: () => ({
|
|
||||||
where: () => ({
|
|
||||||
limit: () => Promise.resolve([]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const req = requestWithPublicId('http://localhost:3000/f/missing-id', 'missing-id');
|
const req = requestWithPublicId('http://localhost:3000/f/missing-id', 'missing-id');
|
||||||
const res = await handleFileRedirect(req);
|
const res = await handleFileRedirect(req);
|
||||||
@@ -114,22 +159,32 @@ describe('File Route Handlers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should redirect to telegram file url with 302', async () => {
|
it('should redirect to telegram file url with 302', async () => {
|
||||||
mockSelect.mockImplementationOnce(() => ({
|
mockFindByPublicId.mockImplementationOnce(async () => ({
|
||||||
from: () => ({
|
publicId: 'test-id',
|
||||||
where: () => ({
|
telegramFileId: 'tg-file-id',
|
||||||
limit: () =>
|
telegramFileUniqueId: 'tg-unique',
|
||||||
Promise.resolve([
|
storageChatId: -100123,
|
||||||
{
|
storageMessageId: 42,
|
||||||
id: 'uuid-123',
|
fileName: 'test.jpg',
|
||||||
publicId: 'test-id',
|
mimeType: 'image/jpeg',
|
||||||
telegramFileId: 'tg-file-id',
|
sizeBytes: 100,
|
||||||
fileName: 'test.jpg',
|
fileType: 'photo',
|
||||||
mimeType: 'image/jpeg',
|
uploaderId: 0,
|
||||||
sizeBytes: 100,
|
fileHash: 'abc123',
|
||||||
},
|
archiveTelegramFileId: null,
|
||||||
]),
|
archiveStorageMessageId: null,
|
||||||
}),
|
archiveFileName: null,
|
||||||
}),
|
archiveEntryName: null,
|
||||||
|
archiveMimeType: null,
|
||||||
|
archiveSizeBytes: null,
|
||||||
|
bucketId: null,
|
||||||
|
s3Key: null,
|
||||||
|
storageBackend: 'telegram',
|
||||||
|
isDeleted: false,
|
||||||
|
multipartUploadId: null,
|
||||||
|
partCount: null,
|
||||||
|
createdAt: new Date('2026-05-18T00:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-05-18T00:00:00.000Z'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const req = requestWithPublicId('http://localhost:3000/f/test-id', 'test-id');
|
const req = requestWithPublicId('http://localhost:3000/f/test-id', 'test-id');
|
||||||
@@ -142,7 +197,7 @@ describe('File Route Handlers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 500 on database or external errors', async () => {
|
it('should return 500 on database or external errors', async () => {
|
||||||
mockSelect.mockImplementationOnce(() => {
|
mockFindByPublicId.mockImplementationOnce(async () => {
|
||||||
throw new Error('DB Connection Error');
|
throw new Error('DB Connection Error');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,13 +211,7 @@ describe('File Route Handlers', () => {
|
|||||||
|
|
||||||
describe('handleFileInfo', () => {
|
describe('handleFileInfo', () => {
|
||||||
it('should return 404 if file is not found in database', async () => {
|
it('should return 404 if file is not found in database', async () => {
|
||||||
mockSelect.mockImplementationOnce(() => ({
|
mockFindByPublicId.mockImplementationOnce(async () => null);
|
||||||
from: () => ({
|
|
||||||
where: () => ({
|
|
||||||
limit: () => Promise.resolve([]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const req = requestWithPublicId('http://localhost:3000/file/missing-id/info', 'missing-id');
|
const req = requestWithPublicId('http://localhost:3000/file/missing-id/info', 'missing-id');
|
||||||
const res = await handleFileInfo(req);
|
const res = await handleFileInfo(req);
|
||||||
@@ -174,21 +223,33 @@ describe('File Route Handlers', () => {
|
|||||||
it('should return file info JSON without internal fields', async () => {
|
it('should return file info JSON without internal fields', async () => {
|
||||||
const dbFile = {
|
const dbFile = {
|
||||||
publicId: 'test-id',
|
publicId: 'test-id',
|
||||||
|
telegramFileId: 'tg-file-id',
|
||||||
|
telegramFileUniqueId: 'tg-unique',
|
||||||
|
storageChatId: -100123,
|
||||||
|
storageMessageId: 42,
|
||||||
fileName: 'image.png',
|
fileName: 'image.png',
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
sizeBytes: 2048,
|
sizeBytes: 2048,
|
||||||
fileType: 'photo',
|
fileType: 'photo',
|
||||||
uploaderId: 99999,
|
uploaderId: 99999,
|
||||||
|
fileHash: null,
|
||||||
|
archiveTelegramFileId: null,
|
||||||
|
archiveStorageMessageId: null,
|
||||||
|
archiveFileName: null,
|
||||||
|
archiveEntryName: null,
|
||||||
|
archiveMimeType: null,
|
||||||
|
archiveSizeBytes: null,
|
||||||
|
bucketId: null,
|
||||||
|
s3Key: null,
|
||||||
|
storageBackend: 'telegram',
|
||||||
|
isDeleted: false,
|
||||||
|
multipartUploadId: null,
|
||||||
|
partCount: null,
|
||||||
createdAt: new Date('2026-05-18T00:00:00.000Z'),
|
createdAt: new Date('2026-05-18T00:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-05-18T00:00:00.000Z'),
|
||||||
};
|
};
|
||||||
|
|
||||||
mockSelect.mockImplementationOnce(() => ({
|
mockFindByPublicId.mockImplementationOnce(async () => dbFile);
|
||||||
from: () => ({
|
|
||||||
where: () => ({
|
|
||||||
limit: () => Promise.resolve([dbFile]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const req = requestWithPublicId('http://localhost:3000/file/test-id/info', 'test-id');
|
const req = requestWithPublicId('http://localhost:3000/file/test-id/info', 'test-id');
|
||||||
const res = await handleFileInfo(req);
|
const res = await handleFileInfo(req);
|
||||||
@@ -208,7 +269,7 @@ describe('File Route Handlers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 500 on database or external errors', async () => {
|
it('should return 500 on database or external errors', async () => {
|
||||||
mockSelect.mockImplementationOnce(() => {
|
mockFindByPublicId.mockImplementationOnce(async () => {
|
||||||
throw new Error('DB Connection Error');
|
throw new Error('DB Connection Error');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user