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";