import { createUploadFileUseCase } from '../../../application/use-cases/upload-file'; import { config } from '../../../env'; import { bucketRepository, chunkedStorage, fileRepository, telegramService, } from '../../../infrastructure/di'; import { buildTelegramFileUrl } from '../../../infrastructure/telegram/file-url'; import { sanitizeFilenameHeader } from '../../../shared/http/filename'; import logger from '../../../shared/logger/index'; import { getErrorMessage } from '../../../shared/utils/file'; import { streamToTemp } from '../../../shared/utils/temp-stream'; import { BucketNameSchema } from '../../../shared/validation/schemas'; /** Lazily built upload use case wired to the DI singletons. */ const getUploadUseCase = () => createUploadFileUseCase({ fileRepo: fileRepository, telegramService, chunkedStorage, config: { baseUrl: config.baseUrl, telegramChunkSizeBytes: config.telegramChunkSizeBytes, storageChatId: config.storageChatId, compressChunkedUploads: config.compressChunkedUploads, chunkCompressionMinSizeBytes: config.chunkCompressionMinSizeBytes, }, }); /** * 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 bucketRepository.list(); const result = await Promise.all( buckets.map(async (b) => ({ id: b.id, name: b.name, createdAt: b.createdAt.toISOString(), objectCount: await fileRepository.countByBucket(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 || !BucketNameSchema.safeParse(body.name).success) { return jsonError('Invalid bucket name. Use lowercase, 3-63 chars, no underscore', 400); } const existing = await bucketRepository.findByName(body.name); if (existing) return jsonError('Bucket already exists', 409); const bucket = await bucketRepository.create(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 bucketRepository.findByName(params.bucket!); if (!bucket) return jsonError('Bucket not found', 404); const count = await fileRepository.countByBucket(bucket.id); if (count > 0) return jsonError('Bucket is not empty', 409); await bucketRepository.delete(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 bucketRepository.findByName(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 fileRepository.listByPrefix( 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 bucketRepository.findByName(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 streamed = await streamToTemp(file.stream().getReader(), { prefix: '/tmp/filedrop-web-' }); const output = await getUploadUseCase()({ tempPath: streamed.tempPath, fileHash: streamed.fileHash, fileName: key.split('/').pop() || 'file', mimeType: file.type || 'application/octet-stream', sizeBytes: streamed.sizeBytes, uploaderId: 0, bucketId: bucket.id, s3Key: key, dedup: 'none', partPrefix: `s3-${bucket.name}-${key.replace(/\//g, '_')}`, signatureBuffer: streamed.signatureBuffer, }); return json( { key, size: output.sizeBytes, etag: output.fileHash, downloadUrl: output.downloadUrl, }, 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 bucketRepository.findByName(params.bucket!); if (!bucket) return jsonError('Bucket not found', 404); await fileRepository.softDelete(bucket.id, params.key!); return json({ success: true }); }; /** * Downloads (proxies) an object from a bucket. * * For chunked objects, builds a streaming response. For regular Telegram * objects, proxies the Telegram CDN body (200 with the file body) so the * bot token never leaks to clients via a redirect URL. * * @param _req - The incoming HTTP request (unused). * @param params - Route parameters containing the bucket name and object key. * @returns A streaming response with the file body, or a JSON error. */ export const handleDownloadObjectV1 = async ( _req: Request, params: RouteParams, ): Promise => { const bucket = await bucketRepository.findByName(params.bucket!); if (!bucket) return jsonError('Bucket not found', 404); const file = await fileRepository.findByBucketAndKey(bucket.id, params.key!); if (!file) return jsonError('Object not found', 404); if (file.storageBackend === 'chunked') { const range = { type: 'none' as const }; return chunkedStorage.createChunkedObjectResponse({ file, range, reqId: '' }); } const fileInfo = await telegramService.getFileInfo(file.telegramFileId); const telegramUrl = buildTelegramFileUrl(fileInfo.file_path, fileInfo.bot_token); const corsHeaders: Record = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Expose-Headers': 'Content-Disposition, Content-Length, Accept-Ranges', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', 'Access-Control-Allow-Headers': 'Range, Content-Type', Vary: 'Origin', }; try { const upstream = await fetch(telegramUrl); if (!upstream.ok) { logger.error('Web API object download failed', { bucket: bucket.name, key: params.key, status: upstream.status, }); return Response.json( { error: 'Upstream download failed' }, { status: upstream.status === 404 ? 404 : 502, headers: corsHeaders }, ); } const upstreamHeaders = new Headers(upstream.headers); return new Response(upstream.body, { status: 200, headers: { 'Content-Type': file.mimeType || upstreamHeaders.get('content-type') || 'application/octet-stream', 'Content-Disposition': `attachment; filename="${sanitizeFilenameHeader(file.fileName)}"`, 'Content-Length': String(file.sizeBytes ?? 0), 'Cache-Control': 'public, max-age=300', ...corsHeaders, }, }); } catch (error: unknown) { logger.error('Web API object proxy error', { bucket: bucket.name, key: params.key, error: getErrorMessage(error), }); return Response.json( { error: 'Upstream download failed' }, { status: 502, headers: corsHeaders }, ); } }; /** * 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 bucketRepository.findByName(params.bucket!); const destBucket = await bucketRepository.findByName(destBucketName); if (!sourceBucket || !destBucket) return jsonError('Bucket not found', 404); const sourceFile = await fileRepository.findByBucketAndKey(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(); await fileRepository.create( buildNewFile({ publicId, telegramFileId: sourceFile.telegramFileId, telegramFileUniqueId: sourceFile.telegramFileUniqueId, storageChatId: sourceFile.storageChatId, storageMessageId: sourceFile.storageMessageId, fileName: sourceFile.fileName, mimeType: sourceFile.mimeType, sizeBytes: Number(sourceFile.sizeBytes), fileType: sourceFile.fileType, uploaderId: 0, fileHash: sourceFile.fileHash, bucketId: destBucket.id, s3Key: body.destKey, storageBackend: 'telegram', }), ); 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); } };