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:
Claude
2026-07-29 16:39:52 +07:00
parent fab91ad69c
commit ad917f6675
10 changed files with 316 additions and 1100 deletions
+2 -618
View File
@@ -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<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);
};
}
+26 -180
View File
@@ -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<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.
*
@@ -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}`,
};
}
+1 -28
View File
@@ -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<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({
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));
+1 -14
View File
@@ -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.
*
@@ -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<Respon
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');
@@ -193,8 +184,7 @@ export const handleFileInfo = async (req: RequestWithParams): Promise<Response>
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');
+97 -112
View File
@@ -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 ───────
/**
@@ -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, {
+3 -59
View File
@@ -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<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.
*
@@ -106,20 +58,12 @@ export const routes = {
},
'/': {
GET: (req: Request): Promise<Response> => {
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<Response> => {
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,
+33
View File
@@ -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'),
);
};
+13
View File
@@ -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
View File
@@ -21,14 +21,19 @@ type FileInfoBody = {
type JsonBody = ErrorBody | FileInfoBody | Record<string, unknown>;
type MockFileRecord = Record<string, unknown>;
type MockSelectChain = {
from: () => {
where: () => {
limit: () => Promise<MockFileRecord[]>;
};
};
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 <T extends JsonBody>(res: Response): Promise<T> => {
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<MockFileRecord | null> => 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<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', () => {
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');
});