Files
TeleUploader/src/interfaces/http/controllers/web-api-controller.ts
T
asepharyana e610f1ca21 refactor(plan): unify bucket validation + lock root-verb routing
- web-api create-bucket and s3 virtual-host now use BucketNameSchema
  (single canonical validator; no inline regex left in src/)
- routes '/': HEAD/DELETE/POST now check shouldHandleS3 with headers
  like every other branch (non-S3 -> 404, never S3-direct blind)
- s3-routing.test.ts: 3 new tests locking the fixed root verbs
2026-09-14 18:17:18 +07:00

448 lines
15 KiB
TypeScript

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<Response> => {
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<Response> => {
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<Response> => {
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<Response> => {
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<Response> => {
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<Response> => {
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<Response> => {
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<string, string> = {
'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<Response> => {
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<Response> => {
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);
}
};