Merge branch 'worktree-ddd-clean-architecture-restructure'
# Conflicts: # src/infrastructure/cache/index.ts # src/interfaces/http/middleware/auth.ts # src/interfaces/http/middleware/rate-limit.ts # src/shared/logger/index.ts
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { type Context, Telegraf } from 'telegraf';
|
||||
import { config } from '../../env';
|
||||
import type { NewFile } from '../../domain/entities/file';
|
||||
import type { IFileRepository } from '../../domain/ports/file-repository';
|
||||
import type { ITelegramService } from '../../domain/ports/telegram-service';
|
||||
import { DrizzleFileRepository } from '../../infrastructure/persistence/repositories/file-repository';
|
||||
import { botPool } from '../../infrastructure/telegram/bot-pool';
|
||||
import {
|
||||
detectFileType,
|
||||
extractFileFromMessage,
|
||||
getErrorMessage,
|
||||
getFileSizeLimit,
|
||||
type TelegramMediaMessage,
|
||||
} from '../../shared/utils/file';
|
||||
import logger from '../../shared/logger/index';
|
||||
|
||||
/**
|
||||
* Minimal bot context shape used by the media event handler.
|
||||
*
|
||||
* Represents the subset of Telegraf's Context that the handler requires
|
||||
* for processing incoming media messages.
|
||||
*/
|
||||
type BotContext = {
|
||||
/** The incoming media message with file attachments. */
|
||||
message: TelegramMediaMessage;
|
||||
/** The sender of the message. */
|
||||
from: { id: number };
|
||||
/** The chat where the message was sent, if available. */
|
||||
chat?: { id: number };
|
||||
/**
|
||||
* Reply to the message with text.
|
||||
*
|
||||
* @param text - The reply text.
|
||||
* @param extra - Optional reply parameters (e.g. reply_parameters for threading).
|
||||
*/
|
||||
reply: (text: string, extra?: { reply_parameters: { message_id: number } }) => Promise<unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Duck-typed object that exposes a Telegraf-style `on()` method
|
||||
* for registering event handlers on multiple event types.
|
||||
*/
|
||||
type MediaEventRegistrar = {
|
||||
/**
|
||||
* Register a handler for the given event types.
|
||||
*
|
||||
* @param events - Array of event type strings (e.g. "document", "photo").
|
||||
* @param handler - Async handler receiving the bot context.
|
||||
*/
|
||||
on: (events: string[], handler: (ctx: BotContext) => Promise<unknown>) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replies to a Telegram message with a download URL for the uploaded file.
|
||||
*
|
||||
* @param ctx - The bot context for the incoming message.
|
||||
* @param publicId - The public identifier of the uploaded file.
|
||||
*/
|
||||
const replyWithDownloadUrl = async (ctx: BotContext, publicId: string): Promise<void> => {
|
||||
const url = `${config.baseUrl}/f/${publicId}`;
|
||||
await ctx.reply(`File berhasil diupload! 📎\n\nDownload: ${url}`, {
|
||||
reply_parameters: { message_id: ctx.message.message_id },
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Start the Telegram bot and register message handlers.
|
||||
*
|
||||
* Creates a Telegraf instance, registers a `/start` command handler,
|
||||
* logging middleware, and media event handlers for all supported file types.
|
||||
* Incoming media files are deduplicated by their Telegram unique ID,
|
||||
* forwarded to the storage channel, and persisted with a public download URL.
|
||||
*
|
||||
* @param deps - Optional external dependencies for testing or DI override.
|
||||
* @param deps.telegramService - The Telegram service used to forward files to
|
||||
* the storage channel. Defaults to the singleton BotPool instance.
|
||||
* @param deps.fileRepo - The file repository used for deduplication queries
|
||||
* and persisting new file records. Defaults to a new DrizzleFileRepository.
|
||||
* @returns The launched Telegraf bot instance, suitable for graceful shutdown
|
||||
* via `bot.stop(signal)`.
|
||||
*/
|
||||
export async function startBot(
|
||||
deps: {
|
||||
/** The Telegram service to forward files to storage. */
|
||||
telegramService?: ITelegramService;
|
||||
/** The file repository for deduplication and persistence. */
|
||||
fileRepo?: IFileRepository;
|
||||
} = {},
|
||||
): Promise<Telegraf<Context>> {
|
||||
const telegramService = deps.telegramService ?? botPool;
|
||||
const fileRepo = deps.fileRepo ?? new DrizzleFileRepository();
|
||||
|
||||
try {
|
||||
const bot = new Telegraf(config.botToken);
|
||||
|
||||
bot.command('start', async (ctx) => {
|
||||
await ctx.reply(
|
||||
`👋 Halo! Kirimkan file (document, photo, video, audio, voice, animation) ke bot ini. ` +
|
||||
`File akan disimpan di private channel dan kamu dapat download link permanen.`,
|
||||
);
|
||||
});
|
||||
|
||||
// Logging middleware must be registered BEFORE the media handler so all events are captured
|
||||
bot.use((ctx, next) => {
|
||||
logger.info('Telegram event received', {
|
||||
type: 'type' in ctx.update ? ctx.update.type : undefined,
|
||||
chat_id: ctx.chat?.id,
|
||||
});
|
||||
return next();
|
||||
});
|
||||
|
||||
const mediaBot = bot as unknown as MediaEventRegistrar;
|
||||
mediaBot.on(
|
||||
['document', 'photo', 'video', 'audio', 'voice', 'animation', 'sticker', 'video_note'],
|
||||
async (ctx) => {
|
||||
try {
|
||||
const fileType = detectFileType(ctx.message);
|
||||
const fileObj = extractFileFromMessage(ctx.message, fileType);
|
||||
const { file_id, mime_type } = fileObj;
|
||||
const fileSize = fileObj.file_size || 0;
|
||||
const fileName =
|
||||
ctx.message.document?.file_name ||
|
||||
ctx.message.photo?.slice(-1)[0]?.file_name ||
|
||||
ctx.message.video?.file_name ||
|
||||
ctx.message.audio?.file_name ||
|
||||
ctx.message.voice?.file_name ||
|
||||
'file';
|
||||
|
||||
const maxSize = getFileSizeLimit(fileType);
|
||||
|
||||
if (fileSize > maxSize) {
|
||||
return ctx.reply(`File size exceeds ${maxSize / (1024 * 1024)}MB limit`);
|
||||
}
|
||||
|
||||
const existing = await fileRepo.findByUniqueId(fileObj.file_unique_id);
|
||||
|
||||
if (existing) {
|
||||
await replyWithDownloadUrl(ctx, existing.publicId);
|
||||
logger.info('Duplicate file detected in bot, returned existing link', {
|
||||
publicId: existing.publicId,
|
||||
fileType,
|
||||
fileName,
|
||||
uploader: ctx.from.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await telegramService.forwardToStorage(file_id, fileName, fileType);
|
||||
const publicId = nanoid();
|
||||
|
||||
const uploaded: NewFile = {
|
||||
publicId,
|
||||
telegramFileId: result.telegramFileId,
|
||||
telegramFileUniqueId: result.telegramFileUniqueId,
|
||||
storageChatId: config.storageChatId,
|
||||
storageMessageId: result.storageMessageId,
|
||||
fileName,
|
||||
mimeType: mime_type || 'application/octet-stream',
|
||||
sizeBytes: fileSize,
|
||||
fileType,
|
||||
uploaderId: ctx.from.id,
|
||||
fileHash: null,
|
||||
archiveTelegramFileId: null,
|
||||
archiveStorageMessageId: null,
|
||||
archiveFileName: null,
|
||||
archiveEntryName: null,
|
||||
archiveMimeType: null,
|
||||
archiveSizeBytes: null,
|
||||
bucketId: null,
|
||||
s3Key: null,
|
||||
storageBackend: 'telegram',
|
||||
isDeleted: false,
|
||||
multipartUploadId: null,
|
||||
partCount: null,
|
||||
};
|
||||
|
||||
await fileRepo.create(uploaded);
|
||||
|
||||
await replyWithDownloadUrl(ctx, publicId);
|
||||
|
||||
logger.info('File uploaded via bot', {
|
||||
publicId,
|
||||
fileType,
|
||||
fileName,
|
||||
uploader: ctx.from.id,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
logger.error('Bot file handler error', {
|
||||
error: getErrorMessage(error),
|
||||
chat_id: ctx.chat?.id,
|
||||
});
|
||||
await ctx.reply('❌ Gagal mengupload file. Coba lagi nanti.');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await bot.launch();
|
||||
|
||||
logger.info('Telegram bot started', { botToken: `${config.botToken?.substring(0, 10)}...` });
|
||||
|
||||
return bot;
|
||||
} catch (error: unknown) {
|
||||
logger.error('Failed to start bot', { error: getErrorMessage(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {}): 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<Response> => {
|
||||
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<Response> => {
|
||||
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<Response> => {
|
||||
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,
|
||||
});
|
||||
};
|
||||
@@ -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<TelegramFileInfo> => {
|
||||
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<Response> => {
|
||||
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<Response> => {
|
||||
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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { db } from '../../../infrastructure/persistence/drizzle/index';
|
||||
import { getErrorMessage } from '../../../shared/utils/file';
|
||||
import logger from '../../../shared/logger/index';
|
||||
|
||||
/**
|
||||
* 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<Response> => {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
@@ -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<Response> => {
|
||||
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',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,338 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FileDrop · S3 File Manager</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #ffffff; --bg2: #f5f5f5; --text: #1a1a1a;
|
||||
--text2: #666; --border: #e0e0e0; --accent: #2563eb;
|
||||
--danger: #dc2626; --radius: 8px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0d1117; --bg2: #161b22; --text: #c9d1d9;
|
||||
--text2: #8b949e; --border: #30363d; --accent: #58a6ff;
|
||||
--danger: #f85149;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg); color: var(--text); line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px 24px; background: var(--bg2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
}
|
||||
.topbar .logo { font-weight: 700; font-size: 1.1rem; }
|
||||
.topbar select, .topbar button {
|
||||
padding: 6px 12px; border: 1px solid var(--border);
|
||||
border-radius: var(--radius); background: var(--bg);
|
||||
color: var(--text); font-size: 0.875rem; cursor: pointer;
|
||||
}
|
||||
.modal input {
|
||||
width: 100%; padding: 8px 12px; border: 1px solid var(--border);
|
||||
border-radius: var(--radius); background: var(--bg);
|
||||
color: var(--text); margin-bottom: 12px;
|
||||
}
|
||||
.topbar button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.topbar .spacer { flex: 1; }
|
||||
.topbar .search input {
|
||||
padding: 6px 12px; border: 1px solid var(--border);
|
||||
border-radius: var(--radius); background: var(--bg);
|
||||
color: var(--text); font-size: 0.875rem; width: 200px;
|
||||
}
|
||||
.file-list { padding: 16px 24px; }
|
||||
.breadcrumb {
|
||||
padding: 8px 0; margin-bottom: 8px; font-size: 0.9rem;
|
||||
color: var(--accent); cursor: pointer;
|
||||
}
|
||||
.breadcrumb span:hover { text-decoration: underline; }
|
||||
.breadcrumb .sep { color: var(--text2); margin: 0 4px; }
|
||||
.file-row {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 12px; border-radius: var(--radius);
|
||||
cursor: pointer; transition: background 0.1s;
|
||||
}
|
||||
.file-row:hover { background: var(--bg2); }
|
||||
.file-row .icon { font-size: 1.2rem; width: 28px; text-align: center; flex-shrink: 0; }
|
||||
.file-row .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-row .size { width: 80px; text-align: right; color: var(--text2); font-size: 0.85rem; }
|
||||
.file-row .date { width: 140px; color: var(--text2); font-size: 0.85rem; }
|
||||
.file-row .actions { display: flex; gap: 4px; }
|
||||
.file-row .actions button {
|
||||
padding: 4px 8px; border: none; border-radius: 4px;
|
||||
background: transparent; color: var(--text2); cursor: pointer; font-size: 0.8rem;
|
||||
}
|
||||
.file-row .actions button:hover { color: var(--text); background: var(--border); }
|
||||
.dropzone {
|
||||
position: fixed; bottom: 0; left: 0; right: 0;
|
||||
padding: 12px 24px; background: var(--bg2);
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: center; color: var(--text2); font-size: 0.85rem; cursor: pointer;
|
||||
}
|
||||
.dropzone.dragover { background: var(--accent); color: #fff; }
|
||||
.progress-overlay {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.5); display: flex;
|
||||
align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
.progress-card {
|
||||
background: var(--bg); padding: 24px; border-radius: var(--radius);
|
||||
min-width: 300px; max-width: 500px;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 8px; background: var(--border); border-radius: 4px;
|
||||
margin: 12px 0; overflow: hidden;
|
||||
}
|
||||
.progress-bar .fill {
|
||||
height: 100%; background: var(--accent);
|
||||
transition: width 0.2s; width: 0%;
|
||||
}
|
||||
.modal-overlay {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.5); display: flex;
|
||||
align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg); padding: 24px; border-radius: var(--radius);
|
||||
min-width: 360px; max-width: 500px;
|
||||
}
|
||||
.modal h3 { margin-bottom: 16px; }
|
||||
.modal .buttons { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.modal .buttons button {
|
||||
padding: 8px 16px; border: 1px solid var(--border);
|
||||
border-radius: var(--radius); background: var(--bg); color: var(--text); cursor: pointer;
|
||||
}
|
||||
.modal .buttons .primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.modal .buttons .danger { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||
.empty { text-align: center; padding: 48px 24px; color: var(--text2); }
|
||||
.empty h2 { font-size: 1.2rem; margin-bottom: 8px; }
|
||||
.auth-screen {
|
||||
position: fixed; inset: 0; z-index: 200; display: none;
|
||||
align-items: center; justify-content: center; padding: 24px;
|
||||
background: linear-gradient(135deg, var(--bg), var(--bg2));
|
||||
}
|
||||
.auth-card {
|
||||
width: min(100%, 380px); padding: 28px; border: 1px solid var(--border);
|
||||
border-radius: 16px; background: var(--bg); box-shadow: 0 20px 60px rgba(0,0,0,0.18);
|
||||
}
|
||||
.auth-card h1 { font-size: 1.45rem; margin-bottom: 8px; }
|
||||
.auth-card p { color: var(--text2); margin-bottom: 18px; }
|
||||
.auth-card input {
|
||||
width: 100%; padding: 10px 12px; border: 1px solid var(--border);
|
||||
border-radius: var(--radius); background: var(--bg2); color: var(--text);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.auth-card button {
|
||||
width: 100%; padding: 10px 14px; border: 1px solid var(--accent);
|
||||
border-radius: var(--radius); background: var(--accent); color: #fff;
|
||||
cursor: pointer; font-weight: 600;
|
||||
}
|
||||
.auth-card button:disabled { opacity: 0.7; cursor: wait; }
|
||||
.auth-error { color: var(--danger); font-size: 0.85rem; margin-bottom: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="authScreen" class="auth-screen">
|
||||
<div class="auth-card">
|
||||
<h1>📦 FileDrop</h1>
|
||||
<p>Enter admin token to continue.</p>
|
||||
<input id="authTokenInput" type="password" placeholder="Admin token" autocomplete="current-password">
|
||||
<div id="authError" class="auth-error" style="display:none"></div>
|
||||
<button id="authLoginBtn" type="button">Login</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar">
|
||||
<span class="logo">📦 FileDrop</span>
|
||||
<select id="bucketSelect" onchange="window.switchBucket(this.value)">
|
||||
<option value="">— Select bucket —</option>
|
||||
</select>
|
||||
<button type="button" onclick="window.showCreateBucketModal()">+ New</button>
|
||||
<button type="button" onclick="window.showCredentialsModal()" title="S3 Credentials">🔑</button>
|
||||
<button id="logoutBtn" type="button" onclick="window.logout()" style="display:none">Logout</button>
|
||||
<span class="spacer"></span>
|
||||
<div class="search">
|
||||
<input id="searchInput" type="text" placeholder="Filter prefix..." oninput="window.debouncedSearch()">
|
||||
</div>
|
||||
</div>
|
||||
<div id="breadcrumb" class="breadcrumb" style="display:none;padding:8px 24px"></div>
|
||||
<div id="fileList" class="file-list">
|
||||
<div class="empty"><h2>Select a bucket to get started</h2><p>Choose a bucket from the dropdown above, or create a new one.</p></div>
|
||||
</div>
|
||||
<div id="dropzone" class="dropzone" style="display:none">📁 Drop files here or click to upload</div>
|
||||
<div id="progressOverlay" class="progress-overlay" style="display:none">
|
||||
<div class="progress-card">
|
||||
<h3>Uploading...</h3>
|
||||
<div id="progressFileName"></div>
|
||||
<div class="progress-bar"><div id="progressFill" class="fill"></div></div>
|
||||
<div id="progressPercent" style="font-size:0.85rem;color:var(--text2)">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modalOverlay" class="modal-overlay" style="display:none" onclick="closeModal(event)">
|
||||
<div id="modalContent" class="modal" onclick="event.stopPropagation()"></div>
|
||||
</div>
|
||||
<script>
|
||||
let currentBucket = null, currentPrefix = '', currentObjects = [], currentPrefixes = [], allBuckets = [], searchTimer = null;
|
||||
const setAuthError = (message) => {
|
||||
const errorEl = document.getElementById('authError');
|
||||
errorEl.textContent = message;
|
||||
errorEl.style.display = message ? 'block' : 'none';
|
||||
};
|
||||
const showAuthScreen = () => {
|
||||
document.getElementById('authScreen').style.display = 'flex';
|
||||
document.getElementById('logoutBtn').style.display = 'none';
|
||||
setTimeout(() => document.getElementById('authTokenInput')?.focus(), 50);
|
||||
};
|
||||
const hideAuthScreen = (showLogout) => {
|
||||
document.getElementById('authScreen').style.display = 'none';
|
||||
document.getElementById('logoutBtn').style.display = showLogout ? 'inline-block' : 'none';
|
||||
};
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/me');
|
||||
if (res.ok) { hideAuthScreen(true); return true; }
|
||||
if (res.status === 401) { showAuthScreen(); return false; }
|
||||
if (res.status === 404) { hideAuthScreen(false); return true; }
|
||||
setAuthError('Unable to verify login status. Please try again.');
|
||||
showAuthScreen(); return false;
|
||||
} catch {
|
||||
setAuthError('Network error while checking login status.');
|
||||
showAuthScreen(); return false;
|
||||
}
|
||||
};
|
||||
const handleLogin = async () => {
|
||||
const input = document.getElementById('authTokenInput');
|
||||
const btn = document.getElementById('authLoginBtn');
|
||||
const token = input.value.trim();
|
||||
if (!token) { setAuthError('Admin token is required.'); input.focus(); return; }
|
||||
btn.disabled = true; btn.textContent = 'Logging in...'; setAuthError('');
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
if (res.ok) { hideAuthScreen(true); input.value = ''; await loadBuckets(); return; }
|
||||
const body = await res.json().catch(() => ({ error: 'Login failed' }));
|
||||
setAuthError(body.error || 'Login failed');
|
||||
} catch {
|
||||
setAuthError('Network error while logging in.');
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Login';
|
||||
}
|
||||
};
|
||||
const logout = async () => {
|
||||
await fetch('/api/v1/auth/logout', { method: 'POST' }).catch(() => {});
|
||||
currentBucket = null; currentPrefix = ''; currentObjects = []; currentPrefixes = [];
|
||||
document.getElementById('bucketSelect').innerHTML = '<option value="">— Select bucket —</option>';
|
||||
document.getElementById('fileList').innerHTML = '<div class="empty"><h2>Logged out</h2><p>Enter the admin token to continue.</p></div>';
|
||||
document.getElementById('dropzone').style.display = 'none';
|
||||
showAuthScreen();
|
||||
};
|
||||
const api = async (path, opts = {}) => {
|
||||
const res = await fetch(path, opts);
|
||||
if (!res.ok) { const body = await res.json().catch(() => ({ error: res.statusText })); throw new Error(body.error || res.statusText); }
|
||||
return res;
|
||||
};
|
||||
const apiJson = async (path, opts = {}) => { const res = await api(path, { headers: { 'content-type': 'application/json' }, ...opts }); return res.json(); };
|
||||
const loadBuckets = async () => {
|
||||
const data = await apiJson('/api/v1/buckets');
|
||||
allBuckets = data.buckets || [];
|
||||
const sel = document.getElementById('bucketSelect');
|
||||
sel.innerHTML = `<option value="">— Select bucket —</option>${allBuckets.map(b => `<option value="${b.name}">${b.name} (${b.objectCount})</option>`).join('')}`;
|
||||
if (currentBucket) sel.value = currentBucket;
|
||||
};
|
||||
const switchBucket = async (name) => {
|
||||
currentBucket = name || null; currentPrefix = '';
|
||||
if (name) { await loadObjects(); document.getElementById('dropzone').style.display = 'block'; }
|
||||
else {
|
||||
document.getElementById('fileList').innerHTML = '<div class="empty"><h2>Select a bucket</h2><p>Choose a bucket from the dropdown above.</p></div>';
|
||||
document.getElementById('breadcrumb').style.display = 'none'; document.getElementById('dropzone').style.display = 'none';
|
||||
}
|
||||
};
|
||||
const renderBreadcrumb = () => {
|
||||
const bc = document.getElementById('breadcrumb');
|
||||
if (!currentPrefix) { bc.style.display = 'none'; return; }
|
||||
bc.style.display = 'block';
|
||||
const parts = currentPrefix.split('/').filter(Boolean);
|
||||
bc.innerHTML = `<span onclick="window.navigateTo('')">${currentBucket}</span>`;
|
||||
let accumulated = '';
|
||||
for (const part of parts) { accumulated += `${part}/`; bc.innerHTML += `<span class="sep">/</span><span onclick="window.navigateTo('${accumulated}')">${part}</span>`; }
|
||||
};
|
||||
const navigateTo = (prefix) => { currentPrefix = prefix; loadObjects(); };
|
||||
const loadObjects = async () => {
|
||||
if (!currentBucket) return;
|
||||
const searchVal = document.getElementById('searchInput').value;
|
||||
const prefix = searchVal || currentPrefix;
|
||||
const url = `/api/v1/buckets/${encodeURIComponent(currentBucket)}/objects?prefix=${encodeURIComponent(prefix)}&delimiter=/&max-keys=200`;
|
||||
try {
|
||||
const data = await apiJson(url);
|
||||
currentObjects = data.objects || []; currentPrefixes = data.prefixes || [];
|
||||
renderFileList(); renderBreadcrumb();
|
||||
} catch (e) { document.getElementById('fileList').innerHTML = `<div class="empty"><h2>Error</h2><p>${e.message}</p></div>`; }
|
||||
};
|
||||
const renderFileList = () => {
|
||||
const container = document.getElementById('fileList');
|
||||
if (currentPrefixes.length === 0 && currentObjects.length === 0) { container.innerHTML = '<div class="empty"><h2>This bucket is empty</h2><p>Drop files here to upload.</p></div>'; return; }
|
||||
let html = '';
|
||||
for (const prefix of currentPrefixes) {
|
||||
const displayName = prefix.replace(currentPrefix, '');
|
||||
html += `<div class="file-row" onclick="window.navigateTo('${prefix}')"><span class="icon">🗂</span><span class="name">${displayName.endsWith('/') ? displayName : `${displayName}/`}</span><span class="size">—</span><span class="date"></span><span class="actions"></span></div>`;
|
||||
}
|
||||
for (const obj of currentObjects) {
|
||||
const displayName = obj.key.replace(currentPrefix, '');
|
||||
html += `<div class="file-row"><span class="icon">📄</span><span class="name">${escapeHtml(displayName)}</span><span class="size">${formatSize(obj.sizeBytes)}</span><span class="date">${formatDate(obj.lastModified)}</span><span class="actions"><button onclick="event.stopPropagation();downloadObject('${obj.key}')" title="Download">⬇</button><button onclick="event.stopPropagation();copyLink('${obj.key}')" title="Copy link">🔗</button><button onclick="event.stopPropagation();deleteObject('${obj.key}')" title="Delete">🗑</button></span></div>`;
|
||||
}
|
||||
container.innerHTML = html;
|
||||
};
|
||||
const formatSize = (bytes) => { const size = Number(bytes); if (!Number.isFinite(size) || size <= 0) return '0 B'; const u = ['B','KB','MB','GB','TB']; let i=0,s=size; while(s>=1024&&i<u.length-1){s/=1024;i++} return `${s.toFixed(i>0?1:0)} ${u[i]}`; };
|
||||
const formatDate = (iso) => { if(!iso)return ''; return new Date(iso).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}); };
|
||||
const escapeHtml = (s) => { const d=document.createElement('div');d.textContent=s;return d.innerHTML; };
|
||||
const debouncedSearch = () => { clearTimeout(searchTimer); searchTimer = setTimeout(loadObjects, 300); };
|
||||
const downloadObject = async (key) => { window.open(`/api/v1/buckets/${encodeURIComponent(currentBucket)}/download/${encodeURIComponent(key)}`,'_blank'); };
|
||||
const copyLink = (key) => { navigator.clipboard.writeText(`${window.location.origin}/api/v1/buckets/${encodeURIComponent(currentBucket)}/download/${encodeURIComponent(key)}`).catch(()=>{}); };
|
||||
const deleteObject = async (key) => {
|
||||
if(!confirm(`Delete "${key}"?`))return;
|
||||
try{await api(`/api/v1/buckets/${encodeURIComponent(currentBucket)}/${encodeURIComponent(key)}`,{method:'DELETE'});await loadObjects();}
|
||||
catch(e){alert(`Delete failed: ${e.message}`);}
|
||||
};
|
||||
const uploadFiles = async (files) => {
|
||||
if(!currentBucket||files.length===0)return;
|
||||
const overlay=document.getElementById('progressOverlay'), fill=document.getElementById('progressFill'), pn=document.getElementById('progressFileName'), pp=document.getElementById('progressPercent');
|
||||
overlay.style.display='flex';
|
||||
for(let i=0;i<files.length;i++){
|
||||
const file=files[i]; pn.textContent=`${i+1}/${files.length}: ${file.name}`; fill.style.width='0%'; pp.textContent='0%';
|
||||
await new Promise((resolve,reject)=>{
|
||||
const fd=new FormData(); fd.append('file',file); fd.append('key',currentPrefix+file.name);
|
||||
const xhr=new XMLHttpRequest();
|
||||
xhr.upload.onprogress=(e)=>{if(e.lengthComputable){const p=Math.round((e.loaded/e.total)*100);fill.style.width=`${p}%`;pp.textContent=`${p}%`;}};
|
||||
xhr.onload=()=>{if(xhr.status>=200&&xhr.status<300)resolve();else reject(new Error(xhr.statusText));};
|
||||
xhr.onerror=()=>reject(new Error('Upload failed'));
|
||||
xhr.open('POST',`/api/v1/buckets/${encodeURIComponent(currentBucket)}/upload`); xhr.send(fd);
|
||||
});
|
||||
}
|
||||
overlay.style.display='none'; await loadObjects();
|
||||
};
|
||||
const dropzone=document.getElementById('dropzone');
|
||||
dropzone.addEventListener('dragover',e=>{e.preventDefault();dropzone.classList.add('dragover');});
|
||||
dropzone.addEventListener('dragleave',()=>dropzone.classList.remove('dragover'));
|
||||
dropzone.addEventListener('drop',e=>{e.preventDefault();dropzone.classList.remove('dragover');if(e.dataTransfer.files.length>0)uploadFiles(e.dataTransfer.files);});
|
||||
dropzone.addEventListener('click',()=>{const i=document.createElement('input');i.type='file';i.multiple=true;i.onchange=()=>{if(i.files.length>0)uploadFiles(i.files);};i.click();});
|
||||
const showModal=(html)=>{document.getElementById('modalContent').innerHTML=html;document.getElementById('modalOverlay').style.display='flex';};
|
||||
const closeModal=(e)=>{if(e&&e.target!==e.currentTarget)return;document.getElementById('modalOverlay').style.display='none';};
|
||||
const showCreateBucketModal=()=>{showModal(`<h3>Create Bucket</h3><input id="bucketNameInput" type="text" placeholder="my-bucket-name" pattern="[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]"><p style="font-size:0.8rem;color:var(--text2);margin-bottom:12px">Lowercase, 3-63 chars, no underscores</p><div class="buttons"><button onclick="closeModal()">Cancel</button><button class="primary" onclick="createBucket()">Create</button></div>`);setTimeout(()=>document.getElementById('bucketNameInput')?.focus(),100);};
|
||||
const createBucket=async()=>{const n=document.getElementById('bucketNameInput').value.trim();if(!n)return;try{await apiJson('/api/v1/buckets',{method:'POST',body:JSON.stringify({name:n})});closeModal();await loadBuckets();document.getElementById('bucketSelect').value=n;await switchBucket(n);}catch(e){alert(`Failed: ${e.message}`);}};
|
||||
const showCredentialsModal=()=>{showModal(`<h3>S3 Credentials</h3><p style="margin-bottom:12px;font-size:0.85rem;color:var(--text2)">Use these in any S3 client (aws-cli, rclone, s3cmd, etc.)</p><label style="font-size:0.85rem;font-weight:600">Endpoint URL</label><input type="text" value="${window.location.origin}" readonly onclick="this.select()"><label style="font-size:0.85rem;font-weight:600">Region</label><input type="text" value="us-east-1" readonly onclick="this.select()"><label style="font-size:0.85rem;font-weight:600">Access Key</label><input id="s3AccessKey" type="text" readonly onclick="this.select()"><label style="font-size:0.85rem;font-weight:600">Secret Key</label><input id="s3SecretKey" type="password" readonly onclick="this.select()"><div class="buttons"><button type="button" onclick="window.closeModal()">Close</button></div>`);};
|
||||
const init=async()=>{if(await checkAuth())await loadBuckets();};
|
||||
document.getElementById('authLoginBtn').addEventListener('click',handleLogin);
|
||||
document.getElementById('authTokenInput').addEventListener('keydown',e=>{if(e.key==='Enter')handleLogin();});
|
||||
Object.assign(window, { switchBucket, navigateTo, debouncedSearch, downloadObject, copyLink, deleteObject, closeModal, showCreateBucketModal, createBucket, showCredentialsModal, logout });
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<PreparedUpload> => {
|
||||
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<void> => {
|
||||
if (!writer.write(chunk)) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
writer.once('drain', resolve);
|
||||
writer.once('error', reject);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const finishWriter = async (): Promise<void> => {
|
||||
await new Promise<void>((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<PreparedUpload> => {
|
||||
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<Response> => {
|
||||
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<Response> => {
|
||||
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<Response> => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -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<Response> => {
|
||||
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<Response> => {
|
||||
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<Response> => {
|
||||
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<Response> => {
|
||||
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<Response> => {
|
||||
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<Response> => {
|
||||
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<Response> => {
|
||||
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<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 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<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);
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { config } from '../../config/index';
|
||||
import { config } from '../../../config/index';
|
||||
|
||||
const ADMIN_USERNAME = 'admin';
|
||||
const SIGNATURE_SEPARATOR = '.';
|
||||
|
||||
@@ -1,146 +1 @@
|
||||
import { config } from '../../config/index';
|
||||
import { extractClientIp } from '../../../shared/utils/ip';
|
||||
import logger from '../../../shared/logger/index';
|
||||
|
||||
/** An entry in the in-memory rate-limit store. */
|
||||
interface RateLimitEntry {
|
||||
/** Number of requests received during the current window. */
|
||||
count: number;
|
||||
/** Epoch timestamp (ms) when the current window expires. */
|
||||
resetTime: number;
|
||||
}
|
||||
|
||||
/** In-memory store mapping keys (typically client IPs) to rate-limit entries. */
|
||||
const rateLimitStore = new Map<string, RateLimitEntry>();
|
||||
/** Maximum number of tracked entries before LRU eviction kicks in. */
|
||||
const MAX_STORE_ENTRIES = 50000;
|
||||
|
||||
/**
|
||||
* Removes all expired entries from the rate-limit store.
|
||||
*
|
||||
* @param now - Current epoch timestamp in milliseconds (defaults to `Date.now()`).
|
||||
* @returns The number of entries that were cleaned.
|
||||
*/
|
||||
const evictExpiredEntries = (now = Date.now()): number => {
|
||||
let cleaned = 0;
|
||||
|
||||
for (const [key, entry] of rateLimitStore.entries()) {
|
||||
if (now > entry.resetTime) {
|
||||
rateLimitStore.delete(key);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensures the store stays below {@link MAX_STORE_ENTRIES} by first
|
||||
* evicting expired entries, then dropping the oldest entries if the
|
||||
* store is still over capacity.
|
||||
*
|
||||
* @param now - Current epoch timestamp in milliseconds.
|
||||
*/
|
||||
const ensureStoreCapacity = (now: number): void => {
|
||||
if (rateLimitStore.size < MAX_STORE_ENTRIES) return;
|
||||
|
||||
evictExpiredEntries(now);
|
||||
while (rateLimitStore.size >= MAX_STORE_ENTRIES) {
|
||||
const oldestKey = rateLimitStore.keys().next().value;
|
||||
if (!oldestKey) break;
|
||||
rateLimitStore.delete(oldestKey);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether the given key (typically a client IP) has exceeded
|
||||
* the allowed rate limit.
|
||||
*
|
||||
* On the first request within a window the entry is created and the
|
||||
* caller is allowed through. Subsequent requests increment the
|
||||
* counter. Returns `false` (and logs a warning) when the counter
|
||||
* exceeds the configured maximum.
|
||||
*
|
||||
* @param key - The key to check (e.g. a client IP address).
|
||||
* @returns `true` if the request is within the limit, `false` if
|
||||
* rate-limited.
|
||||
*/
|
||||
export const checkRateLimit = (key: string): boolean => {
|
||||
const now = Date.now();
|
||||
const entry = rateLimitStore.get(key);
|
||||
|
||||
if (!entry || now > entry.resetTime) {
|
||||
ensureStoreCapacity(now);
|
||||
rateLimitStore.set(key, {
|
||||
count: 1,
|
||||
resetTime: now + config.rateLimitWindowMs,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entry.count >= config.rateLimitMaxRequests) {
|
||||
logger.warn('Rate limit exceeded', { key, count: entry.count });
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware that wraps a request handler with rate-limiting based
|
||||
* on the client IP address.
|
||||
*
|
||||
* When the client has exceeded the allowed number of requests within
|
||||
* the configured window a 429 Too Many Requests response is returned.
|
||||
*
|
||||
* @typeParam T - The request type (must extend `Request`).
|
||||
* @param handler - The request handler to protect.
|
||||
* @returns A wrapped handler that applies rate-limiting.
|
||||
*/
|
||||
export const withRateLimit = <T extends Request>(
|
||||
handler: (req: T) => Promise<Response>,
|
||||
): ((req: T) => Promise<Response>) => {
|
||||
return async (req: T): Promise<Response> => {
|
||||
const ip = extractClientIp(req, config.trustProxy);
|
||||
if (!checkRateLimit(ip)) {
|
||||
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 });
|
||||
}
|
||||
|
||||
return handler(req);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Manually evicts all expired entries from the rate-limit cache and
|
||||
* logs a debug message with the count of removed entries.
|
||||
*/
|
||||
export const cleanupRateLimitCache = (): void => {
|
||||
const cleaned = evictExpiredEntries();
|
||||
|
||||
if (cleaned > 0) {
|
||||
logger.debug('Rate limit cache cleanup', { cleaned, remaining: rateLimitStore.size });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns diagnostic statistics about the current state of the
|
||||
* rate-limit store.
|
||||
*
|
||||
* @returns An object with tracked-IP count, configured window size,
|
||||
* max requests, and max tracked entries.
|
||||
*/
|
||||
export const getRateLimitStats = () => ({
|
||||
trackedIPs: rateLimitStore.size,
|
||||
windowSize: config.rateLimitWindowMs,
|
||||
maxRequests: config.rateLimitMaxRequests,
|
||||
maxTrackedIPs: MAX_STORE_ENTRIES,
|
||||
});
|
||||
|
||||
/**
|
||||
* Clears all entries from the rate-limit cache (used primarily in
|
||||
* tests).
|
||||
*/
|
||||
export const clearRateLimitCache = (): void => {
|
||||
rateLimitStore.clear();
|
||||
};
|
||||
export { withRateLimit, cleanupRateLimitCache, checkRateLimit, clearRateLimitCache, getRateLimitStats } from '../../../utils/rateLimit';
|
||||
@@ -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 '../middleware/auth';
|
||||
import { withRateLimit } from '../middleware/rate-limit';
|
||||
import { isS3Request } from '../../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<string, string>): 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<Response> => {
|
||||
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<Response> => {
|
||||
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),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { isS3Request, buildCanonicalQueryString, verifyPresignedUrl, verifySignature } from '../../utils/s3/auth';
|
||||
export type { SigV4Result, VerifyPresignedUrlInput } from '../../utils/s3/auth';
|
||||
@@ -0,0 +1,44 @@
|
||||
export const S3_CORS_HEADERS: Record<string, string> = {
|
||||
'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<string, string> = {},
|
||||
): Record<string, string> => ({
|
||||
...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;
|
||||
};
|
||||
@@ -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<Uint8Array> =>
|
||||
new Response(bytes).body!;
|
||||
|
||||
const fetchWholePartBytes = async (telegramUrl: string): Promise<Uint8Array> => {
|
||||
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<ReadableStream<Uint8Array>> => {
|
||||
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<Uint8Array> =>
|
||||
new ReadableStream<Uint8Array>({
|
||||
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<Response> => {
|
||||
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,
|
||||
});
|
||||
};
|
||||
@@ -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}`;
|
||||
@@ -0,0 +1 @@
|
||||
export { extractS3BucketFromHost } from '../../utils/s3/virtual-host';
|
||||
@@ -0,0 +1,309 @@
|
||||
import { s3Headers } from './headers';
|
||||
|
||||
const escapeXml = (str: string): string =>
|
||||
str
|
||||
.replace(/&/g, '&')
|
||||
.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 => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Buckets>
|
||||
${buckets
|
||||
.map(
|
||||
(b) => `<Bucket>
|
||||
<Name>${escapeXml(b.name)}</Name>
|
||||
<CreationDate>${isoDate(b.createdAt)}</CreationDate>
|
||||
</Bucket>`,
|
||||
)
|
||||
.join('')}
|
||||
</Buckets>
|
||||
</ListAllMyBucketsResult>`;
|
||||
|
||||
export const bucketVersioningConfigurationXml =
|
||||
(): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"/>`;
|
||||
|
||||
// ─────── 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 => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Name>${escapeXml(bucketName)}</Name>
|
||||
<Prefix>${encodeKey(prefix, encodingType)}</Prefix>
|
||||
<Marker>${encodeKey(marker || '', encodingType)}</Marker>
|
||||
<MaxKeys>${maxKeys}</MaxKeys>
|
||||
<Delimiter>${encodeKey(delimiter || '', encodingType)}</Delimiter>
|
||||
${encodingType ? `<EncodingType>${escapeXml(encodingType)}</EncodingType>` : ''}
|
||||
<IsTruncated>${isTruncated}</IsTruncated>
|
||||
${objects
|
||||
.map(
|
||||
(o) => `<Contents>
|
||||
<Key>${encodeKey(o.key, encodingType)}</Key>
|
||||
<LastModified>${isoDate(o.lastModified)}</LastModified>
|
||||
<ETag>"${o.etag}"</ETag>
|
||||
<Size>${o.sizeBytes}</Size>
|
||||
<StorageClass>STANDARD</StorageClass>
|
||||
</Contents>`,
|
||||
)
|
||||
.join('')}
|
||||
${prefixes
|
||||
.map(
|
||||
(p) => `<CommonPrefixes>
|
||||
<Prefix>${encodeKey(p, encodingType)}</Prefix>
|
||||
</CommonPrefixes>`,
|
||||
)
|
||||
.join('')}
|
||||
${nextMarker ? `<NextMarker>${encodeKey(nextMarker, encodingType)}</NextMarker>` : ''}
|
||||
</ListBucketResult>`;
|
||||
|
||||
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 => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ListBucketResultV2 xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Name>${escapeXml(bucketName)}</Name>
|
||||
<Prefix>${encodeKey(prefix, encodingType)}</Prefix>
|
||||
<MaxKeys>${maxKeys}</MaxKeys>
|
||||
<KeyCount>${keyCount}</KeyCount>
|
||||
${delimiter ? `<Delimiter>${encodeKey(delimiter, encodingType)}</Delimiter>` : ''}
|
||||
${encodingType ? `<EncodingType>${escapeXml(encodingType)}</EncodingType>` : ''}
|
||||
${continuationToken ? `<ContinuationToken>${encodeKey(continuationToken, encodingType)}</ContinuationToken>` : ''}
|
||||
<IsTruncated>${isTruncated}</IsTruncated>
|
||||
${objects
|
||||
.map(
|
||||
(o) => `<Contents>
|
||||
<Key>${encodeKey(o.key, encodingType)}</Key>
|
||||
<LastModified>${isoDate(o.lastModified)}</LastModified>
|
||||
<ETag>"${o.etag}"</ETag>
|
||||
<Size>${o.sizeBytes}</Size>
|
||||
<StorageClass>STANDARD</StorageClass>
|
||||
</Contents>`,
|
||||
)
|
||||
.join('')}
|
||||
${prefixes
|
||||
.map(
|
||||
(p) => `<CommonPrefixes>
|
||||
<Prefix>${encodeKey(p, encodingType)}</Prefix>
|
||||
</CommonPrefixes>`,
|
||||
)
|
||||
.join('')}
|
||||
${nextContinuationToken ? `<NextContinuationToken>${encodeKey(nextContinuationToken, encodingType)}</NextContinuationToken>` : ''}
|
||||
</ListBucketResultV2>`;
|
||||
|
||||
// ─────── Multipart ───────
|
||||
|
||||
export const initiateMultipartUploadXml = (
|
||||
bucketName: string,
|
||||
key: string,
|
||||
uploadId: string,
|
||||
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<InitiateMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Bucket>${escapeXml(bucketName)}</Bucket>
|
||||
<Key>${escapeXml(key)}</Key>
|
||||
<UploadId>${uploadId}</UploadId>
|
||||
</InitiateMultipartUploadResult>`;
|
||||
|
||||
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 => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ListPartsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Bucket>${escapeXml(bucketName)}</Bucket>
|
||||
<Key>${escapeXml(key)}</Key>
|
||||
<UploadId>${uploadId}</UploadId>
|
||||
<MaxParts>${maxParts}</MaxParts>
|
||||
<IsTruncated>${isTruncated}</IsTruncated>
|
||||
${parts
|
||||
.map(
|
||||
(p) => `<Part>
|
||||
<PartNumber>${p.partNumber}</PartNumber>
|
||||
<LastModified>${isoDate(p.createdAt)}</LastModified>
|
||||
<ETag>"${p.etag}"</ETag>
|
||||
<Size>${p.sizeBytes}</Size>
|
||||
</Part>`,
|
||||
)
|
||||
.join('')}
|
||||
</ListPartsResult>`;
|
||||
|
||||
export const listMultipartUploadsXml = (
|
||||
bucketName: string,
|
||||
uploads: { key: string; uploadId: string; initiatedAt: Date; initiatedBy: string }[],
|
||||
maxUploads: number,
|
||||
isTruncated: boolean,
|
||||
nextKeyMarker: string | null,
|
||||
_requestId: string,
|
||||
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ListMultipartUploadsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Bucket>${escapeXml(bucketName)}</Bucket>
|
||||
<KeyMarker></KeyMarker>
|
||||
<UploadIdMarker></UploadIdMarker>
|
||||
${nextKeyMarker ? `<NextKeyMarker>${escapeXml(nextKeyMarker)}</NextKeyMarker>` : ''}
|
||||
<MaxUploads>${maxUploads}</MaxUploads>
|
||||
<IsTruncated>${isTruncated}</IsTruncated>
|
||||
${uploads
|
||||
.map(
|
||||
(u) => `<Upload>
|
||||
<Key>${escapeXml(u.key)}</Key>
|
||||
<UploadId>${u.uploadId}</UploadId>
|
||||
<Initiator><ID>${escapeXml(u.initiatedBy || 's3')}</ID><DisplayName>${escapeXml(u.initiatedBy || 's3')}</DisplayName></Initiator>
|
||||
<Owner><ID>${escapeXml(u.initiatedBy || 's3')}</ID><DisplayName>${escapeXml(u.initiatedBy || 's3')}</DisplayName></Owner>
|
||||
<StorageClass>STANDARD</StorageClass>
|
||||
<Initiated>${isoDate(u.initiatedAt)}</Initiated>
|
||||
</Upload>`,
|
||||
)
|
||||
.join('')}
|
||||
</ListMultipartUploadsResult>`;
|
||||
|
||||
export const completeMultipartUploadXml = (
|
||||
bucketName: string,
|
||||
key: string,
|
||||
etag: string,
|
||||
location: string,
|
||||
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CompleteMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Location>${escapeXml(location)}</Location>
|
||||
<Bucket>${escapeXml(bucketName)}</Bucket>
|
||||
<Key>${escapeXml(key)}</Key>
|
||||
<ETag>"${etag}"</ETag>
|
||||
</CompleteMultipartUploadResult>`;
|
||||
|
||||
// ─────── Delete result ───────
|
||||
|
||||
export const deleteResultXml = (
|
||||
deleted: string[],
|
||||
errors: { key: string; code: string; message: string }[],
|
||||
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
${deleted
|
||||
.map(
|
||||
(key) => `<Deleted>
|
||||
<Key>${escapeXml(key)}</Key>
|
||||
</Deleted>`,
|
||||
)
|
||||
.join('')}
|
||||
${errors
|
||||
.map(
|
||||
(e) => `<Error>
|
||||
<Key>${escapeXml(e.key)}</Key>
|
||||
<Code>${e.code}</Code>
|
||||
<Message>${escapeXml(e.message)}</Message>
|
||||
</Error>`,
|
||||
)
|
||||
.join('')}
|
||||
</DeleteResult>`;
|
||||
|
||||
// ─────── Copy ───────
|
||||
|
||||
export const copyObjectResultXml = (
|
||||
etag: string,
|
||||
lastModified: Date,
|
||||
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CopyObjectResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<ETag>"${etag}"</ETag>
|
||||
<LastModified>${isoDate(lastModified)}</LastModified>
|
||||
</CopyObjectResult>`;
|
||||
|
||||
// ─────── Error ───────
|
||||
|
||||
export const s3ErrorXml = (
|
||||
code: string,
|
||||
message: string,
|
||||
resource: string,
|
||||
requestId: string,
|
||||
): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Error>
|
||||
<Code>${code}</Code>
|
||||
<Message>${escapeXml(message)}</Message>
|
||||
<Resource>${escapeXml(resource)}</Resource>
|
||||
<RequestId>${requestId}</RequestId>
|
||||
<HostId>${requestId}</HostId>
|
||||
</Error>`;
|
||||
|
||||
export const s3ErrorResponse = (
|
||||
code: string,
|
||||
message: string,
|
||||
resource: string,
|
||||
status: number,
|
||||
requestId: string = '',
|
||||
extraHeaders: Record<string, string> = {},
|
||||
): 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>([^<]+)<\/Key>/g), (match) => match[1]);
|
||||
const quiet = body.includes('<Quiet>true</Quiet>') || body.includes('<Quiet>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 = /<Part>[\s\S]*?<\/Part>/g;
|
||||
const partMatch = body.match(partRegex) || [];
|
||||
|
||||
for (const partXml of partMatch) {
|
||||
const numMatch = partXml.match(/<PartNumber>(\d+)<\/PartNumber>/);
|
||||
const etagMatch = partXml.match(/<ETag>"?([^"<\s]+)"?<\/ETag>/);
|
||||
if (numMatch && etagMatch) {
|
||||
parts.push({
|
||||
partNumber: parseInt(numMatch[1], 10),
|
||||
etag: etagMatch[1].replace(/^"/, '').replace(/"$/, ''),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
Reference in New Issue
Block a user