diff --git a/src/application/use-cases/s3-object.ts b/src/application/use-cases/s3-object.ts index 1421ada..96565ca 100644 --- a/src/application/use-cases/s3-object.ts +++ b/src/application/use-cases/s3-object.ts @@ -1,28 +1,13 @@ -import { gzipSync } from 'node:zlib'; -import { nanoid } from 'nanoid'; 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 { 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 { ITelegramService, TelegramFileInfo } from '../../domain/ports/telegram-service'; -import { - computeHash, - DEFAULT_FILE_TYPE, - ensureExtension, - formatCreatedAt, -} from '../../shared/utils/file'; +import type { CompressionAlgorithm } from '../../shared/utils/compress'; // ─── Types ────────────────────────────────────────────────────────── -/** - * Compression algorithm used for chunked object storage. - */ -type CompressionAlgorithm = 'gzip' | null; - /** * A single part source for building a multi-part streaming response. * Each part corresponds to a Telegram-stored file chunk. @@ -178,604 +163,3 @@ export interface S3ObjectDeps { /** Application configuration subset. */ 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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) { - return async (bucketId: string, key: string): Promise => { - return deps.fileRepo.findByBucketAndKey(bucketId, key); - }; -} diff --git a/src/application/use-cases/upload-file.ts b/src/application/use-cases/upload-file.ts index f4ffcc8..caefc6c 100644 --- a/src/application/use-cases/upload-file.ts +++ b/src/application/use-cases/upload-file.ts @@ -2,44 +2,12 @@ import { createReadStream } from 'node:fs'; import { open } from 'node:fs/promises'; import { nanoid } from 'nanoid'; 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 { ITelegramService } from '../../domain/ports/telegram-service'; -import { type CompressionAlgorithm, maybeCompressChunk } from '../../shared/utils/compress'; -import { checkFileSize, computeHash, ensureExtension, getFileType } from '../../shared/utils/file'; +import type { ChunkedStorage } from '../../infrastructure/telegram/chunked-storage'; +import { checkFileSize, ensureExtension, getFileType } from '../../shared/utils/file'; 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. */ export interface UploadFileConfig { /** Server base URL for constructing download links. */ @@ -58,27 +26,14 @@ export interface UploadFileConfig { export interface UploadFileUseCaseDeps { /** File repository for CRUD operations on file records. */ fileRepo: IFileRepository; - /** File-part repository for chunked file metadata. */ - filePartRepo: IFilePartRepository; /** Telegram service for forwarding file content to storage. */ telegramService: ITelegramService; + /** Chunked storage handler for large file uploads. */ + chunkedStorage: ChunkedStorage; /** Application configuration subset. */ 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. * @@ -96,74 +51,6 @@ const readSignatureBuffer = async (tempPath: string): Promise => { } }; -/** - * 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 => { - 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. * @@ -171,9 +58,9 @@ const uploadFileInTelegramChunks = async ( * 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. * 3. Validates the file size against Telegram type-specific limits. - * 4. Chooses a storage strategy — chunked (for files exceeding the chunk - * threshold) or single-message upload. - * 5. Persists the file record (and, for chunked uploads, part records). + * 4. Chooses a storage strategy — chunked (delegated to ChunkedStorage) or + * single-message upload. + * 5. Persists the file record. * 6. Builds and returns the public `UploadOutput` DTO. * * @param deps - The injected dependencies. @@ -212,69 +99,28 @@ export function createUploadFileUseCase(deps: UploadFileUseCaseDeps) { 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) { - // Chunked upload path - const chunkResult = await uploadFileInTelegramChunks( - input.tempPath, - `direct-${input.fileHash.slice(0, 16)}`, - deps.config.telegramChunkSizeBytes, - deps.config.compressChunkedUploads, - deps.config.chunkCompressionMinSizeBytes, - deps.telegramService, - ); - - 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); + const uploadedFile = await deps.chunkedStorage.storeFileInTelegramChunks({ + tempPath: input.tempPath, + partFileNamePrefix: `direct-${input.fileHash.slice(0, 16)}`, + fileName: finalFileName, + mimeType, + sizeBytes: input.sizeBytes, + fileType, + uploaderId: input.uploaderId ?? 0, + bucketId: input.bucketId, + s3Key: input.s3Key, + }); return { - publicId: newFile.publicId, - fileName: newFile.fileName, - mimeType: newFile.mimeType, - sizeBytes: newFile.sizeBytes, - fileType: newFile.fileType, - createdAt: newFile.createdAt, - downloadUrl: `${deps.config.baseUrl}/f/${newFile.publicId}`, + publicId: uploadedFile.publicId, + fileName: uploadedFile.fileName, + mimeType: uploadedFile.mimeType, + sizeBytes: uploadedFile.sizeBytes, + fileType: uploadedFile.fileType, + createdAt: uploadedFile.createdAt, + downloadUrl: `${deps.config.baseUrl}/f/${uploadedFile.publicId}`, }; } diff --git a/src/index.ts b/src/index.ts index 33ab8c5..8719ece 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,10 +5,9 @@ import { startBot } from './interfaces/bot/handler'; import { handleS3Request } from './interfaces/http/controllers/s3-controller'; import { cleanupRateLimitCache } from './interfaces/http/middleware/rate-limit'; 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 { metricsCollector } from './shared/metrics/index'; +import { getS3RouteBucket, shouldHandleS3 } from './shared/utils/s3-detection'; // ─── Auto-run migration at startup ────────────────────────────────────────── try { @@ -18,36 +17,10 @@ try { 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): 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 => { - 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({ port: config.port, routes, fetch: async (req: Request) => { - if (req.method === 'OPTIONS') { - return handleS3Request(req, getS3RouteBucket(req)); - } const headers = Object.fromEntries(req.headers); if (shouldHandleS3(req, headers)) { return handleS3Request(req, getS3RouteBucket(req)); diff --git a/src/infrastructure/telegram/chunked-storage.ts b/src/infrastructure/telegram/chunked-storage.ts index 9a5faab..4f70ede 100644 --- a/src/infrastructure/telegram/chunked-storage.ts +++ b/src/infrastructure/telegram/chunked-storage.ts @@ -11,6 +11,7 @@ import { createGetObjectResponse, type ObjectPartSource } from '../../interfaces import type { RangeParseResult } from '../../interfaces/s3/range'; import { type CompressionAlgorithm, maybeCompressChunk } from '../../shared/utils/compress'; import { computeHash } from '../../shared/utils/file'; +import { asSafeChunkSize } from '../../shared/utils/validation'; /** * Metadata about a single uploaded chunk (part) stored in Telegram. @@ -70,20 +71,6 @@ export interface ChunkedFileInput { 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. * diff --git a/src/interfaces/http/controllers/file-controller.ts b/src/interfaces/http/controllers/file-controller.ts index 50dc20a..972c15a 100644 --- a/src/interfaces/http/controllers/file-controller.ts +++ b/src/interfaces/http/controllers/file-controller.ts @@ -2,7 +2,7 @@ import { createReadStream } from 'node:fs'; import { nanoid } from 'nanoid'; import type { TelegramFileInfo } from '../../../domain/ports/telegram-service'; import { fileInfoCache } from '../../../infrastructure/cache/index'; -import { chunkedStorage } from '../../../infrastructure/di'; +import { chunkedStorage, fileRepository } from '../../../infrastructure/di'; import { botPool } from '../../../infrastructure/telegram/bot-pool'; import logger from '../../../shared/logger/index'; 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 * cache to avoid repeated API calls to Telegram. @@ -103,8 +95,7 @@ export const handleFileRedirect = async (req: RequestWithParams): Promise return fail(400, 'Missing file id'); } - const { findFileByPublicId } = await import('../../../db/files'); - const file = await findFileByPublicId(publicId); + const file = await fileRepository.findByPublicId(publicId); if (!file) { logger.warn('File not found', { publicId }); return fail(404, 'File not found'); diff --git a/src/interfaces/http/controllers/s3-controller.ts b/src/interfaces/http/controllers/s3-controller.ts index 8cf028b..ada8aaa 100644 --- a/src/interfaces/http/controllers/s3-controller.ts +++ b/src/interfaces/http/controllers/s3-controller.ts @@ -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, + 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 ─────── /** @@ -468,62 +557,10 @@ const handleGetObject = async ( reqId, ); - // H3: Conditional headers — If-Match / If-None-Match - const etag = `"${file.fileHash || nanoid(16)}"`; - const lastModified = file.createdAt instanceof Date ? file.createdAt : new Date(file.createdAt); - const ifMatch = headers['if-match']; - 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, - ); - } + // H3: Conditional headers — If-Match / If-None-Match / If-Modified-Since / If-Unmodified-Since + const conditionResult = checkConditionalHeaders(headers, file, `/${bucket}/${key}`, reqId); + if (conditionResult) { + return conditionResult; } // Chunked storage object @@ -736,62 +773,10 @@ const handleHeadObject = async ( reqId, ); - // H3: Conditional headers for HEAD — If-Match / If-None-Match - const etag = `"${file.fileHash || nanoid(16)}"`; - const lastModified = file.createdAt instanceof Date ? file.createdAt : new Date(file.createdAt); - const ifMatch = headers['if-match']; - 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, - ); - } + // H3: Conditional headers for HEAD — If-Match / If-None-Match / If-Modified-Since / If-Unmodified-Since + const headConditionResult = checkConditionalHeaders(headers, file, `/${bucket}/${key}`, reqId); + if (headConditionResult) { + return headConditionResult; } return s3Response(null, 200, reqId, { diff --git a/src/interfaces/http/routes/index.ts b/src/interfaces/http/routes/index.ts index f54c608..db7ae1c 100644 --- a/src/interfaces/http/routes/index.ts +++ b/src/interfaces/http/routes/index.ts @@ -1,7 +1,5 @@ -import { config } from '../../../env'; import { handleSwaggerHtml, handleSwaggerJson } from '../../../routes/swagger'; -import { isS3Request } from '../../s3/auth'; -import { extractS3BucketFromHost } from '../../s3/virtual-host'; +import { getS3RouteBucket, shouldHandleS3 } from '../../../shared/utils/s3-detection'; import { handleLogin, handleLogout, handleMe } from '../controllers/auth-controller'; import { handleFileInfo, handleFileRedirect } from '../controllers/file-controller'; import { handleHealth } from '../controllers/health-controller'; @@ -12,52 +10,6 @@ import { handleWebApiV1 } from '../controllers/web-api-controller'; import { requireAuth } from '../middleware/auth'; 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): 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 => { - 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. * @@ -106,20 +58,12 @@ export const routes = { }, '/': { GET: (req: Request): Promise => { - const headers = Object.fromEntries(req.headers); - if (shouldHandleS3(req, headers)) { - return handleS3Direct(req); - } + if (shouldHandleS3(req)) return handleS3Direct(req); return handleHome(); }, PUT: (req: Request): Promise => { - if (req.method === 'OPTIONS') { - return handleS3Request(req, getS3RouteBucket(req)); - } const headers = Object.fromEntries(req.headers); - if (shouldHandleS3(req, headers)) { - return handleS3Direct(req); - } + if (shouldHandleS3(req, headers)) return handleS3Direct(req); return Promise.resolve(new Response('Not Allowed', { status: 405 })); }, HEAD: handleS3Direct, diff --git a/src/shared/utils/s3-detection.ts b/src/shared/utils/s3-detection.ts new file mode 100644 index 0000000..18a7c25 --- /dev/null +++ b/src/shared/utils/s3-detection.ts @@ -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): 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'), + ); +}; diff --git a/src/shared/utils/validation.ts b/src/shared/utils/validation.ts new file mode 100644 index 0000000..6c20475 --- /dev/null +++ b/src/shared/utils/validation.ts @@ -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; +}; diff --git a/test/files.test.ts b/test/files.test.ts index 73fca36..ff1e640 100644 --- a/test/files.test.ts +++ b/test/files.test.ts @@ -21,14 +21,19 @@ type FileInfoBody = { type JsonBody = ErrorBody | FileInfoBody | Record; -type MockFileRecord = Record; - -type MockSelectChain = { - from: () => { - where: () => { - limit: () => Promise; - }; - }; +type MockFileRecord = { + publicId: string; + fileName: string; + mimeType: string; + sizeBytes: number; + 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 => { @@ -41,25 +46,11 @@ const responseJson = async (res: Response): Promise => { return (await res.json()) as T; }; -// Mock database layer -const emptySelectChain = (): MockSelectChain => ({ - from: () => ({ - where: () => ({ - limit: () => Promise.resolve([]), - }), - }), -}); +// Mock the DI module — file-controller imports fileRepository + chunkedStorage from here +const mockFindByPublicId = mock( + (_publicId: string): Promise => Promise.resolve(null), +); -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) => ({ file_size: 98765, mime_type: 'image/jpeg', @@ -67,27 +58,87 @@ const mockGetFileInfo = mock(async (_telegramFileId: string) => ({ bot_token: '123456:ABC-DEF', })); -mock.module('../src/utils/telegram', () => ({ - forwardToStorage: async () => ({ - telegramFileId: 'mock-tg-id', - telegramFileUniqueId: 'mock-tg-unique', - storageMessageId: 12345, - }), - getFileInfo: mockGetFileInfo, +const mockCreateChunkedObjectResponse = mock(async () => new Response(null, { status: 200 })); + +mock.module('../src/infrastructure/di', () => ({ + fileRepository: { + findByPublicId: mockFindByPublicId, + findByHash: async () => null, + findByUniqueId: async () => null, + findByBucketAndKey: async () => null, + create: async (data: Record) => ({ + ...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', () => { - let handleFileRedirect: typeof import('../src/routes/files').handleFileRedirect; - let handleFileInfo: typeof import('../src/routes/files').handleFileInfo; + let handleFileRedirect: typeof import('../src/interfaces/http/controllers/file-controller').handleFileRedirect; + let handleFileInfo: typeof import('../src/interfaces/http/controllers/file-controller').handleFileInfo; beforeEach(async () => { - mockSelect.mockClear(); + mockFindByPublicId.mockClear(); mockGetFileInfo.mockClear(); + mockCreateChunkedObjectResponse.mockClear(); // Set up mock token 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; handleFileInfo = filesRoute.handleFileInfo; }); @@ -98,13 +149,7 @@ describe('File Route Handlers', () => { describe('handleFileRedirect', () => { it('should return 404 if file is not found in database', async () => { - mockSelect.mockImplementationOnce(() => ({ - from: () => ({ - where: () => ({ - limit: () => Promise.resolve([]), - }), - }), - })); + mockFindByPublicId.mockImplementationOnce(async () => null); const req = requestWithPublicId('http://localhost:3000/f/missing-id', 'missing-id'); const res = await handleFileRedirect(req); @@ -114,22 +159,32 @@ describe('File Route Handlers', () => { }); it('should redirect to telegram file url with 302', async () => { - mockSelect.mockImplementationOnce(() => ({ - from: () => ({ - where: () => ({ - limit: () => - Promise.resolve([ - { - id: 'uuid-123', - publicId: 'test-id', - telegramFileId: 'tg-file-id', - fileName: 'test.jpg', - mimeType: 'image/jpeg', - sizeBytes: 100, - }, - ]), - }), - }), + mockFindByPublicId.mockImplementationOnce(async () => ({ + publicId: 'test-id', + telegramFileId: 'tg-file-id', + telegramFileUniqueId: 'tg-unique', + storageChatId: -100123, + storageMessageId: 42, + fileName: 'test.jpg', + mimeType: 'image/jpeg', + sizeBytes: 100, + fileType: 'photo', + uploaderId: 0, + 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'); @@ -142,7 +197,7 @@ describe('File Route Handlers', () => { }); it('should return 500 on database or external errors', async () => { - mockSelect.mockImplementationOnce(() => { + mockFindByPublicId.mockImplementationOnce(async () => { throw new Error('DB Connection Error'); }); @@ -156,13 +211,7 @@ describe('File Route Handlers', () => { describe('handleFileInfo', () => { it('should return 404 if file is not found in database', async () => { - mockSelect.mockImplementationOnce(() => ({ - from: () => ({ - where: () => ({ - limit: () => Promise.resolve([]), - }), - }), - })); + mockFindByPublicId.mockImplementationOnce(async () => null); const req = requestWithPublicId('http://localhost:3000/file/missing-id/info', 'missing-id'); const res = await handleFileInfo(req); @@ -174,21 +223,33 @@ describe('File Route Handlers', () => { it('should return file info JSON without internal fields', async () => { const dbFile = { publicId: 'test-id', + telegramFileId: 'tg-file-id', + telegramFileUniqueId: 'tg-unique', + storageChatId: -100123, + storageMessageId: 42, fileName: 'image.png', mimeType: 'image/png', sizeBytes: 2048, fileType: 'photo', 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'), + updatedAt: new Date('2026-05-18T00:00:00.000Z'), }; - mockSelect.mockImplementationOnce(() => ({ - from: () => ({ - where: () => ({ - limit: () => Promise.resolve([dbFile]), - }), - }), - })); + mockFindByPublicId.mockImplementationOnce(async () => dbFile); const req = requestWithPublicId('http://localhost:3000/file/test-id/info', 'test-id'); const res = await handleFileInfo(req); @@ -208,7 +269,7 @@ describe('File Route Handlers', () => { }); it('should return 500 on database or external errors', async () => { - mockSelect.mockImplementationOnce(() => { + mockFindByPublicId.mockImplementationOnce(async () => { throw new Error('DB Connection Error'); });