feat: implement authentication routes with login, logout, and user info retrieval

feat: add S3 bucket versioning support and related XML response handling

refactor: rename temporary file paths from 'teleuploader' to 'filedrop' for consistency

fix: update Swagger documentation to reflect new API name and descriptions

test: add unit tests for authentication routes and utilities

test: implement end-to-end tests for S3 bucket configuration and versioning

chore: update environment variable defaults for new service name
This commit is contained in:
asepharyana
2026-07-07 19:58:15 +07:00
parent 144ebe6dd3
commit 340c12d671
28 changed files with 898 additions and 52 deletions
+9 -1
View File
@@ -19,6 +19,9 @@ interface AppConfig {
telegramChunkSizeBytes: number;
compressChunkedUploads: boolean;
chunkCompressionMinSizeBytes: number;
adminApiToken: string;
sessionCookieName: string;
sessionMaxAgeMs: number;
s3AccessKey: string;
s3SecretKey: string;
s3DefaultRegion: string;
@@ -91,7 +94,10 @@ export const config: AppConfig = {
telegramChunkSizeBytes: parseNumber(process.env.TELEGRAM_CHUNK_SIZE_BYTES, 20 * 1024 * 1024),
compressChunkedUploads: process.env.COMPRESS_CHUNKED_UPLOADS !== 'false',
chunkCompressionMinSizeBytes: parseNumber(process.env.CHUNK_COMPRESSION_MIN_SIZE_BYTES, 4096),
s3AccessKey: process.env.S3_ACCESS_KEY || 'teleuploader-admin',
adminApiToken: process.env.ADMIN_API_TOKEN || '',
sessionCookieName: process.env.SESSION_COOKIE_NAME || 'tu_session',
sessionMaxAgeMs: parseNumber(process.env.SESSION_COOKIE_MAX_AGE_SECONDS, 86400) * 1000,
s3AccessKey: process.env.S3_ACCESS_KEY || 'filedrop-admin',
s3SecretKey: process.env.S3_SECRET_KEY || '',
s3DefaultRegion: process.env.S3_DEFAULT_REGION || 'us-east-1',
proxyS3Get: process.env.PROXY_S3_GET !== 'false',
@@ -107,6 +113,8 @@ logger.info('Environment variables loaded', {
botToken: maskSecret(config.botToken),
additionalBotTokens: config.additionalBotTokens.map(maskSecret),
databaseUrl: maskDatabaseUrl(config.databaseUrl),
adminApiToken: maskSecret(config.adminApiToken),
adminApiTokenEnabled: config.adminApiToken.length > 0,
s3AccessKey: maskSecret(config.s3AccessKey),
s3SecretKey: maskSecret(config.s3SecretKey),
},
+95 -4
View File
File diff suppressed because one or more lines are too long
+16 -5
View File
@@ -1,6 +1,7 @@
import { serve } from 'bun';
import { startBot } from './bot';
import { config } from './env';
import { handleLogin, handleLogout, handleMe } from './routes/auth';
import { handleFileInfo, handleFileRedirect } from './routes/files';
import { handleHealth } from './routes/health';
import { handleHome } from './routes/home';
@@ -8,6 +9,7 @@ import { handleS3Request } from './routes/s3';
import { handleSwaggerHtml, handleSwaggerJson } from './routes/swagger';
import { handleUpload } from './routes/upload';
import { handleWebApiV1 } from './routes/web-api';
import { requireAuth } from './utils/auth';
import { fileInfoCache } from './utils/cache';
import logger from './utils/logger';
import { metricsCollector } from './utils/metrics';
@@ -58,7 +60,7 @@ const server = serve({
port: config.port,
routes: {
'/api/upload': {
POST: withRateLimit(handleUpload),
POST: withRateLimit(requireAuth(handleUpload)),
},
'/f/:public_id': {
GET: withRateLimit(handleFileRedirect),
@@ -89,11 +91,20 @@ const server = serve({
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: handleWebApiV1,
POST: handleWebApiV1,
DELETE: handleWebApiV1,
PUT: handleWebApiV1,
GET: requireAuth(handleWebApiV1),
POST: requireAuth(handleWebApiV1),
DELETE: requireAuth(handleWebApiV1),
PUT: requireAuth(handleWebApiV1),
},
},
fetch: async (req: Request) => {
+64
View File
@@ -0,0 +1,64 @@
import { config } from '../env';
import {
checkBearerToken,
clearSessionCookie,
createSessionCookie,
getAuthSession,
isAuthEnabled,
timingSafeCompare,
} from '../utils/auth';
const json = (data: unknown, status = 200, headers: Record<string, string> = {}): Response =>
Response.json(data, { status, headers });
const notFound = (): Response => json({ error: 'Not found' }, 404);
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;
}
};
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);
if (!timingSafeCompare(body.token, config.adminApiToken)) {
return json({ error: 'Invalid token' }, 401);
}
return json({ username: 'admin' }, 200, {
'set-cookie': createSessionCookie('admin'),
});
};
export const handleLogout = async (): Promise<Response> =>
json({ success: true }, 200, {
'set-cookie': clearSessionCookie(),
});
export const handleMe = async (req: Request): Promise<Response> => {
if (!isAuthEnabled()) return notFound();
const session = getAuthSession(req);
if (!session && !checkBearerToken(req.headers.get('authorization'))) {
return json({ error: 'Unauthorized' }, 401);
}
const activeSession = session ?? {
username: 'admin',
expiresAt: null,
method: 'bearer' as const,
};
return json({
username: activeSession.username,
expiresAt: activeSession.expiresAt?.toISOString() ?? null,
});
};
+1 -1
View File
@@ -74,7 +74,7 @@ export const handleFileRedirect = async (req: RequestWithParams): Promise<Respon
return fail(500, 'Server error');
}
const tempZipPath = `/tmp/teleuploader-dl-${nanoid()}.zip`;
const tempZipPath = `/tmp/filedrop-dl-${nanoid()}.zip`;
await Bun.write(tempZipPath, archiveResponse);
const loc = await locateZipEntry(tempZipPath, archiveEntryName);
+22 -2
View File
@@ -26,6 +26,7 @@ import { S3_CORS_HEADERS, s3Headers } from '../utils/s3/headers';
import { createGetObjectResponse, type ObjectPartSource } from '../utils/s3/object-stream';
import { parseRangeHeader, unsatisfiedContentRange } from '../utils/s3/range';
import {
bucketVersioningConfigurationXml,
completeMultipartUploadXml,
copyObjectResultXml,
deleteResultXml,
@@ -147,6 +148,9 @@ export const handleS3Request = async (
// Bucket-level operations
if (!key) {
if (method === 'GET') {
if (searchParams.has('versioning')) {
return handleGetBucketVersioning(bucket, reqId);
}
if (searchParams.has('uploads')) {
return handleListMultipartUploads(bucket, searchParams, reqId);
}
@@ -291,6 +295,22 @@ const handleDeleteBucket = async (bucketName: string, reqId: string): Promise<Re
return s3Response(null, 204, reqId);
};
const handleGetBucketVersioning = async (bucketName: string, reqId: string): Promise<Response> => {
const bucket = await findBucketByName(bucketName);
if (!bucket) {
return s3ErrorResponse(
'NoSuchBucket',
'The specified bucket does not exist.',
`/${bucketName}`,
404,
reqId,
);
}
return s3Response(bucketVersioningConfigurationXml(), 200, reqId, {
'content-type': 'application/xml',
});
};
// ─────── Object Operations ───────
const handleGetObject = async (
@@ -564,7 +584,7 @@ const storeFileToTelegram = async (
contentType: string,
reqId: string,
): Promise<Response> => {
const tempPath = `/tmp/teleuploader-s3-${nanoid()}`;
const tempPath = `/tmp/filedrop-s3-${nanoid()}`;
await Bun.write(tempPath, buffer);
const signatureBuffer = buffer.subarray(0, 16);
@@ -953,7 +973,7 @@ const handleUploadPart = async (
);
}
const tempPath = `/tmp/teleuploader-mp-${nanoid()}`;
const tempPath = `/tmp/filedrop-mp-${nanoid()}`;
await Bun.write(tempPath, buffer);
const forwardResult = await forwardToStorage(
+3 -3
View File
@@ -49,9 +49,9 @@ export const handleSwaggerJson = async (): Promise<Response> => {
const spec = {
openapi: '3.0.0',
info: {
title: 'TeleUploader API',
title: 'FileDrop API',
version: '1.0.0',
description: 'Telegram-backed file uploader API with stream-based downloads.',
description: 'File upload API with stream-based downloads.',
},
servers: [
{
@@ -215,7 +215,7 @@ export const handleSwaggerHtml = async (): Promise<Response> => {
<html lang="en">
<head>
<meta charset="UTF-8">
<title>TeleUploader API Documentation</title>
<title>FileDrop API Documentation</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.17.14/swagger-ui.css">
<style>
html { box-sizing: border-box; overflow-y: scroll; }
+2 -2
View File
@@ -59,7 +59,7 @@ const rejectOversizedRequest = (req: Request): Response | null => {
};
const streamFileToTemp = async (file: File, maxSizeBytes: number): Promise<PreparedUpload> => {
const tempPath = `/tmp/teleuploader-${nanoid()}`;
const tempPath = `/tmp/filedrop-${nanoid()}`;
const writer = createWriteStream(tempPath);
const hasher = new Bun.CryptoHasher('sha256');
const reader = file.stream().getReader();
@@ -123,7 +123,7 @@ const streamFileToTemp = async (file: File, maxSizeBytes: number): Promise<Prepa
};
const writeBufferToTemp = async (fileBuffer: Buffer, fileHash: string): Promise<PreparedUpload> => {
const tempPath = `/tmp/teleuploader-${nanoid()}`;
const tempPath = `/tmp/filedrop-${nanoid()}`;
try {
await Bun.write(tempPath, fileBuffer);
return {
+1 -1
View File
@@ -117,7 +117,7 @@ export const handleUploadObjectV1 = async (
const buffer = Buffer.from(await file.arrayBuffer());
const hash = computeHash(buffer);
const tempPath = `/tmp/teleuploader-web-${nanoid()}`;
const tempPath = `/tmp/filedrop-web-${nanoid()}`;
await Bun.write(tempPath, buffer);
const signatureBuffer = buffer.subarray(0, 16);
+205
View File
@@ -0,0 +1,205 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import { config } from '../env';
const ADMIN_USERNAME = 'admin';
const SIGNATURE_SEPARATOR = '.';
type Handler = (req: Request) => Response | Promise<Response>;
export interface AuthSession {
username: string;
expiresAt: Date | null;
method: 'cookie' | 'bearer';
}
interface CookieOptions {
secret?: string;
cookieName?: string;
maxAgeMs?: number;
}
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;
}
};
export const isAuthEnabled = (secret = config.adminApiToken): boolean => secret.length > 0;
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);
};
export const signCookiePayload = (payload: string, secret: string): string =>
createHmac('sha256', secret).update(payload).digest('base64url');
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;
};
const cookieAttributes = (maxAgeSeconds: number): string =>
[`Max-Age=${maxAgeSeconds}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Secure'].join('; ');
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)}`;
};
export const clearSessionCookie = (cookieName = config.sessionCookieName): string =>
`${cookieName}=; ${cookieAttributes(0)}`;
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;
};
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;
}
};
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);
};
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;
};
export const unauthorizedResponse = (): Response =>
Response.json({ error: 'Unauthorized' }, { status: 401 });
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);
};
};
+1 -1
View File
@@ -7,7 +7,7 @@ const logger = winston.createLogger({
winston.format.errors({ stack: true }),
winston.format.json(),
),
defaultMeta: { service: 'teleuploader' },
defaultMeta: { service: 'filedrop' },
transports: [
// Write all logs including error logs to file
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
+4
View File
@@ -32,6 +32,10 @@ export const listBucketsXml = (
</Buckets>
</ListAllMyBucketsResult>`;
export const bucketVersioningConfigurationXml =
(): string => `<?xml version="1.0" encoding="UTF-8"?>
<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"/>`;
// ─────── Object listing ───────
export const listBucketResultXml = (
+1 -1
View File
@@ -85,7 +85,7 @@ const flushUploads = async (): Promise<void> => {
batch.map((item) => ({ tempPath: item.prepared.tempPath, fileName: item.fileName })),
);
zipTempPath = zip.tempPath;
const archiveFileName = `teleuploader-${nanoid()}.zip`;
const archiveFileName = `filedrop-${nanoid()}.zip`;
const archiveResult = await forwardToStorage(
createReadStream(zip.tempPath),
archiveFileName,
+1 -1
View File
@@ -107,7 +107,7 @@ const calculateFileCrc32 = async (tempPath: string): Promise<number> => {
};
export const createZip = async (files: ZipInputFile[]): Promise<CreatedZip> => {
const tempPath = `/tmp/teleuploader-${nanoid()}.zip`;
const tempPath = `/tmp/filedrop-${nanoid()}.zip`;
const writer = createWriteStream(tempPath);
const hasher = new Bun.CryptoHasher('sha256');
const entries: ZipEntry[] = [];