fix: correct import paths and add missing protocol files for DDD structure
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 '../../../db';
|
||||
import { getErrorMessage } from '../../../shared/utils/file';
|
||||
import logger from '../../../utils/logger';
|
||||
|
||||
/**
|
||||
* Handles the health-check endpoint.
|
||||
*
|
||||
* Verifies database connectivity by executing a simple `SELECT 1` query.
|
||||
* Returns a 200 response with `{ status: 'ok' }` when the database is
|
||||
* reachable, or a 500 response with the error details when it is not.
|
||||
*
|
||||
* @param _req - The incoming HTTP request (unused).
|
||||
* @returns A JSON response indicating the database health status.
|
||||
*/
|
||||
export const handleHealth = async (_req: Request): Promise<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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,357 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { config } from '../../../config/index';
|
||||
|
||||
const ADMIN_USERNAME = 'admin';
|
||||
const SIGNATURE_SEPARATOR = '.';
|
||||
|
||||
/** A request handler function that returns a Response. */
|
||||
type Handler = (req: Request) => Response | Promise<Response>;
|
||||
|
||||
/**
|
||||
* Represents an authenticated user session after successful
|
||||
* authentication via cookie or bearer token.
|
||||
*/
|
||||
export interface AuthSession {
|
||||
/** The authenticated username (always "admin" in this implementation). */
|
||||
username: string;
|
||||
/**
|
||||
* Expiration date of the session, or `null` for bearer-token
|
||||
* sessions which do not expire at the session level.
|
||||
*/
|
||||
expiresAt: Date | null;
|
||||
/** The authentication method used to establish this session. */
|
||||
method: 'cookie' | 'bearer';
|
||||
}
|
||||
|
||||
/** Options for configuring cookie-based session behaviour. */
|
||||
interface CookieOptions {
|
||||
/** HMAC signing secret (defaults to {@link config.adminApiToken}). */
|
||||
secret?: string;
|
||||
/** Name of the session cookie (defaults to {@link config.sessionCookieName}). */
|
||||
cookieName?: string;
|
||||
/** Session lifetime in milliseconds (defaults to {@link config.sessionMaxAgeMs}). */
|
||||
maxAgeMs?: number;
|
||||
}
|
||||
|
||||
/** Shape of the serialised cookie payload. */
|
||||
interface SessionPayload {
|
||||
u: string;
|
||||
e: number;
|
||||
}
|
||||
|
||||
const getSecret = (secret?: string): string => secret ?? config.adminApiToken;
|
||||
const getCookieName = (cookieName?: string): string => cookieName ?? config.sessionCookieName;
|
||||
const getMaxAgeMs = (maxAgeMs?: number): number => maxAgeMs ?? config.sessionMaxAgeMs;
|
||||
|
||||
const encodePayload = (value: string): string =>
|
||||
Buffer.from(value, 'utf8').toString('base64url');
|
||||
|
||||
const decodePayload = (value: string): string | null => {
|
||||
try {
|
||||
return Buffer.from(value, 'base64url').toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether authentication is enabled.
|
||||
*
|
||||
* Authentication is considered enabled when the admin API token is
|
||||
* non-empty.
|
||||
*
|
||||
* @param secret - Secret to check (defaults to `config.adminApiToken`).
|
||||
* @returns `true` when auth is enabled, `false` otherwise.
|
||||
*/
|
||||
export const isAuthEnabled = (secret = config.adminApiToken): boolean => secret.length > 0;
|
||||
|
||||
/**
|
||||
* Compares two strings using a timing-safe algorithm to prevent
|
||||
* timing side-channel attacks.
|
||||
*
|
||||
* @param left - First string to compare.
|
||||
* @param right - Second string to compare.
|
||||
* @returns `true` when the strings are equal, `false` otherwise.
|
||||
*/
|
||||
export const timingSafeCompare = (left: string, right: string): boolean => {
|
||||
const leftBuffer = Buffer.from(left);
|
||||
const rightBuffer = Buffer.from(right);
|
||||
|
||||
if (leftBuffer.length !== rightBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(leftBuffer, rightBuffer);
|
||||
};
|
||||
|
||||
/**
|
||||
* Signs an arbitrary payload string with HMAC-SHA256 using the given
|
||||
* secret, producing a base64url-encoded signature.
|
||||
*
|
||||
* @param payload - The value to sign.
|
||||
* @param secret - HMAC signing key.
|
||||
* @returns The base64url-encoded signature.
|
||||
*/
|
||||
export const signCookiePayload = (payload: string, secret: string): string =>
|
||||
createHmac('sha256', secret).update(payload).digest('base64url');
|
||||
|
||||
/**
|
||||
* Verifies the HMAC signature on a cookie value and returns the
|
||||
* original signed payload.
|
||||
*
|
||||
* The cookie value is expected to be in the format
|
||||
* `<payload>.<signature>`. Returns `null` when the format is
|
||||
* invalid or the signature does not match.
|
||||
*
|
||||
* @param cookieValue - The full cookie value including signature.
|
||||
* @param secret - HMAC signing key.
|
||||
* @returns The unsigned payload string, or `null` on failure.
|
||||
*/
|
||||
export const verifyCookieSignature = (
|
||||
cookieValue: string,
|
||||
secret: string,
|
||||
): string | null => {
|
||||
const separatorIndex = cookieValue.lastIndexOf(SIGNATURE_SEPARATOR);
|
||||
if (separatorIndex <= 0 || separatorIndex === cookieValue.length - 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = cookieValue.slice(0, separatorIndex);
|
||||
const signature = cookieValue.slice(separatorIndex + 1);
|
||||
const expectedSignature = signCookiePayload(payload, secret);
|
||||
|
||||
if (!timingSafeCompare(signature, expectedSignature)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the `Set-Cookie` attribute string for a given max-age in
|
||||
* seconds. The cookie is HttpOnly, SameSite=Lax, Secure, and
|
||||
* scoped to the root path.
|
||||
*
|
||||
* @param maxAgeSeconds - Max-Age in seconds.
|
||||
* @returns The cookie attribute string (excluding name=value).
|
||||
*/
|
||||
const cookieAttributes = (maxAgeSeconds: number): string =>
|
||||
[
|
||||
`Max-Age=${maxAgeSeconds}`,
|
||||
'Path=/',
|
||||
'HttpOnly',
|
||||
'SameSite=Lax',
|
||||
'Secure',
|
||||
].join('; ');
|
||||
|
||||
/**
|
||||
* Creates a signed session cookie string suitable for use as a
|
||||
* `Set-Cookie` header value.
|
||||
*
|
||||
* The cookie embeds a base64url-encoded JSON payload containing the
|
||||
* username and expiration timestamp, signed with HMAC-SHA256.
|
||||
*
|
||||
* @param username - Session username (default `"admin"`).
|
||||
* @param options - Optional cookie settings.
|
||||
* @returns A fully-formed `Set-Cookie` header value.
|
||||
*/
|
||||
export const createSessionCookie = (
|
||||
username = ADMIN_USERNAME,
|
||||
options: CookieOptions = {},
|
||||
): string => {
|
||||
const secret = getSecret(options.secret);
|
||||
const cookieName = getCookieName(options.cookieName);
|
||||
const maxAgeMs = getMaxAgeMs(options.maxAgeMs);
|
||||
const expiresAt = Date.now() + maxAgeMs;
|
||||
const payload = encodePayload(
|
||||
JSON.stringify({ u: username, e: expiresAt } satisfies SessionPayload),
|
||||
);
|
||||
const signature = signCookiePayload(payload, secret);
|
||||
const maxAgeSeconds = Math.max(1, Math.floor(maxAgeMs / 1000));
|
||||
|
||||
return `${cookieName}=${payload}${SIGNATURE_SEPARATOR}${signature}; ${cookieAttributes(maxAgeSeconds)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a `Set-Cookie` header value that immediately expires the
|
||||
* session cookie, effectively logging the user out.
|
||||
*
|
||||
* @param cookieName - Name of the cookie to clear (defaults to
|
||||
* `config.sessionCookieName`).
|
||||
* @returns A `Set-Cookie` header value with Max-Age=0.
|
||||
*/
|
||||
export const clearSessionCookie = (cookieName = config.sessionCookieName): string =>
|
||||
`${cookieName}=; ${cookieAttributes(0)}`;
|
||||
|
||||
/**
|
||||
* Finds the value of a named cookie from a raw `Cookie` header
|
||||
* string.
|
||||
*
|
||||
* @param cookieHeader - The raw `Cookie` header value, or `null`.
|
||||
* @param cookieName - Name of the cookie to look for.
|
||||
* @returns The cookie value, or `null` if not found.
|
||||
*/
|
||||
const findCookieValue = (cookieHeader: string | null, cookieName: string): string | null => {
|
||||
if (!cookieHeader) return null;
|
||||
|
||||
for (const rawCookie of cookieHeader.split(';')) {
|
||||
const cookie = rawCookie.trim();
|
||||
const equalsIndex = cookie.indexOf('=');
|
||||
if (equalsIndex <= 0) continue;
|
||||
|
||||
const name = cookie.slice(0, equalsIndex);
|
||||
if (name === cookieName) {
|
||||
return cookie.slice(equalsIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses an {@link AuthSession} from a signed session cookie.
|
||||
*
|
||||
* The function verifies the HMAC signature, decodes the payload,
|
||||
* and validates the expiration timestamp. Returns `null` when the
|
||||
* cookie is missing, malformed, expired, or the signature is
|
||||
* invalid. Also returns `null` when auth is disabled (empty
|
||||
* admin API token).
|
||||
*
|
||||
* @param cookieHeader - The `Cookie` header value, or `null`.
|
||||
* @param options - Optional overrides for secret / cookie name.
|
||||
* @returns The parsed session, or `null`.
|
||||
*/
|
||||
export const parseSessionFromCookie = (
|
||||
cookieHeader: string | null,
|
||||
options: Pick<CookieOptions, 'secret' | 'cookieName'> = {},
|
||||
): AuthSession | null => {
|
||||
const secret = getSecret(options.secret);
|
||||
const cookieName = getCookieName(options.cookieName);
|
||||
if (!isAuthEnabled(secret)) return null;
|
||||
|
||||
const cookieValue = findCookieValue(cookieHeader, cookieName);
|
||||
if (!cookieValue) return null;
|
||||
|
||||
const encodedPayload = verifyCookieSignature(cookieValue, secret);
|
||||
if (!encodedPayload) return null;
|
||||
|
||||
const rawPayload = decodePayload(encodedPayload);
|
||||
if (!rawPayload) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(rawPayload) as Partial<SessionPayload>;
|
||||
if (payload.u !== ADMIN_USERNAME || typeof payload.e !== 'number') return null;
|
||||
if (!Number.isFinite(payload.e) || payload.e <= Date.now()) return null;
|
||||
|
||||
return {
|
||||
username: payload.u,
|
||||
expiresAt: new Date(payload.e),
|
||||
method: 'cookie',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a `Bearer` token from the `Authorization` header using
|
||||
* timing-safe comparison.
|
||||
*
|
||||
* @param authorizationHeader - The raw `Authorization` header, or `null`.
|
||||
* @param secret - Expected bearer token (defaults to
|
||||
* `config.adminApiToken`).
|
||||
* @returns `true` when the token is valid, `false` otherwise.
|
||||
*/
|
||||
export const checkBearerToken = (
|
||||
authorizationHeader: string | null,
|
||||
secret = config.adminApiToken,
|
||||
): boolean => {
|
||||
if (!isAuthEnabled(secret) || !authorizationHeader) return false;
|
||||
|
||||
const [scheme, ...rest] = authorizationHeader.split(' ');
|
||||
if (scheme !== 'Bearer' || rest.length === 0) return false;
|
||||
|
||||
const token = rest.join(' ').trim();
|
||||
return token.length > 0 && timingSafeCompare(token, secret);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the authenticated session from a request.
|
||||
*
|
||||
* Tries cookie-based authentication first, then falls back to a
|
||||
* Bearer token in the `Authorization` header. When auth is
|
||||
* disabled (empty API token) the function returns a synthetic
|
||||
* session with method `"bearer"` and no expiry, effectively
|
||||
* granting access to all requests.
|
||||
*
|
||||
* @param req - The incoming HTTP request.
|
||||
* @param options - Optional overrides for secret / cookie name.
|
||||
* @returns The authenticated session, or `null` when unauthenticated.
|
||||
*/
|
||||
export const getAuthSession = (
|
||||
req: Request,
|
||||
options: Pick<CookieOptions, 'secret' | 'cookieName'> = {},
|
||||
): AuthSession | null => {
|
||||
const secret = getSecret(options.secret);
|
||||
if (!isAuthEnabled(secret)) {
|
||||
return {
|
||||
username: ADMIN_USERNAME,
|
||||
expiresAt: null,
|
||||
method: 'bearer',
|
||||
};
|
||||
}
|
||||
|
||||
const cookieSession = parseSessionFromCookie(req.headers.get('cookie'), options);
|
||||
if (cookieSession) return cookieSession;
|
||||
|
||||
if (checkBearerToken(req.headers.get('authorization'), secret)) {
|
||||
return {
|
||||
username: ADMIN_USERNAME,
|
||||
expiresAt: null,
|
||||
method: 'bearer',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a 401 Unauthorized JSON response with a standard error
|
||||
* body.
|
||||
*
|
||||
* @returns A `Response` with status 401 and JSON body
|
||||
* `{ error: "Unauthorized" }`.
|
||||
*/
|
||||
export const unauthorizedResponse = (): Response =>
|
||||
Response.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
/**
|
||||
* Middleware that wraps a request handler with authentication.
|
||||
*
|
||||
* When auth is enabled the wrapper checks for a valid session
|
||||
* (cookie or Bearer token) before delegating to the handler.
|
||||
* Unauthenticated requests receive a 401 response. When auth is
|
||||
* disabled the handler is always invoked.
|
||||
*
|
||||
* @param handler - The request handler to protect.
|
||||
* @param options - Optional overrides for secret / cookie name.
|
||||
* @returns A wrapped handler that performs the auth check.
|
||||
*/
|
||||
export const requireAuth = (
|
||||
handler: Handler,
|
||||
options: Pick<CookieOptions, 'secret' | 'cookieName'> = {},
|
||||
): ((req: Request) => Promise<Response>) => {
|
||||
return async (req: Request): Promise<Response> => {
|
||||
const secret = getSecret(options.secret);
|
||||
if (!isAuthEnabled(secret)) {
|
||||
return handler(req);
|
||||
}
|
||||
|
||||
const session = getAuthSession(req, options);
|
||||
if (!session) {
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
return handler(req);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { config } from '../../../config/index';
|
||||
import { handleLogin, handleLogout, handleMe } from '../controllers/auth-controller';
|
||||
import { handleFileRedirect, handleFileInfo } from '../controllers/file-controller';
|
||||
import { handleHealth } from '../controllers/health-controller';
|
||||
import { handleHome } from '../controllers/home-controller';
|
||||
import { handleS3Request } from '../controllers/s3-controller';
|
||||
import { handleSwaggerHtml, handleSwaggerJson } from '../../../routes/swagger';
|
||||
import { handleUpload } from '../controllers/upload-controller';
|
||||
import { handleWebApiV1 } from '../controllers/web-api-controller';
|
||||
import { requireAuth } from '../../../utils/auth';
|
||||
import { withRateLimit } from '../../../utils/rateLimit';
|
||||
import { isS3Request } from '../../../utils/s3/auth';
|
||||
import { extractS3BucketFromHost } from '../../../utils/s3/virtual-host';
|
||||
|
||||
/**
|
||||
* Extracts the S3 bucket name from the request host
|
||||
* if it matches a virtual-hosted-style domain.
|
||||
*
|
||||
* @param req - The incoming HTTP request.
|
||||
* @returns The bucket name if found, or null.
|
||||
*/
|
||||
const getS3RouteBucket = (req: Request): string | null => {
|
||||
const host = req.headers.get('host') || '';
|
||||
return extractS3BucketFromHost(host, config.s3VhostDomains);
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines whether the incoming request appears to be an S3 API request
|
||||
* based on host headers, authorization headers, or query parameters.
|
||||
*
|
||||
* @param req - The incoming HTTP request.
|
||||
* @param headers - A record of parsed request headers.
|
||||
* @returns True if the request should be handled by the S3 handler.
|
||||
*/
|
||||
const shouldHandleS3 = (req: Request, headers: Record<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),
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user