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:
Claude
2026-07-28 18:19:45 +07:00
parent 234ca7b14c
commit 4568644922
34 changed files with 6097 additions and 5 deletions
@@ -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',
},
});
};
+338
View File
@@ -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);
}
};