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:
+9
-1
@@ -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
File diff suppressed because one or more lines are too long
+16
-5
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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' }),
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user