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