From e7657453d536fe622b562de0a8200eba07bcde89 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 17:43:18 +0700 Subject: [PATCH 1/8] feat: create shared utilities layer with JSDoc Co-Authored-By: Claude Opus 5 (1M context) --- src/shared/utils/file.ts | 387 ++++++++++++++++++++++++++++++++++++ src/shared/utils/ip.ts | 26 +++ src/shared/utils/retry.ts | 135 +++++++++++++ src/shared/utils/zip.ts | 401 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 949 insertions(+) create mode 100644 src/shared/utils/file.ts create mode 100644 src/shared/utils/ip.ts create mode 100644 src/shared/utils/retry.ts create mode 100644 src/shared/utils/zip.ts diff --git a/src/shared/utils/file.ts b/src/shared/utils/file.ts new file mode 100644 index 0000000..e6fb427 --- /dev/null +++ b/src/shared/utils/file.ts @@ -0,0 +1,387 @@ +import { unlink } from 'node:fs/promises'; +import logger from '../../utils/logger'; + +/** + * Safely extracts an error message from an unknown value. + * + * @param error - The error value (caught exception, rejection reason, etc.). + * @returns The error message string. + */ +export const getErrorMessage = (error: unknown): string => { + return error instanceof Error ? error.message : String(error); +}; + +/** + * Asynchronously removes a temporary file from disk, logging a warning + * instead of throwing when the operation fails. + * + * @param tempPath - Absolute path to the temporary file. + */ +export const cleanupTempFile = async (tempPath: string): Promise => { + try { + await unlink(tempPath); + } catch (err) { + logger.warn('Failed to cleanup temp file', { tempPath, error: getErrorMessage(err) }); + } +}; + +/** Metadata describing a stored file record. */ +interface FileMetadata { + /** Public-facing unique identifier. */ + publicId: string; + /** Telegram file identifier. */ + telegramFileId: string; + /** Telegram file unique identifier (stable across chats). */ + telegramFileUniqueId: string; + /** ID of the Telegram chat where the file is stored. */ + storageChatId: number; + /** Message ID within the storage chat. */ + storageMessageId: number; + /** Original file name. */ + fileName: string; + /** MIME type of the file. */ + mimeType: string; + /** File size in bytes. */ + sizeBytes: number; + /** Telegram-inferred file type (document, photo, video, etc.). */ + fileType: string; + /** Telegram user ID of the uploader. */ + uploaderId: number; + /** Timestamp when the record was created. */ + createdAt: Date | string | number; +} + +/** Per-file-type size limits in bytes. */ +const FILE_TYPES: Record = { + document: 2 * 1024 * 1024 * 1024, // 2GB + photo: 10 * 1024 * 1024, // 10MB + video: 2 * 1024 * 1024 * 1024, // 2GB + audio: 200 * 1024 * 1024, // 200MB + voice: 200 * 1024 * 1024, // 200MB + animation: 2 * 1024 * 1024 * 1024, // 2GB + sticker: 10 * 1024 * 1024, // 10MB + video_note: 2 * 1024 * 1024 * 1024, // 2GB +}; + +/** + * Determines the Telegram file type from a MIME type and optional caption. + * + * The returned string matches one of the keys in `FILE_TYPES` (document, + * photo, video, audio, voice, animation, sticker, video_note). + * + * @param mime - The MIME type string (may be null). + * @param caption - Optional caption text that may hint at the file type. + * @returns The inferred Telegram file type. + */ +export const getFileType = (mime: string | null, caption?: string): string => { + const mimeUpper = mime?.split('/')[0]?.toLowerCase(); + const captionLower = caption?.toLowerCase(); + + if (mime?.toLowerCase() === 'image/webp' || captionLower?.includes('sticker')) return 'sticker'; + if (captionLower?.includes('video_note')) return 'video_note'; + if (mimeUpper === 'video') return 'video'; + if (mimeUpper === 'audio') return 'audio'; + if (mimeUpper === 'document') return 'document'; + if (mimeUpper === 'image') return captionLower?.includes('gif') ? 'animation' : 'photo'; + if (captionLower?.includes('voice')) return 'voice'; + if (captionLower?.includes('animation')) return 'animation'; + + return mimeUpper === 'application' ? 'application' : 'document'; +}; + +/** + * Checks whether a file's size is within the allowed limit for its type. + * + * @param sizeBytes - File size in bytes. + * @param fileType - One of the recognised Telegram file type keys. + * @returns `true` if the file size is within bounds, `false` otherwise. + */ +export const checkFileSize = (sizeBytes: number, fileType: string): boolean => { + const limit = FILE_TYPES[fileType] || FILE_TYPES.document; + return sizeBytes <= limit; +}; + +/** + * Ensures a file name has a proper extension based on its content. + * + * Magic-bytes (PDF, PNG, JPEG, GIF) are detected from the buffer first; if + * no magic matches, the optional detected MIME type is consulted. + * + * @param fileName - The original file name (may lack an extension). + * @param buffer - At least the first few bytes of file content. + * @param detectedMime - Optional MIME type from an external detector. + * @returns An object with the potentially-corrected file name and MIME type. + */ +export const ensureExtension = ( + fileName: string, + buffer: Buffer, + detectedMime?: string, +): { fileName: string; mimeType: string } => { + const mimeMap: Record = { + 'application/pdf': 'pdf', + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'text/plain': 'txt', + 'application/zip': 'zip', + }; + + let ext: string | null = null; + if (buffer.subarray(0, 4).toString() === '%PDF') { + ext = 'pdf'; + } else if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) { + ext = 'png'; + } else if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + ext = 'jpg'; + } else if (buffer.subarray(0, 4).toString() === 'GIF8') { + ext = 'gif'; + } else if (detectedMime) { + ext = mimeMap[detectedMime.toLowerCase()] || null; + } + + let finalFileName = fileName; + const hasExtension = fileName.includes('.') && fileName.split('.').pop()!.length >= 2; + if (!hasExtension && ext) { + finalFileName = `${fileName}.${ext}`; + } + + const mimeType = ext + ? Object.keys(mimeMap).find((k) => mimeMap[k] === ext) || + detectedMime || + 'application/octet-stream' + : detectedMime || 'application/octet-stream'; + + return { fileName: finalFileName, mimeType }; +}; + +/** Duck-typed object that may carry HTTP-like headers. */ +type HeaderMapRequest = { + headers?: + | { + get?: (name: string) => string | null; + } + | Record; +}; + +/** Duck-typed Telegram file-like object. */ +type FileLike = { + /** File name, if available. */ + fileName?: string; + /** MIME type, if available. */ + mimeType?: string; +}; + +/** Duck-typed Telegram message object that may contain file attachments. */ +type MessageLike = { + document?: FileLike; + photo?: FileLike[]; + audio?: FileLike; + voice?: FileLike; + animation?: FileLike; +}; + +/** + * Safely reads a header value from a request-like object, supporting both + * the Fetch API `Headers#get` interface and plain records. + * + * @param request - An object with an optional `headers` property. + * @param name - The header name (case-insensitive for `get()`). + * @returns The header value, or `undefined` if not present. + */ +const getHeader = (request: HeaderMapRequest | null, name: string): string | undefined => { + const headers = request?.headers; + if (!headers) return undefined; + + const get = 'get' in headers ? headers.get : undefined; + if (typeof get === 'function') return get(name) || undefined; + + return (headers as Record)[name]; +}; + +/** + * Extracts the file name from a Telegram message or an `x-file-name` request + * header. + * + * The request header takes precedence when present. + * + * @param msg - A duck-typed Telegram message object. + * @param request - An optional request-like object for header inspection. + * @returns The extracted file name, or `'file'` if none was found. + */ +export const extractFileName = (msg: MessageLike, request: HeaderMapRequest | null): string => { + const headerFileName = getHeader(request, 'x-file-name'); + if (headerFileName) return headerFileName; + + return ( + msg.document?.fileName || + msg.photo?.slice(-1)[0]?.fileName || + msg.audio?.fileName || + msg.voice?.fileName || + msg.animation?.fileName || + 'file' + ); +}; + +/** + * Extracts the MIME type from a Telegram message or an `x-mime-type` request + * header. + * + * The request header takes precedence when present. + * + * @param msg - A duck-typed Telegram message object. + * @param request - An optional request-like object for header inspection. + * @returns The extracted MIME type, or `'application/octet-stream'` as + * fallback. + */ +export const extractMimeType = (msg: MessageLike, request: HeaderMapRequest | null): string => { + const headerMimeType = getHeader(request, 'x-mime-type'); + if (headerMimeType) return headerMimeType; + + return ( + msg.document?.mimeType || + msg.photo?.slice(-1)[0]?.mimeType || + msg.audio?.mimeType || + msg.voice?.mimeType || + msg.animation?.mimeType || + 'application/octet-stream' + ); +}; + +/** + * Computes the SHA-256 hex digest of a buffer. + * + * @param buffer - The input data. + * @returns The 64-character hex-encoded SHA-256 hash. + */ +export const computeHash = (buffer: Buffer): string => { + const hasher = new Bun.CryptoHasher('sha256'); + hasher.update(buffer); + return hasher.digest('hex'); +}; + +/** Telegram API file object (subset of the full file object). */ +export interface TelegramMessageFile { + /** Unique file identifier. */ + file_id: string; + /** Unique file identifier that is stable across different Telegram chats. */ + file_unique_id: string; + /** File size in bytes, if available. */ + file_size?: number; + /** MIME type, if available. */ + mime_type?: string; + /** Original file name, if available. */ + file_name?: string; +} + +/** Telegram API message object that may carry media attachments. */ +export interface TelegramMediaMessage { + /** Message identifier within the chat. */ + message_id: number; + document?: TelegramMessageFile; + photo?: TelegramMessageFile[]; + video?: TelegramMessageFile; + audio?: TelegramMessageFile; + voice?: TelegramMessageFile; + animation?: TelegramMessageFile; + sticker?: TelegramMessageFile; + video_note?: TelegramMessageFile; +} + +/** + * Extracts the relevant TelegramMessageFile from a media message based on the + * detected file type. + * + * For photos the last (largest) entry in the photo array is returned. + * + * @param msg - The Telegram media message. + * @param fileType - The detected file type (photo, document, video, etc.). + * @returns The matching file descriptor. + */ +export const extractFileFromMessage = ( + msg: TelegramMediaMessage, + fileType: string, +): TelegramMessageFile => { + if (fileType === 'photo') return msg.photo?.slice(-1)[0] as TelegramMessageFile; + if (fileType === 'sticker') return msg.sticker as TelegramMessageFile; + return msg[fileType as keyof TelegramMediaMessage] as TelegramMessageFile; +}; + +/** + * Detects the file type from a Telegram media message by inspecting which + * media fields are populated. + * + * The first populated field in the order document, photo, video, audio, + * voice, animation, sticker, video_note determines the type. + * + * @param msg - The Telegram media message. + * @returns The detected file type string. + */ +export const detectFileType = (msg: TelegramMediaMessage): string => { + if (msg.document) return 'document'; + if (msg.photo) return 'photo'; + if (msg.video) return 'video'; + if (msg.audio) return 'audio'; + if (msg.voice) return 'voice'; + if (msg.animation) return 'animation'; + if (msg.sticker) return 'sticker'; + if (msg.video_note) return 'video_note'; + return 'document'; +}; + +/** + * Returns the maximum allowed file size in bytes for the given file type. + * + * @param fileType - One of the recognised Telegram file type keys. + * @returns The size limit in bytes. + */ +export const getFileSizeLimit = (fileType: string): number => + FILE_TYPES[fileType] || FILE_TYPES.document; + +/** + * Formats a `createdAt` value into an ISO-8601 string. + * + * Accepts a Date instance, a date string, or a Unix timestamp (number). + * + * @param createdAt - The timestamp value to format. + * @returns The ISO-8601 string representation. + */ +export const formatCreatedAt = (createdAt: Date | string | number): string => { + return createdAt instanceof Date ? createdAt.toISOString() : new Date(createdAt).toISOString(); +}; + +/** Public shape of a file in the upload API response. */ +export interface UploadResponse { + /** Public unique identifier. */ + public_id: string; + /** Original file name. */ + file_name: string; + /** MIME type. */ + mime_type: string; + /** File size in bytes. */ + size_bytes: number; + /** Telegram file type. */ + file_type: string; + /** ISO-8601 creation timestamp. */ + created_at: string; + /** Public download URL. */ + download_url: string; +} + +/** + * Builds an API response object from stored file metadata. + * + * @param file - The file metadata record. + * @param baseUrl - The server's base URL used to construct the download link. + * @returns A plain response object suitable for JSON serialisation. + */ +export const buildUploadResponse = (file: FileMetadata, baseUrl: string): UploadResponse => { + return { + public_id: file.publicId, + file_name: file.fileName, + mime_type: file.mimeType, + size_bytes: file.sizeBytes, + file_type: file.fileType, + created_at: formatCreatedAt(file.createdAt), + download_url: `${baseUrl}/f/${file.publicId}`, + }; +}; diff --git a/src/shared/utils/ip.ts b/src/shared/utils/ip.ts new file mode 100644 index 0000000..b51c4f6 --- /dev/null +++ b/src/shared/utils/ip.ts @@ -0,0 +1,26 @@ +import { config } from '../../env'; + +/** + * Extracts the client IP address from a Request object. + * + * When the server is behind a trusted proxy (config.trustProxy is true), this + * function respects the X-Forwarded-For and X-Real-IP headers. Otherwise it + * always returns 127.0.0.1. + * + * @param req - The incoming HTTP request. + * @returns The client IP address as a string. + */ +export const extractClientIp = (req: Request): string => { + if (!config.trustProxy) return '127.0.0.1'; + + const forwardedFor = req.headers.get('x-forwarded-for'); + if (forwardedFor) { + const firstIp = forwardedFor.split(',')[0]?.trim(); + if (firstIp) return firstIp; + } + + const realIp = req.headers.get('x-real-ip')?.trim(); + if (realIp) return realIp; + + return '127.0.0.1'; +}; diff --git a/src/shared/utils/retry.ts b/src/shared/utils/retry.ts new file mode 100644 index 0000000..3ea101d --- /dev/null +++ b/src/shared/utils/retry.ts @@ -0,0 +1,135 @@ +import logger from '../../utils/logger'; + +/** Configuration options for retry behaviour. */ +interface RetryOptions { + /** Maximum number of retry attempts (default: 3). */ + maxRetries?: number; + /** Delay before the first retry in milliseconds (default: 100). */ + initialDelayMs?: number; + /** Maximum delay between retries in milliseconds (default: 5000). */ + maxDelayMs?: number; + /** Multiplier for exponential backoff (default: 2). */ + backoffMultiplier?: number; + /** + * Predicate that determines whether a given error should trigger a retry. + * When omitted, transient network / timeout errors are retried. + */ + shouldRetry?: (error: unknown) => boolean; +} + +const DEFAULT_OPTIONS: Required = { + maxRetries: 3, + initialDelayMs: 100, + maxDelayMs: 5000, + backoffMultiplier: 2, + shouldRetry: (error: unknown) => { + const errorStr = error instanceof Error ? error.message : String(error); + // Retry on transient errors + return ( + errorStr.includes('ECONNREFUSED') || + errorStr.includes('ETIMEDOUT') || + errorStr.includes('ENOTFOUND') || + errorStr.includes('429') || + errorStr.includes('timeout') + ); + }, +}; + +/** + * Executes an async function with exponential backoff retry logic. + * + * The function is retried up to `maxRetries` times. Between attempts the + * delay grows by `backoffMultiplier` (capped at `maxDelayMs`). Only errors + * for which `shouldRetry` returns `true` trigger a retry; all others are + * thrown immediately. When all retries are exhausted the last error is + * thrown. + * + * @param fn - The async function to execute. + * @param options - Optional retry configuration overrides. + * @returns The resolved value of `fn`. + */ +export const withRetry = async ( + fn: () => Promise, + options: RetryOptions = {}, +): Promise => { + const opts = { ...DEFAULT_OPTIONS, ...options }; + let lastError: unknown; + let delay = opts.initialDelayMs; + + for (let attempt = 0; attempt <= opts.maxRetries; attempt++) { + try { + return await fn(); + } catch (error: unknown) { + lastError = error; + const errorStr = error instanceof Error ? error.message : String(error); + + if (attempt === opts.maxRetries || !opts.shouldRetry(error)) { + logger.error('Retry exhausted', { + attempt, + maxRetries: opts.maxRetries, + error: errorStr, + }); + throw error; + } + + logger.warn('Retrying after error', { + attempt, + delay, + error: errorStr, + }); + + await new Promise((resolve) => setTimeout(resolve, delay)); + delay = Math.min(delay * opts.backoffMultiplier, opts.maxDelayMs); + } + } + + throw lastError; +}; + +/** + * Wraps an async function with a configurable timeout. + * + * If `fn` does not settle within `timeoutMs` milliseconds the returned + * promise rejects with a timeout error. The underlying `fn` continues + * executing but its result is ignored. + * + * @param fn - The async function to execute. + * @param timeoutMs - Timeout in milliseconds (default: 30000). + * @returns The resolved value of `fn`. + */ +export const withTimeout = async ( + fn: () => Promise, + timeoutMs: number = 30000, +): Promise => { + return Promise.race([ + fn(), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Operation timeout after ${timeoutMs}ms`)), timeoutMs), + ), + ]); +}; + +/** + * Executes a primary async function and falls back to a secondary function + * if the primary throws. + * + * The fallback function is called only when the primary rejects. If the + * fallback also throws the error propagates to the caller. + * + * @param primary - The primary async function to attempt first. + * @param fallback - The fallback async function invoked on failure. + * @returns The resolved value of `primary` or, on failure, of `fallback`. + */ +export const withFallback = async ( + primary: () => Promise, + fallback: () => Promise, +): Promise => { + try { + return await primary(); + } catch (error: unknown) { + logger.warn('Primary operation failed, using fallback', { + error: error instanceof Error ? error.message : String(error), + }); + return fallback(); + } +}; \ No newline at end of file diff --git a/src/shared/utils/zip.ts b/src/shared/utils/zip.ts new file mode 100644 index 0000000..8c82301 --- /dev/null +++ b/src/shared/utils/zip.ts @@ -0,0 +1,401 @@ +import { once } from 'node:events'; +import { createReadStream, createWriteStream } from 'node:fs'; +import { open, stat } from 'node:fs/promises'; +import { basename } from 'node:path'; +import { finished } from 'node:stream/promises'; +import { nanoid } from 'nanoid'; + +/** Describes a single file to include in a new ZIP archive. */ +export type ZipInputFile = { + /** Absolute path to the file on disk. */ + tempPath: string; + /** Original file name (used to derive the ZIP entry name). */ + fileName: string; +}; + +/** Metadata for one entry in a created ZIP archive. */ +export type ZipEntry = { + /** Original file name as passed to `ZipInputFile`. */ + fileName: string; + /** Sanitised entry name within the archive. */ + entryName: string; + /** CRC-32 checksum of the uncompressed data. */ + crc32: number; + /** Size of the entry when compressed (stored size). */ + compressedSize: number; + /** Size of the uncompressed data. */ + uncompressedSize: number; + /** Byte offset of the local file header in the archive. */ + localHeaderOffset: number; +}; + +/** Result returned after creating a ZIP archive. */ +export type CreatedZip = { + /** Absolute path to the temporary ZIP file on disk. */ + tempPath: string; + /** Total size of the archive in bytes. */ + sizeBytes: number; + /** SHA-256 hex digest of the entire archive content. */ + fileHash: string; + /** Metadata for every entry in the archive. */ + entries: ZipEntry[]; +}; + +/** Pre-computed CRC-32 lookup table (ISO 3309 / IEEE 802.3 polynomial). */ +const CRC32_TABLE = new Uint32Array(256).map((_, index) => { + let value = index; + for (let bit = 0; bit < 8; bit++) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + return value >>> 0; +}); + +/** + * Updates a running CRC-32 checksum with the bytes of a buffer. + * + * @param crc - Current CRC-32 value (typically starts at `0xFFFFFFFF`). + * @param chunk - Buffer of bytes to incorporate. + * @returns The updated CRC-32 value. + */ +const updateCrc32 = (crc: number, chunk: Buffer): number => { + let value = crc; + for (const byte of chunk) { + value = CRC32_TABLE[(value ^ byte) & 0xff] ^ (value >>> 8); + } + return value >>> 0; +}; + +/** + * Converts a JavaScript Date into the MS-DOS date/time format used by ZIP + * local file headers. + * + * @param date - The date to convert (defaults to the current time). + * @returns An object with separate `time` and `date` bit-fields. + */ +const dosDateTime = (date = new Date()): { date: number; time: number } => { + const year = Math.max(date.getFullYear(), 1980); + return { + time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2), + date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(), + }; +}; + +/** + * Writes a 16-bit unsigned integer as a little-endian buffer. + * + * @param value - The integer to write (only the lower 16 bits are used). + * @returns A 2-byte buffer. + */ +const writeUInt16 = (value: number): Buffer => { + const buffer = Buffer.allocUnsafe(2); + buffer.writeUInt16LE(value & 0xffff, 0); + return buffer; +}; + +/** + * Writes a 32-bit unsigned integer as a little-endian buffer. + * + * @param value - The integer to write (interpreted as unsigned). + * @returns A 4-byte buffer. + */ +const writeUInt32 = (value: number): Buffer => { + const buffer = Buffer.allocUnsafe(4); + buffer.writeUInt32LE(value >>> 0, 0); + return buffer; +}; + +/** + * Writes a chunk of data to a writable stream, waiting for the drain event + * if the internal buffer is full (back-pressure handling). + * + * @param writer - The writable stream (e.g. `createWriteStream` result). + * @param chunk - The buffer to write. + */ +const writeChunk = async ( + writer: ReturnType, + chunk: Buffer, +): Promise => { + if (!writer.write(chunk)) { + await once(writer, 'drain'); + } +}; + +/** + * Finishes a writable stream and waits for it to close. + * + * @param writer - The writable stream to end. + */ +const finishWriter = async (writer: ReturnType): Promise => { + writer.end(); + await finished(writer); +}; + +/** + * Sanitises a file name for use as a ZIP entry name. + * + * Strips directory components, replaces path separators with underscores, + * collapses consecutive dots, and ensures uniqueness against the supplied + * set of already-used names by appending a numeric suffix when necessary. + * + * @param fileName - The raw file name to sanitise. + * @param usedNames - A set of entry names already claimed; may be mutated. + * @returns A unique, safe ZIP entry name. + */ +export const sanitizeZipEntryName = (fileName: string, usedNames = new Set()): string => { + const cleaned = basename(fileName) + .replace(/[\\/]+/g, '_') + .replace(/\.\.+/g, '.') + .trim(); + const fallback = cleaned && cleaned !== '.' && cleaned !== '..' ? cleaned : 'file'; + const dotIndex = fallback.lastIndexOf('.'); + const baseName = dotIndex > 0 ? fallback.slice(0, dotIndex) : fallback; + const extension = dotIndex > 0 ? fallback.slice(dotIndex) : ''; + let candidate = fallback; + let counter = 1; + + while (usedNames.has(candidate)) { + candidate = `${baseName}-${counter}${extension}`; + counter++; + } + + usedNames.add(candidate); + return candidate; +}; + +/** + * Calculates the CRC-32 checksum of a file on disk by streaming its + * contents through the lookup-table algorithm. + * + * @param tempPath - Absolute path to the file. + * @returns The CRC-32 value as an unsigned 32-bit integer. + */ +const calculateFileCrc32 = async (tempPath: string): Promise => { + let crc = 0xffffffff; + const reader = createReadStream(tempPath); + for await (const chunk of reader) { + crc = updateCrc32(crc, chunk as Buffer); + } + return (crc ^ 0xffffffff) >>> 0; +}; + +/** + * Creates a ZIP archive (stored-only, no compression) from a list of input + * files and writes it to a temporary path. + * + * The archive uses the standard ZIP format with local file headers, a + * central directory, and an end-of-central-directory record. Each entry is + * stored uncompressed (method 0). The entire archive is SHA-256 hashed + * during writing. + * + * @param files - Array of file descriptors to include in the archive. + * @returns Metadata describing the created archive. + */ +export const createZip = async (files: ZipInputFile[]): Promise => { + const tempPath = `/tmp/filedrop-${nanoid()}.zip`; + const writer = createWriteStream(tempPath); + const hasher = new Bun.CryptoHasher('sha256'); + const entries: ZipEntry[] = []; + const usedNames = new Set(); + let offset = 0; + + const writeHashed = async (chunk: Buffer): Promise => { + hasher.update(chunk); + await writeChunk(writer, chunk); + offset += chunk.byteLength; + }; + + try { + for (const file of files) { + const entryName = sanitizeZipEntryName(file.fileName, usedNames); + const nameBuffer = Buffer.from(entryName); + const fileStats = await stat(file.tempPath); + const { date, time } = dosDateTime(); + const localHeaderOffset = offset; + const crc32 = await calculateFileCrc32(file.tempPath); + + const localHeader = Buffer.concat([ + writeUInt32(0x04034b50), + writeUInt16(20), + writeUInt16(0), + writeUInt16(0), + writeUInt16(time), + writeUInt16(date), + writeUInt32(crc32), + writeUInt32(fileStats.size), + writeUInt32(fileStats.size), + writeUInt16(nameBuffer.byteLength), + writeUInt16(0), + nameBuffer, + ]); + + await writeHashed(localHeader); + const reader = createReadStream(file.tempPath); + for await (const chunk of reader) { + await writeHashed(chunk as Buffer); + } + + entries.push({ + fileName: file.fileName, + entryName, + crc32, + compressedSize: fileStats.size, + uncompressedSize: fileStats.size, + localHeaderOffset, + }); + } + + const centralDirectoryOffset = offset; + for (const entry of entries) { + const nameBuffer = Buffer.from(entry.entryName); + const { date, time } = dosDateTime(); + await writeHashed( + Buffer.concat([ + writeUInt32(0x02014b50), + writeUInt16(20), + writeUInt16(20), + writeUInt16(0), + writeUInt16(0), + writeUInt16(time), + writeUInt16(date), + writeUInt32(entry.crc32), + writeUInt32(entry.compressedSize), + writeUInt32(entry.uncompressedSize), + writeUInt16(nameBuffer.byteLength), + writeUInt16(0), + writeUInt16(0), + writeUInt16(0), + writeUInt16(0), + writeUInt32(0), + writeUInt32(entry.localHeaderOffset), + nameBuffer, + ]), + ); + } + + const centralDirectorySize = offset - centralDirectoryOffset; + await writeHashed( + Buffer.concat([ + writeUInt32(0x06054b50), + writeUInt16(0), + writeUInt16(0), + writeUInt16(entries.length), + writeUInt16(entries.length), + writeUInt32(centralDirectorySize), + writeUInt32(centralDirectoryOffset), + writeUInt16(0), + ]), + ); + + await finishWriter(writer); + + return { + tempPath, + sizeBytes: offset, + fileHash: hasher.digest('hex'), + entries, + }; + } catch (error) { + writer.destroy(); + throw error; + } +}; + +/** + * Extracts a single entry from an in-memory ZIP buffer. + * + * Only stored (uncompressed) entries are supported — entries compressed + * with any method return `null`. + * + * @param zipBuffer - The full ZIP archive as a buffer. + * @param entryName - The exact entry name to extract. + * @returns The entry's data as a buffer, or `null` if not found or + * compressed. + */ +export const extractZipEntry = async ( + zipBuffer: Buffer, + entryName: string, +): Promise => { + let offset = 0; + + while (offset + 30 <= zipBuffer.byteLength) { + const signature = zipBuffer.readUInt32LE(offset); + if (signature !== 0x04034b50) break; + + const compressionMethod = zipBuffer.readUInt16LE(offset + 8); + const compressedSize = zipBuffer.readUInt32LE(offset + 18); + const fileNameLength = zipBuffer.readUInt16LE(offset + 26); + const extraLength = zipBuffer.readUInt16LE(offset + 28); + const nameStart = offset + 30; + const nameEnd = nameStart + fileNameLength; + const dataStart = nameEnd + extraLength; + const dataEnd = dataStart + compressedSize; + const currentName = zipBuffer.subarray(nameStart, nameEnd).toString(); + + if (currentName === entryName) { + if (compressionMethod !== 0) return null; + return zipBuffer.subarray(dataStart, dataEnd); + } + + offset = dataEnd; + } + + return null; +}; + +/** Byte-range location of a stored entry within a ZIP archive on disk. */ +export type LocatedZipEntry = { + /** Byte offset where the entry data begins. */ + start: number; + /** Length of the entry data in bytes. */ + length: number; +}; + +/** + * Locates a stored (uncompressed) entry within a ZIP archive on disk + * without reading the entire file into memory. + * + * Scans local file headers sequentially until the matching entry is found + * or the end of valid headers is reached. + * + * @param zipPath - Absolute path to the ZIP file on disk. + * @param entryName - The exact entry name to locate. + * @returns The byte range of the entry, or `null` if not found or + * compressed. + */ +export const locateZipEntry = async ( + zipPath: string, + entryName: string, +): Promise => { + const handle = await open(zipPath, 'r'); + let offset = 0; + + try { + const header = Buffer.alloc(30); + + while (true) { + const { bytesRead } = await handle.read(header, 0, header.byteLength, offset); + if (bytesRead < header.byteLength) return null; + + const signature = header.readUInt32LE(0); + if (signature !== 0x04034b50) return null; + + const compressionMethod = header.readUInt16LE(8); + const compressedSize = header.readUInt32LE(18); + const fileNameLength = header.readUInt16LE(26); + const extraLength = header.readUInt16LE(28); + const nameBuffer = Buffer.alloc(fileNameLength); + const nameOffset = offset + 30; + await handle.read(nameBuffer, 0, fileNameLength, nameOffset); + + const dataStart = nameOffset + fileNameLength + extraLength; + if (nameBuffer.toString() === entryName) { + if (compressionMethod !== 0) return null; + return { start: dataStart, length: compressedSize }; + } + + offset = dataStart + compressedSize; + } + } finally { + await handle.close(); + } +}; \ No newline at end of file From ee3167fbfba4596449b480d66e6535e5bd91ff6e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 17:48:24 +0700 Subject: [PATCH 2/8] feat: create domain port interfaces with JSDoc Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/ports/bucket-repository.ts | 42 +++++++++ src/domain/ports/file-part-repository.ts | 30 +++++++ src/domain/ports/file-repository.ts | 108 +++++++++++++++++++++++ src/domain/ports/multipart-repository.ts | 70 +++++++++++++++ src/domain/ports/telegram-service.ts | 68 ++++++++++++++ 5 files changed, 318 insertions(+) create mode 100644 src/domain/ports/bucket-repository.ts create mode 100644 src/domain/ports/file-part-repository.ts create mode 100644 src/domain/ports/file-repository.ts create mode 100644 src/domain/ports/multipart-repository.ts create mode 100644 src/domain/ports/telegram-service.ts diff --git a/src/domain/ports/bucket-repository.ts b/src/domain/ports/bucket-repository.ts new file mode 100644 index 0000000..6009d13 --- /dev/null +++ b/src/domain/ports/bucket-repository.ts @@ -0,0 +1,42 @@ +import type { Bucket } from '../entities/bucket'; + +/** + * Repository interface for Bucket entity persistence. + * + * Abstracts the bucket CRUD operations currently in `src/db/buckets.ts`. + */ +export interface IBucketRepository { + /** + * Create a new bucket with the given name. + * @param name - The unique bucket name (S3 naming convention). + * @returns The newly created bucket record. + */ + create(name: string): Promise; + + /** + * Find a bucket by its unique name. + * @param name - The bucket name to look up. + * @returns The matching bucket, or `null` when not found. + */ + findByName(name: string): Promise; + + /** + * List all buckets, ordered alphabetically by name. + * @returns An array of all bucket records. + */ + list(): Promise; + + /** + * Delete a bucket and cascade-delete all associated files and multipart data. + * @param name - The name of the bucket to delete. + * @returns `true` if the bucket was deleted, `false` if it did not exist. + */ + delete(name: string): Promise; + + /** + * Check whether a bucket with the given name exists. + * @param name - The bucket name to check. + * @returns `true` if the bucket exists, `false` otherwise. + */ + exists(name: string): Promise; +} diff --git a/src/domain/ports/file-part-repository.ts b/src/domain/ports/file-part-repository.ts new file mode 100644 index 0000000..16c9432 --- /dev/null +++ b/src/domain/ports/file-part-repository.ts @@ -0,0 +1,30 @@ +import type { FilePart, NewFilePart } from '../entities/file-part'; + +/** + * Repository interface for FilePart entity persistence. + * + * Abstracts the file-part operations currently in `src/db/file-parts.ts`. + * File parts represent the chunks of a large file stored across multiple + * Telegram messages for Telegram-safe storage. + */ +export interface IFilePartRepository { + /** + * Insert multiple file parts in a single operation. + * @param parts - An array of new file part records (auto-generated fields omitted). + */ + insert(parts: NewFilePart[]): Promise; + + /** + * List all file parts for a given file, ordered by part number. + * @param fileId - The UUID of the parent file record. + * @returns An array of file parts. + */ + listByFileId(fileId: string): Promise; + + /** + * Count the number of file parts associated with a file. + * @param fileId - The UUID of the parent file record. + * @returns The part count. + */ + countByFileId(fileId: string): Promise; +} diff --git a/src/domain/ports/file-repository.ts b/src/domain/ports/file-repository.ts new file mode 100644 index 0000000..5b648af --- /dev/null +++ b/src/domain/ports/file-repository.ts @@ -0,0 +1,108 @@ +import type { File, NewFile } from '../entities/file'; + +/** + * An S3-synced file record: a File entity guaranteed to have non-null + * bucketId and s3Key values. + */ +export interface S3FileRecord extends File { + /** S3 bucket UUID (non-null refinement) */ + bucketId: string; + /** S3 object key (non-null refinement) */ + s3Key: string; +} + +/** + * Repository interface for File entity persistence. + * + * Abstracts all file CRUD operations currently spread across + * `src/db/files.ts` and `src/db/files-ext.ts`. + */ +export interface IFileRepository { + /** + * Find a single file by its SHA-256 content hash. + * @param hash - The SHA-256 hash to search for. + * @returns The matching file, or `null` when not found. + */ + findByHash(hash: string): Promise; + + /** + * Find a single file by its public-facing short identifier. + * @param publicId - The public ID to look up. + * @returns The matching file, or `null` when not found. + */ + findByPublicId(publicId: string): Promise; + + /** + * Find a single file by its Telegram file unique ID (stable across bot tokens). + * @param telegramFileUniqueId - The Telegram unique file ID. + * @returns The matching file, or `null` when not found. + */ + findByUniqueId(telegramFileUniqueId: string): Promise; + + /** + * Find a single file by its S3 bucket and object key. + * @param bucketId - The bucket UUID. + * @param s3Key - The S3 object key. + * @returns The matching file, or `null` when not found. + */ + findByBucketAndKey(bucketId: string, s3Key: string): Promise; + + /** + * Create a new file record. + * @param file - The file data (auto-generated fields omitted). + * @returns The newly created file record with all fields populated. + */ + create(file: NewFile): Promise; + + /** + * List objects within a bucket, optionally filtered by prefix and delimiter. + * + * When `delimiter` is `"/"`, common prefixes (pseudo-directories) are + * returned separately and objects whose key continues past the delimiter + * are omitted from the `objects` array. + * + * @param bucketId - The bucket UUID to list from. + * @param prefix - Key prefix to filter by. + * @param delimiter - Delimiter character (e.g. `"/"`) or `null` for flat listing. + * @param maxKeys - Maximum number of object records to return. + * @param startAfter - Return only keys strictly greater than this value, or `null`. + * @returns A list of matching S3 file records and discovered common prefixes. + */ + listByPrefix( + bucketId: string, + prefix: string, + delimiter: string | null, + maxKeys: number, + startAfter: string | null, + ): Promise<{ objects: S3FileRecord[]; prefixes: string[] }>; + + /** + * Soft-delete a single file by bucket and key. + * @param bucketId - The bucket UUID. + * @param s3Key - The S3 object key. + * @returns `true` if a row was soft-deleted, `false` otherwise. + */ + softDelete(bucketId: string, s3Key: string): Promise; + + /** + * Soft-delete multiple files within a bucket in batch. + * @param bucketId - The bucket UUID. + * @param keys - Array of S3 object keys to delete. + * @returns The number of rows actually soft-deleted. + */ + softDeleteBatch(bucketId: string, keys: string[]): Promise; + + /** + * Count non-deleted objects in a bucket. + * @param bucketId - The bucket UUID. + * @returns The object count. + */ + countByBucket(bucketId: string): Promise; + + /** + * Find soft-deleted (orphaned) file records in a bucket. + * @param bucketId - The bucket UUID. + * @returns An array of orphaned file records. + */ + findOrphansByBucket(bucketId: string): Promise; +} diff --git a/src/domain/ports/multipart-repository.ts b/src/domain/ports/multipart-repository.ts new file mode 100644 index 0000000..ce35d4b --- /dev/null +++ b/src/domain/ports/multipart-repository.ts @@ -0,0 +1,70 @@ +import type { MultipartUpload, MultipartPart } from '../entities/multipart'; + +/** + * Repository interface for S3 multipart upload persistence. + * + * Abstracts the multipart upload operations currently in `src/db/multipart.ts`. + * Manages both multipart upload sessions and their individual parts. + */ +export interface IMultipartRepository { + /** + * Initiate a new multipart upload session. + * @param bucketId - The UUID of the target bucket. + * @param s3Key - The S3 object key being uploaded. + * @param initiatedBy - Identifier of the entity that initiated the upload. + * @returns The newly generated upload ID (nanoid). + */ + create(bucketId: string, s3Key: string, initiatedBy: string): Promise; + + /** + * Find an in-progress multipart upload by its upload ID. + * @param uploadId - The upload identifier. + * @returns The matching upload, or `null` if not found or not in progress. + */ + findById(uploadId: string): Promise; + + /** + * Mark a multipart upload as completed. + * @param uploadId - The upload identifier to complete. + */ + complete(uploadId: string): Promise; + + /** + * Mark a multipart upload as aborted. + * @param uploadId - The upload identifier to abort. + */ + abort(uploadId: string): Promise; + + /** + * Insert a single part record for a multipart upload. + * @param part - The part data (auto-generated fields omitted). + */ + insertPart(part: Omit): Promise; + + /** + * List all parts for a multipart upload, ordered by part number. + * @param uploadId - The upload identifier. + * @returns An array of multipart parts. + */ + listParts(uploadId: string): Promise; + + /** + * List in-progress multipart uploads within a bucket, with pagination. + * + * Results are ordered by S3 key and initiation timestamp. + * + * @param bucketId - The UUID of the bucket. + * @param maxUploads - Maximum number of uploads to return (clamped 1-1000). + * @param keyMarker - Return only uploads whose S3 key is strictly greater than this, or `null`. + * @returns A list of uploads and pagination metadata. + */ + listByBucket( + bucketId: string, + maxUploads: number, + keyMarker: string | null, + ): Promise<{ + uploads: MultipartUpload[]; + isTruncated: boolean; + nextKeyMarker: string | null; + }>; +} diff --git a/src/domain/ports/telegram-service.ts b/src/domain/ports/telegram-service.ts new file mode 100644 index 0000000..4bf9188 --- /dev/null +++ b/src/domain/ports/telegram-service.ts @@ -0,0 +1,68 @@ +/** + * Result of forwarding a file to Telegram storage. + */ +export interface ForwardResult { + /** The Telegram file_id for retrieving the file */ + telegramFileId: string; + /** The Telegram unique file_id (stable across bot tokens) */ + telegramFileUniqueId: string; + /** The message ID within the storage chat */ + storageMessageId: number; +} + +/** + * File information returned by Telegram's getFile API. + */ +export interface TelegramFileInfo { + /** File size in bytes */ + file_size: number; + /** MIME type of the file */ + mime_type: string; + /** Path on Telegram's file server for downloading */ + file_path: string; + /** Bot token that owns the retrieved file */ + bot_token: string; +} + +/** + * Abstraction over Telegram bot API operations. + * + * Defines the contract for forwarding files to Telegram storage, + * retrieving file metadata, and managing concurrent uploads. + */ +export interface ITelegramService { + /** + * Forward a file chunk to the configured Telegram storage chat. + * + * @param fileChunk - The file data (ReadStream, Buffer, or file path). + * @param fileName - The original file name. + * @param fileType - The file type classification (e.g. "photo", "document"). + * @returns The Telegram identifiers of the stored file. + */ + forwardToStorage( + fileChunk: unknown, + fileName: string, + fileType: string, + ): Promise; + + /** + * Retrieve file metadata from Telegram by file ID. + * + * Tries all configured bots; returns info from the first that owns the file. + * + * @param telegramFileId - The Telegram file_id to look up. + * @returns Metadata including size, MIME type, download path, and bot token. + */ + getFileInfo(telegramFileId: string): Promise; + + /** + * Enqueue a task for sequential upload execution. + * + * Ensures only one Telegram upload runs at a time to avoid + * rate limits and resource contention. + * + * @param task - An async function performing the upload. + * @returns The result of the task. + */ + enqueueUpload(task: () => Promise): Promise; +} From 5e29589f1a501b807e629e5ae2c69b0b289d468b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 17:56:49 +0700 Subject: [PATCH 3/8] feat: create application DTOs Add data-transfer-object interfaces for the application layer: upload, file, bucket, S3, and auth domains. Co-Authored-By: Claude Opus 5 (1M context) --- src/application/dto/auth.ts | 46 ++++++++++++++++++ src/application/dto/bucket.ts | 47 +++++++++++++++++++ src/application/dto/file.ts | 68 +++++++++++++++++++++++++++ src/application/dto/s3.ts | 87 +++++++++++++++++++++++++++++++++++ src/application/dto/upload.ts | 46 ++++++++++++++++++ 5 files changed, 294 insertions(+) create mode 100644 src/application/dto/auth.ts create mode 100644 src/application/dto/bucket.ts create mode 100644 src/application/dto/file.ts create mode 100644 src/application/dto/s3.ts create mode 100644 src/application/dto/upload.ts diff --git a/src/application/dto/auth.ts b/src/application/dto/auth.ts new file mode 100644 index 0000000..ebeb605 --- /dev/null +++ b/src/application/dto/auth.ts @@ -0,0 +1,46 @@ +/** + * Input for the login endpoint. + * The caller provides the admin API token to obtain a session cookie. + */ +export interface LoginInput { + /** Admin API token for authentication */ + token: string; +} + +/** + * Active authentication session information. + */ +export interface AuthSession { + /** Authenticated username (currently always "admin") */ + username: string; + /** Session expiry timestamp; null for bearer-token sessions */ + expiresAt: Date | null; + /** Authentication method used */ + method: 'cookie' | 'bearer'; +} + +/** + * Response payload for a successful login. + */ +export interface LoginResponse { + /** Authenticated username */ + username: string; +} + +/** + * Response payload for logout. + */ +export interface LogoutResponse { + /** Whether the logout succeeded */ + success: boolean; +} + +/** + * Response payload for the current-user (/me) endpoint. + */ +export interface UserInfoResponse { + /** Authenticated username */ + username: string; + /** ISO-8601 session expiry timestamp; null when using bearer token */ + expiresAt: string | null; +} diff --git a/src/application/dto/bucket.ts b/src/application/dto/bucket.ts new file mode 100644 index 0000000..e6f2736 --- /dev/null +++ b/src/application/dto/bucket.ts @@ -0,0 +1,47 @@ +/** + * Input for creating a new bucket. + */ +export interface CreateBucketInput { + /** Bucket name (must match S3 naming rules: 3-63 chars, lowercase, no underscore) */ + name: string; +} + +/** + * Single bucket representation returned by bucket endpoints. + */ +export interface BucketResponse { + /** Bucket UUID */ + id: string; + /** Bucket name */ + name: string; + /** ISO-8601 timestamp of when the bucket was created */ + createdAt: string; + /** Number of non-deleted objects in the bucket */ + objectCount?: number; +} + +/** + * Response payload for the list-buckets endpoint. + */ +export interface BucketListResponse { + /** Array of buckets */ + buckets: BucketResponse[]; +} + +/** + * Response payload for bucket creation. + */ +export interface CreateBucketResponse { + /** Bucket UUID */ + id: string; + /** Bucket name */ + name: string; +} + +/** + * Response payload for bucket deletion. + */ +export interface DeleteBucketResponse { + /** Whether the deletion succeeded */ + success: boolean; +} diff --git a/src/application/dto/file.ts b/src/application/dto/file.ts new file mode 100644 index 0000000..7bb9d2a --- /dev/null +++ b/src/application/dto/file.ts @@ -0,0 +1,68 @@ +/** + * Public file information response returned by the file-info endpoint. + * Mirrors the JSON shape of GET /file/:publicId/info. + */ +export interface FileInfoResponse { + /** Public, shareable identifier (nanoid) */ + public_id: string; + /** Stored file name */ + file_name: string; + /** MIME type of the stored file */ + mime_type: string; + /** File size in bytes */ + size_bytes: number; + /** High-level file category (e.g. "document", "photo") */ + file_type: string; + /** ISO-8601 timestamp of when the file record was created */ + created_at: string; +} + +/** + * Summary-level file metadata used internally for constructing + * upload responses and object listing entries. + */ +export interface FileMetadata { + /** Public, shareable identifier (nanoid) */ + publicId: string; + /** Telegram file identifier used to retrieve the file from Telegram CDN */ + telegramFileId: string; + /** Telegram unique file identifier (persists across re‑uploads) */ + telegramFileUniqueId: string; + /** Chat ID where the file or archive was stored */ + storageChatId: number; + /** Message ID of the stored file or archive */ + storageMessageId: number; + /** Stored file name */ + fileName: string; + /** MIME type of the stored file */ + mimeType: string; + /** File size in bytes */ + sizeBytes: number; + /** High-level file category */ + fileType: string; + /** Telegram user ID of the uploader; 0 when unknown or system */ + uploaderId: number; + /** Timestamp of file record creation */ + createdAt: Date | string | number; +} + +/** + * Upload response shape returned to API callers. + * Mirrors the JSON output of the /api/upload endpoint. + */ +export interface UploadResponse { + /** Public, shareable identifier */ + public_id: string; + /** Stored file name */ + file_name: string; + /** MIME type */ + mime_type: string; + /** File size in bytes */ + size_bytes: number; + /** High-level file category */ + file_type: string; + /** ISO-8601 creation timestamp */ + created_at: string; + /** Public download URL */ + download_url: string; +} diff --git a/src/application/dto/s3.ts b/src/application/dto/s3.ts new file mode 100644 index 0000000..61d72a7 --- /dev/null +++ b/src/application/dto/s3.ts @@ -0,0 +1,87 @@ +/** + * A single S3 object as it appears in listing results. + */ +export interface S3ObjectResponse { + /** The object key (full path within the bucket) */ + key: string; + /** Stored file name (basename of the key) */ + fileName: string; + /** MIME type of the stored object */ + mimeType: string; + /** Object size in bytes */ + sizeBytes: number; + /** High-level file category */ + fileType: string; + /** SHA-256 hex digest of the object content */ + etag: string | null; + /** ISO-8601 timestamp of last modification */ + lastModified: string; + /** Public download URL */ + downloadUrl: string; +} + +/** + * Response payload for S3 ListObjectsV1 / ListObjectsV2. + */ +export interface S3ListObjectsResponse { + /** Array of object summaries */ + objects: S3ObjectResponse[]; + /** Common prefixes when a delimiter was used (e.g. "folder/" entries) */ + prefixes: string[]; + /** Whether more results are available */ + isTruncated: boolean; + /** Token to pass as continuation-token to retrieve the next page */ + nextContinuationToken: string | null; +} + +/** + * Input for the copy-object operation (Web API v1). + */ +export interface S3CopyObjectInput { + /** Source object key within the same or source bucket */ + sourceKey: string; + /** Destination bucket name; defaults to the source bucket when omitted */ + destBucket?: string; + /** Destination object key */ + destKey: string; +} + +/** + * Response payload for the copy-object operation. + */ +export interface S3CopyObjectResponse { + /** Source object key that was copied */ + sourceKey: string; + /** Destination object key */ + destKey: string; + /** Destination bucket name */ + destBucket: string; +} + +/** + * Summary of a multipart upload in listing results. + */ +export interface S3MultipartUploadResponse { + /** The object key being uploaded */ + key: string; + /** Upload identifier (nanoid) */ + uploadId: string; + /** ISO-8601 timestamp when the upload was initiated */ + initiatedAt: Date; + /** Identifier string of the upload initiator */ + initiatedBy: string; +} + +/** + * Summary of a single part within a multipart upload. + */ +export interface S3MultipartPartResponse { + /** 1-indexed part number */ + partNumber: number; + /** ETag of the part content */ + etag: string; + /** Part size in bytes */ + sizeBytes: number; + /** ISO-8601 timestamp when the part was stored */ + createdAt: Date; +} diff --git a/src/application/dto/upload.ts b/src/application/dto/upload.ts new file mode 100644 index 0000000..454568c --- /dev/null +++ b/src/application/dto/upload.ts @@ -0,0 +1,46 @@ +/** + * Input for the upload file use case. + * Carries all metadata needed to persist an uploaded file, + * including its temporary location on disk and optional bucket/S3 context. + */ +export interface UploadInput { + /** Absolute path to the temporary file on disk */ + tempPath: string; + /** SHA-256 hex digest of the file content */ + fileHash: string; + /** Original file name (may include extension) */ + fileName: string; + /** MIME type detected from content inspection or request header */ + mimeType: string; + /** High-level file category (e.g. "document", "photo", "video") */ + fileType: string; + /** File size in bytes */ + sizeBytes: number; + /** Telegram user ID of the uploader; 0 when unknown or system */ + uploaderId?: number; + /** Target bucket UUID for S3-compatible storage; null when un-bucketed */ + bucketId?: string | null; + /** Object key within the bucket for S3-compatible storage; null when un-bucketed */ + s3Key?: string | null; +} + +/** + * Output from the upload file use case. + * Contains the public-facing file metadata returned to the caller. + */ +export interface UploadOutput { + /** Public, shareable identifier (nanoid) */ + publicId: string; + /** Stored file name (may have been normalized with extension) */ + fileName: string; + /** MIME type of the stored file */ + mimeType: string; + /** File size in bytes */ + sizeBytes: number; + /** High-level file category */ + fileType: string; + /** ISO-8601 timestamp of when the file record was created */ + createdAt: Date; + /** Public download URL */ + downloadUrl: string; +} From e2de8c424536b0c24c329e1dd048123a0efbf321 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 17:59:49 +0700 Subject: [PATCH 4/8] feat: create application use cases (upload, get-file, auth) Create upload-file.ts use case with factory pattern supporting dedup, file type detection, size validation, and chunked/single storage strategies. Create get-file.ts use case supporting redirect, chunked, and archive-entry retrieval strategies. Create authenticate.ts use case with login, logout, and me operations. All use cases use dependency injection and return typed DTOs. Co-Authored-By: Claude Opus 5 (1M context) --- src/application/use-cases/authenticate.ts | 108 +++++++ src/application/use-cases/get-file.ts | 176 +++++++++++ src/application/use-cases/upload-file.ts | 360 ++++++++++++++++++++++ 3 files changed, 644 insertions(+) create mode 100644 src/application/use-cases/authenticate.ts create mode 100644 src/application/use-cases/get-file.ts create mode 100644 src/application/use-cases/upload-file.ts diff --git a/src/application/use-cases/authenticate.ts b/src/application/use-cases/authenticate.ts new file mode 100644 index 0000000..e9a3097 --- /dev/null +++ b/src/application/use-cases/authenticate.ts @@ -0,0 +1,108 @@ +import { timingSafeEqual } from 'node:crypto'; +import type { LoginInput, LoginResponse, LogoutResponse, UserInfoResponse, AuthSession } from '../dto/auth'; + +/** Subset of application configuration consumed by the authenticate use case. */ +export interface AuthUseCaseConfig { + /** Admin API token used to authenticate login requests. */ + adminApiToken: string; + /** Name of the session cookie. */ + sessionCookieName: string; + /** Session lifetime in milliseconds. */ + sessionMaxAgeMs: number; +} + +/** Dependencies required by the authenticate use case factory. */ +export interface AuthenticateUseCaseDeps { + /** Application configuration subset. */ + config: AuthUseCaseConfig; +} + +/** + * Performs a constant-time string comparison to prevent timing attacks. + * + * @param left - The first string to compare. + * @param right - The second string to compare. + * @returns `true` if the strings are equal, `false` otherwise. + */ +const timingSafeCompare = (left: string, right: string): boolean => { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + + if (leftBuffer.length !== rightBuffer.length) { + return false; + } + + return timingSafeEqual(leftBuffer, rightBuffer); +}; + +/** + * Checks whether authentication is enabled based on the configured token. + * + * @param adminApiToken - The admin API token value. + * @returns `true` if the token is non-empty (auth is enabled). + */ +const isAuthEnabled = (adminApiToken: string): boolean => adminApiToken.length > 0; + +/** + * Creates a factory function for the login use case. + * + * Validates the provided admin API token and returns session metadata on + * success. The caller (controller/adapter) is responsible for translating + * the result into an HTTP response (e.g. setting a session cookie). + * + * @param deps - The injected dependencies. + * @returns An async function accepting login input and returning a login response. + */ +export function createLoginUseCase(deps: AuthenticateUseCaseDeps) { + return async (input: LoginInput): Promise => { + if (!isAuthEnabled(deps.config.adminApiToken)) { + return { username: 'admin' }; + } + + if (!timingSafeCompare(input.token, deps.config.adminApiToken)) { + throw new Error('Invalid token'); + } + + return { username: 'admin' }; + }; +} + +/** + * Creates a factory function for the logout use case. + * + * Always succeeds — the caller is responsible for clearing the session cookie. + * + * @returns An async function returning a logout response. + */ +export function createLogoutUseCase() { + return async (): Promise => { + return { success: true }; + }; +} + +/** + * Creates a factory function for the current-user (me) use case. + * + * Accepts an already-parsed auth session (from cookie or bearer token) and + * returns the user info response. The caller (controller/adapter) is + * responsible for extracting the session from the raw HTTP request. + * + * @param deps - The injected dependencies. + * @returns An async function accepting an optional session and returning user info. + */ +export function createMeUseCase(deps: AuthenticateUseCaseDeps) { + return async (session: AuthSession | null): Promise => { + if (!isAuthEnabled(deps.config.adminApiToken)) { + return null; + } + + if (!session) { + return null; + } + + return { + username: session.username, + expiresAt: session.expiresAt?.toISOString() ?? null, + }; + }; +} \ No newline at end of file diff --git a/src/application/use-cases/get-file.ts b/src/application/use-cases/get-file.ts new file mode 100644 index 0000000..69bf8ab --- /dev/null +++ b/src/application/use-cases/get-file.ts @@ -0,0 +1,176 @@ +import type { File } from '../../domain/entities/file'; +import type { IFileRepository } from '../../domain/ports/file-repository'; +import type { ITelegramService, TelegramFileInfo } from '../../domain/ports/telegram-service'; + +/** + * Result type for a simple file-info lookup. + */ +export interface FileInfoResult { + /** Whether the file was found. */ + found: true; + /** Public unique identifier. */ + publicId: string; + /** Original file name. */ + fileName: string; + /** MIME type. */ + mimeType: string; + /** File size in bytes. */ + sizeBytes: number; + /** Telegram file type (document, photo, video, etc.). */ + fileType: string; + /** ISO-8601 creation timestamp. */ + createdAt: string; +} + +/** + * Result type for a file-not-found lookup. + */ +export interface FileNotFoundResult { + /** Always `false` for a not-found result. */ + found: false; +} + +/** + * Discriminated union of all possible file-info lookup outcomes. + */ +export type GetFileInfoResult = FileInfoResult | FileNotFoundResult; + +/** + * Describes a redirect-based file retrieval. + */ +export interface RedirectRetrieval { + /** Discriminant. */ + type: 'redirect'; + /** The resolved file entity. */ + file: File; + /** Full Telegram CDN URL to redirect the client to. */ + redirectUrl: string; + /** Cached Telegram file metadata. */ + fileInfo: TelegramFileInfo; +} + +/** + * Describes a chunked file retrieval that needs a multi-part response. + */ +export interface ChunkedRetrieval { + /** Discriminant. */ + type: 'chunked'; + /** The resolved file entity. */ + file: File; +} + +/** + * Describes an archive-entry file retrieval. + */ +export interface ArchiveEntryRetrieval { + /** Discriminant. */ + type: 'archive-entry'; + /** The resolved file entity. */ + file: File; + /** Telegram file metadata for the archive container. */ + archiveInfo: TelegramFileInfo; + /** Name of the entry within the archive. */ + entryName: string; +} + +/** + * Discriminated union of all possible file retrieval outcomes. + */ +export type FileRetrievalResult = RedirectRetrieval | ChunkedRetrieval | ArchiveEntryRetrieval; + +/** Subset of application configuration consumed by the get-file use case. */ +export interface GetFileConfig { + /** Server base URL (used in constructing archive download URLs). */ + baseUrl: string; +} + +/** Dependencies required by the get-file use case factory. */ +export interface GetFileUseCaseDeps { + /** File repository for looking up file records. */ + fileRepo: IFileRepository; + /** Telegram service for resolving file identifiers to download paths. */ + telegramService: ITelegramService; + /** Application configuration subset. */ + config: GetFileConfig; +} + +/** + * Creates a factory function for the get-file info use case. + * + * Looks up a file by its public identifier and returns its metadata. + * + * @param deps - The injected dependencies. + * @returns An async function accepting a public ID and returning file info. + */ +export function createGetFileInfoUseCase(deps: Pick) { + return async (publicId: string): Promise => { + const file = await deps.fileRepo.findByPublicId(publicId); + if (!file) { + return { found: false }; + } + + return { + found: true, + publicId: file.publicId, + fileName: file.fileName, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + fileType: file.fileType, + createdAt: formatCreatedAtForInfo(file.createdAt), + }; + }; +} + +/** + * Formats a date-like value into an ISO-8601 string. + * + * @param date - A Date instance, date string, or numeric timestamp. + * @returns The ISO-8601 string. + */ +const formatCreatedAtForInfo = (date: Date | string | number): string => { + if (date instanceof Date) return date.toISOString(); + return new Date(date).toISOString(); +}; + +/** + * Creates a factory function for the get-file retrieval use case. + * + * Determines how a file should be delivered to the client: + * - **redirect**: For regular (non-chunked, non-archive) files — returns a + * Telegram CDN redirect URL. + * - **chunked**: For files stored across multiple Telegram parts — returns + * the file entity so the caller can build a multi-part streaming response. + * - **archive-entry**: For files stored inside a Telegram archive (zip) — + * returns the archive's Telegram metadata and the entry name so the caller + * can extract and stream the entry. + * + * @param deps - The injected dependencies. + * @returns An async function accepting a public ID and returning a retrieval result. + */ +export function createGetFileUseCase(deps: GetFileUseCaseDeps) { + return async (publicId: string): Promise => { + const file = await deps.fileRepo.findByPublicId(publicId); + if (!file) { + return null; + } + + // Chunked file — return the entity for multi-part response building + if (file.storageBackend === 'chunked') { + return { type: 'chunked', file }; + } + + // Archive entry — resolve the archive's Telegram location + const archiveEntryName = file.archiveEntryName; + if (archiveEntryName) { + const archiveFileId = file.archiveTelegramFileId || file.telegramFileId; + const archiveInfo = await deps.telegramService.getFileInfo(archiveFileId); + return { type: 'archive-entry', file, archiveInfo, entryName: archiveEntryName }; + } + + // Regular file — resolve Telegram CDN path for a redirect + const fileInfo = await deps.telegramService.getFileInfo(file.telegramFileId); + const redirectUrl = `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`; + + return { type: 'redirect', file, redirectUrl, fileInfo }; + }; +} \ No newline at end of file diff --git a/src/application/use-cases/upload-file.ts b/src/application/use-cases/upload-file.ts new file mode 100644 index 0000000..3dfd30c --- /dev/null +++ b/src/application/use-cases/upload-file.ts @@ -0,0 +1,360 @@ +import { randomUUID } from 'node:crypto'; +import { open } from 'node:fs/promises'; +import { createReadStream } from 'node:fs'; +import { gzipSync } from 'node:zlib'; +import { nanoid } from 'nanoid'; +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 { UploadInput, UploadOutput } from '../dto/upload'; +import { getFileType, checkFileSize, ensureExtension, computeHash, formatCreatedAt } from '../../shared/utils/file'; + +/** Compression algorithm string literal used in chunked storage. */ +type ChunkCompressionAlgorithm = 'gzip' | null; + +/** 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: ChunkCompressionAlgorithm; + /** 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. */ + baseUrl: string; + /** Maximum chunk size in bytes for Telegram chunked uploads. */ + telegramChunkSizeBytes: number; + /** Telegram chat ID where file parts are stored. */ + storageChatId: number; + /** Whether to attempt gzip compression on each chunk. */ + compressChunkedUploads: boolean; + /** Minimum chunk size in bytes below which compression is skipped. */ + chunkCompressionMinSizeBytes: number; +} + +/** Dependencies required by the upload-file use case factory. */ +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; + /** 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; +}; + +/** + * 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: ChunkCompressionAlgorithm } => { + 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' }; +}; + +/** + * Reads the first 16 bytes from a file on disk for magic-byte detection. + * + * @param tempPath - Absolute path to the temporary file. + * @returns A buffer containing up to 16 bytes. + */ +const readSignatureBuffer = async (tempPath: string): Promise => { + const handle = await open(tempPath, 'r'); + try { + const buf = Buffer.alloc(16); + const { bytesRead } = await handle.read(buf, 0, 16, 0); + return buf.subarray(0, bytesRead); + } finally { + await handle.close(); + } +}; + +/** + * 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. + * + * The returned use case: + * 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). + * 6. Builds and returns the public `UploadOutput` DTO. + * + * @param deps - The injected dependencies. + * @returns An async function accepting `UploadInput` and returning `UploadOutput`. + */ +export function createUploadFileUseCase(deps: UploadFileUseCaseDeps) { + return async (input: UploadInput): Promise => { + // 1. Check deduplication by content hash + const existing = await deps.fileRepo.findByHash(input.fileHash); + if (existing) { + return { + publicId: existing.publicId, + fileName: existing.fileName, + mimeType: existing.mimeType, + sizeBytes: existing.sizeBytes, + fileType: existing.fileType, + createdAt: existing.createdAt instanceof Date ? existing.createdAt : new Date(existing.createdAt), + downloadUrl: `${deps.config.baseUrl}/f/${existing.publicId}`, + }; + } + + // 2. Read signature bytes for magic-byte-based extension detection + const signatureBuffer = await readSignatureBuffer(input.tempPath); + + const { fileName: finalFileName, mimeType } = ensureExtension( + input.fileName, + signatureBuffer, + input.mimeType, + ); + + // 3. Determine Telegram file type and validate size + const fileType = getFileType(mimeType, finalFileName); + + if (!checkFileSize(input.sizeBytes, fileType)) { + throw new Error(`File size exceeds ${fileType} limit`); + } + + // 4. Upload — chunked for files above the threshold, single otherwise + 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 = randomUUID(); + const publicId = nanoid(); + + const newFile = await deps.fileRepo.create({ + publicId, + telegramFileId: firstPart.telegramFileId, + telegramFileUniqueId: firstPart.telegramFileUniqueId, + storageChatId: deps.config.storageChatId, + storageMessageId: firstPart.storageMessageId, + fileName: finalFileName, + mimeType, + sizeBytes: chunkResult.totalSizeBytes, + fileType, + uploaderId: input.uploaderId ?? 0, + fileHash: chunkResult.fileHash, + archiveTelegramFileId: null, + archiveStorageMessageId: null, + archiveFileName: null, + archiveEntryName: null, + archiveMimeType: null, + archiveSizeBytes: null, + bucketId: input.bucketId ?? null, + s3Key: input.s3Key ?? null, + storageBackend: 'chunked', + isDeleted: false, + multipartUploadId: null, + partCount: chunkResult.parts.length, + }); + + const fileParts: NewFilePart[] = chunkResult.parts.map((part) => ({ + fileId, + partNumber: part.partNumber, + telegramFileId: part.telegramFileId, + telegramFileUniqueId: part.telegramFileUniqueId, + storageChatId: deps.config.storageChatId, + storageMessageId: part.storageMessageId, + sizeBytes: part.sizeBytes, + storedSizeBytes: part.storedSizeBytes, + compressionAlgorithm: part.compressionAlgorithm, + etag: part.etag, + })); + + await deps.filePartRepo.insert(fileParts); + + return { + 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}`, + }; + } + + // 5. Single-message upload path + const forwardResult = await deps.telegramService.forwardToStorage( + createReadStream(input.tempPath), + finalFileName, + fileType, + ); + + const singlePublicId = nanoid(); + + const createdFile = await deps.fileRepo.create({ + publicId: singlePublicId, + telegramFileId: forwardResult.telegramFileId, + telegramFileUniqueId: forwardResult.telegramFileUniqueId, + storageChatId: deps.config.storageChatId, + storageMessageId: forwardResult.storageMessageId, + fileName: finalFileName, + mimeType, + sizeBytes: input.sizeBytes, + fileType, + uploaderId: input.uploaderId ?? 0, + fileHash: input.fileHash, + archiveTelegramFileId: null, + archiveStorageMessageId: null, + archiveFileName: null, + archiveEntryName: null, + archiveMimeType: null, + archiveSizeBytes: null, + bucketId: input.bucketId ?? null, + s3Key: input.s3Key ?? null, + storageBackend: 'telegram', + isDeleted: false, + multipartUploadId: null, + partCount: null, + }); + + return { + publicId: createdFile.publicId, + fileName: createdFile.fileName, + mimeType: createdFile.mimeType, + sizeBytes: createdFile.sizeBytes, + fileType: createdFile.fileType, + createdAt: createdFile.createdAt, + downloadUrl: `${deps.config.baseUrl}/f/${createdFile.publicId}`, + }; + }; +} \ No newline at end of file From ea48e8fc037a1c1b0b0a91899ba58c84aad1a845 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 18:04:01 +0700 Subject: [PATCH 5/8] feat: create application use cases (bucket, s3-object, multipart) - manage-bucket.ts: extract bucket CRUD logic with validation (createListBuckets, createGetBucket, createCreateBucket, createDeleteBucket, createBucketExists) - s3-object.ts: extract S3 object operations (createGetObject, createHeadObject, createPutObject, createCopyObject, createDeleteObject, createDeleteObjects, createListObjects, createFindObject) - multipart-upload.ts: extract S3 multipart upload logic (createInitiateMultipartUpload, createUploadPart, createCompleteMultipartUpload, createAbortMultipartUpload, createListMultipartUploads, createListParts) All use cases follow the existing factory function pattern with dependency injection via repository/telegram service interfaces. Co-Authored-By: Claude Opus 5 (1M context) --- src/application/use-cases/manage-bucket.ts | 168 ++++ src/application/use-cases/multipart-upload.ts | 378 +++++++++ src/application/use-cases/s3-object.ts | 774 ++++++++++++++++++ 3 files changed, 1320 insertions(+) create mode 100644 src/application/use-cases/manage-bucket.ts create mode 100644 src/application/use-cases/multipart-upload.ts create mode 100644 src/application/use-cases/s3-object.ts diff --git a/src/application/use-cases/manage-bucket.ts b/src/application/use-cases/manage-bucket.ts new file mode 100644 index 0000000..a51e0b9 --- /dev/null +++ b/src/application/use-cases/manage-bucket.ts @@ -0,0 +1,168 @@ +import type { Bucket } from '../../domain/entities/bucket'; +import type { IBucketRepository } from '../../domain/ports/bucket-repository'; +import type { IFileRepository } from '../../domain/ports/file-repository'; + +/** + * S3 bucket name validation regex. + * + * Bucket names must be 3-63 characters, start/end with a lowercase letter or + * digit, and contain only lowercase letters, digits, dots, and hyphens. + */ +const BUCKET_NAME_REGEX = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/; + +/** + * Error type for bucket-level application errors that carry an S3-compatible + * error code and an HTTP status suggestion. + */ +export class BucketError extends Error { + /** S3-compatible error code (e.g. "NoSuchBucket", "BucketAlreadyExists"). */ + 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 = 'BucketError'; + this.code = code; + this.status = status; + } +} + +/** + * Describes a bucket entry returned by the list-buckets use case, + * enriched with the current count of non-deleted objects. + */ +export interface BucketWithCount { + /** The bucket domain entity. */ + bucket: Bucket; + /** Number of non-deleted objects in the bucket. */ + objectCount: number; +} + +/** Dependencies required by the manage-bucket use case factories. */ +export interface ManageBucketDeps { + /** Bucket repository for CRUD operations. */ + bucketRepo: IBucketRepository; + /** File repository for counting and checking objects within buckets. */ + fileRepo: IFileRepository; +} + +/** + * Creates a use case that lists all buckets together with their object counts. + * + * @param deps - The injected dependencies. + * @returns An async function that returns a list of buckets with counts. + */ +export function createListBucketsUseCase(deps: ManageBucketDeps) { + return async (): Promise => { + const buckets = await deps.bucketRepo.list(); + const results = await Promise.all( + buckets.map(async (bucket) => ({ + bucket, + objectCount: await deps.fileRepo.countByBucket(bucket.id), + })), + ); + return results; + }; +} + +/** + * Creates a use case that retrieves a single bucket by its name. + * + * @param deps - The injected dependencies. + * @returns An async function accepting a bucket name and returning the + * bucket, or `null` when not found. + */ +export function createGetBucketUseCase(deps: ManageBucketDeps) { + return async (name: string): Promise => { + return deps.bucketRepo.findByName(name); + }; +} + +/** + * Creates a use case that creates a new bucket. + * + * Validates the bucket name format and checks for duplicates before + * persisting. + * + * @param deps - The injected dependencies. + * @returns An async function accepting a bucket name and returning the + * newly created bucket. + * @throws {BucketError} When the name is invalid or the bucket already exists. + */ +export function createCreateBucketUseCase(deps: ManageBucketDeps) { + return async (name: string): Promise => { + if (!BUCKET_NAME_REGEX.test(name)) { + throw new BucketError( + 'InvalidBucketName', + 'The specified bucket is not valid.', + 400, + ); + } + + const existing = await deps.bucketRepo.findByName(name); + if (existing) { + throw new BucketError( + 'BucketAlreadyExists', + 'The requested bucket name is not available.', + 409, + ); + } + + return deps.bucketRepo.create(name); + }; +} + +/** + * Creates a use case that deletes a bucket. + * + * Ensures the bucket exists and is empty (no non-deleted objects) before + * proceeding with deletion. + * + * @param deps - The injected dependencies. + * @returns An async function accepting a bucket name. Returns `true` when + * the bucket was deleted, throws when the bucket is missing or + * not empty. + * @throws {BucketError} When the bucket does not exist or is not empty. + */ +export function createDeleteBucketUseCase(deps: ManageBucketDeps) { + return async (name: string): Promise => { + const bucket = await deps.bucketRepo.findByName(name); + if (!bucket) { + throw new BucketError( + 'NoSuchBucket', + 'The specified bucket does not exist.', + 404, + ); + } + + const objectCount = await deps.fileRepo.countByBucket(bucket.id); + if (objectCount > 0) { + throw new BucketError( + 'BucketNotEmpty', + 'The bucket you tried to delete is not empty.', + 409, + ); + } + + return deps.bucketRepo.delete(name); + }; +} + +/** + * Creates a use case that checks whether a bucket exists. + * + * @param deps - The injected dependencies. + * @returns An async function accepting a bucket name and returning `true` + * when the bucket exists. + */ +export function createBucketExistsUseCase(deps: ManageBucketDeps) { + return async (name: string): Promise => { + return deps.bucketRepo.exists(name); + }; +} \ No newline at end of file diff --git a/src/application/use-cases/multipart-upload.ts b/src/application/use-cases/multipart-upload.ts new file mode 100644 index 0000000..4bf8148 --- /dev/null +++ b/src/application/use-cases/multipart-upload.ts @@ -0,0 +1,378 @@ +import { nanoid } from 'nanoid'; +import type { MultipartUpload } from '../../domain/entities/multipart'; +import type { IBucketRepository } from '../../domain/ports/bucket-repository'; +import type { IFileRepository } from '../../domain/ports/file-repository'; +import type { IMultipartRepository } from '../../domain/ports/multipart-repository'; +import type { ITelegramService } from '../../domain/ports/telegram-service'; +import { computeHash } from '../../shared/utils/file'; + +// ─── Types ────────────────────────────────────────────────────────── + +/** + * A single part reference as submitted in a complete-multipart-upload request. + */ +export interface CompletePartInput { + /** 1-based part number. */ + partNumber: number; + /** ETag returned when the part was uploaded. */ + etag: string; +} + +/** + * Result of initiating a multipart upload. + */ +export interface InitiateMultipartResult { + /** The generated upload identifier (nanoid). */ + uploadId: string; + /** The bucket name. */ + bucket: string; + /** The S3 object key. */ + key: string; +} + +/** + * Result of uploading a single part. + */ +export interface UploadPartResult { + /** ETag of the uploaded part (SHA-256 hex digest). */ + etag: string; +} + +/** + * Result of completing a multipart upload. + */ +export interface CompleteMultipartResult { + /** Public-facing unique identifier of the created file record. */ + publicId: string; + /** The S3 location URL of the completed object. */ + location: string; + /** Combined ETag (all part etags joined by hyphens). */ + etag: string; + /** Total object size in bytes. */ + sizeBytes: number; +} + +/** + * Summary of a single part within a multipart upload used in listing results. + */ +export interface PartSummary { + /** 1-based part number. */ + partNumber: number; + /** ETag of the part content. */ + etag: string; + /** Part size in bytes. */ + sizeBytes: number; + /** ISO-8601 timestamp when the part was stored. */ + createdAt: Date; +} + +/** + * Result of listing multipart uploads within a bucket. + */ +export interface ListMultipartUploadsResult { + /** Array of in-progress upload summaries. */ + uploads: MultipartUpload[]; + /** Whether more results are available. */ + isTruncated: boolean; + /** Marker for the next page, or null when not truncated. */ + nextKeyMarker: string | null; +} + +// ─── Config ───────────────────────────────────────────────────────── + +/** Subset of application configuration consumed by the multipart use cases. */ +export interface MultipartConfig { + /** Maximum chunk size in bytes for Telegram uploads (part size limit). */ + telegramChunkSizeBytes: number; + /** Telegram chat ID where part data is stored. */ + storageChatId: number; + /** Server base URL for constructing location URLs. */ + baseUrl: string; +} + +/** Dependencies required by the multipart upload use case factories. */ +export interface MultipartDeps { + /** Bucket repository for bucket lookups. */ + bucketRepo: IBucketRepository; + /** File repository for creating the final file record on completion. */ + fileRepo: IFileRepository; + /** Multipart repository for managing upload sessions and parts. */ + multipartRepo: IMultipartRepository; + /** Telegram service for forwarding part data to storage. */ + telegramService: ITelegramService; + /** Application configuration subset. */ + config: MultipartConfig; +} + +// ─── Use Case Factories ───────────────────────────────────────────── + +/** + * Creates a use case that initiates an S3 multipart upload. + * + * Validates the bucket exists and creates a new multipart upload session. + * + * @param deps - The injected dependencies. + * @returns An async function accepting bucket name and object key, returning + * the upload initiation result, or `null` when the bucket is not found. + */ +export function createInitiateMultipartUploadUseCase(deps: MultipartDeps) { + return async ( + bucketName: string, + key: string, + ): Promise => { + const bucket = await deps.bucketRepo.findByName(bucketName); + if (!bucket) return null; + + const uploadId = await deps.multipartRepo.create(bucket.id, key, 's3'); + + return { uploadId, bucket: bucketName, key }; + }; +} + +/** + * Creates a use case that uploads a single part of a multipart upload. + * + * Validates the part number range (1-10000), checks the upload session exists + * and matches the expected key, checks part size against the configured limit, + * forwards the part data to Telegram storage, and persists the part record. + * + * @param deps - The injected dependencies. + * @returns An async function accepting upload details and part data, returning + * the part ETag, or `null` when the upload session is not found. + */ +export function createUploadPartUseCase(deps: MultipartDeps) { + return async (input: { + /** Bucket name for the multipart upload. */ + bucketName: string; + /** S3 object key for the multipart upload. */ + key: string; + /** Upload identifier returned by initiate. */ + uploadId: string; + /** 1-based part number (1-10000). */ + partNumber: number; + /** Raw part data. */ + body: Buffer; + }): Promise => { + if (input.partNumber < 1 || input.partNumber > 10000) { + throw new MultipartError( + 'InvalidArgument', + 'Part number must be an integer between 1 and 10000', + 400, + ); + } + + const multipart = await deps.multipartRepo.findById(input.uploadId); + if (!multipart || multipart.s3Key !== input.key) { + return null; + } + + if (input.body.byteLength > deps.config.telegramChunkSizeBytes) { + throw new MultipartError( + 'EntityTooLarge', + `Your proposed upload part size (${input.body.byteLength} bytes) exceeds the maximum allowed part size (${deps.config.telegramChunkSizeBytes} bytes) for this storage backend. Use smaller part sizes.`, + 400, + ); + } + + const forwardResult = await deps.telegramService.forwardToStorage( + input.body, + `mp-${input.uploadId}-part-${input.partNumber}`, + 'document', + ); + + const etag = computeHash(input.body); + + await deps.multipartRepo.insertPart({ + uploadId: input.uploadId, + partNumber: input.partNumber, + telegramFileId: forwardResult.telegramFileId, + telegramFileUniqueId: forwardResult.telegramFileUniqueId, + storageMessageId: forwardResult.storageMessageId, + sizeBytes: input.body.byteLength, + etag, + }); + + return { etag: `"${etag}"` }; + }; +} + +/** + * Error type for multipart-level application errors. + */ +export class MultipartError 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 = 'MultipartError'; + this.code = code; + this.status = status; + } +} + +/** + * Creates a use case that completes an S3 multipart upload. + * + * Validates the submitted part list (all parts must be present and in ascending + * order), creates the final file record referencing the first part's Telegram + * data, marks the upload session as completed, and returns the combined result. + * + * @param deps - The injected dependencies. + * @returns An async function accepting upload details and submitted parts, + * returning the completion result, or `null` when the upload session + * is not found. + */ +export function createCompleteMultipartUploadUseCase(deps: MultipartDeps) { + return async (input: { + /** Bucket name for the multipart upload. */ + bucketName: string; + /** S3 object key for the multipart upload. */ + key: string; + /** Upload identifier. */ + uploadId: string; + /** Parts submitted by the client (in ascending part number order). */ + parts: CompletePartInput[]; + }): Promise => { + const multipart = await deps.multipartRepo.findById(input.uploadId); + if (!multipart) return null; + + const storedParts = await deps.multipartRepo.listParts(input.uploadId); + + // Validate ascending part order + const partNumbers = input.parts.map((p) => p.partNumber); + if (partNumbers.length > 1 && partNumbers.some((n, i) => i > 0 && n <= partNumbers[i - 1])) { + throw new MultipartError( + 'InvalidPartOrder', + 'The list of parts was not in ascending order.', + 400, + ); + } + + // Validate part count matches + if (input.parts.length !== storedParts.length) { + throw new MultipartError( + 'InvalidPart', + 'One or more specified parts could not be found.', + 400, + ); + } + + const totalSize = storedParts.reduce((sum, p) => sum + p.sizeBytes, 0); + const firstPart = storedParts[0]; + if (!firstPart) { + throw new MultipartError('InternalError', 'Multipart object has no parts.', 500); + } + + const publicId = nanoid(); + + await deps.fileRepo.create({ + publicId, + telegramFileId: firstPart.telegramFileId, + telegramFileUniqueId: firstPart.telegramFileUniqueId, + storageChatId: deps.config.storageChatId, + storageMessageId: firstPart.storageMessageId, + fileName: input.key.split('/').pop() || 'file', + mimeType: 'application/octet-stream', + sizeBytes: totalSize, + fileType: 'document', + uploaderId: 0, + fileHash: null, + archiveTelegramFileId: null, + archiveStorageMessageId: null, + archiveFileName: null, + archiveEntryName: null, + archiveMimeType: null, + archiveSizeBytes: null, + bucketId: multipart.bucketId, + s3Key: input.key, + storageBackend: 'telegram', + isDeleted: false, + multipartUploadId: input.uploadId, + partCount: null, + }); + + await deps.multipartRepo.complete(input.uploadId); + + const location = `${deps.config.baseUrl}/${input.bucketName}/${input.key}`; + const combinedEtag = storedParts.map((p) => p.etag).join('-'); + + return { + publicId, + location, + etag: combinedEtag, + sizeBytes: totalSize, + }; + }; +} + +/** + * Creates a use case that aborts an S3 multipart upload. + * + * @param deps - The injected dependencies. + * @returns An async function accepting an upload identifier, returning `true` + * when the upload was aborted, or `null` when the upload session + * is not found. + */ +export function createAbortMultipartUploadUseCase(deps: MultipartDeps) { + return async (uploadId: string): Promise => { + const multipart = await deps.multipartRepo.findById(uploadId); + if (!multipart) return null; + + await deps.multipartRepo.abort(uploadId); + return true; + }; +} + +/** + * Creates a use case that lists in-progress multipart uploads within a bucket. + * + * @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 createListMultipartUploadsUseCase(deps: MultipartDeps) { + return async (input: { + /** Bucket name to list uploads from. */ + bucketName: string; + /** Maximum number of uploads to return (clamped 1-1000). */ + maxUploads: number; + /** Return only uploads whose S3 key is strictly greater than this, or null. */ + keyMarker: string | null; + }): Promise => { + const bucket = await deps.bucketRepo.findByName(input.bucketName); + if (!bucket) return null; + + return deps.multipartRepo.listByBucket(bucket.id, input.maxUploads, input.keyMarker); + }; +} + +/** + * Creates a use case that lists parts of a specific multipart upload. + * + * @param deps - The injected dependencies. + * @returns An async function accepting an upload identifier and returning the + * list of parts, or `null` when the upload session is not found. + */ +export function createListPartsUseCase(deps: MultipartDeps) { + return async (uploadId: string): Promise => { + const multipart = await deps.multipartRepo.findById(uploadId); + if (!multipart) return null; + + const parts = await deps.multipartRepo.listParts(uploadId); + + return parts.map((p) => ({ + partNumber: p.partNumber, + etag: p.etag, + sizeBytes: p.sizeBytes, + createdAt: p.createdAt, + })); + }; +} \ No newline at end of file diff --git a/src/application/use-cases/s3-object.ts b/src/application/use-cases/s3-object.ts new file mode 100644 index 0000000..d125404 --- /dev/null +++ b/src/application/use-cases/s3-object.ts @@ -0,0 +1,774 @@ +import { randomUUID } from 'node:crypto'; +import { gzipSync } from 'node:zlib'; +import { nanoid } from 'nanoid'; +import type { File, NewFile } from '../../domain/entities/file'; +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 { IMultipartRepository } from '../../domain/ports/multipart-repository'; +import type { ITelegramService, TelegramFileInfo } from '../../domain/ports/telegram-service'; +import { ensureExtension, computeHash, formatCreatedAt } from '../../shared/utils/file'; + +// ─── 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. + */ +export interface ObjectPartSource { + /** Telegram file identifier for retrieving this part. */ + telegramFileId: string; + /** Telegram CDN URL for downloading this part. */ + telegramUrl: string; + /** Original size of this part in bytes. */ + sizeBytes: number; + /** 1-based part number within the object. */ + partNumber: number; + /** Stored (post-compression) size in bytes, when applicable. */ + storedSizeBytes?: number; + /** Compression algorithm applied, or null if uncompressed. */ + compressionAlgorithm?: CompressionAlgorithm; +} + +/** + * A regular (direct) S3 object resolved to a Telegram CDN URL. + */ +export interface DirectObjectResult { + /** Discriminant. */ + type: 'direct'; + /** The resolved file entity. */ + file: File; + /** Full Telegram CDN URL for downloading the object. */ + telegramUrl: string; + /** Telegram file metadata. */ + fileInfo: TelegramFileInfo; +} + +/** + * A chunked S3 object stored across multiple Telegram file parts. + */ +export interface ChunkedObjectResult { + /** Discriminant. */ + type: 'chunked'; + /** The resolved file entity. */ + file: File; +} + +/** + * An S3 object assembled from a completed multipart upload. + */ +export interface MultipartObjectResult { + /** Discriminant. */ + type: 'multipart'; + /** The resolved file entity. */ + file: File; + /** Resolved part sources with Telegram CDN URLs. */ + parts: ObjectPartSource[]; +} + +/** + * Discriminated union of all possible S3 get-object outcomes. + */ +export type GetObjectResult = DirectObjectResult | ChunkedObjectResult | MultipartObjectResult; + +/** + * Result of an S3 put-object operation. + */ +export interface PutObjectResult { + /** SHA-256 hex digest of the object content. */ + etag: string; +} + +/** + * Result of an S3 copy-object operation. + */ +export interface CopyObjectResult { + /** SHA-256 hex digest of the source object content. */ + etag: string; + /** ISO-8601 timestamp of the copy operation. */ + lastModified: string; +} + +/** + * A single S3 object as returned in listing results. + */ +export interface ListObjectEntry { + /** The object key (full path within the bucket). */ + key: string; + /** Object size in bytes. */ + sizeBytes: number; + /** SHA-256 hex digest or fallback identifier. */ + etag: string; + /** ISO-8601 timestamp of last modification. */ + lastModified: string; + /** MIME type of the stored object. */ + mimeType: string; +} + +/** + * Result of an S3 list-objects operation (both V1 and V2). + */ +export interface ListObjectsResult { + /** Array of object summaries. */ + objects: ListObjectEntry[]; + /** Common prefixes when a delimiter was used. */ + prefixes: string[]; + /** Whether more results are available. */ + isTruncated: boolean; + /** The last key in the returned page, for use as the next marker. */ + nextMarker: string | null; +} + +/** + * Head-object metadata. + */ +export interface HeadObjectMetadata { + /** MIME type of the object. */ + contentType: string; + /** Object size in bytes. */ + contentLength: number; + /** Entity tag (SHA-256 hex digest or fallback). */ + etag: string; + /** ISO-8601 timestamp of last modification. */ + lastModified: string; +} + +// ─── Config ───────────────────────────────────────────────────────── + +/** Subset of application configuration consumed by the s3-object use cases. */ +export interface S3ObjectConfig { + /** Maximum chunk size in bytes for Telegram chunked uploads. */ + telegramChunkSizeBytes: number; + /** Whether gzip compression is enabled for chunked uploads. */ + compressChunkedUploads: boolean; + /** Minimum chunk size in bytes below which compression is skipped. */ + chunkCompressionMinSizeBytes: number; + /** Telegram chat ID where file parts are stored. */ + storageChatId: number; + /** Server base URL for constructing download links. */ + baseUrl: string; + /** Whether to proxy S3 GET requests through the server. */ + proxyS3Get: boolean; +} + +/** Dependencies required by the s3-object use case factories. */ +export interface S3ObjectDeps { + /** Bucket repository for bucket lookups. */ + bucketRepo: IBucketRepository; + /** File repository for object CRUD operations. */ + fileRepo: IFileRepository; + /** File-part repository for chunked upload part records. */ + filePartRepo: IFilePartRepository; + /** Multipart repository for resolving multipart-upload objects. */ + multipartRepo: IMultipartRepository; + /** Telegram service for uploading and resolving file metadata. */ + telegramService: ITelegramService; + /** 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 = randomUUID(); + const publicId = nanoid(); + + await deps.fileRepo.create({ + publicId, + telegramFileId: firstPart.telegramFileId, + telegramFileUniqueId: firstPart.telegramFileUniqueId, + storageChatId, + storageMessageId: firstPart.storageMessageId, + fileName: finalFileName, + mimeType, + sizeBytes: chunkResult.totalSizeBytes, + fileType: 'document', + uploaderId: 0, + fileHash: chunkResult.fileHash, + archiveTelegramFileId: null, + archiveStorageMessageId: null, + archiveFileName: null, + archiveEntryName: null, + archiveMimeType: null, + archiveSizeBytes: null, + bucketId: bucket.id, + s3Key: key, + storageBackend: 'chunked', + isDeleted: false, + multipartUploadId: null, + 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({ + publicId, + telegramFileId: forwardResult.telegramFileId, + telegramFileUniqueId: forwardResult.telegramFileUniqueId, + storageChatId, + storageMessageId: forwardResult.storageMessageId, + fileName: finalFileName, + mimeType, + sizeBytes: body.byteLength, + fileType: 'document', + uploaderId: 0, + fileHash: hash, + archiveTelegramFileId: null, + archiveStorageMessageId: null, + archiveFileName: null, + archiveEntryName: null, + archiveMimeType: null, + archiveSizeBytes: null, + bucketId: bucket.id, + s3Key: key, + storageBackend: 'telegram', + isDeleted: false, + multipartUploadId: null, + 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({ + publicId, + telegramFileId: sourceFile.telegramFileId, + telegramFileUniqueId: sourceFile.telegramFileUniqueId, + storageChatId: sourceFile.storageChatId, + storageMessageId: sourceFile.storageMessageId, + fileName: sourceFile.fileName, + mimeType: sourceFile.mimeType, + sizeBytes: sourceFile.sizeBytes, + fileType: sourceFile.fileType, + uploaderId: 0, + fileHash: sourceFile.fileHash, + 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', + isDeleted: false, + multipartUploadId: null, + 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); + }; +} \ No newline at end of file From 234ca7b14cb870f9f2082e22f358ee06f4b69f17 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 18:12:48 +0700 Subject: [PATCH 6/8] feat: rewire entry point to new architecture Co-Authored-By: Claude Opus 5 (1M context) --- src/config/index.ts | 3 + src/index.ts | 79 ++--------- src/infrastructure/cache/index.ts | 1 + src/{bot.ts => interfaces/bot/handler.ts} | 104 ++++++++++++--- src/interfaces/http/middleware/rate-limit.ts | 1 + src/interfaces/s3/auth.ts | 2 + src/interfaces/s3/virtual-host.ts | 1 + src/shared/logger/index.ts | 3 + src/shared/metrics/index.ts | 1 + test/bootstrap.test.ts | 5 +- test/bot.test.ts | 130 +++++++++---------- 11 files changed, 180 insertions(+), 150 deletions(-) create mode 100644 src/config/index.ts create mode 100644 src/infrastructure/cache/index.ts rename src/{bot.ts => interfaces/bot/handler.ts} (51%) create mode 100644 src/interfaces/http/middleware/rate-limit.ts create mode 100644 src/interfaces/s3/auth.ts create mode 100644 src/interfaces/s3/virtual-host.ts create mode 100644 src/shared/logger/index.ts create mode 100644 src/shared/metrics/index.ts diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..1381863 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,3 @@ +import { config } from '../env'; + +export { config }; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 9647305..9f00f84 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,21 +1,14 @@ import { serve } from 'bun'; -import { startBot } from './bot'; -import { config } from './env'; -import { handleLogin, handleLogout, handleMe } from './routes/auth'; -import { handleFileInfo, handleFileRedirect } from './routes/files'; -import { handleHealth } from './routes/health'; -import { handleHome } from './routes/home'; -import { handleS3Request } from './routes/s3'; -import { handleSwaggerHtml, handleSwaggerJson } from './routes/swagger'; -import { handleUpload } from './routes/upload'; -import { handleWebApiV1 } from './routes/web-api'; -import { requireAuth } from './utils/auth'; -import { fileInfoCache } from './utils/cache'; -import logger from './utils/logger'; -import { metricsCollector } from './utils/metrics'; -import { cleanupRateLimitCache, withRateLimit } from './utils/rateLimit'; -import { isS3Request } from './utils/s3/auth'; -import { extractS3BucketFromHost } from './utils/s3/virtual-host'; +import { config } from './config/index'; +import { startBot } from './interfaces/bot/handler'; +import { routes } from './interfaces/http/routes/index'; +import { isS3Request } from './interfaces/s3/auth'; +import { handleS3Request } from './interfaces/http/controllers/s3-controller'; +import { extractS3BucketFromHost } from './interfaces/s3/virtual-host'; +import { fileInfoCache } from './infrastructure/cache/index'; +import { cleanupRateLimitCache } from './interfaces/http/middleware/rate-limit'; +import { logger } from './shared/logger/index'; +import { metricsCollector } from './shared/metrics/index'; // ─── Auto-run migration at startup ────────────────────────────────────────── try { @@ -58,55 +51,7 @@ const handleMaybeS3Root = (req: Request): Response | Promise => { const server = serve({ port: config.port, - routes: { - '/api/upload': { - POST: withRateLimit(handleUpload), - }, - '/f/:public_id': { - GET: withRateLimit(handleFileRedirect), - }, - '/file/:public_id/info': { - GET: withRateLimit(handleFileInfo), - }, - '/health': { - GET: handleHealth, - }, - '/docs': { - GET: handleSwaggerHtml, - }, - '/swagger.json': { - GET: handleSwaggerJson, - }, - '/': { - GET: (req: Request) => { - const headers = Object.fromEntries(req.headers); - if (shouldHandleS3(req, headers)) { - return handleS3Request(req, getS3RouteBucket(req)); - } - return handleHome(); - }, - PUT: handleMaybeS3Root, - HEAD: handleMaybeS3Root, - DELETE: handleMaybeS3Root, - POST: handleMaybeS3Root, - OPTIONS: handleMaybeS3Root, - }, - '/api/v1/auth/login': { - POST: withRateLimit(handleLogin), - }, - '/api/v1/auth/logout': { - POST: handleLogout, - }, - '/api/v1/auth/me': { - GET: handleMe, - }, - '/api/v1/*': { - GET: requireAuth(handleWebApiV1), - POST: requireAuth(handleWebApiV1), - DELETE: requireAuth(handleWebApiV1), - PUT: requireAuth(handleWebApiV1), - }, - }, + routes, fetch: async (req: Request) => { if (req.method === 'OPTIONS') { return handleS3Request(req, getS3RouteBucket(req)); @@ -163,4 +108,4 @@ setInterval( 5 * 60 * 1000, ); -logger.info('Application running successfully'); +logger.info('Application running successfully'); \ No newline at end of file diff --git a/src/infrastructure/cache/index.ts b/src/infrastructure/cache/index.ts new file mode 100644 index 0000000..5dcf929 --- /dev/null +++ b/src/infrastructure/cache/index.ts @@ -0,0 +1 @@ +export { fileInfoCache, Cache } from '../../utils/cache'; \ No newline at end of file diff --git a/src/bot.ts b/src/interfaces/bot/handler.ts similarity index 51% rename from src/bot.ts rename to src/interfaces/bot/handler.ts index ac1116a..cdaa252 100644 --- a/src/bot.ts +++ b/src/interfaces/bot/handler.ts @@ -1,29 +1,62 @@ import { nanoid } from 'nanoid'; import { type Context, Telegraf } from 'telegraf'; -import { db, files as fileSchema } from './db'; -import { findFileByUniqueId } from './db/files'; -import { config } from './env'; +import { config } from '../../env'; +import type { NewFile } from '../../domain/entities/file'; +import type { IFileRepository } from '../../domain/ports/file-repository'; +import type { ITelegramService } from '../../domain/ports/telegram-service'; +import { DrizzleFileRepository } from '../../infrastructure/persistence/repositories/file-repository'; +import { botPool } from '../../infrastructure/telegram/bot-pool'; import { detectFileType, extractFileFromMessage, getErrorMessage, getFileSizeLimit, type TelegramMediaMessage, -} from './utils/file'; -import logger from './utils/logger'; -import { forwardToStorage } from './utils/telegram'; +} from '../../shared/utils/file'; +import logger from '../../utils/logger'; +/** + * Minimal bot context shape used by the media event handler. + * + * Represents the subset of Telegraf's Context that the handler requires + * for processing incoming media messages. + */ type BotContext = { + /** The incoming media message with file attachments. */ message: TelegramMediaMessage; + /** The sender of the message. */ from: { id: number }; + /** The chat where the message was sent, if available. */ chat?: { id: number }; + /** + * Reply to the message with text. + * + * @param text - The reply text. + * @param extra - Optional reply parameters (e.g. reply_parameters for threading). + */ reply: (text: string, extra?: { reply_parameters: { message_id: number } }) => Promise; }; +/** + * Duck-typed object that exposes a Telegraf-style `on()` method + * for registering event handlers on multiple event types. + */ type MediaEventRegistrar = { + /** + * Register a handler for the given event types. + * + * @param events - Array of event type strings (e.g. "document", "photo"). + * @param handler - Async handler receiving the bot context. + */ on: (events: string[], handler: (ctx: BotContext) => Promise) => void; }; +/** + * Replies to a Telegram message with a download URL for the uploaded file. + * + * @param ctx - The bot context for the incoming message. + * @param publicId - The public identifier of the uploaded file. + */ const replyWithDownloadUrl = async (ctx: BotContext, publicId: string): Promise => { const url = `${config.baseUrl}/f/${publicId}`; await ctx.reply(`File berhasil diupload! 📎\n\nDownload: ${url}`, { @@ -31,7 +64,33 @@ const replyWithDownloadUrl = async (ctx: BotContext, publicId: string): Promise< }); }; -export const startBot = async (): Promise> => { +/** + * Start the Telegram bot and register message handlers. + * + * Creates a Telegraf instance, registers a `/start` command handler, + * logging middleware, and media event handlers for all supported file types. + * Incoming media files are deduplicated by their Telegram unique ID, + * forwarded to the storage channel, and persisted with a public download URL. + * + * @param deps - Optional external dependencies for testing or DI override. + * @param deps.telegramService - The Telegram service used to forward files to + * the storage channel. Defaults to the singleton BotPool instance. + * @param deps.fileRepo - The file repository used for deduplication queries + * and persisting new file records. Defaults to a new DrizzleFileRepository. + * @returns The launched Telegraf bot instance, suitable for graceful shutdown + * via `bot.stop(signal)`. + */ +export async function startBot( + deps: { + /** The Telegram service to forward files to storage. */ + telegramService?: ITelegramService; + /** The file repository for deduplication and persistence. */ + fileRepo?: IFileRepository; + } = {}, +): Promise> { + const telegramService = deps.telegramService ?? botPool; + const fileRepo = deps.fileRepo ?? new DrizzleFileRepository(); + try { const bot = new Telegraf(config.botToken); @@ -74,7 +133,7 @@ export const startBot = async (): Promise> => { return ctx.reply(`File size exceeds ${maxSize / (1024 * 1024)}MB limit`); } - const existing = await findFileByUniqueId(fileObj.file_unique_id); + const existing = await fileRepo.findByUniqueId(fileObj.file_unique_id); if (existing) { await replyWithDownloadUrl(ctx, existing.publicId); @@ -87,25 +146,36 @@ export const startBot = async (): Promise> => { return; } - const result = await forwardToStorage(file_id, fileName, fileType); + const result = await telegramService.forwardToStorage(file_id, fileName, fileType); const publicId = nanoid(); - const uploaded = { - publicId: publicId, + const uploaded: NewFile = { + publicId, telegramFileId: result.telegramFileId, telegramFileUniqueId: result.telegramFileUniqueId, storageChatId: config.storageChatId, storageMessageId: result.storageMessageId, - fileName: fileName, + fileName, mimeType: mime_type || 'application/octet-stream', sizeBytes: fileSize, - fileType: fileType, + fileType, uploaderId: ctx.from.id, - createdAt: new Date(), - updatedAt: new Date(), + 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, }; - await db.insert(fileSchema).values(uploaded); + await fileRepo.create(uploaded); await replyWithDownloadUrl(ctx, publicId); @@ -134,4 +204,4 @@ export const startBot = async (): Promise> => { logger.error('Failed to start bot', { error: getErrorMessage(error) }); throw error; } -}; +} diff --git a/src/interfaces/http/middleware/rate-limit.ts b/src/interfaces/http/middleware/rate-limit.ts new file mode 100644 index 0000000..790ea39 --- /dev/null +++ b/src/interfaces/http/middleware/rate-limit.ts @@ -0,0 +1 @@ +export { withRateLimit, cleanupRateLimitCache, checkRateLimit, clearRateLimitCache, getRateLimitStats } from '../../../utils/rateLimit'; \ No newline at end of file diff --git a/src/interfaces/s3/auth.ts b/src/interfaces/s3/auth.ts new file mode 100644 index 0000000..6a14c0f --- /dev/null +++ b/src/interfaces/s3/auth.ts @@ -0,0 +1,2 @@ +export { isS3Request, buildCanonicalQueryString, verifyPresignedUrl, verifySignature } from '../../utils/s3/auth'; +export type { SigV4Result, VerifyPresignedUrlInput } from '../../utils/s3/auth'; \ No newline at end of file diff --git a/src/interfaces/s3/virtual-host.ts b/src/interfaces/s3/virtual-host.ts new file mode 100644 index 0000000..d0ed1f1 --- /dev/null +++ b/src/interfaces/s3/virtual-host.ts @@ -0,0 +1 @@ +export { extractS3BucketFromHost } from '../../utils/s3/virtual-host'; \ No newline at end of file diff --git a/src/shared/logger/index.ts b/src/shared/logger/index.ts new file mode 100644 index 0000000..a657f2b --- /dev/null +++ b/src/shared/logger/index.ts @@ -0,0 +1,3 @@ +import logger from '../../utils/logger'; + +export { logger }; \ No newline at end of file diff --git a/src/shared/metrics/index.ts b/src/shared/metrics/index.ts new file mode 100644 index 0000000..8905300 --- /dev/null +++ b/src/shared/metrics/index.ts @@ -0,0 +1 @@ +export { metricsCollector, MetricsCollector } from '../../utils/metrics'; \ No newline at end of file diff --git a/test/bootstrap.test.ts b/test/bootstrap.test.ts index 08aa332..9a54266 100644 --- a/test/bootstrap.test.ts +++ b/test/bootstrap.test.ts @@ -36,7 +36,7 @@ const mockRequireAuth = mock( Response.json({ error: 'Unauthorized' }, { status: 401 }), ); -mock.module('../src/bot', () => ({ +mock.module('../src/interfaces/bot/handler', () => ({ startBot: mockStartBot, })); @@ -65,6 +65,9 @@ mock.module('../src/routes/auth', () => ({ mock.module('../src/utils/rateLimit', () => ({ cleanupRateLimitCache: mock(), + clearRateLimitCache: mock(), + checkRateLimit: mock(() => true), + getRateLimitStats: mock(() => ({})), withRateLimit: ( handler: (req: T) => Promise, ): ((req: T) => Promise) => handler, diff --git a/test/bot.test.ts b/test/bot.test.ts index 8382f03..36045e3 100644 --- a/test/bot.test.ts +++ b/test/bot.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; -import type { TelegramMediaMessage } from '../src/utils/file'; +import type { TelegramMediaMessage } from '../src/shared/utils/file'; import logger from '../src/utils/logger'; // Mock environment @@ -46,61 +46,43 @@ mock.module('telegraf', () => ({ Telegraf: MockTelegraf, })); -// Mock database -const mockInsert = mock(() => ({ - values: mock(() => Promise.resolve()), -})); -type ExistingFile = { - publicId: string; - telegramFileId: string; - telegramFileUniqueId: string; -}; - -const mockFindFileByUniqueId = mock((): Promise => Promise.resolve(null)); - -mock.module('../src/db/index', () => ({ - db: { - insert: mockInsert, - }, - files: {}, -})); - -mock.module('../src/db/files', () => ({ - findFileByUniqueId: mockFindFileByUniqueId, -})); - -// Mock forwardToStorage -const mockForwardToStorage = mock(() => - Promise.resolve({ - telegramFileId: 'stored_file_id', - telegramFileUniqueId: 'stored_unique_id', - storageMessageId: 9999, - }), -); -mock.module('../src/utils/telegram', () => ({ - forwardToStorage: mockForwardToStorage, -})); - const infoSpy = spyOn(logger, 'info'); const errorSpy = spyOn(logger, 'error'); describe('Telegram Bot Handler', () => { + let mockTelegramService: { forwardToStorage: ReturnType }; + let mockFileRepo: { findByUniqueId: ReturnType; create: ReturnType }; + beforeEach(() => { mockLaunch.mockClear(); mockCommand.mockClear(); mockOn.mockClear(); mockUse.mockClear(); - mockInsert.mockClear(); - mockFindFileByUniqueId.mockClear(); - mockFindFileByUniqueId.mockResolvedValue(null); - mockForwardToStorage.mockClear(); infoSpy.mockClear(); errorSpy.mockClear(); + + mockTelegramService = { + forwardToStorage: mock(() => + Promise.resolve({ + telegramFileId: 'stored_file_id', + telegramFileUniqueId: 'stored_unique_id', + storageMessageId: 9999, + }), + ), + }; + + mockFileRepo = { + findByUniqueId: mock((): Promise => Promise.resolve(null)), + create: mock(() => Promise.resolve()), + }; }); it('should initialize and launch the bot', async () => { - const { startBot } = await import('../src/bot'); - const bot = await startBot(); + const { startBot } = await import('../src/interfaces/bot/handler'); + const bot = await startBot({ + telegramService: mockTelegramService, + fileRepo: mockFileRepo, + }); expect(bot).toBeDefined(); expect(mockCommand).toHaveBeenCalledWith('start', expect.any(Function)); @@ -113,8 +95,11 @@ describe('Telegram Bot Handler', () => { }); it('should handle /start command', async () => { - const { startBot } = await import('../src/bot'); - await startBot(); + const { startBot } = await import('../src/interfaces/bot/handler'); + await startBot({ + telegramService: mockTelegramService, + fileRepo: mockFileRepo, + }); const startHandler = getStartHandler(); const replyMock = mock(() => Promise.resolve()); @@ -127,8 +112,11 @@ describe('Telegram Bot Handler', () => { }); it('should process document uploads and save to db', async () => { - const { startBot } = await import('../src/bot'); - await startBot(); + const { startBot } = await import('../src/interfaces/bot/handler'); + await startBot({ + telegramService: mockTelegramService, + fileRepo: mockFileRepo, + }); const fileHandler = getFileHandler(); const replyMock = mock(() => Promise.resolve()); @@ -150,8 +138,8 @@ describe('Telegram Bot Handler', () => { }; await fileHandler(ctx); - expect(mockForwardToStorage).toHaveBeenCalledWith('doc_123', 'cv.pdf', 'document'); - expect(mockInsert).toHaveBeenCalled(); + expect(mockTelegramService.forwardToStorage).toHaveBeenCalledWith('doc_123', 'cv.pdf', 'document'); + expect(mockFileRepo.create).toHaveBeenCalled(); expect(replyMock).toHaveBeenCalledWith( expect.stringContaining('File berhasil diupload'), expect.any(Object), @@ -159,8 +147,11 @@ describe('Telegram Bot Handler', () => { }); it('should reject uploads exceeding max size limit', async () => { - const { startBot } = await import('../src/bot'); - await startBot(); + const { startBot } = await import('../src/interfaces/bot/handler'); + await startBot({ + telegramService: mockTelegramService, + fileRepo: mockFileRepo, + }); const fileHandler = getFileHandler(); const replyMock = mock(() => Promise.resolve()); @@ -183,18 +174,21 @@ describe('Telegram Bot Handler', () => { }; await fileHandler(ctx); - expect(mockForwardToStorage).not.toHaveBeenCalled(); + expect(mockTelegramService.forwardToStorage).not.toHaveBeenCalled(); expect(replyMock).toHaveBeenCalledWith(expect.stringContaining('exceeds')); }); it('should return existing download link for duplicates without uploading again', async () => { - const { startBot } = await import('../src/bot'); - await startBot(); + const { startBot } = await import('../src/interfaces/bot/handler'); + await startBot({ + telegramService: mockTelegramService, + fileRepo: mockFileRepo, + }); const fileHandler = getFileHandler(); const replyMock = mock(() => Promise.resolve()); - mockFindFileByUniqueId.mockResolvedValueOnce({ + mockFileRepo.findByUniqueId.mockResolvedValueOnce({ publicId: 'already_exists_abc', telegramFileId: 'stored_file_id', telegramFileUniqueId: 'doc_uniq_123', @@ -218,8 +212,8 @@ describe('Telegram Bot Handler', () => { }; await fileHandler(ctx); - expect(mockForwardToStorage).not.toHaveBeenCalled(); - expect(mockInsert).not.toHaveBeenCalled(); + expect(mockTelegramService.forwardToStorage).not.toHaveBeenCalled(); + expect(mockFileRepo.create).not.toHaveBeenCalled(); expect(replyMock).toHaveBeenCalledWith( expect.stringContaining('already_exists_abc'), expect.any(Object), @@ -227,8 +221,11 @@ describe('Telegram Bot Handler', () => { }); it('should process sticker uploads', async () => { - const { startBot } = await import('../src/bot'); - await startBot(); + const { startBot } = await import('../src/interfaces/bot/handler'); + await startBot({ + telegramService: mockTelegramService, + fileRepo: mockFileRepo, + }); const fileHandler = getFileHandler(); const replyMock = mock(() => Promise.resolve()); @@ -248,8 +245,8 @@ describe('Telegram Bot Handler', () => { }; await fileHandler(ctx); - expect(mockForwardToStorage).toHaveBeenCalledWith('sticker_123', 'file', 'sticker'); - expect(mockInsert).toHaveBeenCalled(); + expect(mockTelegramService.forwardToStorage).toHaveBeenCalledWith('sticker_123', 'file', 'sticker'); + expect(mockFileRepo.create).toHaveBeenCalled(); expect(replyMock).toHaveBeenCalledWith( expect.stringContaining('File berhasil diupload'), expect.any(Object), @@ -257,8 +254,11 @@ describe('Telegram Bot Handler', () => { }); it('should process video note uploads', async () => { - const { startBot } = await import('../src/bot'); - await startBot(); + const { startBot } = await import('../src/interfaces/bot/handler'); + await startBot({ + telegramService: mockTelegramService, + fileRepo: mockFileRepo, + }); const fileHandler = getFileHandler(); const replyMock = mock(() => Promise.resolve()); @@ -278,8 +278,8 @@ describe('Telegram Bot Handler', () => { }; await fileHandler(ctx); - expect(mockForwardToStorage).toHaveBeenCalledWith('video_note_123', 'file', 'video_note'); - expect(mockInsert).toHaveBeenCalled(); + expect(mockTelegramService.forwardToStorage).toHaveBeenCalledWith('video_note_123', 'file', 'video_note'); + expect(mockFileRepo.create).toHaveBeenCalled(); expect(replyMock).toHaveBeenCalledWith( expect.stringContaining('File berhasil diupload'), expect.any(Object), @@ -289,4 +289,4 @@ describe('Telegram Bot Handler', () => { afterAll(() => { mock.restore(); }); -}); +}); \ No newline at end of file From 4568644922433395d11f32e79a0f408b30b0d680 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 18:19:45 +0700 Subject: [PATCH 7/8] fix: correct import paths and add missing protocol files for DDD structure Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/entities/bucket.ts | 14 + src/domain/entities/file-part.ts | 43 + src/domain/entities/file.ts | 64 + src/domain/entities/multipart.ts | 43 + src/infrastructure/cache/index.ts | 49 +- .../persistence/drizzle/index.ts | 16 + .../persistence/drizzle/migrate.ts | 56 + .../persistence/drizzle/schema.ts | 77 + .../repositories/bucket-repository.ts | 100 ++ .../repositories/file-part-repository.ts | 106 ++ .../repositories/file-repository.ts | 241 +++ .../repositories/multipart-repository.ts | 149 ++ src/infrastructure/telegram/bot-pool.ts | 213 +++ .../telegram/chunked-storage.ts | 336 ++++ src/infrastructure/telegram/types.ts | 130 ++ src/infrastructure/telegram/upload-batcher.ts | 238 +++ src/infrastructure/telegram/upload-queue.ts | 81 + src/interfaces/bot/handler.ts | 2 +- .../http/controllers/auth-controller.ts | 151 ++ .../http/controllers/file-controller.ts | 215 +++ .../http/controllers/health-controller.ts | 25 + .../http/controllers/home-controller.ts | 19 + src/interfaces/http/controllers/home.html | 338 ++++ .../http/controllers/s3-controller.ts | 1469 +++++++++++++++++ .../http/controllers/upload-controller.ts | 402 +++++ .../http/controllers/web-api-controller.ts | 438 +++++ src/interfaces/http/middleware/auth.ts | 357 ++++ src/interfaces/http/routes/index.ts | 121 ++ src/interfaces/s3/headers.ts | 44 + src/interfaces/s3/object-stream.ts | 130 ++ src/interfaces/s3/range.ts | 46 + src/interfaces/s3/xml.ts | 309 ++++ src/shared/errors/index.ts | 73 + src/shared/logger/index.ts | 7 +- 34 files changed, 6097 insertions(+), 5 deletions(-) create mode 100644 src/domain/entities/bucket.ts create mode 100644 src/domain/entities/file-part.ts create mode 100644 src/domain/entities/file.ts create mode 100644 src/domain/entities/multipart.ts create mode 100644 src/infrastructure/persistence/drizzle/index.ts create mode 100644 src/infrastructure/persistence/drizzle/migrate.ts create mode 100644 src/infrastructure/persistence/drizzle/schema.ts create mode 100644 src/infrastructure/persistence/repositories/bucket-repository.ts create mode 100644 src/infrastructure/persistence/repositories/file-part-repository.ts create mode 100644 src/infrastructure/persistence/repositories/file-repository.ts create mode 100644 src/infrastructure/persistence/repositories/multipart-repository.ts create mode 100644 src/infrastructure/telegram/bot-pool.ts create mode 100644 src/infrastructure/telegram/chunked-storage.ts create mode 100644 src/infrastructure/telegram/types.ts create mode 100644 src/infrastructure/telegram/upload-batcher.ts create mode 100644 src/infrastructure/telegram/upload-queue.ts create mode 100644 src/interfaces/http/controllers/auth-controller.ts create mode 100644 src/interfaces/http/controllers/file-controller.ts create mode 100644 src/interfaces/http/controllers/health-controller.ts create mode 100644 src/interfaces/http/controllers/home-controller.ts create mode 100644 src/interfaces/http/controllers/home.html create mode 100644 src/interfaces/http/controllers/s3-controller.ts create mode 100644 src/interfaces/http/controllers/upload-controller.ts create mode 100644 src/interfaces/http/controllers/web-api-controller.ts create mode 100644 src/interfaces/http/middleware/auth.ts create mode 100644 src/interfaces/http/routes/index.ts create mode 100644 src/interfaces/s3/headers.ts create mode 100644 src/interfaces/s3/object-stream.ts create mode 100644 src/interfaces/s3/range.ts create mode 100644 src/interfaces/s3/xml.ts create mode 100644 src/shared/errors/index.ts diff --git a/src/domain/entities/bucket.ts b/src/domain/entities/bucket.ts new file mode 100644 index 0000000..5ea1644 --- /dev/null +++ b/src/domain/entities/bucket.ts @@ -0,0 +1,14 @@ +/** + * Core domain entity representing an S3-compatible storage bucket. + * Buckets group objects for the S3-compatible API layer. + */ +export interface Bucket { + /** Primary key, UUID */ + id: string; + /** Bucket name (unique, max 63 chars, S3 naming convention) */ + name: string; + /** Record creation timestamp */ + createdAt: Date; + /** Record last-updated timestamp */ + updatedAt: Date; +} diff --git a/src/domain/entities/file-part.ts b/src/domain/entities/file-part.ts new file mode 100644 index 0000000..6cf520b --- /dev/null +++ b/src/domain/entities/file-part.ts @@ -0,0 +1,43 @@ +/** + * Supported compression algorithms for stored file parts. + * - `"gzip"`: Gzip compression was applied + * - `null`: No compression applied + */ +export type CompressionAlgorithm = 'gzip' | null; + +/** + * Core domain entity representing a chunk (part) of a file stored in Telegram. + * Large files are split into multiple parts for Telegram-safe storage. + */ +export interface FilePart { + /** Primary key, auto-increment */ + id: number; + /** Foreign key to the parent File record (UUID) */ + fileId: string; + /** Sequential part number (1-based within the file) */ + partNumber: number; + /** Telegram file_id for retrieving this part */ + telegramFileId: string; + /** Telegram unique file_id (stable across bot tokens) */ + telegramFileUniqueId: string; + /** Chat ID where this part is stored */ + storageChatId: number; + /** Message ID within the storage chat */ + storageMessageId: number; + /** Original size of this part in bytes */ + sizeBytes: number; + /** Stored (post-compression) size in bytes */ + storedSizeBytes: number; + /** Compression algorithm applied, or null if uncompressed */ + compressionAlgorithm: CompressionAlgorithm; + /** ETag for this part (hash of the stored content) */ + etag: string; + /** Record creation timestamp */ + createdAt: Date; +} + +/** + * Input type for creating a new FilePart record. + * Omits auto-generated fields (id, createdAt). + */ +export type NewFilePart = Omit; diff --git a/src/domain/entities/file.ts b/src/domain/entities/file.ts new file mode 100644 index 0000000..c49dde3 --- /dev/null +++ b/src/domain/entities/file.ts @@ -0,0 +1,64 @@ +/** + * Core domain entity representing a file stored in Telegram. + * Contains both Telegram metadata and optional S3-compatible fields. + */ +export interface File { + /** Primary key, UUID */ + id: string; + /** Public-facing unique identifier (short, URL-safe) */ + publicId: string; + /** Telegram file_id for retrieving the file */ + telegramFileId: string; + /** Telegram unique file_id (stable across bot tokens) */ + telegramFileUniqueId: string; + /** Chat ID where the file is stored */ + storageChatId: number; + /** Message ID within the storage chat */ + storageMessageId: number; + /** Original file name */ + fileName: string; + /** MIME type of the file */ + mimeType: string; + /** File size in bytes */ + sizeBytes: number; + /** File type classification (e.g. "photo", "document", "video") */ + fileType: string; + /** Telegram user ID of the uploader */ + uploaderId: number; + /** SHA-256 hash of file contents, or null */ + fileHash: string | null; + /** Telegram file_id of the archive (zip) containing this file, or null */ + archiveTelegramFileId: string | null; + /** Message ID of the archive message, or null */ + archiveStorageMessageId: number | null; + /** File name within the archive, or null */ + archiveFileName: string | null; + /** Entry name/path within the archive, or null */ + archiveEntryName: string | null; + /** MIME type of the archive entry, or null */ + archiveMimeType: string | null; + /** Size of the archive entry in bytes, or null */ + archiveSizeBytes: number | null; + /** S3 bucket ID if stored via S3-compatible API, or null */ + bucketId: string | null; + /** S3 object key if stored via S3-compatible API, or null */ + s3Key: string | null; + /** Storage backend identifier, defaults to "telegram" */ + storageBackend: string | null; + /** Soft-delete flag */ + isDeleted: boolean | null; + /** S3 multipart upload ID if uploaded in parts, or null */ + multipartUploadId: string | null; + /** Number of file_parts for chunked storage, or null */ + partCount: number | null; + /** Record creation timestamp */ + createdAt: Date; + /** Record last-updated timestamp */ + updatedAt: Date; +} + +/** + * Input type for creating a new File record. + * Omits auto-generated fields (id, createdAt, updatedAt). + */ +export type NewFile = Omit; diff --git a/src/domain/entities/multipart.ts b/src/domain/entities/multipart.ts new file mode 100644 index 0000000..d143fd4 --- /dev/null +++ b/src/domain/entities/multipart.ts @@ -0,0 +1,43 @@ +/** + * Core domain entity representing an S3 multipart upload session. + * Tracks in-progress multipart uploads within a bucket. + */ +export interface MultipartUpload { + /** Unique upload identifier (nanoid) */ + uploadId: string; + /** Foreign key to the parent Bucket (UUID) */ + bucketId: string; + /** S3 object key being uploaded */ + s3Key: string; + /** Timestamp when the upload was initiated */ + initiatedAt: Date; + /** Upload status: "in_progress", "completed", or "aborted" */ + status: string; + /** Identifier of the entity that initiated the upload */ + initiatedBy: string; +} + +/** + * Core domain entity representing an individual part of an S3 multipart upload. + * Each part is stored as a separate Telegram message. + */ +export interface MultipartPart { + /** Primary key, auto-increment */ + id: number; + /** Foreign key to the parent MultipartUpload */ + uploadId: string; + /** Sequential part number (1-based within the upload) */ + partNumber: number; + /** Telegram file_id for retrieving this part */ + telegramFileId: string; + /** Telegram unique file_id (stable across bot tokens) */ + telegramFileUniqueId: string; + /** Message ID within the storage chat */ + storageMessageId: number; + /** Part size in bytes */ + sizeBytes: number; + /** ETag for this part */ + etag: string; + /** Record creation timestamp */ + createdAt: Date; +} diff --git a/src/infrastructure/cache/index.ts b/src/infrastructure/cache/index.ts index 5dcf929..c6e9914 100644 --- a/src/infrastructure/cache/index.ts +++ b/src/infrastructure/cache/index.ts @@ -1 +1,48 @@ -export { fileInfoCache, Cache } from '../../utils/cache'; \ No newline at end of file +/** + * Generic in-memory cache with TTL (time-to-live) support. + * Entries expire after a configurable duration and are lazily evicted on access. + * + * @typeParam T - The type of values stored in the cache + */ +export class Cache { + private store = new Map>(); + private ttlMs: number; + + constructor(ttlSeconds = 3600) { + this.ttlMs = ttlSeconds * 1000; + } + + set(key: string, value: T): void { + this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs }); + } + + get(key: string): T | null { + const entry = this.store.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + return null; + } + return entry.value; + } + + has(key: string): boolean { return this.get(key) !== null; } + delete(key: string): void { this.store.delete(key); } + clear(): void { this.store.clear(); } + size(): number { return this.store.size; } + + cleanup(): number { + let removed = 0; + const now = Date.now(); + for (const [key, entry] of this.store.entries()) { + if (now > entry.expiresAt) { this.store.delete(key); removed++; } + } + return removed; + } +} + +interface CacheEntry { value: T; expiresAt: number } + +export const fileInfoCache = new Cache<{ + file_size: number; mime_type: string; file_path: string; bot_token: string; +}>(3600); \ No newline at end of file diff --git a/src/infrastructure/persistence/drizzle/index.ts b/src/infrastructure/persistence/drizzle/index.ts new file mode 100644 index 0000000..315f6f0 --- /dev/null +++ b/src/infrastructure/persistence/drizzle/index.ts @@ -0,0 +1,16 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import { fileParts, files } from './schema'; + +const client = postgres(process.env.DATABASE_URL!, { + max: 10, + idle_timeout: 20, + connect_timeout: 10, +}); + +/** Drizzle ORM database client initialized with the files and fileParts schema. */ +export const db = drizzle(client, { schema: { fileParts, files } }); + +export { fileParts, files }; + +export default db; diff --git a/src/infrastructure/persistence/drizzle/migrate.ts b/src/infrastructure/persistence/drizzle/migrate.ts new file mode 100644 index 0000000..19493dc --- /dev/null +++ b/src/infrastructure/persistence/drizzle/migrate.ts @@ -0,0 +1,56 @@ +import postgres from 'postgres'; +import { config } from '../../../env'; +import { getErrorMessage } from '../../../shared/utils/file'; +import logger from '../../../shared/logger/index'; + +/** + * Run raw SQL migration from schema.sql. + * Safe to call multiple times — all statements use IF NOT EXISTS. + * Searches multiple relative paths to support execution from compiled dist, + * bun --hot, or direct script invocation. + */ +export const runMigration = async (): Promise => { + // In compiled dist: import.meta.dir = .../dist/infrastructure/persistence/drizzle/ + // In source via bun --hot: import.meta.dir = .../src/infrastructure/persistence/drizzle/ + const dir = import.meta.dir || ''; + const candidates = [ + `${dir}/../../../../schema.sql`, // from dist/ + `${dir}/../../../schema.sql`, // from src/infrastructure/persistence/ + `${dir}/../../schema.sql`, // from src/infrastructure/ + `${dir}/../schema.sql`, // from src/infrastructure/persistence/drizzle/ + `${dir}/schema.sql`, // from next to file (bun run directly) + ]; + + let schemaSql: string | null = null; + for (const p of candidates) { + const file = Bun.file(p); + const exists = await file.exists(); + if (exists) { + schemaSql = await file.text(); + break; + } + } + + if (!schemaSql) { + logger.error(`Migration failed: schema.sql not found (tried ${candidates.join(', ')})`); + process.exitCode = 1; + return; + } + + const sql = postgres(config.databaseUrl, { max: 1 }); + + try { + await sql.unsafe(schemaSql); + logger.info('Database migration completed'); + } catch (error: unknown) { + logger.error('Database migration failed', { error: getErrorMessage(error) }); + process.exitCode = 1; + } finally { + await sql.end(); + } +}; + +// When run directly: `bun src/infrastructure/persistence/drizzle/migrate.ts` +if (import.meta.path === Bun.main) { + await runMigration(); +} diff --git a/src/infrastructure/persistence/drizzle/schema.ts b/src/infrastructure/persistence/drizzle/schema.ts new file mode 100644 index 0000000..d7b3e58 --- /dev/null +++ b/src/infrastructure/persistence/drizzle/schema.ts @@ -0,0 +1,77 @@ +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; +import { + bigint, + boolean, + integer, + pgTable, + serial, + text, + timestamp, + uuid, +} from 'drizzle-orm/pg-core'; + +/** + * Files table definition. + * Stores metadata about uploaded files including Telegram storage references, + * S3 bucket information, multipart upload tracking, and archive entries. + */ +export const files = pgTable('files', { + id: uuid('id').primaryKey().defaultRandom(), + publicId: text('public_id').unique().notNull(), + telegramFileId: text('telegram_file_id').notNull(), + telegramFileUniqueId: text('telegram_file_unique_id').notNull(), + storageChatId: bigint('storage_chat_id', { mode: 'number' }).notNull(), + storageMessageId: bigint('storage_message_id', { mode: 'number' }).notNull(), + fileName: text('file_name').notNull(), + mimeType: text('mime_type').notNull(), + sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(), + fileType: text('file_type').notNull(), + uploaderId: bigint('uploader_id', { mode: 'number' }).notNull(), + fileHash: text('file_hash'), + archiveTelegramFileId: text('archive_telegram_file_id'), + archiveStorageMessageId: bigint('archive_storage_message_id', { mode: 'number' }), + archiveFileName: text('archive_file_name'), + archiveEntryName: text('archive_entry_name'), + archiveMimeType: text('archive_mime_type'), + archiveSizeBytes: bigint('archive_size_bytes', { mode: 'number' }), + bucketId: text('bucket_id'), + s3Key: text('s3_key'), + storageBackend: text('storage_backend').default('telegram'), + isDeleted: boolean('is_deleted').default(false), + multipartUploadId: text('multipart_upload_id'), + partCount: integer('part_count'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +/** + * File parts table definition. + * Stores chunks of multipart uploads with per-part Telegram storage references + * and compression metadata. + */ +export const fileParts = pgTable('file_parts', { + id: serial('id').primaryKey(), + fileId: uuid('file_id').notNull(), + partNumber: integer('part_number').notNull(), + telegramFileId: text('telegram_file_id').notNull(), + telegramFileUniqueId: text('telegram_file_unique_id').notNull(), + storageChatId: bigint('storage_chat_id', { mode: 'number' }).notNull(), + storageMessageId: bigint('storage_message_id', { mode: 'number' }).notNull(), + sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(), + storedSizeBytes: bigint('stored_size_bytes', { mode: 'number' }).notNull(), + compressionAlgorithm: text('compression_algorithm'), + etag: text('etag').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +/** Type representing a file row selected from the database. */ +export type File = InferSelectModel; + +/** Type representing a file row being inserted into the database. */ +export type NewFile = InferInsertModel; + +/** Type representing a file part row selected from the database. */ +export type FilePart = InferSelectModel; + +/** Type representing a file part row being inserted into the database. */ +export type NewFilePart = InferInsertModel; diff --git a/src/infrastructure/persistence/repositories/bucket-repository.ts b/src/infrastructure/persistence/repositories/bucket-repository.ts new file mode 100644 index 0000000..1d422da --- /dev/null +++ b/src/infrastructure/persistence/repositories/bucket-repository.ts @@ -0,0 +1,100 @@ +import { sql } from 'drizzle-orm'; +import { db } from '../drizzle/index'; +import type { Bucket } from '../../../domain/entities/bucket'; +import type { IBucketRepository } from '../../../domain/ports/bucket-repository'; + +/** Raw result row from `db.execute()`. */ +type QueryRow = Record; +/** Array of raw result rows. */ +type QueryResult = QueryRow[]; + +/** + * Maps a raw database row to a {@link Bucket} domain entity. + */ +const mapRowToBucket = (row: Record): Bucket => ({ + id: row.id as string, + name: row.name as string, + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), +}); + +/** + * Drizzle-backed implementation of {@link IBucketRepository}. + * + * Delegates to the same SQL queries as the original `src/db/buckets.ts` + * module, using raw SQL for drizzle tables that are not part of the + * typed schema. + */ +export class DrizzleBucketRepository implements IBucketRepository { + /** + * {@inheritDoc IBucketRepository.create} + */ + async create(name: string): Promise { + const result = (await db.execute( + sql`INSERT INTO buckets (name) VALUES (${name}) RETURNING id, name, created_at, updated_at`, + )) as unknown as QueryResult; + return mapRowToBucket(result[0]!); + } + + /** + * {@inheritDoc IBucketRepository.findByName} + */ + async findByName(name: string): Promise { + const result = (await db.execute( + sql`SELECT id, name, created_at, updated_at FROM buckets WHERE name = ${name}`, + )) as unknown as QueryResult; + if (result.length === 0) return null; + return mapRowToBucket(result[0]!); + } + + /** + * {@inheritDoc IBucketRepository.list} + */ + async list(): Promise { + const result = (await db.execute( + sql`SELECT id, name, created_at, updated_at FROM buckets ORDER BY name`, + )) as unknown as QueryResult; + return result.map(mapRowToBucket); + } + + /** + * {@inheritDoc IBucketRepository.delete} + * + * Cascade-deletes multipart and file rows that hold foreign-key + * references to the bucket before deleting the bucket itself. + * Failures during cascade are silently caught to match the original + * defensive-cleanup behaviour. + */ + async delete(name: string): Promise { + // Cascade-delete rows that hold FK references to the bucket + await db + .execute( + sql`DELETE FROM multipart_parts WHERE upload_id IN (SELECT upload_id FROM multipart_uploads WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name}))`, + ) + .catch(() => {}); + await db + .execute( + sql`DELETE FROM multipart_uploads WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`, + ) + .catch(() => {}); + await db + .execute( + sql`DELETE FROM files WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`, + ) + .catch(() => {}); + const result = (await db.execute( + sql`DELETE FROM buckets WHERE name = ${name}`, + )) as unknown as QueryResult; + return result.length > 0; + } + + /** + * {@inheritDoc IBucketRepository.exists} + */ + async exists(name: string): Promise { + const result = (await db.execute( + sql`SELECT 1 FROM buckets WHERE name = ${name}`, + )) as unknown as QueryResult; + return result.length > 0; + } +} diff --git a/src/infrastructure/persistence/repositories/file-part-repository.ts b/src/infrastructure/persistence/repositories/file-part-repository.ts new file mode 100644 index 0000000..0545b4f --- /dev/null +++ b/src/infrastructure/persistence/repositories/file-part-repository.ts @@ -0,0 +1,106 @@ +import { sql } from 'drizzle-orm'; +import { db } from '../drizzle/index'; +import type { FilePart, NewFilePart } from '../../../domain/entities/file-part'; +import type { IFilePartRepository } from '../../../domain/ports/file-part-repository'; + +/** Compression algorithm type matching the domain entity. */ +type CompressionAlgorithm = 'gzip' | null; + +/** Safely converts a raw value to a number, defaulting to 0. */ +const toNumber = (value: unknown): number => Number(value ?? 0); + +/** + * Maps a raw database row (snake_case keys) to a {@link FilePart} + * domain entity. + */ +const mapRowToFilePart = (row: Record): FilePart => ({ + id: toNumber(row.id), + fileId: row.file_id as string, + partNumber: toNumber(row.part_number), + telegramFileId: row.telegram_file_id as string, + telegramFileUniqueId: row.telegram_file_unique_id as string, + storageChatId: toNumber(row.storage_chat_id), + storageMessageId: toNumber(row.storage_message_id), + sizeBytes: toNumber(row.size_bytes), + storedSizeBytes: toNumber(row.stored_size_bytes), + compressionAlgorithm: + (row.compression_algorithm as CompressionAlgorithm) || null, + etag: row.etag as string, + createdAt: new Date(row.created_at as string), +}); + +/** + * Drizzle-backed implementation of {@link IFilePartRepository}. + * + * Delegates to the same SQL queries as the original `src/db/file-parts.ts` + * module, using raw SQL for all operations. + */ +export class DrizzleFilePartRepository implements IFilePartRepository { + /** + * {@inheritDoc IFilePartRepository.insert} + */ + async insert(parts: NewFilePart[]): Promise { + for (const part of parts) { + await db.execute( + sql`INSERT INTO file_parts ( + file_id, + part_number, + telegram_file_id, + telegram_file_unique_id, + storage_chat_id, + storage_message_id, + size_bytes, + stored_size_bytes, + compression_algorithm, + etag + ) VALUES ( + ${part.fileId}::uuid, + ${part.partNumber}, + ${part.telegramFileId}, + ${part.telegramFileUniqueId}, + ${part.storageChatId}, + ${part.storageMessageId}, + ${part.sizeBytes}, + ${part.storedSizeBytes}, + ${part.compressionAlgorithm}, + ${part.etag} + )`, + ); + } + } + + /** + * {@inheritDoc IFilePartRepository.listByFileId} + */ + async listByFileId(fileId: string): Promise { + const result = (await db.execute( + sql`SELECT id, + file_id, + part_number, + telegram_file_id, + telegram_file_unique_id, + storage_chat_id, + storage_message_id, + size_bytes, + stored_size_bytes, + compression_algorithm, + etag, + created_at + FROM file_parts + WHERE file_id = ${fileId}::uuid + ORDER BY part_number`, + )) as unknown as Record[]; + + return result.map(mapRowToFilePart); + } + + /** + * {@inheritDoc IFilePartRepository.countByFileId} + */ + async countByFileId(fileId: string): Promise { + const result = (await db.execute( + sql`SELECT COUNT(*) AS count FROM file_parts WHERE file_id = ${fileId}::uuid`, + )) as unknown as Record[]; + return toNumber(result[0]?.count); + } +} diff --git a/src/infrastructure/persistence/repositories/file-repository.ts b/src/infrastructure/persistence/repositories/file-repository.ts new file mode 100644 index 0000000..a9cce3d --- /dev/null +++ b/src/infrastructure/persistence/repositories/file-repository.ts @@ -0,0 +1,241 @@ +import { and, eq, sql } from 'drizzle-orm'; +import { db, files as fileSchema } from '../drizzle/index'; +import type { File, NewFile } from '../../../domain/entities/file'; +import type { + IFileRepository, + S3FileRecord, +} from '../../../domain/ports/file-repository'; + +/** Safely converts a raw value to a number, defaulting to 0. */ +const toNumber = (value: unknown): number => Number(value ?? 0); + +/** + * Escape special LIKE wildcard characters (`%`, `_`, `\`) so that + * a user-supplied prefix can be safely used in a LIKE expression. + */ +const escapeLike = (s: string): string => s.replace(/[%_\\]/g, '\\$&'); + +/** + * Maps a raw database row (snake_case keys) to an {@link S3FileRecord} + * domain entity. Used only when raw SQL via `db.execute()` returns + * un-typed result sets. + */ +const mapDbRowToS3Record = (row: Record): S3FileRecord => ({ + id: row.id as string, + publicId: row.public_id as string, + telegramFileId: row.telegram_file_id as string, + telegramFileUniqueId: row.telegram_file_unique_id as string, + storageChatId: toNumber(row.storage_chat_id), + storageMessageId: toNumber(row.storage_message_id), + fileName: row.file_name as string, + mimeType: row.mime_type as string, + sizeBytes: toNumber(row.size_bytes), + fileType: row.file_type as string, + uploaderId: toNumber(row.uploader_id), + fileHash: row.file_hash as string | null, + archiveTelegramFileId: row.archive_telegram_file_id as string | null, + archiveStorageMessageId: + row.archive_storage_message_id === null + ? null + : toNumber(row.archive_storage_message_id), + archiveFileName: row.archive_file_name as string | null, + archiveEntryName: row.archive_entry_name as string | null, + archiveMimeType: row.archive_mime_type as string | null, + archiveSizeBytes: + row.archive_size_bytes === null + ? null + : toNumber(row.archive_size_bytes), + bucketId: row.bucket_id as string, + s3Key: row.s3_key as string, + storageBackend: (row.storage_backend as string) || 'telegram', + isDeleted: row.is_deleted as boolean, + multipartUploadId: row.multipart_upload_id as string | null, + partCount: + row.part_count === null || row.part_count === undefined + ? null + : toNumber(row.part_count), + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), +}); + +/** + * Drizzle-backed implementation of {@link IFileRepository}. + * + * Delegates to the same SQL queries as the original `src/db/files.ts` and + * `src/db/files-ext.ts` modules while presenting a clean domain interface. + */ +export class DrizzleFileRepository implements IFileRepository { + /** + * {@inheritDoc IFileRepository.findByHash} + */ + async findByHash(hash: string): Promise { + const result = await db + .select() + .from(fileSchema) + .where(eq(fileSchema.fileHash, hash)) + .limit(1); + return result[0] || null; + } + + /** + * {@inheritDoc IFileRepository.findByPublicId} + */ + async findByPublicId(publicId: string): Promise { + const result = await db + .select() + .from(fileSchema) + .where(eq(fileSchema.publicId, publicId)) + .limit(1); + return result[0] || null; + } + + /** + * {@inheritDoc IFileRepository.findByUniqueId} + */ + async findByUniqueId(telegramFileUniqueId: string): Promise { + const result = await db + .select() + .from(fileSchema) + .where(eq(fileSchema.telegramFileUniqueId, telegramFileUniqueId)) + .limit(1); + return result[0] || null; + } + + /** + * {@inheritDoc IFileRepository.findByBucketAndKey} + */ + async findByBucketAndKey( + bucketId: string, + s3Key: string, + ): Promise { + const result = await db + .select() + .from(fileSchema) + .where( + and( + eq(fileSchema.bucketId, bucketId), + eq(fileSchema.s3Key, s3Key), + eq(fileSchema.isDeleted, false), + ), + ) + .limit(1); + return result[0] || null; + } + + /** + * {@inheritDoc IFileRepository.create} + */ + async create(file: NewFile): Promise { + const result = await db + .insert(fileSchema) + .values(file) + .returning(); + return result[0]!; + } + + /** + * {@inheritDoc IFileRepository.listByPrefix} + */ + async listByPrefix( + bucketId: string, + prefix: string, + delimiter: string | null, + maxKeys: number, + startAfter: string | null, + ): Promise<{ objects: S3FileRecord[]; prefixes: string[] }> { + let query = prefix + ? sql`SELECT * FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false AND s3_key LIKE ${`${escapeLike(prefix)}%`}` + : sql`SELECT * FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false`; + + if (startAfter) { + query = sql`${query} AND s3_key > ${startAfter}`; + } + + query = sql`${query} ORDER BY s3_key LIMIT ${maxKeys + 1}`; + + const rawResult = (await db.execute( + query, + )) as unknown as Record[]; + + if (delimiter === '/') { + const prefixSet = new Set(); + const objects: S3FileRecord[] = []; + + for (const row of rawResult) { + const s3Key = row.s3_key as string; + const relativeKey = s3Key.substring(prefix.length); + const slashIndex = relativeKey.indexOf('/'); + if (slashIndex >= 0) { + const folderPrefix = + prefix + relativeKey.substring(0, slashIndex + 1); + if (folderPrefix !== prefix) { + prefixSet.add(folderPrefix); + } + } else { + objects.push(mapDbRowToS3Record(row)); + } + } + + return { + objects: objects.slice(0, maxKeys), + prefixes: Array.from(prefixSet).sort(), + }; + } + + return { + objects: rawResult.slice(0, maxKeys).map(mapDbRowToS3Record), + prefixes: [], + }; + } + + /** + * {@inheritDoc IFileRepository.softDelete} + */ + async softDelete(bucketId: string, s3Key: string): Promise { + const result = (await db.execute( + sql`UPDATE files SET is_deleted = true WHERE bucket_id = ${bucketId}::uuid AND s3_key = ${s3Key} RETURNING id`, + )) as unknown as Record[]; + return result.length > 0; + } + + /** + * {@inheritDoc IFileRepository.softDeleteBatch} + */ + async softDeleteBatch( + bucketId: string, + keys: string[], + ): Promise { + let deleted = 0; + for (const key of keys) { + const ok = await this.softDelete(bucketId, key); + if (ok) deleted++; + } + return deleted; + } + + /** + * {@inheritDoc IFileRepository.countByBucket} + */ + async countByBucket(bucketId: string): Promise { + const result = (await db.execute( + sql`SELECT count(*) as count FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false`, + )) as unknown as Record[]; + return Number(result[0]?.count || 0); + } + + /** + * {@inheritDoc IFileRepository.findOrphansByBucket} + */ + async findOrphansByBucket(bucketId: string): Promise { + return await db + .select() + .from(fileSchema) + .where( + and( + eq(fileSchema.bucketId, bucketId), + eq(fileSchema.isDeleted, true), + ), + ) + .limit(100); + } +} diff --git a/src/infrastructure/persistence/repositories/multipart-repository.ts b/src/infrastructure/persistence/repositories/multipart-repository.ts new file mode 100644 index 0000000..a8a1fe6 --- /dev/null +++ b/src/infrastructure/persistence/repositories/multipart-repository.ts @@ -0,0 +1,149 @@ +import { sql } from 'drizzle-orm'; +import { nanoid } from 'nanoid'; +import { db } from '../drizzle/index'; +import type { MultipartUpload, MultipartPart } from '../../../domain/entities/multipart'; +import type { IMultipartRepository } from '../../../domain/ports/multipart-repository'; + +/** + * Maps a raw database row to a {@link MultipartUpload} domain entity. + */ +const mapRowToMultipartUpload = ( + r: Record, +): MultipartUpload => ({ + uploadId: r.upload_id as string, + bucketId: r.bucket_id as string, + s3Key: r.s3_key as string, + initiatedAt: new Date(r.initiated_at as string), + status: r.status as string, + initiatedBy: (r.initiated_by as string | null) || '', +}); + +/** + * Drizzle-backed implementation of {@link IMultipartRepository}. + * + * Delegates to the same SQL queries as the original `src/db/multipart.ts` + * module, using raw SQL for all operations on the un-typed + * `multipart_uploads` and `multipart_parts` tables. + */ +export class DrizzleMultipartRepository implements IMultipartRepository { + /** + * {@inheritDoc IMultipartRepository.create} + */ + async create( + bucketId: string, + s3Key: string, + initiatedBy: string, + ): Promise { + const uploadId = nanoid(32); + await db.execute( + sql`INSERT INTO multipart_uploads (upload_id, bucket_id, s3_key, initiated_by) VALUES (${uploadId}, ${bucketId}, ${s3Key}, ${initiatedBy})`, + ); + return uploadId; + } + + /** + * {@inheritDoc IMultipartRepository.findById} + */ + async findById(uploadId: string): Promise { + const result = (await db.execute( + sql`SELECT upload_id, bucket_id, s3_key, initiated_at, status FROM multipart_uploads WHERE upload_id = ${uploadId} AND status = 'in_progress'`, + )) as unknown as Record[]; + if (result.length === 0) return null; + const r = result[0]!; + return { + uploadId: r.upload_id as string, + bucketId: r.bucket_id as string, + s3Key: r.s3_key as string, + initiatedAt: new Date(r.initiated_at as string), + status: r.status as string, + initiatedBy: '', + }; + } + + /** + * {@inheritDoc IMultipartRepository.complete} + */ + async complete(uploadId: string): Promise { + await db.execute( + sql`UPDATE multipart_uploads SET status = 'completed' WHERE upload_id = ${uploadId}`, + ); + } + + /** + * {@inheritDoc IMultipartRepository.abort} + */ + async abort(uploadId: string): Promise { + await db.execute( + sql`UPDATE multipart_uploads SET status = 'aborted' WHERE upload_id = ${uploadId}`, + ); + } + + /** + * {@inheritDoc IMultipartRepository.insertPart} + */ + async insertPart( + part: Omit, + ): Promise { + await db.execute( + sql`INSERT INTO multipart_parts (upload_id, part_number, telegram_file_id, telegram_file_unique_id, storage_message_id, size_bytes, etag) + VALUES (${part.uploadId}, ${part.partNumber}, ${part.telegramFileId}, ${part.telegramFileUniqueId}, ${part.storageMessageId}, ${part.sizeBytes}, ${part.etag})`, + ); + } + + /** + * {@inheritDoc IMultipartRepository.listParts} + */ + async listParts(uploadId: string): Promise { + const result = (await db.execute( + sql`SELECT id, upload_id, part_number, telegram_file_id, telegram_file_unique_id, storage_message_id, size_bytes, etag, created_at + FROM multipart_parts WHERE upload_id = ${uploadId} ORDER BY part_number`, + )) as unknown as Record[]; + return result.map((r) => ({ + id: r.id as number, + uploadId: r.upload_id as string, + partNumber: r.part_number as number, + telegramFileId: r.telegram_file_id as string, + telegramFileUniqueId: r.telegram_file_unique_id as string, + storageMessageId: r.storage_message_id as number, + sizeBytes: Number(r.size_bytes), + etag: r.etag as string, + createdAt: new Date(r.created_at as string), + })); + } + + /** + * {@inheritDoc IMultipartRepository.listByBucket} + */ + async listByBucket( + bucketId: string, + maxUploads: number, + keyMarker: string | null, + ): Promise<{ + uploads: MultipartUpload[]; + isTruncated: boolean; + nextKeyMarker: string | null; + }> { + const limit = Math.min(Math.max(maxUploads || 1000, 1), 1000); + const result = (await db.execute( + keyMarker + ? sql`SELECT upload_id, bucket_id, s3_key, initiated_at, status, initiated_by + FROM multipart_uploads + WHERE bucket_id = ${bucketId}::uuid AND status = 'in_progress' AND s3_key > ${keyMarker} + ORDER BY s3_key, initiated_at + LIMIT ${limit + 1}` + : sql`SELECT upload_id, bucket_id, s3_key, initiated_at, status, initiated_by + FROM multipart_uploads + WHERE bucket_id = ${bucketId}::uuid AND status = 'in_progress' + ORDER BY s3_key, initiated_at + LIMIT ${limit + 1}`, + )) as unknown as Record[]; + + const uploads = result.slice(0, limit).map(mapRowToMultipartUpload); + return { + uploads, + isTruncated: result.length > limit, + nextKeyMarker: + result.length > limit ? uploads.at(-1)?.s3Key || null : null, + }; + } +} diff --git a/src/infrastructure/telegram/bot-pool.ts b/src/infrastructure/telegram/bot-pool.ts new file mode 100644 index 0000000..f57524d --- /dev/null +++ b/src/infrastructure/telegram/bot-pool.ts @@ -0,0 +1,213 @@ +import { Telegraf } from 'telegraf'; +import { config } from '../../env'; +import logger from '../../shared/logger/index'; +import type { ITelegramService, ForwardResult, TelegramFileInfo } from '../../domain/ports/telegram-service'; +import { enqueueUpload } from './upload-queue'; +import { + sendMethodMap, + extractUploadedFile, + buildSendPayload, + type TelegramMessageResult, + type SendMethod, +} from './types'; + +/** + * Sleep for a given number of seconds. + * + * Used as a backoff mechanism when all bots in the pool are rate-limited. + * + * @param seconds - Number of seconds to sleep. + * @returns A promise that resolves after the specified delay. + */ +const sleep = (seconds: number): Promise => { + return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); +}; + +/** + * Manages a pool of Telegram bots with automatic rotation and rate-limit handling. + * + * Distributes uploads across multiple bot tokens to maximise throughput. + * When a bot receives a 429 (rate-limit) error, the pool instantly rotates + * to the next available bot. If all bots are rate-limited, a coordinated + * sleep is performed before retrying. + * + * Implements the {@link ITelegramService} contract. + */ +export class BotPool implements ITelegramService { + private readonly bots: Telegraf[]; + private readonly botTokens: string[]; + private nextBotIndex = 0; + + /** Create a new BotPool from the application configuration. */ + constructor() { + this.botTokens = Array.from(new Set([config.botToken, ...config.additionalBotTokens])); + this.bots = this.botTokens.map((token) => new Telegraf(token)); + } + + /** + * Claim the next bot index using round-robin rotation. + * + * @returns The index of the selected bot. + */ + private claimBotIndex(): number { + const botIndex = this.nextBotIndex; + this.nextBotIndex = (this.nextBotIndex + 1) % this.bots.length; + return botIndex; + } + + /** + * Execute a Telegram API action with automatic retry and bot rotation. + * + * On 429 errors the pool either: + * 1. Rotates to the next bot immediately (if another bot is available), or + * 2. Sleeps for the required duration after all bots are exhausted, then retries. + * + * @param action - The action to execute on a bot instance. + * @param retries - Number of full-pool retry cycles remaining. + * @param attemptedBots - Number of bots attempted in the current cycle. + * @returns The result of the action. + */ + private async executeWithBotRetry( + action: (botInstance: Telegraf, botToken: string) => Promise, + retries = 5, + attemptedBots = 0, + ): Promise { + const botIndex = this.claimBotIndex(); + const currentBot = this.bots[botIndex]; + const currentToken = this.botTokens[botIndex]; + try { + return await action(currentBot, currentToken); + } catch (error: unknown) { + const errorStr = error instanceof Error ? error.message : String(error); + const match = errorStr.match(/retry after (\d+)/i); + + if (match) { + const nextIndex = this.nextBotIndex; + const nextAttemptedBots = attemptedBots + 1; + + if (nextAttemptedBots < this.bots.length) { + logger.info( + `Bot Index ${botIndex} hit 429. Instantly rotating to Bot Index ${nextIndex}...`, + ); + return this.executeWithBotRetry(action, retries, nextAttemptedBots); + } + + if (retries > 0) { + const seconds = parseInt(match[1], 10); + logger.warn( + `All bots in the pool are rate-limited. Sleeping for ${seconds} seconds...`, + { error: errorStr }, + ); + await sleep(seconds); + return this.executeWithBotRetry(action, retries - 1, 0); + } + } + throw error; + } + } + + /** + * Forward a file chunk to the configured Telegram storage chat. + * + * The upload is queued (via {@link enqueueUpload}) and executed with + * automatic bot rotation on rate-limit errors. + * + * @param fileChunk - The file data (ReadStream, Buffer, or file path). + * @param fileName - The original file name. + * @param fileType - The file type classification (e.g. "document", "photo"). + * @returns The Telegram identifiers of the stored file. + */ + async forwardToStorage( + fileChunk: unknown, + fileName: string, + fileType: string, + ): Promise { + try { + const result = await this.enqueueUpload(async () => { + const filePayload = { source: fileChunk, filename: fileName }; + const sendMethodName = sendMethodMap[fileType] || 'sendDocument'; + const payload = buildSendPayload(fileType, fileName); + + return this.executeWithBotRetry((activeBot) => { + const telegram = activeBot.telegram as unknown as Record; + return telegram[sendMethodName](config.storageChatId, filePayload, payload); + }); + }); + + const uploadedFile = extractUploadedFile(result, fileType); + logger.info('File forwarded to storage', { fileName, message: result.message_id }); + + return { + telegramFileId: uploadedFile?.file_id || '', + telegramFileUniqueId: uploadedFile?.file_unique_id || '', + storageMessageId: result.message_id, + }; + } catch (error: unknown) { + logger.error('Failed to forward file to storage', { + fileName, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + /** + * Retrieve file metadata from Telegram by file ID. + * + * Tries all configured bots sequentially; returns info from the first + * bot that can retrieve the file. Errors indicating the file belongs + * to a different bot are silently skipped. + * + * @param telegramFileId - The Telegram file_id to look up. + * @returns Metadata including size, MIME type, download path, and bot token. + */ + async getFileInfo(telegramFileId: string): Promise { + let lastError: unknown; + for (const activeBot of this.bots) { + try { + const result = await activeBot.telegram.getFile(telegramFileId); + const fileData = result as unknown as Omit; + return { + file_size: fileData.file_size || 0, + mime_type: fileData.mime_type || 'application/octet-stream', + file_path: fileData.file_path || '', + bot_token: activeBot.telegram.token, + }; + } catch (error: unknown) { + lastError = error; + const errorStr = error instanceof Error ? error.message : String(error); + if ( + errorStr.includes('wrong file_id') || + errorStr.includes('file is temporarily unavailable') || + errorStr.includes('retry after') + ) { + continue; + } + throw error; + } + } + + logger.error('Failed to get file info from any bot', { + error: lastError instanceof Error ? lastError.message : String(lastError), + }); + throw lastError; + } + + /** + * Enqueue a task for sequential upload execution. + * + * Delegates to the shared upload queue to ensure only a limited number + * of Telegram uploads run concurrently. + * + * @param task - An async function performing the upload. + * @returns The result of the task. + */ + enqueueUpload(task: () => Promise): Promise { + return enqueueUpload(task); + } +} + +/** + * Singleton BotPool instance initialised from application configuration. + */ +export const botPool = new BotPool(); diff --git a/src/infrastructure/telegram/chunked-storage.ts b/src/infrastructure/telegram/chunked-storage.ts new file mode 100644 index 0000000..e7ec67f --- /dev/null +++ b/src/infrastructure/telegram/chunked-storage.ts @@ -0,0 +1,336 @@ +import { createReadStream } from 'node:fs'; +import { gzipSync } from 'node:zlib'; +import { nanoid } from 'nanoid'; +import { config } from '../../env'; +import { computeHash } from '../../utils/file'; +import { createGetObjectResponse, type ObjectPartSource } from '../../utils/s3/object-stream'; +import type { RangeParseResult } from '../../utils/s3/range'; +import type { IFileRepository } from '../../domain/ports/file-repository'; +import type { IFilePartRepository } from '../../domain/ports/file-part-repository'; +import type { ITelegramService } from '../../domain/ports/telegram-service'; +import type { File as FileEntity } from '../../domain/entities/file'; +import type { NewFilePart, CompressionAlgorithm } from '../../domain/entities/file-part'; + +/** + * Chunk compression algorithm identifier. + * `"gzip"` if gzip compression was applied, `null` for uncompressed. + */ +export type ChunkCompressionAlgorithm = CompressionAlgorithm; + +/** + * Metadata about a single uploaded chunk (part) stored in Telegram. + */ +export interface ChunkedUploadPart { + /** 1-based part number within the file */ + partNumber: number; + /** Telegram file_id for retrieving this part */ + telegramFileId: string; + /** Telegram unique file_id (stable across bot tokens) */ + telegramFileUniqueId: string; + /** Message ID within the storage chat */ + storageMessageId: number; + /** Original (pre-compression) size in bytes */ + sizeBytes: number; + /** Stored (post-compression) size in bytes */ + storedSizeBytes: number; + /** Compression algorithm applied, or null */ + compressionAlgorithm: ChunkCompressionAlgorithm; + /** ETag (SHA-256 hash) of the original chunk */ + etag: string; +} + +/** + * Result of uploading a file in Telegram chunks. + */ +export interface ChunkedUploadResult { + /** Ordered list of uploaded parts */ + parts: ChunkedUploadPart[]; + /** SHA-256 hash of the complete file content */ + fileHash: string; + /** Total file size in bytes */ + totalSizeBytes: number; +} + +/** + * Input parameters for storing a file via chunked Telegram uploads. + */ +export interface ChunkedFileInput { + /** Path to the temporary file on disk */ + tempPath: string; + /** Prefix for generated part file names */ + partFileNamePrefix: string; + /** Original file name */ + fileName: string; + /** MIME type of the file */ + mimeType: string; + /** File size in bytes */ + sizeBytes: number; + /** File type classification (e.g. "document", "video") */ + fileType: string; + /** Telegram user ID of the uploader */ + uploaderId: number; + /** S3 bucket ID if the file is also tracked in S3, or null */ + bucketId?: string | null; + /** S3 object key if the file is also tracked in S3, or null */ + s3Key?: string | null; +} + +/** + * Validate and sanitise the Telegram chunk size. + * + * @param chunkSizeBytes - The desired chunk size in bytes. + * @returns The validated chunk size. + * @throws {Error} If the chunk size is not a safe positive integer. + */ +const asSafeChunkSize = (chunkSizeBytes: number): number => { + if (!Number.isSafeInteger(chunkSizeBytes) || chunkSizeBytes <= 0) { + throw new Error('Invalid Telegram chunk size'); + } + return chunkSizeBytes; +}; + +/** + * Optionally compress a chunk with gzip. + * + * Compression is skipped if: + * - The `compress` flag is false. + * - The chunk is smaller than `compressionMinSizeBytes`. + * - The compressed result is larger than the original. + * + * @param chunk - The raw chunk buffer. + * @param compress - Whether compression is enabled. + * @param compressionMinSizeBytes - Minimum chunk size to attempt compression. + * @returns The (possibly compressed) bytes and the algorithm used. + */ +const maybeCompressChunk = ( + chunk: Buffer, + compress: boolean, + compressionMinSizeBytes: number, +): { bytes: Buffer; compressionAlgorithm: ChunkCompressionAlgorithm } => { + 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' }; +}; + +/** + * Manages chunked storage of large files in Telegram. + * + * Large files are split into smaller chunks, each uploaded as a separate + * Telegram document. File and part metadata is persisted through the + * provided repository interfaces. + * + * Injects dependencies via constructor — can be used with any + * {@link IFileRepository}, {@link IFilePartRepository}, and + * {@link ITelegramService} implementation. + */ +export class ChunkedStorage { + /** + * @param fileRepository - Repository for File entity persistence. + * @param filePartRepository - Repository for FilePart entity persistence. + * @param telegramService - Service for Telegram API interactions. + */ + constructor( + private readonly fileRepository: IFileRepository, + private readonly filePartRepository: IFilePartRepository, + private readonly telegramService: ITelegramService, + ) {} + + /** + * Upload a file to Telegram in chunks and return chunk metadata. + * + * Reads the file from disk in fixed-size chunks, compresses each chunk + * if beneficial, and forwards each chunk to Telegram storage. + * + * @param input - Upload parameters including temp path, chunk size, and compression settings. + * @returns Metadata about all uploaded chunks and the file hash. + */ + async uploadFileInTelegramChunks(input: { + tempPath: string; + partFileNamePrefix: string; + chunkSizeBytes: number; + compress: boolean; + compressionMinSizeBytes: number; + }): Promise { + const chunkSizeBytes = asSafeChunkSize(input.chunkSizeBytes); + const hasher = new Bun.CryptoHasher('sha256'); + const parts: ChunkedUploadPart[] = []; + let totalSizeBytes = 0; + let partNumber = 0; + + const stream = createReadStream(input.tempPath, { highWaterMark: chunkSizeBytes }); + + 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, + input.compress, + input.compressionMinSizeBytes, + ); + const forwardResult = await this.telegramService.forwardToStorage( + bytes, + `${input.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, + }; + } + + /** + * Upload a file to Telegram in chunks and persist file + part records. + * + * Combines chunk upload ({@link uploadFileInTelegramChunks}) with + * repository persistence for both the File and FilePart entities. + * + * @param input - The file metadata and upload parameters. + * @returns The persisted File entity. + */ + async storeFileInTelegramChunks(input: ChunkedFileInput): Promise { + const upload = await this.uploadFileInTelegramChunks({ + tempPath: input.tempPath, + partFileNamePrefix: input.partFileNamePrefix, + chunkSizeBytes: config.telegramChunkSizeBytes, + compress: config.compressChunkedUploads, + compressionMinSizeBytes: config.chunkCompressionMinSizeBytes, + }); + + const firstPart = upload.parts[0]; + if (!firstPart) { + throw new Error('Chunked upload produced no parts'); + } + + const publicId = nanoid(); + + const file = await this.fileRepository.create({ + publicId, + telegramFileId: firstPart.telegramFileId, + telegramFileUniqueId: firstPart.telegramFileUniqueId, + storageChatId: config.storageChatId, + storageMessageId: firstPart.storageMessageId, + fileName: input.fileName, + mimeType: input.mimeType, + sizeBytes: upload.totalSizeBytes, + fileType: input.fileType, + uploaderId: input.uploaderId, + fileHash: upload.fileHash, + archiveTelegramFileId: null, + archiveStorageMessageId: null, + archiveFileName: null, + archiveEntryName: null, + archiveMimeType: null, + archiveSizeBytes: null, + bucketId: input.bucketId ?? null, + s3Key: input.s3Key ?? null, + storageBackend: 'chunked', + isDeleted: false, + multipartUploadId: null, + partCount: upload.parts.length, + }); + + const fileParts: NewFilePart[] = upload.parts.map((part) => ({ + fileId: file.id, + partNumber: part.partNumber, + telegramFileId: part.telegramFileId, + telegramFileUniqueId: part.telegramFileUniqueId, + storageChatId: config.storageChatId, + storageMessageId: part.storageMessageId, + sizeBytes: part.sizeBytes, + storedSizeBytes: part.storedSizeBytes, + compressionAlgorithm: part.compressionAlgorithm, + etag: part.etag, + })); + + await this.filePartRepository.insert(fileParts); + return file; + } + + /** + * Build a list of object-part sources for reconstructing a chunked file. + * + * Queries the file-part repository and enriches each part with + * the Telegram download URL by calling {@link ITelegramService.getFileInfo}. + * + * @param file - The File entity whose parts should be resolved. + * @returns An ordered list of object part sources ready for streaming. + */ + async buildChunkedObjectSources(file: FileEntity): Promise { + const parts = await this.filePartRepository.listByFileId(file.id); + const sources: ObjectPartSource[] = []; + + for (const part of parts) { + const fileInfo = await this.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, + storedSizeBytes: part.storedSizeBytes, + compressionAlgorithm: part.compressionAlgorithm, + partNumber: part.partNumber, + }); + } + + return sources; + } + + /** + * Create an HTTP Response that streams a chunked file's content. + * + * Supports HTTP range requests for partial content delivery. + * The response is constructed by reassembling parts in order and + * optionally decompressing gzip-compressed parts. + * + * @param input - Parameters including the file entity, range, and request ID. + * @returns A Response object streaming the requested byte range. + */ + async createChunkedObjectResponse(input: { + file: FileEntity; + range: RangeParseResult; + reqId: string; + }): Promise { + const parts = await this.buildChunkedObjectSources(input.file); + if (parts.length === 0) { + throw new Error('Chunked object has no parts'); + } + + return createGetObjectResponse({ + reqId: input.reqId, + contentType: input.file.mimeType, + etag: input.file.fileHash || parts.map((p) => p.telegramFileId).join('-'), + lastModified: + input.file.createdAt instanceof Date + ? input.file.createdAt + : new Date(input.file.createdAt), + totalSize: Number(input.file.sizeBytes), + parts, + range: input.range, + }); + } +} diff --git a/src/infrastructure/telegram/types.ts b/src/infrastructure/telegram/types.ts new file mode 100644 index 0000000..12b2981 --- /dev/null +++ b/src/infrastructure/telegram/types.ts @@ -0,0 +1,130 @@ +/** + * Telegram-specific types used internally by the infrastructure layer. + * + * These types represent the raw Telegram Bot API response shapes and + * the internal abstractions built on top of them. The higher-level domain + * types (ForwardResult, TelegramFileInfo) are defined in + * src/domain/ports/telegram-service.ts. + */ + +/** + * File reference within a Telegram message result. + * Contains identifiers returned by the Telegram API for uploaded media. + */ +export interface UploadedTelegramFile { + /** Telegram file_id for retrieving the file */ + file_id?: string; + /** Telegram unique file_id (stable across bot tokens) */ + file_unique_id?: string; +} + +/** + * Result structure returned by Telegram send* API methods. + * Covers all media types a Telegram message can carry. + */ +export interface TelegramMessageResult { + /** Unique message identifier inside the chat */ + message_id: number; + /** Sent document, if applicable */ + document?: UploadedTelegramFile; + /** Sent photo (array of sizes, last element is largest), if applicable */ + photo?: UploadedTelegramFile[]; + /** Sent video, if applicable */ + video?: UploadedTelegramFile; + /** Sent audio, if applicable */ + audio?: UploadedTelegramFile; + /** Sent voice message, if applicable */ + voice?: UploadedTelegramFile; + /** Sent animation (GIF), if applicable */ + animation?: UploadedTelegramFile; + /** Sent sticker, if applicable */ + sticker?: UploadedTelegramFile; + /** Sent video note, if applicable */ + video_note?: UploadedTelegramFile; + /** Catch-all for any additional Telegram response fields */ + [key: string]: unknown; +} + +/** + * Payload structure for sending a file via the Telegram Bot API. + * + * @internal + */ +export type FilePayload = { source: unknown; filename: string }; + +/** + * Additional optional payload for Telegram send method calls. + * + * @internal + */ +export type SendPayload = { caption?: string }; + +/** + * Function signature for Telegram send* method calls on a bot instance. + * + * @internal + */ +export type SendMethod = ( + chatId: number, + filePayload: FilePayload, + payload?: SendPayload, +) => Promise; + +/** + * Mapping from file type identifier to Telegram Bot API method name. + * + * Each key corresponds to a Telegram media type; the value is the + * method name to call on `bot.telegram`. + */ +export const sendMethodMap: Record = { + photo: 'sendPhoto', + audio: 'sendAudio', + video: 'sendVideo', + voice: 'sendVoice', + animation: 'sendAnimation', + sticker: 'sendSticker', + document: 'sendDocument', + video_note: 'sendDocument', +}; + +/** + * Extract the uploaded file reference from a Telegram message result + * based on the media type present in the result. + * + * Falls back to looking up the file type key directly on the result object. + * + * @param result - The message result from a Telegram send* call. + * @param fileType - The file type classification (e.g. "document", "photo"). + * @returns The uploaded file reference, or `undefined` if none was found. + */ +export const extractUploadedFile = ( + result: TelegramMessageResult, + fileType: string, +): UploadedTelegramFile | undefined => { + if (result.document) return result.document; + if (result.photo) return result.photo?.slice(-1)[0]; + if (result.video) return result.video; + if (result.audio) return result.audio; + if (result.voice) return result.voice; + if (result.animation) return result.animation; + if (result.sticker) return result.sticker; + if (result.video_note) return result.video_note; + return result[fileType] as UploadedTelegramFile | undefined; +}; + +/** + * Build the send payload (caption, etc.) for a Telegram send* method call. + * + * Stickers do not support captions. Documents get a labelled caption + * with the file name. All other types use the plain file name as caption. + * + * @param fileType - The file type (e.g. "document", "photo", "sticker"). + * @param fileName - The file name to use in the caption. + * @returns The payload object with caption (or empty for sticker). + */ +export const buildSendPayload = (fileType: string, fileName: string): SendPayload => { + const basePayload: SendPayload = { caption: fileName }; + if (fileType === 'sticker') return {}; + if (fileType === 'document') return { caption: `📁 ${fileName}` }; + return basePayload; +}; diff --git a/src/infrastructure/telegram/upload-batcher.ts b/src/infrastructure/telegram/upload-batcher.ts new file mode 100644 index 0000000..76662a8 --- /dev/null +++ b/src/infrastructure/telegram/upload-batcher.ts @@ -0,0 +1,238 @@ +import { createReadStream } from 'node:fs'; +import { nanoid } from 'nanoid'; +import { config } from '../../env'; +import { cleanupTempFile } from '../../shared/utils/file'; +import { createZip, type ZipEntry } from '../../shared/utils/zip'; +import type { IFileRepository } from '../../domain/ports/file-repository'; +import type { ITelegramService } from '../../domain/ports/telegram-service'; +import type { File as FileEntity, NewFile } from '../../domain/entities/file'; + +/** + * Metadata about a prepared upload before it is submitted to the batcher. + */ +export type PreparedUpload = { + /** Temporary file path on disk */ + tempPath: string; + /** SHA-256 hash of the file contents */ + fileHash: string; + /** File size in bytes */ + sizeBytes: number; + /** First bytes of the file for MIME detection */ + signatureBuffer: Buffer; +}; + +/** + * A fully materialised file record returned from the batcher. + */ +export type UploadedFile = FileEntity; + +/** + * An item ready for batched upload to Telegram storage. + */ +export type BatchUploadItem = { + /** Prepared upload metadata */ + prepared: PreparedUpload; + /** Original file name */ + fileName: string; + /** MIME type of the file */ + mimeType: string; + /** File type classification (e.g. "document", "photo") */ + fileType: string; +}; + +/** + * Internal pending upload tracking type, extending BatchUploadItem + * with resolve/reject callbacks. + */ +type PendingUpload = BatchUploadItem & { + resolve: (file: FileEntity) => void; + reject: (error: unknown) => void; +}; + +/** Time window in milliseconds during which uploads are batched together. */ +const BATCH_WINDOW_MS = 2000; + +/** + * Batches multiple file uploads into a single ZIP archive before forwarding + * them to Telegram storage. This reduces the number of Telegram API calls + * and improves throughput for small-file workloads. + * + * Injects dependencies via constructor — can be used with any + * {@link IFileRepository} and {@link ITelegramService} implementation. + */ +export class UploadBatcher { + private readonly pendingUploads: PendingUpload[] = []; + private flushTimer: ReturnType | null = null; + + /** + * @param fileRepository - Repository for persisting file records. + * @param telegramService - Service for forwarding files to Telegram storage. + */ + constructor( + private readonly fileRepository: IFileRepository, + private readonly telegramService: ITelegramService, + ) {} + + /** + * Build a NewFile record from a batch item and its archive metadata. + * + * @param item - The batched upload item. + * @param entry - ZIP entry metadata for the individual file. + * @param archive - Archive-level Telegram storage metadata. + * @returns A NewFile record ready for repository insertion. + */ + private buildUploadedFile( + item: BatchUploadItem, + entry: ZipEntry, + archive: { + telegramFileId: string; + telegramFileUniqueId: string; + storageMessageId: number; + fileName: string; + sizeBytes: number; + }, + ): NewFile { + return { + publicId: nanoid(), + telegramFileId: archive.telegramFileId, + telegramFileUniqueId: archive.telegramFileUniqueId, + storageChatId: config.storageChatId, + storageMessageId: archive.storageMessageId, + fileName: item.fileName, + mimeType: item.mimeType || 'application/octet-stream', + sizeBytes: item.prepared.sizeBytes, + fileType: item.fileType, + uploaderId: 0, + fileHash: item.prepared.fileHash, + archiveTelegramFileId: archive.telegramFileId, + archiveStorageMessageId: archive.storageMessageId, + archiveFileName: archive.fileName, + archiveEntryName: entry.entryName, + archiveMimeType: 'application/zip', + archiveSizeBytes: archive.sizeBytes, + bucketId: null, + s3Key: null, + storageBackend: null, + isDeleted: null, + multipartUploadId: null, + partCount: null, + }; + } + + /** + * Flush all pending uploads by zipping them together and sending + * the archive to Telegram storage. + */ + private async flushUploads(): Promise { + if (this.flushTimer) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + + const batch = this.pendingUploads.splice(0); + if (batch.length === 0) return; + + let zipTempPath: string | null = null; + + try { + const zip = await createZip( + batch.map((item) => ({ tempPath: item.prepared.tempPath, fileName: item.fileName })), + ); + zipTempPath = zip.tempPath; + const archiveFileName = `filedrop-${nanoid()}.zip`; + const archiveResult = await this.telegramService.forwardToStorage( + createReadStream(zip.tempPath), + archiveFileName, + 'document', + ); + + const newFileInputs = batch.map((item, index) => + this.buildUploadedFile(item, zip.entries[index], { + telegramFileId: archiveResult.telegramFileId, + telegramFileUniqueId: archiveResult.telegramFileUniqueId, + storageMessageId: archiveResult.storageMessageId, + fileName: archiveFileName, + sizeBytes: zip.sizeBytes, + }), + ); + + // Persist each file record through the repository + const createdFiles = await Promise.all( + newFileInputs.map((input) => this.fileRepository.create(input)), + ); + + for (let i = 0; i < batch.length; i++) { + batch[i].resolve(createdFiles[i]); + } + } catch (error) { + for (const item of batch) { + item.reject(error); + } + } finally { + await Promise.all(batch.map((item) => cleanupTempFile(item.prepared.tempPath))); + if (zipTempPath) await cleanupTempFile(zipTempPath); + // Reschedule timer if new items arrived during async processing + if (this.pendingUploads.length > 0 && !this.flushTimer) { + this.flushTimer = setTimeout(() => { + void this.flushUploads(); + }, BATCH_WINDOW_MS); + } + } + } + + /** + * Calculate total size of all pending uploads in bytes. + * + * @returns The sum of all pending file sizes. + */ + private getPendingSize(): number { + return this.pendingUploads.reduce((total, item) => total + item.prepared.sizeBytes, 0); + } + + /** + * Enqueue a prepared upload for batched processing. + * + * The upload is held for up to {@link BATCH_WINDOW_MS} milliseconds + * (or until the batch size/byte thresholds in config are exceeded) + * before being flushed to Telegram storage. + * + * @param item - The prepared upload item to enqueue. + * @returns A promise that resolves with the fully created File record. + */ + enqueuePreparedUpload(item: BatchUploadItem): Promise { + return new Promise((resolve, reject) => { + this.pendingUploads.push({ ...item, resolve, reject }); + + if (!this.flushTimer) { + this.flushTimer = setTimeout(() => { + void this.flushUploads(); + }, BATCH_WINDOW_MS); + } + + if ( + this.pendingUploads.length >= config.batchMaxItems || + this.getPendingSize() >= config.batchMaxSizeBytes + ) { + void this.flushUploads(); + } + }); + } + + /** + * Immediately flush all pending uploads, regardless of batch size. + * + * @returns A promise that resolves when the flush is complete. + */ + async flushPendingUploads(): Promise { + await this.flushUploads(); + } + + /** + * Get the number of uploads currently waiting in the batch queue. + * + * @returns The pending upload count. + */ + getPendingUploadCount(): number { + return this.pendingUploads.length; + } +} diff --git a/src/infrastructure/telegram/upload-queue.ts b/src/infrastructure/telegram/upload-queue.ts new file mode 100644 index 0000000..913f53b --- /dev/null +++ b/src/infrastructure/telegram/upload-queue.ts @@ -0,0 +1,81 @@ +import PQueue from 'p-queue'; +import { config } from '../../env'; +import logger from '../../shared/logger/index'; + +/** + * P-queue instance for serialising Telegram upload tasks. + * + * Concurrency is governed by {@link config.uploadConcurrency}. + * Built-in logging emits warnings when the queue grows beyond 5 pending items. + */ +const uploadQueue = new PQueue({ + concurrency: config.uploadConcurrency, +}); + +/* Monitor queue growth and emit warnings for large backlogs */ +uploadQueue.on('add', () => { + const stats = getQueueStats(); + if (stats.size > 5) { + logger.warn('Upload queue building up', { pending: stats.pending, size: stats.size }); + } +}); + +uploadQueue.on('next', () => { + const stats = getQueueStats(); + logger.debug('Processing next upload', { pending: stats.pending, size: stats.size }); +}); + +/** + * Enqueue an upload task to be executed by the queue. + * + * Tasks are executed in FIFO order, subject to the concurrency limit. + * + * @param task - An async function representing the upload operation. + * @returns A promise that resolves with the task's result. + */ +export const enqueueUpload = (task: () => Promise): Promise => { + return uploadQueue.add(task); +}; + +/** + * Get current queue statistics. + * + * @returns An object with `pending` (actively executing) and `size` (waiting) counts. + */ +export const getQueueStats = (): { pending: number; size: number } => ({ + pending: uploadQueue.pending, + size: uploadQueue.size, +}); + +/** + * Get the number of items waiting in the queue (not yet started). + * + * @returns The number of queued items. + */ +export const getQueueSize = (): number => uploadQueue.size; + +/** + * Get the number of items currently being processed. + * + * @returns The number of pending (in-flight) items. + */ +export const getPendingCount = (): number => uploadQueue.pending; + +/** + * Clear all pending items and wait for in-flight ones to finish. + * + * @returns A promise that resolves when the queue is idle after clearing. + */ +export const clearQueue = async (): Promise => { + uploadQueue.clear(); + await uploadQueue.onIdle(); +}; + +/** + * Wait for the queue to become idle (all tasks finished). + * + * @returns A promise that resolves when no tasks are pending or in-flight. + */ +export const waitForQueue = async (): Promise => { + await uploadQueue.onIdle(); +}; diff --git a/src/interfaces/bot/handler.ts b/src/interfaces/bot/handler.ts index cdaa252..6c297d6 100644 --- a/src/interfaces/bot/handler.ts +++ b/src/interfaces/bot/handler.ts @@ -13,7 +13,7 @@ import { getFileSizeLimit, type TelegramMediaMessage, } from '../../shared/utils/file'; -import logger from '../../utils/logger'; +import logger from '../../shared/logger/index'; /** * Minimal bot context shape used by the media event handler. diff --git a/src/interfaces/http/controllers/auth-controller.ts b/src/interfaces/http/controllers/auth-controller.ts new file mode 100644 index 0000000..1c660a3 --- /dev/null +++ b/src/interfaces/http/controllers/auth-controller.ts @@ -0,0 +1,151 @@ +import { config } from '../../../config/index'; +import { + clearSessionCookie, + createSessionCookie, + getAuthSession, + isAuthEnabled, + checkBearerToken, +} from '../../../utils/auth'; +import { + createLoginUseCase, + createLogoutUseCase, + createMeUseCase, + type AuthSession, +} from '../../../application/use-cases/authenticate'; + +/** + * Helper that builds a JSON Response with optional extra headers. + * + * @param data - The JSON-serialisable body. + * @param status - HTTP status code (default 200). + * @param headers - Optional extra response headers. + * @returns A JSON Response. + */ +const json = (data: unknown, status = 200, headers: Record = {}): Response => + Response.json(data, { status, headers }); + +/** + * Returns a standard 404 Not Found JSON response. + * + * Used to hide auth endpoints when auth is disabled. + * + * @returns A 404 JSON response. + */ +const notFound = (): Response => json({ error: 'Not found' }, 404); + +/** + * Parses the login request body, extracting the `token` field. + * + * @param req - The incoming HTTP request with a JSON body. + * @returns The login token payload, or `null` when the body is invalid. + */ +const readLoginBody = async (req: Request): Promise<{ token: string } | null> => { + try { + const body = (await req.json()) as { token?: unknown }; + if (typeof body.token !== 'string' || body.token.length === 0) return null; + return { token: body.token }; + } catch { + return null; + } +}; + +/** + * Handles the login endpoint. + * + * Reads the admin API token from the request body, validates it via the + * login use case, and sets a session cookie on success. + * + * When auth is disabled the endpoint returns 404. + * + * @param req - The incoming HTTP request. + * @returns A JSON response with login status and a Set-Cookie header. + */ +export const handleLogin = async (req: Request): Promise => { + if (!isAuthEnabled()) return notFound(); + + const body = await readLoginBody(req); + if (!body) return json({ error: 'Token is required' }, 400); + + try { + const loginUseCase = createLoginUseCase({ + config: { + adminApiToken: config.adminApiToken, + sessionCookieName: config.sessionCookieName, + sessionMaxAgeMs: config.sessionMaxAgeMs, + }, + }); + + const result = await loginUseCase({ token: body.token }); + + return json({ username: result.username }, 200, { + 'set-cookie': createSessionCookie('admin'), + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Invalid token'; + if (message === 'Invalid token') { + return json({ error: 'Invalid token' }, 401); + } + return json({ error: message }, 500); + } +}; + +/** + * Handles the logout endpoint. + * + * Clears the session cookie and returns a success response. + * + * @returns A JSON response with a cleared Set-Cookie header. + */ +export const handleLogout = async (): Promise => { + const logoutUseCase = createLogoutUseCase(); + await logoutUseCase(); + + return json({ success: true }, 200, { + 'set-cookie': clearSessionCookie(), + }); +}; + +/** + * Handles the current-user (me) endpoint. + * + * Extracts the authentication session from the request (cookie or bearer + * token) and returns the user info via the me use case. + * + * When auth is disabled the endpoint returns 404. + * + * @param req - The incoming HTTP request. + * @returns A JSON response with user info, or 401 when unauthenticated. + */ +export const handleMe = async (req: Request): Promise => { + if (!isAuthEnabled()) return notFound(); + + const session: AuthSession | null = getAuthSession(req); + if (!session && !checkBearerToken(req.headers.get('authorization'))) { + return json({ error: 'Unauthorized' }, 401); + } + + const meUseCase = createMeUseCase({ + config: { + adminApiToken: config.adminApiToken, + sessionCookieName: config.sessionCookieName, + sessionMaxAgeMs: config.sessionMaxAgeMs, + }, + }); + + const activeSession = session ?? { + username: 'admin', + expiresAt: null, + method: 'bearer' as const, + }; + + const result = await meUseCase(activeSession); + + if (!result) { + return json({ error: 'Unauthorized' }, 401); + } + + return json({ + username: result.username, + expiresAt: result.expiresAt, + }); +}; \ No newline at end of file diff --git a/src/interfaces/http/controllers/file-controller.ts b/src/interfaces/http/controllers/file-controller.ts new file mode 100644 index 0000000..9b22b1b --- /dev/null +++ b/src/interfaces/http/controllers/file-controller.ts @@ -0,0 +1,215 @@ +import { createReadStream } from 'node:fs'; +import { nanoid } from 'nanoid'; +import { config } from '../../../config/index'; +import { fileInfoCache } from '../../../infrastructure/cache/index'; +import { createChunkedObjectResponse } from '../../../utils/chunked-storage'; +import { cleanupTempFile, formatCreatedAt, getErrorMessage } from '../../../shared/utils/file'; +import logger from '../../../shared/logger/index'; +import { getFileInfo, type TelegramFileInfo } from '../../../utils/telegram'; +import { locateZipEntry } from '../../../utils/zip'; + +/** + * Extended Request type that includes route parameter access. + */ +type RequestWithParams = Request & { + /** Route parameters extracted by the router. */ + params?: { + /** Public file identifier. */ + public_id?: string; + }; +}; + +/** + * 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. + * + * @param telegramFileId - The Telegram file identifier to resolve. + * @param publicId - The public file ID (used for logging). + * @returns The resolved Telegram file info. + */ +const getTelegramFileInfo = async (telegramFileId: string, publicId: string): Promise => { + const cacheKey = `file_info_${telegramFileId}`; + const cached = fileInfoCache.get(cacheKey) as TelegramFileInfo | null; + + if (cached) { + logger.debug('File info from cache', { publicId, cacheKey }); + return cached; + } + + const fileInfo = await getFileInfo(telegramFileId); + fileInfoCache.set(cacheKey, fileInfo); + logger.debug('File info cached', { publicId, cacheKey }); + + return fileInfo; +}; + +/** + * Builds a Telegram CDN download URL from a file path and bot token. + * + * @param filePath - The Telegram file path returned by getFile. + * @param botToken - The bot token used to authenticate the download. + * @returns The full Telegram CDN URL. + */ +const buildTelegramFileUrl = (filePath: string, botToken: string): string => + `https://api.telegram.org/file/bot${botToken}/${filePath}`; + +/** + * Sanitises a file name for use in a Content-Disposition header, removing + * characters that could enable header injection. + * + * @param fileName - The raw file name. + * @returns The sanitised file name. + */ +const sanitizeFilenameHeader = (fileName: string): string => + fileName.replace(/[\\"]/g, '').replace(/[\n\r]/g, ''); + +/** + * Returns a JSON error response with the given status code and message. + * + * @param status - HTTP status code. + * @param error - Error message. + * @returns A JSON Response. + */ +const fail = (status: number, error: string): Response => Response.json({ error }, { status }); + +/** + * Handles file redirect requests. + * + * Looks up a file by its public identifier and determines the best delivery + * method: + * - **chunked** files are streamed via the chunked-object response builder. + * - **archive-entry** files are extracted from a Telegram-stored zip archive + * and streamed as a single file. + * - **regular** files are redirected to the Telegram CDN URL (302). + * + * @param req - The incoming HTTP request with a `public_id` route parameter. + * @returns A redirect or streaming response, or a JSON error. + */ +export const handleFileRedirect = async (req: RequestWithParams): Promise => { + const publicId = req.params?.public_id; + try { + if (!publicId) { + return fail(400, 'Missing file id'); + } + + const { findFileByPublicId } = await import('../../../db/files'); + const file = await findFileByPublicId(publicId); + if (!file) { + logger.warn('File not found', { publicId }); + return fail(404, 'File not found'); + } + + if (file.storageBackend === 'chunked') { + if (file.archiveEntryName) { + return fail(501, 'Archive entry extraction is not supported for chunked files'); + } + const range = { type: 'none' as const }; + return createChunkedObjectResponse({ file, range, reqId: '' }); + } + + const archiveEntryName = file.archiveEntryName; + if (archiveEntryName) { + const archiveFileId = file.archiveTelegramFileId || file.telegramFileId; + const archiveInfo = await getTelegramFileInfo(archiveFileId, publicId); + const archiveResponse = await fetch( + buildTelegramFileUrl(archiveInfo.file_path, archiveInfo.bot_token), + ); + + if (!archiveResponse.ok) { + logger.error('Archive download failed', { publicId, status: archiveResponse.status }); + return fail(500, 'Server error'); + } + + const tempZipPath = `/tmp/filedrop-dl-${nanoid()}.zip`; + await Bun.write(tempZipPath, archiveResponse); + + const loc = await locateZipEntry(tempZipPath, archiveEntryName); + if (!loc) { + await cleanupTempFile(tempZipPath); + logger.error('Archive entry not found', { publicId, archiveEntryName }); + return fail(404, 'File not found'); + } + + const fileStream = createReadStream(tempZipPath, { + start: loc.start, + end: loc.start + loc.length - 1, + }); + + fileStream.on('close', () => { + void cleanupTempFile(tempZipPath); + }); + fileStream.on('error', () => { + void cleanupTempFile(tempZipPath); + }); + + return new Response(fileStream as unknown as ReadableStream, { + status: 200, + headers: { + 'Content-Type': file.mimeType || 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${sanitizeFilenameHeader(file.fileName)}"`, + 'Content-Length': String(loc.length), + }, + }); + } + + const fileInfo = await getTelegramFileInfo(file.telegramFileId, publicId); + const redirectUrl = buildTelegramFileUrl(fileInfo.file_path, fileInfo.bot_token); + + return new Response(null, { + status: 302, + headers: { + Location: redirectUrl, + }, + }); + } catch (error: unknown) { + logger.error('File redirect error', { publicId, error: getErrorMessage(error) }); + return fail(500, 'Server error'); + } +}; + +/** + * Handles file info requests. + * + * Looks up a file by its public identifier and returns its metadata as JSON. + * + * @param req - The incoming HTTP request with a `public_id` route parameter. + * @returns A JSON response with file metadata, or 404 when not found. + */ +export const handleFileInfo = async (req: RequestWithParams): Promise => { + const publicId = req.params?.public_id; + try { + if (!publicId) { + return fail(400, 'Missing file id'); + } + + const { findFileByPublicId } = await import('../../../db/files'); + const file = await findFileByPublicId(publicId); + if (!file) { + logger.warn('File not found', { publicId }); + return fail(404, 'File not found'); + } + + return Response.json( + { + public_id: file.publicId, + file_name: file.fileName, + mime_type: file.mimeType, + size_bytes: file.sizeBytes, + file_type: file.fileType, + created_at: formatCreatedAt(file.createdAt), + }, + { status: 200 }, + ); + } catch (error: unknown) { + logger.error('File info error', { publicId, error: getErrorMessage(error) }); + return fail(500, 'Server error'); + } +}; \ No newline at end of file diff --git a/src/interfaces/http/controllers/health-controller.ts b/src/interfaces/http/controllers/health-controller.ts new file mode 100644 index 0000000..8f26ca5 --- /dev/null +++ b/src/interfaces/http/controllers/health-controller.ts @@ -0,0 +1,25 @@ +import { sql } from 'drizzle-orm'; +import { db } from '../../../db'; +import { getErrorMessage } from '../../../shared/utils/file'; +import logger from '../../../utils/logger'; + +/** + * Handles the health-check endpoint. + * + * Verifies database connectivity by executing a simple `SELECT 1` query. + * Returns a 200 response with `{ status: 'ok' }` when the database is + * reachable, or a 500 response with the error details when it is not. + * + * @param _req - The incoming HTTP request (unused). + * @returns A JSON response indicating the database health status. + */ +export const handleHealth = async (_req: Request): Promise => { + try { + await db.execute(sql`SELECT 1`); + return Response.json({ status: 'ok' }, { status: 200 }); + } catch (error: unknown) { + const message = getErrorMessage(error); + logger.error('Health check failed', { error: message }); + return Response.json({ status: 'error', error: message }, { status: 500 }); + } +}; \ No newline at end of file diff --git a/src/interfaces/http/controllers/home-controller.ts b/src/interfaces/http/controllers/home-controller.ts new file mode 100644 index 0000000..6f4df5a --- /dev/null +++ b/src/interfaces/http/controllers/home-controller.ts @@ -0,0 +1,19 @@ +import type { BunFile } from 'bun'; + +/** + * Handles the home/dashboard page request. + * + * Reads the `home.html` file from the adjacent directory and serves it as + * an HTML response with UTF-8 charset. + * + * @returns An HTML response containing the home page content. + */ +export const handleHome = async (): Promise => { + const html = await (Bun.file(`${import.meta.dir}/home.html`) as BunFile).text(); + return new Response(html, { + status: 200, + headers: { + 'content-type': 'text/html; charset=utf-8', + }, + }); +}; \ No newline at end of file diff --git a/src/interfaces/http/controllers/home.html b/src/interfaces/http/controllers/home.html new file mode 100644 index 0000000..f807e81 --- /dev/null +++ b/src/interfaces/http/controllers/home.html @@ -0,0 +1,338 @@ + + + + + + FileDrop · S3 File Manager + + + +
+
+

📦 FileDrop

+

Enter admin token to continue.

+ + + +
+
+
+ + + + + + + +
+ +
+

Select a bucket to get started

Choose a bucket from the dropdown above, or create a new one.

+
+ + + + + + diff --git a/src/interfaces/http/controllers/s3-controller.ts b/src/interfaces/http/controllers/s3-controller.ts new file mode 100644 index 0000000..f3f1616 --- /dev/null +++ b/src/interfaces/http/controllers/s3-controller.ts @@ -0,0 +1,1469 @@ +import { createReadStream } from 'node:fs'; +import { nanoid } from 'nanoid'; +import { + createBucket, + deleteBucket, + findBucketByName, + listBuckets, +} from '../../../db/buckets'; +import { + countBucketObjects, + findFileByBucketAndKey, + listObjectsByPrefix, + softDeleteFile, +} from '../../../db/files-ext'; +import { + abortMultipartUpload, + completeMultipartUpload, + createMultipartUpload, + findMultipartUpload, + insertMultipartPart, + listMultipartParts, + listMultipartUploadsByBucket, +} from '../../../db/multipart'; +import type { File } from '../../../db/schema'; +import { config } from '../../../config/index'; +import { + createChunkedObjectResponse, + storeFileInTelegramChunks, +} from '../../../utils/chunked-storage'; +import { + cleanupTempFile, + computeHash, + ensureExtension, + getErrorMessage, +} from '../../../shared/utils/file'; +import logger from '../../../shared/logger/index'; +import { verifyPresignedUrl, verifySignature } from '../../../utils/s3/auth'; +import { S3_CORS_HEADERS, s3Headers } from '../../../utils/s3/headers'; +import { createGetObjectResponse, type ObjectPartSource } from '../../../utils/s3/object-stream'; +import { parseRangeHeader, unsatisfiedContentRange } from '../../../utils/s3/range'; +import { + bucketVersioningConfigurationXml, + completeMultipartUploadXml, + copyObjectResultXml, + deleteResultXml, + initiateMultipartUploadXml, + listBucketResultXml, + listBucketsXml, + listBucketV2ResultXml, + listMultipartUploadsXml, + listPartsXml, + parseCompleteMultipartBody, + parseDeleteObjectsBody, + s3ErrorResponse, +} from '../../../utils/s3/xml'; +import { forwardToStorage, getFileInfo } from '../../../utils/telegram'; + +/** + * The default S3 region returned when no region is explicitly configured. + */ +const REGION = config.s3DefaultRegion || 'us-east-1'; + +/** + * Generates a unique request identifier for S3 responses. + * + * @returns A hex string suitable for x-amz-request-id and x-amz-id-2. + */ +const REQUEST_ID = (): string => nanoid(16); + +/** + * Builds a standard S3 response with the appropriate headers. + * + * @param body - The XML or empty response body. + * @param status - HTTP status code. + * @param reqId - The request identifier for S3 headers. + * @param extraHeaders - Optional extra response headers. + * @returns An S3-formatted Response. + */ +const s3Response = ( + body: string | null, + status: number, + reqId: string, + extraHeaders: Record = {}, +): Response => new Response(body, { status, headers: s3Headers(reqId, extraHeaders) }); + +/** + * Builds an S3 OPTIONS preflight response with CORS headers. + * + * @returns A 204 No Content Response. + */ +const s3OptionsResponse = (): Response => + new Response(null, { status: 204, headers: S3_CORS_HEADERS }); + +/** + * Parses an S3 pathname into bucket and key components. + * + * Supports path-style URLs such as `/bucket-name/key/with/prefix`. + * + * @param pathname - The URL pathname. + * @returns An object with the extracted bucket and key (both may be null). + */ +const parseS3Path = (pathname: string): { bucket: string | null; key: string | null } => { + const parts = pathname.split('/').filter(Boolean); + if (parts.length === 0) return { bucket: null, key: null }; + if (parts.length === 1) return { bucket: parts[0], key: null }; + return { bucket: parts[0], key: parts.slice(1).join('/') }; +}; + +/** + * Converts a Request's headers into a plain key-value record (all keys + * lowercased) for SigV4 signature verification. + * + * @param req - The incoming HTTP request. + * @returns A record of lowercased header key-value pairs. + */ +const headersToRecord = (req: Request): Record => { + const record: Record = {}; + for (const [key, value] of req.headers.entries()) { + record[key.toLowerCase()] = value; + } + return record; +}; + +/** + * Main S3 request dispatcher. + * + * Parses the request (method, path, query parameters, headers), validates + * the SigV4 signature or presigned URL, and dispatches to the appropriate + * bucket, object, or multipart operation handler. + * + * Supports both path-style (`/bucket/key`) and virtual-hosted-style + * (`bucket.example.com/key`) addressing. + * + * @param req - The incoming S3 HTTP request. + * @param virtualHostBucket - When the request was routed through a + * virtual-hosted domain, the extracted bucket + * name; otherwise `null`. + * @returns An S3-formatted Response. + */ +export const handleS3Request = async ( + req: Request, + virtualHostBucket: string | null = null, +): Promise => { + const method = req.method; + const url = new URL(req.url); + const pathname = url.pathname; + const { bucket, key } = virtualHostBucket + ? { + bucket: virtualHostBucket, + key: pathname === '/' ? null : decodeURIComponent(pathname.slice(1)), + } + : parseS3Path(pathname); + const headers = headersToRecord(req); + const searchParams = url.searchParams; + const reqId = REQUEST_ID(); + + // Handle CORS preflight + if (method === 'OPTIONS') { + return s3OptionsResponse(); + } + + // SigV4 authentication + const isPresigned = searchParams.has('X-Amz-Signature'); + const authResult = isPresigned + ? await verifyPresignedUrl({ + url: req.url, + method, + headers, + s3AccessKey: config.s3AccessKey, + s3SecretKey: config.s3SecretKey, + region: REGION, + }) + : await verifySignature( + method, + req.url, + headers, + null, + config.s3AccessKey, + config.s3SecretKey, + REGION, + ); + + if (!authResult.isValid) { + const status = authResult.errorCode === 'NotImplemented' ? 501 : 403; + const message = + authResult.errorCode === 'NotImplemented' + ? 'aws-chunked streaming payloads are not supported.' + : isPresigned + ? 'Presigned URL verification failed' + : 'Authentication required'; + return s3ErrorResponse( + authResult.errorCode || 'AccessDenied', + message, + pathname, + status, + reqId, + ); + } + + try { + // ── Root: ListBuckets / Service-level operations ── + if (!bucket) { + if (method === 'GET') { + return handleListBuckets(reqId); + } + return s3ErrorResponse( + 'MethodNotAllowed', + 'The specified method is not allowed against this resource.', + '/', + 405, + reqId, + ); + } + + // ── Bucket-level operations ── + if (!key) { + if (method === 'GET') { + if (searchParams.has('versioning')) { + return handleGetBucketVersioning(bucket, reqId); + } + if (searchParams.has('uploads')) { + return handleListMultipartUploads(bucket, searchParams, reqId); + } + const listType = searchParams.get('list-type'); + if (listType === '2') { + return handleListObjectsV2(bucket, searchParams, reqId); + } + return handleListObjectsV1(bucket, searchParams, reqId); + } + if (method === 'PUT') return handleCreateBucket(bucket, reqId); + if (method === 'HEAD') return handleHeadBucket(bucket, reqId); + if (method === 'DELETE') return handleDeleteBucket(bucket, reqId); + if (method === 'POST') { + if (searchParams.has('delete')) { + const body = await req.text(); + return handleDeleteObjects(bucket, body, reqId); + } + if (searchParams.has('tagging')) { + return s3Response(null, 204, reqId); + } + } + return s3ErrorResponse( + 'MethodNotAllowed', + 'The specified method is not allowed against this resource.', + `/${bucket}`, + 405, + reqId, + ); + } + + // ── Object-level: Multipart operations ── + if (searchParams.has('uploads') && method === 'POST') { + return handleCreateMultipartUpload(bucket, key, searchParams, reqId); + } + if (searchParams.has('uploadId') && searchParams.has('partNumber') && method === 'PUT') { + return handleUploadPart(bucket, key, searchParams, req, reqId); + } + if (searchParams.has('uploadId') && method === 'POST') { + const body = await req.text(); + return handleCompleteMultipartUpload(bucket, key, searchParams, body, reqId); + } + if (searchParams.has('uploadId') && method === 'DELETE') { + return handleAbortMultipartUpload(bucket, key, searchParams, reqId); + } + if (searchParams.has('uploadId') && method === 'GET') { + return handleListParts(bucket, key, searchParams, reqId); + } + + // ── Standard object operations ── + if (method === 'GET') return handleGetObject(bucket, key, searchParams, headers, reqId); + if (method === 'HEAD') return handleHeadObject(bucket, key, reqId); + if (method === 'PUT') return handlePutObject(bucket, key, searchParams, headers, req, reqId); + if (method === 'DELETE') return handleDeleteObject(bucket, key, reqId); + + return s3ErrorResponse( + 'MethodNotAllowed', + 'The specified method is not allowed against this resource.', + `/${bucket}/${key}`, + 405, + reqId, + ); + } catch (error: unknown) { + logger.error('S3 operation error', { bucket, key, error: getErrorMessage(error) }); + return s3ErrorResponse( + 'InternalError', + 'We encountered an internal error. Please try again.', + pathname, + 500, + reqId, + ); + } +}; + +// ─────── Bucket Operations ─────── + +/** + * Handles GET / — lists all buckets as an S3 ListAllMyBuckets XML response. + * + * @param reqId - The request identifier for S3 headers. + * @returns An S3 XML response with the bucket list. + */ +const handleListBuckets = async (reqId: string): Promise => { + const buckets = await listBuckets(); + const xml = listBucketsXml(buckets, reqId); + return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' }); +}; + +/** + * Handles PUT /{bucket} — creates a new S3 bucket. + * + * Validates the bucket name format and checks for duplicates. + * + * @param bucketName - The requested bucket name. + * @param reqId - The request identifier for S3 headers. + * @returns An S3 XML response indicating success or failure. + */ +const handleCreateBucket = async (bucketName: string, reqId: string): Promise => { + if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucketName)) { + return s3ErrorResponse( + 'InvalidBucketName', + 'The specified bucket is not valid.', + `/${bucketName}`, + 400, + reqId, + ); + } + const existing = await findBucketByName(bucketName); + if (existing) { + return s3ErrorResponse( + 'BucketAlreadyExists', + 'The requested bucket name is not available.', + `/${bucketName}`, + 409, + reqId, + ); + } + await createBucket(bucketName); + return s3Response(null, 200, reqId); +}; + +/** + * Handles HEAD /{bucket} — checks whether a bucket exists. + * + * @param bucketName - The bucket name to check. + * @param reqId - The request identifier for S3 headers. + * @returns A 200 response when the bucket exists, or an S3 XML error. + */ +const handleHeadBucket = async (bucketName: string, reqId: string): Promise => { + const bucket = await findBucketByName(bucketName); + if (!bucket) { + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucketName}`, + 404, + reqId, + ); + } + return s3Response(null, 200, reqId); +}; + +/** + * Handles DELETE /{bucket} — deletes a bucket. + * + * Fails with `BucketNotEmpty` if the bucket still contains objects. + * + * @param bucketName - The bucket name to delete. + * @param reqId - The request identifier for S3 headers. + * @returns A 204 response on success, or an S3 XML error. + */ +const handleDeleteBucket = async (bucketName: string, reqId: string): Promise => { + const bucket = await findBucketByName(bucketName); + if (!bucket) { + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucketName}`, + 404, + reqId, + ); + } + const objCount = await countBucketObjects(bucket.id); + if (objCount > 0) { + return s3ErrorResponse( + 'BucketNotEmpty', + 'The bucket you tried to delete is not empty.', + `/${bucketName}`, + 409, + reqId, + ); + } + await deleteBucket(bucketName); + return s3Response(null, 204, reqId); +}; + +/** + * Handles GET /{bucket}?versioning — returns the bucket versioning + * configuration (always disabled in this implementation). + * + * @param bucketName - The bucket name. + * @param reqId - The request identifier for S3 headers. + * @returns An S3 XML response with the versioning configuration. + */ +const handleGetBucketVersioning = async (bucketName: string, reqId: string): Promise => { + const bucket = await findBucketByName(bucketName); + if (!bucket) { + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucketName}`, + 404, + reqId, + ); + } + return s3Response(bucketVersioningConfigurationXml(), 200, reqId, { + 'content-type': 'application/xml', + }); +}; + +// ─────── Object Operations ─────── + +/** + * Handles GET /{bucket}/{key} — retrieves an S3 object. + * + * Supports chunked objects (streaming multi-part response), multipart + * objects (assembled from a completed multipart upload), and regular + * Telegram-stored objects (proxy streaming or 302 redirect depending + * on configuration). HTTP Range headers are respected when present. + * + * @param bucket - The bucket name. + * @param key - The object key. + * @param _searchParams - URL query parameters (unused for GET). + * @param headers - The request headers (used for Range and etag checks). + * @param reqId - The request identifier for S3 headers. + * @returns An S3 response with the object content or an error. + */ +const handleGetObject = async ( + bucket: string, + key: string, + _searchParams: URLSearchParams, + headers: Record, + reqId: string, +): Promise => { + const bucketRecord = await findBucketByName(bucket); + if (!bucketRecord) + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); + + const file = await findFileByBucketAndKey(bucketRecord.id, key); + if (!file) + return s3ErrorResponse( + 'NoSuchKey', + 'The specified key does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); + + // Chunked storage object + if (file.storageBackend === 'chunked') { + const totalSize = Number(file.sizeBytes); + const range = parseRangeHeader(headers.range || null, totalSize); + if (range.type === 'invalid') { + return s3ErrorResponse( + 'InvalidRange', + 'The requested range is not satisfiable.', + `/${bucket}/${key}`, + 416, + reqId, + { + 'content-range': unsatisfiedContentRange(totalSize), + }, + ); + } + try { + return await createChunkedObjectResponse({ file, range, reqId }); + } catch (error) { + logger.warn('Chunked object content fetch failed', { key, error: getErrorMessage(error) }); + return s3ErrorResponse( + 'InternalError', + 'Failed to fetch object content from storage', + `/${bucket}/${key}`, + 502, + reqId, + ); + } + } + + // Multipart upload assembled object + if (file.multipartUploadId) { + return handleGetMultipartObject(file, bucket, key, headers, reqId); + } + + // Regular Telegram object + const fileInfo = await getFileInfo(file.telegramFileId); + const redirectUrl = `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`; + + const totalSize = file.sizeBytes; + const range = parseRangeHeader(headers.range || null, totalSize); + if (range.type === 'invalid') { + return s3ErrorResponse( + 'InvalidRange', + 'The requested range is not satisfiable.', + `/${bucket}/${key}`, + 416, + reqId, + { + 'content-range': unsatisfiedContentRange(totalSize), + }, + ); + } + + if (!config.proxyS3Get) { + // Legacy 302 redirect path (when proxy is disabled) + return s3Response(null, 302, reqId, { location: redirectUrl }); + } + + const part: ObjectPartSource = { + telegramFileId: file.telegramFileId, + telegramUrl: redirectUrl, + sizeBytes: file.sizeBytes, + partNumber: 1, + }; + + try { + return await createGetObjectResponse({ + reqId, + contentType: file.mimeType, + etag: file.fileHash || '', + lastModified: file.createdAt instanceof Date ? file.createdAt : new Date(file.createdAt), + totalSize: file.sizeBytes, + parts: [part], + range, + }); + } catch (error) { + logger.warn('Telegram content fetch failed', { + fileId: file.telegramFileId, + error: getErrorMessage(error), + }); + return s3ErrorResponse( + 'InternalError', + 'Failed to fetch object content from storage', + `/${bucket}/${key}`, + 502, + reqId, + ); + } +}; + +/** + * Handles GET for objects assembled from a completed multipart upload. + * + * Resolves the Telegram CDN URLs for each part and builds a multi-part + * streaming response, respecting HTTP Range headers. + * + * @param file - The file entity with a `multipartUploadId` reference. + * @param bucket - The bucket name. + * @param key - The object key. + * @param headers - The request headers (for Range parsing). + * @param reqId - The request identifier for S3 headers. + * @returns An S3 response streaming the assembled object content. + */ +const handleGetMultipartObject = async ( + file: File, + bucket: string, + key: string, + headers: Record, + reqId: string, +): Promise => { + const uploadId = file.multipartUploadId!; + const parts = await listMultipartParts(uploadId); + + if (parts.length === 0) { + return s3ErrorResponse( + 'InternalError', + 'Multipart object has no parts.', + `/${bucket}/${key}`, + 500, + reqId, + ); + } + + const totalSize = parts.reduce((sum, p) => sum + Number(p.sizeBytes), 0); + const range = parseRangeHeader(headers.range || null, totalSize); + if (range.type === 'invalid') { + return s3ErrorResponse( + 'InvalidRange', + 'The requested range is not satisfiable.', + `/${bucket}/${key}`, + 416, + reqId, + { + 'content-range': unsatisfiedContentRange(totalSize), + }, + ); + } + + const sources: ObjectPartSource[] = []; + for (const part of parts) { + const fileInfo = await getFileInfo(part.telegramFileId); + sources.push({ + telegramFileId: part.telegramFileId, + telegramUrl: `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`, + sizeBytes: part.sizeBytes, + partNumber: part.partNumber, + }); + } + + if (!config.proxyS3Get) { + return s3Response(null, 302, reqId, { location: sources[0]?.telegramUrl ?? '' }); + } + + try { + return await createGetObjectResponse({ + reqId, + contentType: file.mimeType, + etag: file.fileHash || parts.map((p) => p.etag).join('-'), + lastModified: file.createdAt instanceof Date ? file.createdAt : new Date(file.createdAt), + totalSize, + parts: sources, + range, + }); + } catch (error) { + logger.warn('Telegram multipart content fetch failed', { + uploadId: file.multipartUploadId, + error: getErrorMessage(error), + }); + return s3ErrorResponse( + 'InternalError', + 'Failed to fetch object content from storage', + `/${bucket}/${key}`, + 502, + reqId, + ); + } +}; + +/** + * Handles HEAD /{bucket}/{key} — returns object metadata without the body. + * + * @param bucket - The bucket name. + * @param key - The object key. + * @param reqId - The request identifier for S3 headers. + * @returns An S3 response with object metadata headers. + */ +const handleHeadObject = async (bucket: string, key: string, reqId: string): Promise => { + const bucketRecord = await findBucketByName(bucket); + if (!bucketRecord) + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); + + const file = await findFileByBucketAndKey(bucketRecord.id, key); + if (!file) + return s3ErrorResponse( + 'NoSuchKey', + 'The specified key does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); + + return s3Response(null, 200, reqId, { + 'content-type': file.mimeType, + 'content-length': String(file.sizeBytes), + etag: `"${file.fileHash || nanoid(16)}"`, + 'last-modified': + file.createdAt instanceof Date ? file.createdAt.toUTCString() : new Date().toUTCString(), + 'accept-ranges': 'bytes', + 'cache-control': 'public, max-age=31536000', + }); +}; + +/** + * Handles PUT /{bucket}/{key} — uploads an S3 object. + * + * Supports regular binary uploads, copy-object via `x-amz-copy-source`, + * and tag operations. Large files are stored as chunked objects (across + * multiple Telegram messages), while smaller files use a single Telegram + * message. + * + * @param bucket - The bucket name. + * @param key - The object key. + * @param searchParams - URL query parameters. + * @param headers - The request headers. + * @param req - The incoming HTTP request with the object body. + * @param reqId - The request identifier for S3 headers. + * @returns An S3 response with the object etag or an error. + */ +const handlePutObject = async ( + bucket: string, + key: string, + searchParams: URLSearchParams, + headers: Record, + req: Request, + reqId: string, +): Promise => { + const bucketRecord = await findBucketByName(bucket); + if (!bucketRecord) + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); + + // Tag operations are idempotent no-ops + if (searchParams.has('tagging')) { + return s3Response(null, 204, reqId); + } + + // Copy-object path + const copySource = headers['x-amz-copy-source']; + if (copySource) { + return handleCopyObject(bucket, key, copySource, headers, bucketRecord.id, reqId); + } + + // Regular PUT: read raw binary body + const body = await req.arrayBuffer(); + const fileBuffer = Buffer.from(body); + const contentType = headers['content-type'] || 'application/octet-stream'; + const hash = computeHash(fileBuffer); + + // Idempotent PUT: if the object already exists, skip upload + const existing = await findFileByBucketAndKey(bucketRecord.id, key); + if (existing) { + return s3Response(null, 200, reqId, { etag: `"${hash}"` }); + } + + return await storeFileToTelegram(fileBuffer, hash, key, bucketRecord, contentType, reqId); +}; + +/** + * Stores a file buffer to Telegram storage as an S3 object. + * + * Handles both chunked (large files) and single-message (small files) paths. + * + * @param buffer - The raw file content buffer. + * @param hash - Pre-computed SHA-256 hex digest. + * @param key - The S3 object key. + * @param bucketRecord - The resolved bucket record (id and name). + * @param contentType - The MIME type from the request Content-Type header. + * @param reqId - The request identifier for S3 headers. + * @returns An S3 response with the etag of the stored object. + */ +const storeFileToTelegram = async ( + buffer: Buffer, + hash: string, + key: string, + bucketRecord: { id: string; name: string }, + contentType: string, + reqId: string, +): Promise => { + const tempPath = `/tmp/filedrop-s3-${nanoid()}`; + await Bun.write(tempPath, buffer); + + const signatureBuffer = buffer.subarray(0, 16); + const fileName = key.split('/').pop() || 'file'; + const { fileName: finalFileName, mimeType } = ensureExtension( + fileName, + signatureBuffer, + contentType, + ); + + const bucketId = bucketRecord.id; + const partFileNamePrefix = `s3-${bucketRecord.name}-${key.replace(/\//g, '_')}`; + + if (buffer.byteLength > config.telegramChunkSizeBytes) { + const file = await storeFileInTelegramChunks({ + tempPath, + partFileNamePrefix, + fileName: finalFileName, + mimeType, + sizeBytes: buffer.byteLength, + fileType: 'document', + uploaderId: 0, + bucketId, + s3Key: key, + }); + await cleanupTempFile(tempPath); + return s3Response(null, 200, reqId, { etag: `"${file.fileHash}"` }); + } + + const forwardResult = await forwardToStorage( + createReadStream(tempPath), + partFileNamePrefix, + 'document', + ); + + const publicId = nanoid(); + const { db, files: fileSchema } = await import('../../../db/index'); + + await db.insert(fileSchema).values({ + publicId, + telegramFileId: forwardResult.telegramFileId, + telegramFileUniqueId: forwardResult.telegramFileUniqueId, + storageChatId: config.storageChatId, + storageMessageId: forwardResult.storageMessageId, + fileName: finalFileName, + mimeType, + sizeBytes: buffer.byteLength, + fileType: 'document', + uploaderId: 0, + fileHash: hash, + bucketId, + s3Key: key, + storageBackend: 'telegram', + isDeleted: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await cleanupTempFile(tempPath); + + return s3Response(null, 200, reqId, { etag: `"${hash}"` }); +}; + +/** + * Handles PUT /{bucket}/{key} with an `x-amz-copy-source` header. + * + * Creates a new file record referencing the same Telegram-stored data as + * the source object. Chunked source objects are not supported for copy. + * + * @param _destBucket - The destination bucket name (unused — bucket record + * already resolved). + * @param destKey - The destination object key. + * @param rawCopySource - The raw `x-amz-copy-source` header value. + * @param headers - The request headers (for conditional copy checks). + * @param destBucketId - The UUID of the destination bucket. + * @param reqId - The request identifier for S3 headers. + * @returns An S3 XML response with the copy result or an error. + */ +const handleCopyObject = async ( + _destBucket: string, + destKey: string, + rawCopySource: string, + headers: Record, + destBucketId: string, + reqId: string, +): Promise => { + const copySource = decodeURIComponent(rawCopySource); + const sourcePath = copySource.startsWith('/') ? copySource.slice(1) : copySource; + const parts = sourcePath.split('/'); + const sourceBucket = parts[0]; + const sourceKey = parts.slice(1).join('/'); + + const sourceBucketRecord = await findBucketByName(sourceBucket); + if (!sourceBucketRecord) + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + copySource, + 404, + reqId, + ); + + const sourceFile = await findFileByBucketAndKey(sourceBucketRecord.id, sourceKey); + if (!sourceFile) + return s3ErrorResponse( + 'NoSuchKey', + 'The specified key does not exist.', + copySource, + 404, + reqId, + ); + + // Chunked objects cannot be copied yet + if (sourceFile.storageBackend === 'chunked') { + return s3ErrorResponse( + 'NotImplemented', + 'Copying chunked objects is not yet implemented.', + copySource, + 501, + reqId, + ); + } + + // Conditional copy: if-match / if-none-match checks + const ifMatch = headers['x-amz-copy-source-if-match']; + const ifNoneMatch = headers['x-amz-copy-source-if-none-match']; + if (ifMatch && sourceFile.fileHash && ifMatch !== `"${sourceFile.fileHash}"`) { + return s3ErrorResponse( + 'PreconditionFailed', + 'The preconditions you specified did not hold.', + copySource, + 412, + reqId, + ); + } + if (ifNoneMatch && sourceFile.fileHash && ifNoneMatch === `"${sourceFile.fileHash}"`) { + return s3ErrorResponse( + 'PreconditionFailed', + 'The preconditions you specified did not hold.', + copySource, + 412, + reqId, + ); + } + + const publicId = nanoid(); + const { db, files: fileSchema } = await import('../../../db/index'); + + await db.insert(fileSchema).values({ + publicId, + telegramFileId: sourceFile.telegramFileId, + telegramFileUniqueId: sourceFile.telegramFileUniqueId, + storageChatId: sourceFile.storageChatId, + storageMessageId: sourceFile.storageMessageId, + fileName: sourceFile.fileName, + mimeType: sourceFile.mimeType, + sizeBytes: sourceFile.sizeBytes, + fileType: sourceFile.fileType, + uploaderId: 0, + fileHash: sourceFile.fileHash, + bucketId: destBucketId, + s3Key: destKey, + storageBackend: 'telegram', + isDeleted: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const xml = copyObjectResultXml(sourceFile.fileHash || nanoid(16), new Date()); + return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' }); +}; + +/** + * Handles DELETE /{bucket}/{key} — soft-deletes an S3 object. + * + * @param bucket - The bucket name. + * @param key - The object key to delete. + * @param reqId - The request identifier for S3 headers. + * @returns A 204 response on success, or an S3 XML error. + */ +const handleDeleteObject = async ( + bucket: string, + key: string, + reqId: string, +): Promise => { + const bucketRecord = await findBucketByName(bucket); + if (!bucketRecord) + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); + + await softDeleteFile(bucketRecord.id, key); + return s3Response(null, 204, reqId); +}; + +/** + * Handles POST /{bucket}?delete — batch-deletes multiple S3 objects. + * + * Parses the XML Delete request body, soft-deletes each key, and returns + * an XML delete result. + * + * @param bucket - The bucket name. + * @param body - The raw XML request body. + * @param reqId - The request identifier for S3 headers. + * @returns An S3 XML response listing deleted keys. + */ +const handleDeleteObjects = async ( + bucket: string, + body: string, + reqId: string, +): Promise => { + const bucketRecord = await findBucketByName(bucket); + if (!bucketRecord) + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucket}`, + 404, + reqId, + ); + + const { keys, quiet } = parseDeleteObjectsBody(body); + const deletedKeys: string[] = []; + for (const key of keys) { + const ok = await softDeleteFile(bucketRecord.id, key); + if (ok) deletedKeys.push(key); + } + const xml = quiet ? deleteResultXml([], []) : deleteResultXml(deletedKeys, []); + return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' }); +}; + +// ─────── Object Listing ─────── + +/** + * Handles GET /{bucket} (ListObjectsV1 with query parameters). + * + * @param bucket - The bucket name. + * @param searchParams - URL query parameters (prefix, delimiter, max-keys, + * marker, encoding-type). + * @param reqId - The request identifier for S3 headers. + * @returns An S3 XML ListBucketResult response. + */ +const handleListObjectsV1 = async ( + bucket: string, + searchParams: URLSearchParams, + reqId: string, +): Promise => { + const bucketRecord = await findBucketByName(bucket); + if (!bucketRecord) + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucket}`, + 404, + reqId, + ); + + const prefix = searchParams.get('prefix') || ''; + const delimiter = searchParams.get('delimiter') || null; + const maxKeys = Math.min(Number.parseInt(searchParams.get('max-keys') || '1000', 10), 1000); + const marker = searchParams.get('marker') || null; + const encodingType = searchParams.get('encoding-type') || null; + + const { objects, prefixes: commonPrefixes } = await listObjectsByPrefix( + bucketRecord.id, + prefix, + delimiter, + maxKeys, + marker, + ); + + const isTruncated = objects.length > maxKeys; + const displayObjects = objects.slice(0, maxKeys); + const nextMarker = isTruncated + ? (displayObjects[displayObjects.length - 1]?.s3Key ?? null) + : null; + + const xml = listBucketResultXml( + bucket, + displayObjects.map((o) => ({ + key: o.s3Key ?? '', + sizeBytes: o.sizeBytes, + etag: o.fileHash || nanoid(16), + lastModified: o.createdAt instanceof Date ? o.createdAt : new Date(), + mimeType: o.mimeType, + })), + commonPrefixes, + isTruncated, + marker, + maxKeys, + prefix, + delimiter, + nextMarker, + reqId, + encodingType, + ); + + return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' }); +}; + +/** + * Handles GET /{bucket}?list-type=2 (ListObjectsV2). + * + * @param bucket - The bucket name. + * @param searchParams - URL query parameters (prefix, delimiter, max-keys, + * continuation-token, start-after, encoding-type). + * @param reqId - The request identifier for S3 headers. + * @returns An S3 XML ListBucketV2Result response. + */ +const handleListObjectsV2 = async ( + bucket: string, + searchParams: URLSearchParams, + reqId: string, +): Promise => { + const bucketRecord = await findBucketByName(bucket); + if (!bucketRecord) + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucket}`, + 404, + reqId, + ); + + const prefix = searchParams.get('prefix') || ''; + const delimiter = searchParams.get('delimiter') || null; + const maxKeys = Math.min(Number.parseInt(searchParams.get('max-keys') || '1000', 10), 1000); + const continuationToken = searchParams.get('continuation-token') || null; + const startAfter = searchParams.get('start-after') || null; + const encodingType = searchParams.get('encoding-type') || null; + + const { objects, prefixes: commonPrefixes } = await listObjectsByPrefix( + bucketRecord.id, + prefix, + delimiter, + maxKeys, + continuationToken || startAfter, + ); + + const isTruncated = objects.length > maxKeys; + const displayObjects = objects.slice(0, maxKeys); + const nextContinuationToken = isTruncated + ? (displayObjects[displayObjects.length - 1]?.s3Key ?? null) + : null; + + const xml = listBucketV2ResultXml( + bucket, + displayObjects.map((o) => ({ + key: o.s3Key ?? '', + sizeBytes: o.sizeBytes, + etag: o.fileHash || nanoid(16), + lastModified: o.createdAt instanceof Date ? o.createdAt : new Date(), + mimeType: o.mimeType, + })), + commonPrefixes, + isTruncated, + maxKeys, + prefix, + delimiter, + continuationToken, + nextContinuationToken, + displayObjects.length, + reqId, + encodingType, + ); + + return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' }); +}; + +// ─────── Multipart Upload ─────── + +/** + * Handles POST /{bucket}/{key}?uploads — initiates a multipart upload. + * + * @param bucket - The bucket name. + * @param key - The object key being uploaded. + * @param _searchParams - URL query parameters (unused). + * @param reqId - The request identifier for S3 headers. + * @returns An S3 XML InitiateMultipartUpload response. + */ +const handleCreateMultipartUpload = async ( + bucket: string, + key: string, + _searchParams: URLSearchParams, + reqId: string, +): Promise => { + const bucketRecord = await findBucketByName(bucket); + if (!bucketRecord) + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); + + const uploadId = await createMultipartUpload(bucketRecord.id, key, 's3'); + + const xml = initiateMultipartUploadXml(bucket, key, uploadId); + return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' }); +}; + +/** + * Handles PUT /{bucket}/{key}?uploadId=&partNumber= — uploads a single + * part of a multipart upload. + * + * @param bucket - The bucket name. + * @param key - The object key. + * @param searchParams - URL query parameters containing uploadId and + * partNumber. + * @param req - The incoming HTTP request with the part body. + * @param reqId - The request identifier for S3 headers. + * @returns An S3 response with the part etag, or an error. + */ +const handleUploadPart = async ( + bucket: string, + key: string, + searchParams: URLSearchParams, + req: Request, + reqId: string, +): Promise => { + const uploadId = searchParams.get('uploadId')!; + const partNumber = Number.parseInt(searchParams.get('partNumber')!, 10); + if (partNumber < 1 || partNumber > 10000) { + return s3ErrorResponse( + 'InvalidArgument', + 'Part number must be an integer between 1 and 10000', + `/${bucket}/${key}`, + 400, + reqId, + ); + } + + const multipart = await findMultipartUpload(uploadId); + if (!multipart || multipart.s3Key !== key) { + return s3ErrorResponse( + 'NoSuchUpload', + 'The specified upload does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); + } + + const body = await req.arrayBuffer(); + const buffer = Buffer.from(body); + + if (buffer.byteLength > config.telegramChunkSizeBytes) { + return s3ErrorResponse( + 'EntityTooLarge', + `Your proposed upload part size (${buffer.byteLength} bytes) exceeds the maximum allowed part size (${config.telegramChunkSizeBytes} bytes) for this storage backend. Use smaller part sizes.`, + `/${bucket}/${key}`, + 400, + reqId, + ); + } + + const tempPath = `/tmp/filedrop-mp-${nanoid()}`; + await Bun.write(tempPath, buffer); + + const forwardResult = await forwardToStorage( + createReadStream(tempPath), + `mp-${uploadId}-part-${partNumber}`, + 'document', + ); + + await cleanupTempFile(tempPath); + + const etag = computeHash(buffer); + await insertMultipartPart({ + uploadId, + partNumber, + telegramFileId: forwardResult.telegramFileId, + telegramFileUniqueId: forwardResult.telegramFileUniqueId, + storageMessageId: forwardResult.storageMessageId, + sizeBytes: buffer.byteLength, + etag, + }); + + return s3Response(null, 200, reqId, { etag: `"${etag}"` }); +}; + +/** + * Handles POST /{bucket}/{key}?uploadId= — completes a multipart upload. + * + * Validates the submitted part list (all parts present, ascending order), + * creates the final file record, and marks the upload as completed. + * + * @param bucket - The bucket name. + * @param key - The object key. + * @param searchParams - URL query parameters containing uploadId. + * @param body - The raw XML request body containing the complete part list. + * @param reqId - The request identifier for S3 headers. + * @returns An S3 XML CompleteMultipartUpload response. + */ +const handleCompleteMultipartUpload = async ( + bucket: string, + key: string, + searchParams: URLSearchParams, + body: string, + reqId: string, +): Promise => { + const uploadId = searchParams.get('uploadId')!; + const multipart = await findMultipartUpload(uploadId); + if (!multipart) { + return s3ErrorResponse( + 'NoSuchUpload', + 'The specified upload does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); + } + + const parts = parseCompleteMultipartBody(body); + const storedParts = await listMultipartParts(uploadId); + + // Validate ascending part order + const partNumbers = parts.map((p) => p.partNumber); + if (partNumbers.length > 1 && partNumbers.some((n, i) => i > 0 && n <= partNumbers[i - 1])) { + return s3ErrorResponse( + 'InvalidPartOrder', + 'The list of parts was not in ascending order.', + `/${bucket}/${key}`, + 400, + reqId, + ); + } + + if (parts.length !== storedParts.length) { + return s3ErrorResponse( + 'InvalidPart', + 'One or more specified parts could not be found.', + `/${bucket}/${key}`, + 400, + reqId, + ); + } + + const totalSize = storedParts.reduce((sum, p) => sum + p.sizeBytes, 0); + + const publicId = nanoid(); + const { db, files: fileSchema } = await import('../../../db/index'); + + await db.insert(fileSchema).values({ + publicId, + telegramFileId: storedParts[0]!.telegramFileId, + telegramFileUniqueId: storedParts[0]!.telegramFileUniqueId, + storageChatId: config.storageChatId, + storageMessageId: storedParts[0]!.storageMessageId, + fileName: key.split('/').pop() || 'file', + mimeType: 'application/octet-stream', + sizeBytes: totalSize, + fileType: 'document', + uploaderId: 0, + bucketId: multipart.bucketId, + s3Key: key, + storageBackend: 'telegram', + isDeleted: false, + multipartUploadId: uploadId, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await completeMultipartUpload(uploadId); + + const location = `${config.baseUrl}/${bucket}/${key}`; + const combinedEtag = storedParts.map((p) => p.etag).join('-'); + const xml = completeMultipartUploadXml(bucket, key, combinedEtag, location); + + return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' }); +}; + +/** + * Handles GET /{bucket}?uploads — lists in-progress multipart uploads. + * + * @param bucket - The bucket name. + * @param searchParams - URL query parameters (max-uploads, key-marker). + * @param reqId - The request identifier for S3 headers. + * @returns An S3 XML ListMultipartUploadsResult response. + */ +const handleListMultipartUploads = async ( + bucket: string, + searchParams: URLSearchParams, + reqId: string, +): Promise => { + const bucketRecord = await findBucketByName(bucket); + if (!bucketRecord) + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucket}`, + 404, + reqId, + ); + + const maxUploads = Math.min(Number.parseInt(searchParams.get('max-uploads') || '1000', 10), 1000); + const keyMarker = searchParams.get('key-marker') || null; + const { uploads, isTruncated, nextKeyMarker } = await listMultipartUploadsByBucket( + bucketRecord.id, + maxUploads, + keyMarker, + ); + + const xml = listMultipartUploadsXml( + bucket, + uploads.map((u) => ({ + key: u.s3Key, + uploadId: u.uploadId, + initiatedAt: u.initiatedAt, + initiatedBy: u.initiatedBy, + })), + maxUploads, + isTruncated, + nextKeyMarker, + reqId, + ); + + return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' }); +}; + +/** + * Handles DELETE /{bucket}/{key}?uploadId= — aborts a multipart upload. + * + * @param bucket - The bucket name. + * @param key - The object key. + * @param searchParams - URL query parameters containing uploadId. + * @param reqId - The request identifier for S3 headers. + * @returns A 204 response on success, or an S3 XML error. + */ +const handleAbortMultipartUpload = async ( + bucket: string, + key: string, + searchParams: URLSearchParams, + reqId: string, +): Promise => { + const uploadId = searchParams.get('uploadId')!; + const multipart = await findMultipartUpload(uploadId); + if (!multipart) { + return s3ErrorResponse( + 'NoSuchUpload', + 'The specified upload does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); + } + + await abortMultipartUpload(uploadId); + return s3Response(null, 204, reqId); +}; + +/** + * Handles GET /{bucket}/{key}?uploadId= — lists uploaded parts of a + * multipart upload. + * + * @param bucket - The bucket name. + * @param key - The object key. + * @param searchParams - URL query parameters containing uploadId and + * optional max-parts. + * @param reqId - The request identifier for S3 headers. + * @returns An S3 XML ListPartsResult response. + */ +const handleListParts = async ( + bucket: string, + key: string, + searchParams: URLSearchParams, + reqId: string, +): Promise => { + const uploadId = searchParams.get('uploadId')!; + const multipart = await findMultipartUpload(uploadId); + if (!multipart) { + return s3ErrorResponse( + 'NoSuchUpload', + 'The specified upload does not exist.', + `/${bucket}/${key}`, + 404, + reqId, + ); + } + + const parts = await listMultipartParts(uploadId); + const maxParts = Math.min(Number.parseInt(searchParams.get('max-parts') || '1000', 10), 1000); + + const xml = listPartsXml( + bucket, + key, + uploadId, + parts.map((p) => ({ + partNumber: p.partNumber, + etag: p.etag, + sizeBytes: p.sizeBytes, + createdAt: p.createdAt, + })), + maxParts, + false, + reqId, + ); + + return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' }); +}; \ No newline at end of file diff --git a/src/interfaces/http/controllers/upload-controller.ts b/src/interfaces/http/controllers/upload-controller.ts new file mode 100644 index 0000000..0601bd0 --- /dev/null +++ b/src/interfaces/http/controllers/upload-controller.ts @@ -0,0 +1,402 @@ +import { createWriteStream } from 'node:fs'; +import { nanoid } from 'nanoid'; +import { config } from '../../../config/index'; +import { + buildUploadResponse, + checkFileSize, + cleanupTempFile, + computeHash, + ensureExtension, + extractMimeType, + getErrorMessage, + getFileType, +} from '../../../shared/utils/file'; +import logger from '../../../shared/logger/index'; +import { metricsCollector } from '../../../shared/metrics/index'; +import { enqueuePreparedUpload, type PreparedUpload } from '../../../utils/uploadBatcher'; +import { storeFileInTelegramChunks } from '../../../utils/chunked-storage'; +import { findFileByHash } from '../../../db/files'; + +/** + * Maximum allowed size (in bytes) for a base64 JSON upload. + * JSON uploads are limited to 50 MB because base64 encoding adds ~33% + * overhead and large payloads strain the JSON parser. + */ +const JSON_UPLOAD_LIMIT_BYTES = 50 * 1024 * 1024; + +/** Number of leading bytes read for magic-byte / signature detection. */ +const SIGNATURE_BYTES = 16; + +/** + * Payload structure accepted by the JSON upload endpoint. + */ +interface JsonUploadPayload { + /** Base64-encoded file data (optionally with a data URI prefix). */ + file?: unknown; + /** Optional file name. */ + fileName?: string; +} + +/** + * Parses a base64-encoded file string, optionally stripping the data URI + * prefix. + * + * Accepts both bare base64 strings and RFC 2397 data URIs (e.g. + * `data:image/png;base64,...`). + * + * @param file - The base64 string, with or without a data URI prefix. + * @returns The raw base64 payload and the detected MIME type. + */ +const parseBase64File = (file: string): { base64Data: string; mimeType: string } => { + if (!file.startsWith('data:')) { + return { base64Data: file, mimeType: 'application/octet-stream' }; + } + + const match = file.match(/^data:([^;]+);base64,(.+)$/); + return match + ? { base64Data: match[2], mimeType: match[1] } + : { base64Data: file, mimeType: 'application/octet-stream' }; +}; + +/** + * Extracts the Content-Length header value as a number. + * + * @param req - The incoming HTTP request. + * @returns The content length in bytes, or `null` when the header is missing + * or invalid. + */ +const getContentLength = (req: Request): number | null => { + const value = req.headers.get('content-length'); + if (!value) return null; + + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +}; + +/** + * Checks whether the request body exceeds the configured maximum size and + * returns an error response if it does. + * + * @param req - The incoming HTTP request. + * @returns A 413 Response when the request is too large, or `null` when + * the size is within bounds (or unknown). + */ +const rejectOversizedRequest = (req: Request): Response | null => { + const contentLength = getContentLength(req); + if (contentLength !== null && contentLength > config.maxRequestBodyBytes) { + return Response.json({ error: 'Request body too large' }, { status: 413 }); + } + + return null; +}; + +/** + * Streams a multipart `File` to a temporary file on disk while computing + * its SHA-256 hash and extracting the signature (first 16 bytes). + * + * Backpressure from the write stream is respected via the drain event. + * + * @param file - The multipart `File` object. + * @param maxSizeBytes - Maximum allowed file size; an error is thrown if + * the stream exceeds this limit. + * @returns A fully prepared upload descriptor with hash, size, and temp path. + * @throws {Error} When the file size exceeds `maxSizeBytes`. + */ +const streamFileToTemp = async (file: File, maxSizeBytes: number): Promise => { + const tempPath = `/tmp/filedrop-${nanoid()}`; + const writer = createWriteStream(tempPath); + const hasher = new Bun.CryptoHasher('sha256'); + const reader = file.stream().getReader(); + const signatureChunks: Buffer[] = []; + let signatureBytes = 0; + let sizeBytes = 0; + + const writeChunk = async (chunk: Buffer): Promise => { + if (!writer.write(chunk)) { + await new Promise((resolve, reject) => { + writer.once('drain', resolve); + writer.once('error', reject); + }); + } + }; + + const finishWriter = async (): Promise => { + await new Promise((resolve, reject) => { + writer.end(() => resolve()); + writer.once('error', reject); + }); + }; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = Buffer.from(value); + sizeBytes += chunk.byteLength; + if (sizeBytes > maxSizeBytes) { + throw new Error('File size exceeds upload limit'); + } + + hasher.update(chunk); + await writeChunk(chunk); + + if (signatureBytes < SIGNATURE_BYTES) { + const remaining = SIGNATURE_BYTES - signatureBytes; + const signatureChunk = chunk.subarray(0, remaining); + signatureChunks.push(signatureChunk); + signatureBytes += signatureChunk.byteLength; + } + } + + await finishWriter(); + + return { + tempPath, + fileHash: hasher.digest('hex'), + sizeBytes, + signatureBuffer: Buffer.concat(signatureChunks, signatureBytes), + }; + } catch (error) { + writer.destroy(); + await cleanupTempFile(tempPath); + throw error; + } finally { + reader.releaseLock(); + } +}; + +/** + * Writes an in-memory buffer to a temporary file on disk. + * + * Used for base64 JSON uploads where the decoded data is already in a Buffer. + * + * @param fileBuffer - The decoded file content. + * @param fileHash - Pre-computed SHA-256 hex digest. + * @returns A prepared upload descriptor. + */ +const writeBufferToTemp = async (fileBuffer: Buffer, fileHash: string): Promise => { + const tempPath = `/tmp/filedrop-${nanoid()}`; + try { + await Bun.write(tempPath, fileBuffer); + return { + tempPath, + fileHash, + sizeBytes: fileBuffer.byteLength, + signatureBuffer: fileBuffer.subarray(0, SIGNATURE_BYTES), + }; + } catch (error) { + await cleanupTempFile(tempPath); + throw error; + } +}; + +/** + * Handles a multipart/form-data file upload. + * + * Steps: + * 1. Parse the multipart form and extract the file. + * 2. Stream the file to a temp location, computing its hash. + * 3. Check for deduplication by content hash. + * 4. Determine the MIME type, file name, and Telegram file type. + * 5. Validate file size limits. + * 6. Upload to Telegram (chunked or single-message). + * 7. Return the upload response JSON. + * + * @param req - The incoming HTTP request with a multipart body. + * @returns A JSON response with the uploaded file metadata. + */ +const handleMultipartUpload = async (req: Request): Promise => { + try { + const formData = await req.formData(); + const file = formData.get('file'); + const fileName = + (formData.get('fileName') as string) || (file instanceof File ? file.name : null) || 'file'; + + if (!file || !(file instanceof File)) { + return Response.json({ error: 'No file provided' }, { status: 400 }); + } + + if (file.size > config.maxRequestBodyBytes) { + return Response.json({ error: 'File size exceeds upload limit' }, { status: 413 }); + } + + const prepared = await streamFileToTemp(file, config.maxRequestBodyBytes); + + const existingFile = await findFileByHash(prepared.fileHash); + if (existingFile) { + await cleanupTempFile(prepared.tempPath); + return Response.json(buildUploadResponse(existingFile, config.baseUrl), { status: 200 }); + } + + const rawMimeType = file.type || extractMimeType({}, req) || 'application/octet-stream'; + const { fileName: finalFileName, mimeType } = ensureExtension( + fileName, + prepared.signatureBuffer, + rawMimeType, + ); + const fileType = getFileType(mimeType, finalFileName); + + if (!checkFileSize(prepared.sizeBytes, fileType)) { + await cleanupTempFile(prepared.tempPath); + return Response.json({ error: `File size exceeds ${fileType} limit` }, { status: 400 }); + } + + if (prepared.sizeBytes > config.telegramChunkSizeBytes) { + const uploadedFile = await storeFileInTelegramChunks({ + tempPath: prepared.tempPath, + partFileNamePrefix: `direct-${prepared.fileHash?.slice(0, 16) || 'upload'}`, + fileName: finalFileName, + mimeType, + sizeBytes: prepared.sizeBytes, + fileType, + uploaderId: 0, + }); + await cleanupTempFile(prepared.tempPath); + return Response.json(buildUploadResponse(uploadedFile, config.baseUrl), { status: 200 }); + } + + const uploaded = await enqueuePreparedUpload({ + prepared, + fileName: finalFileName, + mimeType, + fileType, + }); + + return Response.json(buildUploadResponse(uploaded, config.baseUrl), { status: 200 }); + } catch (error: unknown) { + const message = getErrorMessage(error); + logger.error('Multipart upload error', { error: message }); + return Response.json({ error: message }, { status: 500 }); + } +}; + +/** + * Handles an application/json file upload where the file is sent as a + * base64-encoded string. + * + * Steps: + * 1. Parse the JSON body and extract the base64 file data. + * 2. Decode and estimate the file size; reject if too large for JSON. + * 3. Write the decoded buffer to a temp file. + * 4. Check deduplication by content hash. + * 5. Determine MIME type, file name, and Telegram file type. + * 6. Validate file size limits. + * 7. Upload to Telegram (chunked or single-message). + * 8. Return the upload response JSON. + * + * @param req - The incoming HTTP request with a JSON body. + * @returns A JSON response with the uploaded file metadata. + */ +const handleJSONUpload = async (req: Request): Promise => { + try { + const { file, fileName = 'file' } = (await req.json()) as JsonUploadPayload; + + if (!file || typeof file !== 'string') { + return Response.json( + { error: 'Invalid JSON. Must include "file" (base64) and optional "fileName"' }, + { status: 400 }, + ); + } + + const { base64Data, mimeType: rawMimeType } = parseBase64File(file); + const estimatedSizeBytes = Math.floor((base64Data.length * 3) / 4); + if ( + estimatedSizeBytes > JSON_UPLOAD_LIMIT_BYTES || + estimatedSizeBytes > config.maxRequestBodyBytes + ) { + return Response.json( + { + error: + 'JSON base64 uploads are limited to 50MB. Use multipart/form-data for larger files', + }, + { status: 400 }, + ); + } + + const fileBytes = Buffer.from(base64Data, 'base64'); + const hash = computeHash(fileBytes); + + const existingFile = await findFileByHash(hash); + if (existingFile) { + return Response.json(buildUploadResponse(existingFile, config.baseUrl), { status: 200 }); + } + + const fileTypeRaw = getFileType(rawMimeType, fileName); + const fileType = fileTypeRaw === 'application' ? 'document' : fileTypeRaw; + + const { fileName: finalFileName, mimeType } = ensureExtension(fileName, fileBytes, rawMimeType); + + if (!checkFileSize(fileBytes.byteLength, fileType)) { + return Response.json({ error: `File size exceeds ${fileType} limit` }, { status: 400 }); + } + + const prepared = await writeBufferToTemp(fileBytes, hash); + + if (prepared.sizeBytes > config.telegramChunkSizeBytes) { + const uploadedFile = await storeFileInTelegramChunks({ + tempPath: prepared.tempPath, + partFileNamePrefix: `direct-${prepared.fileHash?.slice(0, 16) || 'json'}`, + fileName: finalFileName, + mimeType, + sizeBytes: prepared.sizeBytes, + fileType, + uploaderId: 0, + }); + await cleanupTempFile(prepared.tempPath); + return Response.json(buildUploadResponse(uploadedFile, config.baseUrl), { status: 200 }); + } + + const uploaded = await enqueuePreparedUpload({ + prepared, + fileName: finalFileName, + mimeType, + fileType, + }); + + return Response.json(buildUploadResponse(uploaded, config.baseUrl), { status: 200 }); + } catch (error: unknown) { + const message = getErrorMessage(error); + logger.error('JSON upload error', { error: message }); + return Response.json({ error: message }, { status: 500 }); + } +}; + +/** + * Main upload request handler. + * + * Dispatches to either the multipart or JSON handler based on the request + * Content-Type header, returning an appropriate error for unsupported + * content types. + * + * Recording of upload metrics is handled centrally in this function. + * + * @param req - The incoming HTTP request. + * @returns A JSON response with the uploaded file metadata or an error. + */ +export const handleUpload = async (req: Request): Promise => { + const startTime = performance.now(); + try { + const contentType = req.headers.get('content-type') || ''; + const oversizedResponse = rejectOversizedRequest(req); + if (oversizedResponse) return oversizedResponse; + + if (contentType.includes('multipart/form-data')) { + return handleMultipartUpload(req); + } else if (contentType.includes('application/json')) { + return handleJSONUpload(req); + } + + return Response.json( + { error: 'Unsupported content type. Use multipart/form-data or application/json' }, + { status: 400 }, + ); + } catch (error: unknown) { + metricsCollector.recordError(); + const message = getErrorMessage(error); + logger.error('Upload error', { error: message }); + return Response.json({ error: message }, { status: 500 }); + } finally { + metricsCollector.recordUploadTime(performance.now() - startTime); + } +}; \ No newline at end of file diff --git a/src/interfaces/http/controllers/web-api-controller.ts b/src/interfaces/http/controllers/web-api-controller.ts new file mode 100644 index 0000000..1de647f --- /dev/null +++ b/src/interfaces/http/controllers/web-api-controller.ts @@ -0,0 +1,438 @@ +import { createReadStream } from 'node:fs'; +import { nanoid } from 'nanoid'; +import { createBucket, deleteBucket, findBucketByName, listBuckets } from '../../../db/buckets'; +import { + countBucketObjects, + findFileByBucketAndKey, + listObjectsByPrefix, + softDeleteFile, +} from '../../../db/files-ext'; +import { config } from '../../../config/index'; +import { createChunkedObjectResponse, storeFileInTelegramChunks } from '../../../utils/chunked-storage'; +import { cleanupTempFile, computeHash, ensureExtension, getErrorMessage } from '../../../shared/utils/file'; +import logger from '../../../shared/logger/index'; +import { forwardToStorage, getFileInfo } from '../../../utils/telegram'; + +/** + * Route parameters extracted from the URL path. + */ +type RouteParams = { bucket?: string; key?: string }; + +/** + * Returns a successful JSON Response. + * + * @param data - The JSON-serialisable body. + * @param status - HTTP status code (default 200). + * @returns A JSON Response. + */ +const json = (data: unknown, status = 200): Response => Response.json(data, { status }); + +/** + * Returns a JSON error Response. + * + * @param error - The error message. + * @param status - HTTP status code. + * @returns A JSON Response. + */ +const jsonError = (error: string, status: number): Response => Response.json({ error }, { status }); + +// ─────── Bucket endpoints ─────── + +/** + * Lists all buckets together with their object counts. + * + * @returns A JSON response with the bucket list. + */ +export const handleListBucketsV1 = async (): Promise => { + const buckets = await listBuckets(); + const result = await Promise.all( + buckets.map(async (b) => ({ + id: b.id, + name: b.name, + createdAt: b.createdAt.toISOString(), + objectCount: await countBucketObjects(b.id), + })), + ); + return json({ buckets: result }); +}; + +/** + * Creates a new bucket. + * + * Validates the bucket name format and checks for duplicates before creating. + * + * @param req - The incoming HTTP request with a JSON body containing `name`. + * @returns A JSON response with the created bucket or an error. + */ +export const handleCreateBucketV1 = async (req: Request): Promise => { + const body = (await req.json()) as { name?: string }; + if (!body.name || !/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(body.name)) { + return jsonError('Invalid bucket name. Use lowercase, 3-63 chars, no underscore', 400); + } + const existing = await findBucketByName(body.name); + if (existing) return jsonError('Bucket already exists', 409); + const bucket = await createBucket(body.name); + return json({ id: bucket.id, name: bucket.name }, 201); +}; + +/** + * Deletes a bucket by name. + * + * Ensures the bucket exists and is empty before deletion. + * + * @param _req - The incoming HTTP request (unused). + * @param params - Route parameters containing the bucket name. + * @returns A JSON response indicating success or an error. + */ +export const handleDeleteBucketV1 = async ( + _req: Request, + params: RouteParams, +): Promise => { + const bucket = await findBucketByName(params.bucket!); + if (!bucket) return jsonError('Bucket not found', 404); + const count = await countBucketObjects(bucket.id); + if (count > 0) return jsonError('Bucket is not empty', 409); + await deleteBucket(params.bucket!); + return json({ success: true }); +}; + +// ─────── Object endpoints ─────── + +/** + * Lists objects within a bucket (with prefix filtering and pagination). + * + * @param req - The incoming HTTP request with query parameters. + * @param params - Route parameters containing the bucket name. + * @returns A JSON response with the object list. + */ +export const handleListObjectsV1 = async (req: Request, params: RouteParams): Promise => { + const bucket = await findBucketByName(params.bucket!); + if (!bucket) return jsonError('Bucket not found', 404); + + const url = new URL(req.url); + const prefix = url.searchParams.get('prefix') || ''; + const delimiter = url.searchParams.get('delimiter') || '/'; + const maxKeys = Number.parseInt(url.searchParams.get('max-keys') || '1000', 10); + const continuationToken = url.searchParams.get('continuation-token') || null; + + const { objects, prefixes } = await listObjectsByPrefix( + bucket.id, + prefix, + delimiter, + maxKeys, + continuationToken, + ); + const isTruncated = objects.length > maxKeys; + const displayObjects = objects.slice(0, maxKeys); + + return json({ + objects: displayObjects.map((o) => ({ + key: o.s3Key, + fileName: o.fileName, + mimeType: o.mimeType, + sizeBytes: Number(o.sizeBytes), + fileType: o.fileType, + etag: o.fileHash, + lastModified: + o.createdAt instanceof Date + ? o.createdAt.toISOString() + : new Date(o.createdAt).toISOString(), + downloadUrl: `${config.baseUrl}/f/${o.publicId}`, + })), + prefixes, + isTruncated, + nextContinuationToken: isTruncated ? displayObjects[displayObjects.length - 1]?.s3Key : null, + }); +}; + +/** + * Uploads an object to a bucket (Web API V1). + * + * Accepts multipart/form-data with a `file` field and optional `key` field. + * + * @param req - The incoming HTTP request with a multipart body. + * @param params - Route parameters containing the bucket name. + * @returns A JSON response with the object metadata. + */ +export const handleUploadObjectV1 = async ( + req: Request, + params: RouteParams, +): Promise => { + const bucket = await findBucketByName(params.bucket!); + if (!bucket) return jsonError('Bucket not found', 404); + + const formData = await req.formData(); + const file = formData.get('file'); + + if (!file || !(file instanceof File)) { + return jsonError('No file provided', 400); + } + + const key = (formData.get('key') as string) || file.name; + const buffer = Buffer.from(await file.arrayBuffer()); + const hash = computeHash(buffer); + + const tempPath = `/tmp/filedrop-web-${nanoid()}`; + await Bun.write(tempPath, buffer); + + const signatureBuffer = buffer.subarray(0, 16); + const { fileName: finalFileName, mimeType } = ensureExtension( + key.split('/').pop() || 'file', + signatureBuffer, + file.type || 'application/octet-stream', + ); + + const partFileNamePrefix = `s3-${bucket.name}-${key.replace(/\//g, '_')}`; + + if (buffer.byteLength > config.telegramChunkSizeBytes) { + const uploadedFile = await storeFileInTelegramChunks({ + tempPath, + partFileNamePrefix, + fileName: finalFileName, + mimeType, + sizeBytes: buffer.byteLength, + fileType: 'document', + uploaderId: 0, + bucketId: bucket.id, + s3Key: key, + }); + await cleanupTempFile(tempPath); + return json( + { + key, + size: buffer.byteLength, + etag: hash, + downloadUrl: `${config.baseUrl}/f/${uploadedFile.publicId}`, + }, + 201, + ); + } + + const forwardResult = await forwardToStorage( + createReadStream(tempPath), + partFileNamePrefix, + 'document', + ); + + const publicId = nanoid(); + const { db, files: fileSchema } = await import('../../../db/index'); + + await db.insert(fileSchema).values({ + publicId, + telegramFileId: forwardResult.telegramFileId, + telegramFileUniqueId: forwardResult.telegramFileUniqueId, + storageChatId: config.storageChatId, + storageMessageId: forwardResult.storageMessageId, + fileName: finalFileName, + mimeType, + sizeBytes: buffer.byteLength, + fileType: 'document', + uploaderId: 0, + fileHash: hash, + bucketId: bucket.id, + s3Key: key, + storageBackend: 'telegram', + isDeleted: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await cleanupTempFile(tempPath); + + return json( + { key, size: buffer.byteLength, etag: hash, downloadUrl: `${config.baseUrl}/f/${publicId}` }, + 201, + ); +}; + +/** + * Deletes an object from a bucket (soft delete). + * + * @param _req - The incoming HTTP request (unused). + * @param params - Route parameters containing the bucket name and object key. + * @returns A JSON response indicating success. + */ +export const handleDeleteObjectV1 = async ( + _req: Request, + params: RouteParams, +): Promise => { + const bucket = await findBucketByName(params.bucket!); + if (!bucket) return jsonError('Bucket not found', 404); + await softDeleteFile(bucket.id, params.key!); + return json({ success: true }); +}; + +/** + * Downloads (or redirects to) an object from a bucket. + * + * For chunked objects, builds a streaming response. For regular Telegram + * objects, issues a 302 redirect to the Telegram CDN URL. + * + * @param _req - The incoming HTTP request (unused). + * @param params - Route parameters containing the bucket name and object key. + * @returns A redirect or streaming response, or a JSON error. + */ +export const handleDownloadObjectV1 = async ( + _req: Request, + params: RouteParams, +): Promise => { + const bucket = await findBucketByName(params.bucket!); + if (!bucket) return jsonError('Bucket not found', 404); + + const file = await findFileByBucketAndKey(bucket.id, params.key!); + if (!file) return jsonError('Object not found', 404); + + if (file.storageBackend === 'chunked') { + const range = { type: 'none' as const }; + return createChunkedObjectResponse({ file, range, reqId: '' }); + } + + const fileInfo = await getFileInfo(file.telegramFileId); + const redirectUrl = `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`; + + return new Response(null, { status: 302, headers: { Location: redirectUrl } }); +}; + +/** + * Copies an object from one location to another within the same or a + * different bucket. + * + * Creates a new file record referencing the same Telegram-stored data as + * the source object. + * + * @param req - The incoming HTTP request with a JSON body specifying source + * and destination keys and the destination bucket. + * @param params - Route parameters containing the source bucket name. + * @returns A JSON response with the copy result, or an error. + */ +export const handleCopyObjectV1 = async (req: Request, params: RouteParams): Promise => { + const body = (await req.json()) as { + sourceKey?: string; + destBucket?: string; + destKey?: string; + }; + + if (!body.sourceKey || !body.destKey) { + return jsonError('sourceKey and destKey are required', 400); + } + + const destBucketName = body.destBucket || params.bucket!; + const sourceBucket = await findBucketByName(params.bucket!); + const destBucket = await findBucketByName(destBucketName); + + if (!sourceBucket || !destBucket) return jsonError('Bucket not found', 404); + + const sourceFile = await findFileByBucketAndKey(sourceBucket.id, body.sourceKey); + if (!sourceFile) return jsonError('Source object not found', 404); + + if (sourceFile.storageBackend === 'chunked') { + return json({ error: 'Copying chunked objects is not implemented' }, 501); + } + + const publicId = nanoid(); + const { db, files: fileSchema } = await import('../../../db/index'); + + await db.insert(fileSchema).values({ + publicId, + telegramFileId: sourceFile.telegramFileId, + telegramFileUniqueId: sourceFile.telegramFileUniqueId, + storageChatId: sourceFile.storageChatId, + storageMessageId: sourceFile.storageMessageId, + fileName: sourceFile.fileName, + mimeType: sourceFile.mimeType, + sizeBytes: sourceFile.sizeBytes, + fileType: sourceFile.fileType, + uploaderId: 0, + fileHash: sourceFile.fileHash, + bucketId: destBucket.id, + s3Key: body.destKey, + storageBackend: 'telegram', + isDeleted: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + + return json({ sourceKey: body.sourceKey, destKey: body.destKey, destBucket: destBucketName }); +}; + +/** + * Main Web API V1 request router. + * + * Parses the request path and method, then dispatches to the appropriate + * handler function for bucket and object operations. + * + * @param req - The incoming HTTP request. + * @returns A JSON response from the matched handler, or 404. + */ +export const handleWebApiV1 = async (req: Request): Promise => { + const url = new URL(req.url); + const pathname = url.pathname.replace(/^\/api\/v1/, ''); + const parts = pathname.split('/').filter(Boolean); + const method = req.method; + + try { + // GET /api/v1/buckets + if (parts.length === 1 && parts[0] === 'buckets' && method === 'GET') { + return await handleListBucketsV1(); + } + + // POST /api/v1/buckets + if (parts.length === 1 && parts[0] === 'buckets' && method === 'POST') { + return await handleCreateBucketV1(req); + } + + // DELETE /api/v1/buckets/{name} + if (parts.length === 2 && parts[0] === 'buckets' && method === 'DELETE') { + return await handleDeleteBucketV1(req, { bucket: parts[1] }); + } + + // GET /api/v1/buckets/{name}/objects + if ( + parts.length === 3 && + parts[0] === 'buckets' && + parts[2] === 'objects' && + method === 'GET' + ) { + return await handleListObjectsV1(req, { bucket: parts[1] }); + } + + // POST /api/v1/buckets/{name}/upload + if ( + parts.length === 3 && + parts[0] === 'buckets' && + parts[2] === 'upload' && + method === 'POST' + ) { + return await handleUploadObjectV1(req, { bucket: parts[1] }); + } + + // POST /api/v1/buckets/{name}/copy + if (parts.length === 3 && parts[0] === 'buckets' && parts[2] === 'copy' && method === 'POST') { + return await handleCopyObjectV1(req, { bucket: parts[1] }); + } + + // DELETE /api/v1/buckets/{name}/{key+} + if (parts.length >= 3 && parts[0] === 'buckets' && method === 'DELETE') { + const bucket = parts[1]; + const key = parts.slice(2).join('/'); + return await handleDeleteObjectV1(req, { bucket, key }); + } + + // GET /api/v1/buckets/{name}/download/{key+} + if ( + parts.length >= 4 && + parts[0] === 'buckets' && + parts[2] === 'download' && + method === 'GET' + ) { + const bucket = parts[1]; + const key = parts.slice(3).join('/'); + return await handleDownloadObjectV1(req, { bucket, key }); + } + + return jsonError('Not found', 404); + } catch (error: unknown) { + logger.error('Web API error', { path: pathname, error: getErrorMessage(error) }); + return jsonError('Internal server error', 500); + } +}; \ No newline at end of file diff --git a/src/interfaces/http/middleware/auth.ts b/src/interfaces/http/middleware/auth.ts new file mode 100644 index 0000000..87e4d50 --- /dev/null +++ b/src/interfaces/http/middleware/auth.ts @@ -0,0 +1,357 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { config } from '../../../config/index'; + +const ADMIN_USERNAME = 'admin'; +const SIGNATURE_SEPARATOR = '.'; + +/** A request handler function that returns a Response. */ +type Handler = (req: Request) => Response | Promise; + +/** + * Represents an authenticated user session after successful + * authentication via cookie or bearer token. + */ +export interface AuthSession { + /** The authenticated username (always "admin" in this implementation). */ + username: string; + /** + * Expiration date of the session, or `null` for bearer-token + * sessions which do not expire at the session level. + */ + expiresAt: Date | null; + /** The authentication method used to establish this session. */ + method: 'cookie' | 'bearer'; +} + +/** Options for configuring cookie-based session behaviour. */ +interface CookieOptions { + /** HMAC signing secret (defaults to {@link config.adminApiToken}). */ + secret?: string; + /** Name of the session cookie (defaults to {@link config.sessionCookieName}). */ + cookieName?: string; + /** Session lifetime in milliseconds (defaults to {@link config.sessionMaxAgeMs}). */ + maxAgeMs?: number; +} + +/** Shape of the serialised cookie payload. */ +interface SessionPayload { + u: string; + e: number; +} + +const getSecret = (secret?: string): string => secret ?? config.adminApiToken; +const getCookieName = (cookieName?: string): string => cookieName ?? config.sessionCookieName; +const getMaxAgeMs = (maxAgeMs?: number): number => maxAgeMs ?? config.sessionMaxAgeMs; + +const encodePayload = (value: string): string => + Buffer.from(value, 'utf8').toString('base64url'); + +const decodePayload = (value: string): string | null => { + try { + return Buffer.from(value, 'base64url').toString('utf8'); + } catch { + return null; + } +}; + +/** + * Checks whether authentication is enabled. + * + * Authentication is considered enabled when the admin API token is + * non-empty. + * + * @param secret - Secret to check (defaults to `config.adminApiToken`). + * @returns `true` when auth is enabled, `false` otherwise. + */ +export const isAuthEnabled = (secret = config.adminApiToken): boolean => secret.length > 0; + +/** + * Compares two strings using a timing-safe algorithm to prevent + * timing side-channel attacks. + * + * @param left - First string to compare. + * @param right - Second string to compare. + * @returns `true` when the strings are equal, `false` otherwise. + */ +export const timingSafeCompare = (left: string, right: string): boolean => { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + + if (leftBuffer.length !== rightBuffer.length) { + return false; + } + + return timingSafeEqual(leftBuffer, rightBuffer); +}; + +/** + * Signs an arbitrary payload string with HMAC-SHA256 using the given + * secret, producing a base64url-encoded signature. + * + * @param payload - The value to sign. + * @param secret - HMAC signing key. + * @returns The base64url-encoded signature. + */ +export const signCookiePayload = (payload: string, secret: string): string => + createHmac('sha256', secret).update(payload).digest('base64url'); + +/** + * Verifies the HMAC signature on a cookie value and returns the + * original signed payload. + * + * The cookie value is expected to be in the format + * `.`. Returns `null` when the format is + * invalid or the signature does not match. + * + * @param cookieValue - The full cookie value including signature. + * @param secret - HMAC signing key. + * @returns The unsigned payload string, or `null` on failure. + */ +export const verifyCookieSignature = ( + cookieValue: string, + secret: string, +): string | null => { + const separatorIndex = cookieValue.lastIndexOf(SIGNATURE_SEPARATOR); + if (separatorIndex <= 0 || separatorIndex === cookieValue.length - 1) { + return null; + } + + const payload = cookieValue.slice(0, separatorIndex); + const signature = cookieValue.slice(separatorIndex + 1); + const expectedSignature = signCookiePayload(payload, secret); + + if (!timingSafeCompare(signature, expectedSignature)) { + return null; + } + + return payload; +}; + +/** + * Builds the `Set-Cookie` attribute string for a given max-age in + * seconds. The cookie is HttpOnly, SameSite=Lax, Secure, and + * scoped to the root path. + * + * @param maxAgeSeconds - Max-Age in seconds. + * @returns The cookie attribute string (excluding name=value). + */ +const cookieAttributes = (maxAgeSeconds: number): string => + [ + `Max-Age=${maxAgeSeconds}`, + 'Path=/', + 'HttpOnly', + 'SameSite=Lax', + 'Secure', + ].join('; '); + +/** + * Creates a signed session cookie string suitable for use as a + * `Set-Cookie` header value. + * + * The cookie embeds a base64url-encoded JSON payload containing the + * username and expiration timestamp, signed with HMAC-SHA256. + * + * @param username - Session username (default `"admin"`). + * @param options - Optional cookie settings. + * @returns A fully-formed `Set-Cookie` header value. + */ +export const createSessionCookie = ( + username = ADMIN_USERNAME, + options: CookieOptions = {}, +): string => { + const secret = getSecret(options.secret); + const cookieName = getCookieName(options.cookieName); + const maxAgeMs = getMaxAgeMs(options.maxAgeMs); + const expiresAt = Date.now() + maxAgeMs; + const payload = encodePayload( + JSON.stringify({ u: username, e: expiresAt } satisfies SessionPayload), + ); + const signature = signCookiePayload(payload, secret); + const maxAgeSeconds = Math.max(1, Math.floor(maxAgeMs / 1000)); + + return `${cookieName}=${payload}${SIGNATURE_SEPARATOR}${signature}; ${cookieAttributes(maxAgeSeconds)}`; +}; + +/** + * Creates a `Set-Cookie` header value that immediately expires the + * session cookie, effectively logging the user out. + * + * @param cookieName - Name of the cookie to clear (defaults to + * `config.sessionCookieName`). + * @returns A `Set-Cookie` header value with Max-Age=0. + */ +export const clearSessionCookie = (cookieName = config.sessionCookieName): string => + `${cookieName}=; ${cookieAttributes(0)}`; + +/** + * Finds the value of a named cookie from a raw `Cookie` header + * string. + * + * @param cookieHeader - The raw `Cookie` header value, or `null`. + * @param cookieName - Name of the cookie to look for. + * @returns The cookie value, or `null` if not found. + */ +const findCookieValue = (cookieHeader: string | null, cookieName: string): string | null => { + if (!cookieHeader) return null; + + for (const rawCookie of cookieHeader.split(';')) { + const cookie = rawCookie.trim(); + const equalsIndex = cookie.indexOf('='); + if (equalsIndex <= 0) continue; + + const name = cookie.slice(0, equalsIndex); + if (name === cookieName) { + return cookie.slice(equalsIndex + 1); + } + } + + return null; +}; + +/** + * Parses an {@link AuthSession} from a signed session cookie. + * + * The function verifies the HMAC signature, decodes the payload, + * and validates the expiration timestamp. Returns `null` when the + * cookie is missing, malformed, expired, or the signature is + * invalid. Also returns `null` when auth is disabled (empty + * admin API token). + * + * @param cookieHeader - The `Cookie` header value, or `null`. + * @param options - Optional overrides for secret / cookie name. + * @returns The parsed session, or `null`. + */ +export const parseSessionFromCookie = ( + cookieHeader: string | null, + options: Pick = {}, +): AuthSession | null => { + const secret = getSecret(options.secret); + const cookieName = getCookieName(options.cookieName); + if (!isAuthEnabled(secret)) return null; + + const cookieValue = findCookieValue(cookieHeader, cookieName); + if (!cookieValue) return null; + + const encodedPayload = verifyCookieSignature(cookieValue, secret); + if (!encodedPayload) return null; + + const rawPayload = decodePayload(encodedPayload); + if (!rawPayload) return null; + + try { + const payload = JSON.parse(rawPayload) as Partial; + if (payload.u !== ADMIN_USERNAME || typeof payload.e !== 'number') return null; + if (!Number.isFinite(payload.e) || payload.e <= Date.now()) return null; + + return { + username: payload.u, + expiresAt: new Date(payload.e), + method: 'cookie', + }; + } catch { + return null; + } +}; + +/** + * Validates a `Bearer` token from the `Authorization` header using + * timing-safe comparison. + * + * @param authorizationHeader - The raw `Authorization` header, or `null`. + * @param secret - Expected bearer token (defaults to + * `config.adminApiToken`). + * @returns `true` when the token is valid, `false` otherwise. + */ +export const checkBearerToken = ( + authorizationHeader: string | null, + secret = config.adminApiToken, +): boolean => { + if (!isAuthEnabled(secret) || !authorizationHeader) return false; + + const [scheme, ...rest] = authorizationHeader.split(' '); + if (scheme !== 'Bearer' || rest.length === 0) return false; + + const token = rest.join(' ').trim(); + return token.length > 0 && timingSafeCompare(token, secret); +}; + +/** + * Extracts the authenticated session from a request. + * + * Tries cookie-based authentication first, then falls back to a + * Bearer token in the `Authorization` header. When auth is + * disabled (empty API token) the function returns a synthetic + * session with method `"bearer"` and no expiry, effectively + * granting access to all requests. + * + * @param req - The incoming HTTP request. + * @param options - Optional overrides for secret / cookie name. + * @returns The authenticated session, or `null` when unauthenticated. + */ +export const getAuthSession = ( + req: Request, + options: Pick = {}, +): AuthSession | null => { + const secret = getSecret(options.secret); + if (!isAuthEnabled(secret)) { + return { + username: ADMIN_USERNAME, + expiresAt: null, + method: 'bearer', + }; + } + + const cookieSession = parseSessionFromCookie(req.headers.get('cookie'), options); + if (cookieSession) return cookieSession; + + if (checkBearerToken(req.headers.get('authorization'), secret)) { + return { + username: ADMIN_USERNAME, + expiresAt: null, + method: 'bearer', + }; + } + + return null; +}; + +/** + * Creates a 401 Unauthorized JSON response with a standard error + * body. + * + * @returns A `Response` with status 401 and JSON body + * `{ error: "Unauthorized" }`. + */ +export const unauthorizedResponse = (): Response => + Response.json({ error: 'Unauthorized' }, { status: 401 }); + +/** + * Middleware that wraps a request handler with authentication. + * + * When auth is enabled the wrapper checks for a valid session + * (cookie or Bearer token) before delegating to the handler. + * Unauthenticated requests receive a 401 response. When auth is + * disabled the handler is always invoked. + * + * @param handler - The request handler to protect. + * @param options - Optional overrides for secret / cookie name. + * @returns A wrapped handler that performs the auth check. + */ +export const requireAuth = ( + handler: Handler, + options: Pick = {}, +): ((req: Request) => Promise) => { + return async (req: Request): Promise => { + const secret = getSecret(options.secret); + if (!isAuthEnabled(secret)) { + return handler(req); + } + + const session = getAuthSession(req, options); + if (!session) { + return unauthorizedResponse(); + } + + return handler(req); + }; +}; diff --git a/src/interfaces/http/routes/index.ts b/src/interfaces/http/routes/index.ts new file mode 100644 index 0000000..46c1cad --- /dev/null +++ b/src/interfaces/http/routes/index.ts @@ -0,0 +1,121 @@ +import { config } from '../../../config/index'; +import { handleLogin, handleLogout, handleMe } from '../controllers/auth-controller'; +import { handleFileRedirect, handleFileInfo } from '../controllers/file-controller'; +import { handleHealth } from '../controllers/health-controller'; +import { handleHome } from '../controllers/home-controller'; +import { handleS3Request } from '../controllers/s3-controller'; +import { handleSwaggerHtml, handleSwaggerJson } from '../../../routes/swagger'; +import { handleUpload } from '../controllers/upload-controller'; +import { handleWebApiV1 } from '../controllers/web-api-controller'; +import { requireAuth } from '../../../utils/auth'; +import { withRateLimit } from '../../../utils/rateLimit'; +import { isS3Request } from '../../../utils/s3/auth'; +import { extractS3BucketFromHost } from '../../../utils/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. + */ +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 }); +}; + +/** + * Defines all HTTP routes for the application. + * + * Each route maps a URL pattern to its corresponding handler function(s), + * with middleware such as rate limiting and authentication applied where needed. + * This table is designed to be passed as the `routes` option to `Bun.serve()`. + * + * Route patterns follow Bun's routing syntax: + * - Static paths: `/health` + * - Parameterized paths: `/f/:public_id` + * - Wildcard paths: `/api/v1/*` + */ +export const routes = { + '/api/upload': { + POST: withRateLimit(handleUpload), + }, + '/f/:public_id': { + GET: withRateLimit(handleFileRedirect), + }, + '/file/:public_id/info': { + GET: withRateLimit(handleFileInfo), + }, + '/health': { + GET: handleHealth, + }, + '/docs': { + GET: handleSwaggerHtml, + }, + '/swagger.json': { + GET: handleSwaggerJson, + }, + '/': { + GET: (req: Request): Promise => { + const headers = Object.fromEntries(req.headers); + if (shouldHandleS3(req, headers)) { + return handleS3Request(req, getS3RouteBucket(req)); + } + return handleHome(); + }, + PUT: handleMaybeS3Root, + HEAD: handleMaybeS3Root, + DELETE: handleMaybeS3Root, + POST: handleMaybeS3Root, + OPTIONS: handleMaybeS3Root, + }, + '/api/v1/auth/login': { + POST: withRateLimit(handleLogin), + }, + '/api/v1/auth/logout': { + POST: handleLogout, + }, + '/api/v1/auth/me': { + GET: handleMe, + }, + '/api/v1/*': { + GET: requireAuth(handleWebApiV1), + POST: requireAuth(handleWebApiV1), + DELETE: requireAuth(handleWebApiV1), + PUT: requireAuth(handleWebApiV1), + }, +}; diff --git a/src/interfaces/s3/headers.ts b/src/interfaces/s3/headers.ts new file mode 100644 index 0000000..809527d --- /dev/null +++ b/src/interfaces/s3/headers.ts @@ -0,0 +1,44 @@ +export const S3_CORS_HEADERS: Record = { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET, PUT, HEAD, DELETE, POST, OPTIONS', + 'access-control-allow-headers': [ + 'Authorization', + 'Content-Type', + 'Content-MD5', + 'Range', + 'If-Match', + 'If-None-Match', + 'If-Modified-Since', + 'If-Unmodified-Since', + 'X-Amz-*', + 'x-amz-*', + ].join(', '), + 'access-control-expose-headers': [ + 'Accept-Ranges', + 'Content-Length', + 'Content-Range', + 'Content-Type', + 'ETag', + 'Last-Modified', + 'x-amz-id-2', + 'x-amz-request-id', + ].join(', '), + 'access-control-max-age': '86400', +}; + +export const s3Headers = ( + requestId: string, + extraHeaders: Record = {}, +): Record => ({ + ...S3_CORS_HEADERS, + ...(requestId ? { 'x-amz-request-id': requestId, 'x-amz-id-2': requestId } : {}), + ...extraHeaders, +}); + +export const applyS3Headers = (headers: Headers, requestId: string): Headers => { + const result = new Headers(headers); + for (const [key, value] of Object.entries(s3Headers(requestId))) { + result.set(key, value); + } + return result; +}; diff --git a/src/interfaces/s3/object-stream.ts b/src/interfaces/s3/object-stream.ts new file mode 100644 index 0000000..ca1e211 --- /dev/null +++ b/src/interfaces/s3/object-stream.ts @@ -0,0 +1,130 @@ +import { gunzipSync } from 'node:zlib'; +import { applyS3Headers } from './headers'; +import { contentRange, type RangeParseResult } from './range'; + +export interface ObjectPartSource { + telegramFileId: string; + telegramUrl: string; + sizeBytes: number; + partNumber: number; + storedSizeBytes?: number; + compressionAlgorithm?: 'gzip' | null; +} + +export interface ObjectResponseInput { + reqId: string; + contentType: string; + etag: string; + lastModified: Date; + totalSize: number; + parts: ObjectPartSource[]; + range: RangeParseResult; +} + +interface PlannedPart { + part: ObjectPartSource; + relativeStart: number; + relativeEnd: number; +} + +const baseHeaders = (input: ObjectResponseInput, contentLength: number): Headers => { + const headers = new Headers({ + 'content-type': input.contentType, + 'content-length': String(contentLength), + etag: `"${input.etag}"`, + 'last-modified': input.lastModified.toUTCString(), + 'x-amz-request-id': input.reqId, + 'accept-ranges': 'bytes', + 'cache-control': 'public, max-age=31536000', + }); + return headers; +}; + +const planParts = (parts: ObjectPartSource[], start: number, end: number): PlannedPart[] => { + const planned: PlannedPart[] = []; + let offset = 0; + for (const part of parts) { + const partStart = offset; + const partEnd = offset + part.sizeBytes - 1; + offset += part.sizeBytes; + if (end < partStart || start > partEnd) continue; + planned.push({ + part, + relativeStart: Math.max(start, partStart) - partStart, + relativeEnd: Math.min(end, partEnd) - partStart, + }); + } + return planned; +}; + +const streamFromBytes = (bytes: Uint8Array): ReadableStream => + new Response(bytes).body!; + +const fetchWholePartBytes = async (telegramUrl: string): Promise => { + const res = await fetch(telegramUrl); + if (!res.ok) throw new Error(`Telegram fetch failed: ${res.status}`); + return new Uint8Array(await res.arrayBuffer()); +}; + +const fetchPartBody = async (planned: PlannedPart): Promise> => { + const wantsWholePart = + planned.relativeStart === 0 && planned.relativeEnd === planned.part.sizeBytes - 1; + + if (planned.part.compressionAlgorithm === 'gzip') { + const storedBytes = await fetchWholePartBytes(planned.part.telegramUrl); + const bytes = gunzipSync(storedBytes); + return streamFromBytes(bytes.subarray(planned.relativeStart, planned.relativeEnd + 1)); + } + + const rangeHeader = `bytes=${planned.relativeStart}-${planned.relativeEnd}`; + const res = await fetch( + planned.part.telegramUrl, + wantsWholePart ? undefined : { headers: { range: rangeHeader } }, + ); + if (!res.ok) throw new Error(`Telegram fetch failed: ${res.status}`); + if (wantsWholePart || res.status === 206) return res.body!; + + const bytes = new Uint8Array(await res.arrayBuffer()); + return streamFromBytes(bytes.slice(planned.relativeStart, planned.relativeEnd + 1)); +}; + +const concatPartStreams = (plannedParts: PlannedPart[]): ReadableStream => + new ReadableStream({ + async start(controller) { + try { + for (const planned of plannedParts) { + const stream = await fetchPartBody(planned); + const reader = stream.getReader(); + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value) controller.enqueue(value); + } + } + controller.close(); + } catch (error) { + controller.error(error); + } + }, + }); + +export const createGetObjectResponse = async (input: ObjectResponseInput): Promise => { + if (input.range.type === 'invalid') { + throw new Error('createGetObjectResponse received invalid range'); + } + + const start = input.range.type === 'valid' ? input.range.start : 0; + const end = input.range.type === 'valid' ? input.range.end : input.totalSize - 1; + const plannedParts = planParts(input.parts, start, end); + const contentLength = end >= start ? end - start + 1 : 0; + const headers = applyS3Headers(baseHeaders(input, contentLength), input.reqId); + + if (input.range.type === 'valid') { + headers.set('content-range', contentRange(start, end, input.totalSize)); + } + + return new Response(concatPartStreams(plannedParts), { + status: input.range.type === 'valid' ? 206 : 200, + headers, + }); +}; diff --git a/src/interfaces/s3/range.ts b/src/interfaces/s3/range.ts new file mode 100644 index 0000000..3a1f274 --- /dev/null +++ b/src/interfaces/s3/range.ts @@ -0,0 +1,46 @@ +export type RangeParseResult = + | { type: 'none' } + | { type: 'valid'; start: number; end: number } + | { type: 'invalid' }; + +const DECIMAL = /^\d+$/; + +export const parseRangeHeader = (rangeHeader: string | null, size: number): RangeParseResult => { + if (!rangeHeader) return { type: 'none' }; + if (!Number.isSafeInteger(size) || size < 0) return { type: 'invalid' }; + if (!rangeHeader.startsWith('bytes=')) return { type: 'invalid' }; + + const spec = rangeHeader.slice('bytes='.length).trim(); + if (spec.includes(',')) return { type: 'invalid' }; + + const dash = spec.indexOf('-'); + if (dash === -1) return { type: 'invalid' }; + + const startText = spec.slice(0, dash).trim(); + const endText = spec.slice(dash + 1).trim(); + if (!startText && !endText) return { type: 'invalid' }; + if (size === 0) return { type: 'invalid' }; + + if (!startText) { + if (!DECIMAL.test(endText)) return { type: 'invalid' }; + const suffixLength = Number.parseInt(endText, 10); + if (suffixLength <= 0) return { type: 'invalid' }; + return { type: 'valid', start: Math.max(size - suffixLength, 0), end: size - 1 }; + } + + if (!DECIMAL.test(startText)) return { type: 'invalid' }; + const start = Number.parseInt(startText, 10); + if (start >= size) return { type: 'invalid' }; + + if (!endText) return { type: 'valid', start, end: size - 1 }; + if (!DECIMAL.test(endText)) return { type: 'invalid' }; + + const requestedEnd = Number.parseInt(endText, 10); + if (requestedEnd < start) return { type: 'invalid' }; + return { type: 'valid', start, end: Math.min(requestedEnd, size - 1) }; +}; + +export const contentRange = (start: number, end: number, size: number): string => + `bytes ${start}-${end}/${size}`; + +export const unsatisfiedContentRange = (size: number): string => `bytes */${size}`; diff --git a/src/interfaces/s3/xml.ts b/src/interfaces/s3/xml.ts new file mode 100644 index 0000000..6b2d5db --- /dev/null +++ b/src/interfaces/s3/xml.ts @@ -0,0 +1,309 @@ +import { s3Headers } from './headers'; + +const escapeXml = (str: string): string => + str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const isoDate = (d: Date): string => d.toISOString().replace(/\.\d{3}Z$/, 'Z'); + +const encodeKey = (value: string, encodingType: string | null = null): string => + encodingType === 'url' ? encodeURIComponent(value) : escapeXml(value); + +// ─────── Bucket operations ─────── + +export const listBucketsXml = ( + buckets: { name: string; createdAt: Date }[], + _requestId: string, +): string => ` + + + ${buckets + .map( + (b) => ` + ${escapeXml(b.name)} + ${isoDate(b.createdAt)} + `, + ) + .join('')} + +`; + +export const bucketVersioningConfigurationXml = + (): string => ` +`; + +// ─────── Object listing ─────── + +export const listBucketResultXml = ( + bucketName: string, + objects: { key: string; sizeBytes: number; etag: string; lastModified: Date; mimeType: string }[], + prefixes: string[], + isTruncated: boolean, + marker: string | null, + maxKeys: number, + prefix: string, + delimiter: string | null, + nextMarker: string | null, + _requestId: string, + encodingType: string | null = null, +): string => ` + + ${escapeXml(bucketName)} + ${encodeKey(prefix, encodingType)} + ${encodeKey(marker || '', encodingType)} + ${maxKeys} + ${encodeKey(delimiter || '', encodingType)} + ${encodingType ? `${escapeXml(encodingType)}` : ''} + ${isTruncated} + ${objects + .map( + (o) => ` + ${encodeKey(o.key, encodingType)} + ${isoDate(o.lastModified)} + "${o.etag}" + ${o.sizeBytes} + STANDARD + `, + ) + .join('')} + ${prefixes + .map( + (p) => ` + ${encodeKey(p, encodingType)} + `, + ) + .join('')} + ${nextMarker ? `${encodeKey(nextMarker, encodingType)}` : ''} +`; + +export const listBucketV2ResultXml = ( + bucketName: string, + objects: { key: string; sizeBytes: number; etag: string; lastModified: Date; mimeType: string }[], + prefixes: string[], + isTruncated: boolean, + maxKeys: number, + prefix: string, + delimiter: string | null, + continuationToken: string | null, + nextContinuationToken: string | null, + keyCount: number, + _requestId: string, + encodingType: string | null = null, +): string => ` + + ${escapeXml(bucketName)} + ${encodeKey(prefix, encodingType)} + ${maxKeys} + ${keyCount} + ${delimiter ? `${encodeKey(delimiter, encodingType)}` : ''} + ${encodingType ? `${escapeXml(encodingType)}` : ''} + ${continuationToken ? `${encodeKey(continuationToken, encodingType)}` : ''} + ${isTruncated} + ${objects + .map( + (o) => ` + ${encodeKey(o.key, encodingType)} + ${isoDate(o.lastModified)} + "${o.etag}" + ${o.sizeBytes} + STANDARD + `, + ) + .join('')} + ${prefixes + .map( + (p) => ` + ${encodeKey(p, encodingType)} + `, + ) + .join('')} + ${nextContinuationToken ? `${encodeKey(nextContinuationToken, encodingType)}` : ''} +`; + +// ─────── Multipart ─────── + +export const initiateMultipartUploadXml = ( + bucketName: string, + key: string, + uploadId: string, +): string => ` + + ${escapeXml(bucketName)} + ${escapeXml(key)} + ${uploadId} +`; + +export const listPartsXml = ( + bucketName: string, + key: string, + uploadId: string, + parts: { partNumber: number; etag: string; sizeBytes: number; createdAt: Date }[], + maxParts: number, + isTruncated: boolean, + _requestId: string, +): string => ` + + ${escapeXml(bucketName)} + ${escapeXml(key)} + ${uploadId} + ${maxParts} + ${isTruncated} + ${parts + .map( + (p) => ` + ${p.partNumber} + ${isoDate(p.createdAt)} + "${p.etag}" + ${p.sizeBytes} + `, + ) + .join('')} +`; + +export const listMultipartUploadsXml = ( + bucketName: string, + uploads: { key: string; uploadId: string; initiatedAt: Date; initiatedBy: string }[], + maxUploads: number, + isTruncated: boolean, + nextKeyMarker: string | null, + _requestId: string, +): string => ` + + ${escapeXml(bucketName)} + + + ${nextKeyMarker ? `${escapeXml(nextKeyMarker)}` : ''} + ${maxUploads} + ${isTruncated} + ${uploads + .map( + (u) => ` + ${escapeXml(u.key)} + ${u.uploadId} + ${escapeXml(u.initiatedBy || 's3')}${escapeXml(u.initiatedBy || 's3')} + ${escapeXml(u.initiatedBy || 's3')}${escapeXml(u.initiatedBy || 's3')} + STANDARD + ${isoDate(u.initiatedAt)} + `, + ) + .join('')} +`; + +export const completeMultipartUploadXml = ( + bucketName: string, + key: string, + etag: string, + location: string, +): string => ` + + ${escapeXml(location)} + ${escapeXml(bucketName)} + ${escapeXml(key)} + "${etag}" +`; + +// ─────── Delete result ─────── + +export const deleteResultXml = ( + deleted: string[], + errors: { key: string; code: string; message: string }[], +): string => ` + + ${deleted + .map( + (key) => ` + ${escapeXml(key)} + `, + ) + .join('')} + ${errors + .map( + (e) => ` + ${escapeXml(e.key)} + ${e.code} + ${escapeXml(e.message)} + `, + ) + .join('')} +`; + +// ─────── Copy ─────── + +export const copyObjectResultXml = ( + etag: string, + lastModified: Date, +): string => ` + + "${etag}" + ${isoDate(lastModified)} +`; + +// ─────── Error ─────── + +export const s3ErrorXml = ( + code: string, + message: string, + resource: string, + requestId: string, +): string => ` + + ${code} + ${escapeXml(message)} + ${escapeXml(resource)} + ${requestId} + ${requestId} +`; + +export const s3ErrorResponse = ( + code: string, + message: string, + resource: string, + status: number, + requestId: string = '', + extraHeaders: Record = {}, +): Response => + new Response(s3ErrorXml(code, message, resource, requestId), { + status, + headers: s3Headers(requestId, { + 'content-type': 'application/xml', + ...extraHeaders, + }), + }); + +// ─────── DeleteObjects XML parser ─────── + +export const parseDeleteObjectsBody = (body: string): { keys: string[]; quiet: boolean } => { + const keys = Array.from(body.matchAll(/([^<]+)<\/Key>/g), (match) => match[1]); + const quiet = body.includes('true') || body.includes('true '); + return { keys, quiet }; +}; + +// ─────── CompleteMultipartUpload XML parser ─────── + +export interface CompletePart { + partNumber: number; + etag: string; +} + +export const parseCompleteMultipartBody = (body: string): CompletePart[] => { + const parts: CompletePart[] = []; + const partRegex = /[\s\S]*?<\/Part>/g; + const partMatch = body.match(partRegex) || []; + + for (const partXml of partMatch) { + const numMatch = partXml.match(/(\d+)<\/PartNumber>/); + const etagMatch = partXml.match(/"?([^"<\s]+)"?<\/ETag>/); + if (numMatch && etagMatch) { + parts.push({ + partNumber: parseInt(numMatch[1], 10), + etag: etagMatch[1].replace(/^"/, '').replace(/"$/, ''), + }); + } + } + + return parts; +}; diff --git a/src/shared/errors/index.ts b/src/shared/errors/index.ts new file mode 100644 index 0000000..ad6fb05 --- /dev/null +++ b/src/shared/errors/index.ts @@ -0,0 +1,73 @@ +/** + * Base domain error class for all application-specific errors. + * Extends the built-in Error with a fixed name property for reliable + * instance checking across layers. + */ +export class DomainError extends Error { + constructor(msg: string) { + super(msg); + this.name = 'DomainError'; + } +} + +/** + * Thrown when a requested file cannot be found in storage. + */ +export class FileNotFoundError extends DomainError { + constructor(msg: string) { + super(msg); + this.name = 'FileNotFoundError'; + } +} + +/** + * Thrown when a requested bucket does not exist. + */ +export class BucketNotFoundError extends DomainError { + constructor(msg: string) { + super(msg); + this.name = 'BucketNotFoundError'; + } +} + +/** + * Thrown when a file exceeds the maximum allowed size for upload. + */ +export class FileTooLargeError extends DomainError { + constructor(msg: string) { + super(msg); + this.name = 'FileTooLargeError'; + } +} + +/** + * Thrown when an attempt is made to upload a file that already exists + * (detected by content hash deduplication). + */ +export class DuplicateFileError extends DomainError { + constructor(msg: string) { + super(msg); + this.name = 'DuplicateFileError'; + } +} + +/** + * Thrown when authentication fails or a valid session is not present. + */ +export class AuthenticationError extends DomainError { + constructor(msg: string) { + super(msg); + this.name = 'AuthenticationError'; + } +} + +/** + * Thrown when input validation fails (e.g. missing required fields, + * invalid format, or constraint violations). + */ +export class ValidationError extends DomainError { + constructor(msg: string) { + super(msg); + this.name = 'ValidationError'; + } +} \ No newline at end of file diff --git a/src/shared/logger/index.ts b/src/shared/logger/index.ts index a657f2b..571879c 100644 --- a/src/shared/logger/index.ts +++ b/src/shared/logger/index.ts @@ -1,3 +1,4 @@ -import logger from '../../utils/logger'; - -export { logger }; \ No newline at end of file +import _logger from "../../utils/logger"; +export default _logger; +export { _logger as logger }; +export type { Logger } from "winston"; From ee10cb494ea15766659a573108d88fb85f21739a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 18:21:57 +0700 Subject: [PATCH 8/8] fix: resolve code review issues - import paths and structure cleanup - Fix infrastructure imports: chunked-storage uses new path for shared/utils and interfaces/s3 - Fix health-controller: imports from infrastructure/persistence/drizzle instead of old db/ - Fix routes/index.ts: imports from new interfaces/s3 and middleware paths - Marked Telegram-specific types in shared/utils/file.ts as future extraction Co-Authored-By: Claude Opus 5 (1M context) --- src/infrastructure/telegram/chunked-storage.ts | 6 +++--- src/interfaces/http/controllers/health-controller.ts | 4 ++-- src/interfaces/http/routes/index.ts | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/infrastructure/telegram/chunked-storage.ts b/src/infrastructure/telegram/chunked-storage.ts index e7ec67f..068c47e 100644 --- a/src/infrastructure/telegram/chunked-storage.ts +++ b/src/infrastructure/telegram/chunked-storage.ts @@ -2,9 +2,9 @@ import { createReadStream } from 'node:fs'; import { gzipSync } from 'node:zlib'; import { nanoid } from 'nanoid'; import { config } from '../../env'; -import { computeHash } from '../../utils/file'; -import { createGetObjectResponse, type ObjectPartSource } from '../../utils/s3/object-stream'; -import type { RangeParseResult } from '../../utils/s3/range'; +import { computeHash } from '../../shared/utils/file'; +import { createGetObjectResponse, type ObjectPartSource } from '../../interfaces/s3/object-stream'; +import type { RangeParseResult } from '../../interfaces/s3/range'; import type { IFileRepository } from '../../domain/ports/file-repository'; import type { IFilePartRepository } from '../../domain/ports/file-part-repository'; import type { ITelegramService } from '../../domain/ports/telegram-service'; diff --git a/src/interfaces/http/controllers/health-controller.ts b/src/interfaces/http/controllers/health-controller.ts index 8f26ca5..caf5f43 100644 --- a/src/interfaces/http/controllers/health-controller.ts +++ b/src/interfaces/http/controllers/health-controller.ts @@ -1,7 +1,7 @@ import { sql } from 'drizzle-orm'; -import { db } from '../../../db'; +import { db } from '../../../infrastructure/persistence/drizzle/index'; import { getErrorMessage } from '../../../shared/utils/file'; -import logger from '../../../utils/logger'; +import logger from '../../../shared/logger/index'; /** * Handles the health-check endpoint. diff --git a/src/interfaces/http/routes/index.ts b/src/interfaces/http/routes/index.ts index 46c1cad..06af502 100644 --- a/src/interfaces/http/routes/index.ts +++ b/src/interfaces/http/routes/index.ts @@ -7,9 +7,9 @@ import { handleS3Request } from '../controllers/s3-controller'; import { handleSwaggerHtml, handleSwaggerJson } from '../../../routes/swagger'; import { handleUpload } from '../controllers/upload-controller'; import { handleWebApiV1 } from '../controllers/web-api-controller'; -import { requireAuth } from '../../../utils/auth'; -import { withRateLimit } from '../../../utils/rateLimit'; -import { isS3Request } from '../../../utils/s3/auth'; +import { requireAuth } from '../middleware/auth'; +import { withRateLimit } from '../middleware/rate-limit'; +import { isS3Request } from '../../s3/auth'; import { extractS3BucketFromHost } from '../../../utils/s3/virtual-host'; /**