refactor(fase1c): routing S3 fixes + auth/swagger/index/env

- routes: GET / teruskan headers ke shouldHandleS3; OPTIONS jawab
  204 CORS generik bila bukan S3, handleS3Direct bila S3; komentar
  bypass rate-limit S3 dipertahankan (registry abort pada 429)
- auth-controller: readLoginBody via LoginBodySchema; handleMe sederhanakan
  (getAuthSession sudah cek bearer); import AuthSession dari dto
- swagger: pindah src/routes -> src/interfaces/http/swagger; tambah path
  auth, /api/v1, /{bucket}, /{bucket}/{key}; version dari config.appVersion
- index: unref ketiga setInterval agar tak menahan process
- env: PORT fail-fast via PositiveIntSchema (default 4000 bila tak diset);
  log config turun ke debug
- metrics: dokumentasikan uploadThroughput/queueSize/botUtilization
- test baru test/s3-routing.test.ts (GET / S3 vs home, OPTIONS 204 CORS)
This commit is contained in:
asepharyana
2026-09-14 17:26:03 +07:00
parent 5d01a9405f
commit b164b8826f
9 changed files with 443 additions and 47 deletions
+26 -2
View File
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs';
import logger from './shared/logger/index';
import { TELEGRAM_CHUNK_SIZE_MAX_BYTES } from './shared/utils/validation';
import { PositiveIntSchema } from './shared/validation/schemas';
interface AppConfig {
/** Application version (read from package.json, kept in sync by semantic-release prepare.mjs) */
@@ -88,6 +89,27 @@ const parseNumber = (value: string | undefined, fallback: number): number => {
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
/**
* Parses the PORT env var with fail-fast validation.
*
* Defaults to 4000 ONLY when the variable is absent or empty. A present but
* invalid value ('abc', '-5', '0') throws so the service refuses to start
* misconfigured instead of silently listening on the wrong port.
*
* @param value - The raw `PORT` env var value.
* @returns The validated port number.
* @throws {Error} When the value is present but not a positive integer.
*/
const parsePort = (value: string | undefined): number => {
if (value === undefined || value === '') return 4000;
const parsed = PositiveIntSchema.safeParse(value);
if (!parsed.success) {
logger.error(`Invalid PORT value: ${JSON.stringify(value)} — PORT must be a positive integer`);
throw new Error('PORT must be a positive integer');
}
return parsed.data;
};
const parseTokens = (value: string | undefined): string[] =>
(value || '')
.split(',')
@@ -172,7 +194,7 @@ export const config: AppConfig = {
storageChatId: parseInt(process.env.STORAGE_CHANNEL_ID!, 10),
baseUrl: process.env.BASE_URL!,
databaseUrl: process.env.DATABASE_URL!,
port: parseInt(process.env.PORT!, 10) || 4000,
port: parsePort(process.env.PORT),
nodeEnv: process.env.NODE_ENV || 'development',
logLevel: process.env.LOG_LEVEL || 'info',
rateLimitWindowMs: parseNumber(process.env.RATE_LIMIT_WINDOW_MS, 60000),
@@ -197,7 +219,9 @@ export const config: AppConfig = {
),
};
logger.info('Environment variables loaded', {
// Debug-level: every import of env.ts would otherwise dump the full config
// (secrets masked, but still one noisy line per test file) to the log stream.
logger.debug('Environment variables loaded', {
config: {
...config,
botTokens: config.botTokens.map(maskSecret),
+13 -2
View File
@@ -49,8 +49,16 @@ const gracefulShutdown = async (signal: string): Promise<void> => {
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
// Periodic maintenance intervals
setInterval(cleanupRateLimitCache, 60000);
// Periodic maintenance intervals. Timers are unref'd so they never keep the
// process alive on their own (safe no-op where `unref` is unavailable).
const unref = (timer: unknown): void => {
if (typeof timer === 'object' && timer !== null && 'unref' in timer) {
(timer as { unref?: () => void }).unref?.();
}
};
unref(setInterval(cleanupRateLimitCache, 60000));
unref(
setInterval(
() => {
const removed = fileInfoCache.cleanup();
@@ -59,7 +67,9 @@ setInterval(
}
},
5 * 60 * 1000,
),
);
unref(
setInterval(
() => {
const snapshot = metricsCollector.getSnapshot();
@@ -71,6 +81,7 @@ setInterval(
});
},
5 * 60 * 1000,
),
);
logger.info('Application running successfully');
@@ -1,12 +1,12 @@
import type { AuthSession } from '../../../application/dto/auth';
import {
type AuthSession,
createLoginUseCase,
createLogoutUseCase,
createMeUseCase,
} from '../../../application/use-cases/authenticate';
import { config } from '../../../env';
import { LoginBodySchema } from '../../../shared/validation/schemas';
import {
checkBearerToken,
clearSessionCookie,
createSessionCookie,
getAuthSession,
@@ -36,14 +36,17 @@ const notFound = (): Response => json({ error: 'Not found' }, 404);
/**
* Parses the login request body, extracting the `token` field.
*
* Validated through {@link LoginBodySchema} (the canonical boundary schema
* for `POST /api/v1/auth/login`).
*
* @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 };
const parsed = LoginBodySchema.safeParse(await req.json());
if (!parsed.success) return null;
return { token: parsed.data.token };
} catch {
return null;
}
@@ -119,8 +122,10 @@ export const handleLogout = async (): Promise<Response> => {
export const handleMe = async (req: Request): Promise<Response> => {
if (!isAuthEnabled()) return notFound();
// getAuthSession already checks the bearer token (cookie first, then
// Authorization header) — no second check needed here.
const session: AuthSession | null = getAuthSession(req);
if (!session && !checkBearerToken(req.headers.get('authorization'))) {
if (!session) {
return json({ error: 'Unauthorized' }, 401);
}
@@ -132,13 +137,7 @@ export const handleMe = async (req: Request): Promise<Response> => {
},
});
const activeSession = session ?? {
username: 'admin',
expiresAt: null,
method: 'bearer' as const,
};
const result = await meUseCase(activeSession);
const result = await meUseCase(session);
if (!result) {
return json({ error: 'Unauthorized' }, 401);
+39 -5
View File
@@ -1,4 +1,3 @@
import { handleSwaggerHtml, handleSwaggerJson } from '../../../routes/swagger';
import { getS3RouteBucket, shouldHandleS3 } from '../../../shared/utils/s3-detection';
import { handleLogin, handleLogout, handleMe } from '../controllers/auth-controller';
import { handleFileInfo, handleFileRedirect } from '../controllers/file-controller';
@@ -9,6 +8,7 @@ import { handleUpload } from '../controllers/upload-controller';
import { handleWebApiV1 } from '../controllers/web-api-controller';
import { requireAuth } from '../middleware/auth';
import { withRateLimit } from '../middleware/rate-limit';
import { handleSwaggerHtml, handleSwaggerJson } from '../swagger';
/**
* Dispatches an S3 request directly, bypassing rate limiting.
@@ -16,7 +16,7 @@ import { withRateLimit } from '../middleware/rate-limit';
* S3 API calls (used by Docker registry for blob pushes) must not be
* rate-limited — large concurrent layer uploads would hit the limit and
* fail. The Docker registry client retries on 5xx, not 4xx, so a 429
* would abort the entire push.
* would abort the entire push. Do NOT wrap this in withRateLimit.
*
* @param req - The incoming S3 request.
* @returns The S3 response.
@@ -25,6 +25,40 @@ const handleS3Direct = (req: Request): Promise<Response> => {
return handleS3Request(req, getS3RouteBucket(req));
};
/**
* Generic CORS preflight for non-S3 API requests.
*
* S3 preflights are answered by the S3 controller (S3 XML CORS headers);
* anything else (dashboard fetches, future API endpoints) gets a plain
* permissive 204 so browsers can proceed.
*
* @returns A 204 No Content Response with permissive CORS headers.
*/
const apiOptionsResponse = (): Response =>
new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, PUT, HEAD, DELETE, POST, PATCH, OPTIONS',
'Access-Control-Allow-Headers':
'Authorization, Content-Type, X-Amz-Date, X-Amz-Content-Sha256',
},
});
/**
* Handles an OPTIONS request on the catch-all route.
*
* S3 clients preflight with SigV4 headers — those go to the S3 handler.
* Anything else is a generic API preflight and gets a plain 204.
*
* @param req - The incoming OPTIONS request.
* @returns The S3 or generic CORS preflight response.
*/
const handleCatchAllOptions = (req: Request): Promise<Response> => {
if (shouldHandleS3(req, Object.fromEntries(req.headers))) return handleS3Direct(req);
return Promise.resolve(apiOptionsResponse());
};
/**
* Defines all HTTP routes for the application.
*
@@ -58,7 +92,7 @@ export const routes = {
},
'/': {
GET: (req: Request): Promise<Response> => {
if (shouldHandleS3(req)) return handleS3Direct(req);
if (shouldHandleS3(req, Object.fromEntries(req.headers))) return handleS3Direct(req);
return handleHome();
},
PUT: (req: Request): Promise<Response> => {
@@ -69,7 +103,7 @@ export const routes = {
HEAD: handleS3Direct,
DELETE: handleS3Direct,
POST: handleS3Direct,
OPTIONS: handleS3Direct,
OPTIONS: handleCatchAllOptions,
},
// Catch-all for S3 path-style requests (/{bucket}/{key} ...)
// Only intercepts requests with S3 auth headers; others get 404.
@@ -98,7 +132,7 @@ export const routes = {
if (shouldHandleS3(req, Object.fromEntries(req.headers))) return handleS3Direct(req);
return Promise.resolve(new Response('Not Found', { status: 404 }));
},
OPTIONS: handleS3Direct,
OPTIONS: handleCatchAllOptions,
},
'/api/v1/auth/login': {
POST: withRateLimit(handleLogin),
@@ -1,4 +1,4 @@
import { config } from '../env';
import { config } from '../../env';
const errorSchema = (example: string) => ({
type: 'object',
@@ -50,8 +50,9 @@ export const handleSwaggerJson = async (): Promise<Response> => {
openapi: '3.0.0',
info: {
title: 'FileDrop API',
version: '1.0.0',
description: 'File upload API with stream-based downloads.',
version: config.appVersion,
description:
'File upload API with stream-based downloads, S3-compatible object storage, and admin auth.',
},
servers: [
{
@@ -204,6 +205,156 @@ export const handleSwaggerJson = async (): Promise<Response> => {
},
},
},
'/api/v1/auth/login': {
post: {
summary: 'Admin Login',
description:
'Validates the admin API token and sets a signed session cookie. Returns 404 when auth is disabled.',
requestBody: {
required: true,
content: jsonContent(
objectSchema({
token: { type: 'string', example: 'admin-secret-token' },
}),
),
},
responses: {
'200': {
description: 'Login successful; session cookie set.',
content: jsonContent(
objectSchema({
username: { type: 'string', example: 'admin' },
}),
),
},
'400': {
description: 'Token is required.',
content: jsonContent(errorSchema('Token is required')),
},
'401': {
description: 'Invalid token.',
content: jsonContent(errorSchema('Invalid token')),
},
},
},
},
'/api/v1/auth/logout': {
post: {
summary: 'Admin Logout',
description: 'Clears the session cookie.',
responses: {
'200': {
description: 'Logout successful.',
content: jsonContent(
objectSchema({
success: { type: 'boolean', example: true },
}),
),
},
},
},
},
'/api/v1/auth/me': {
get: {
summary: 'Current User',
description:
'Returns the authenticated user from the session cookie or bearer token. Returns 404 when auth is disabled.',
responses: {
'200': {
description: 'User info.',
content: jsonContent(
objectSchema({
username: { type: 'string', example: 'admin' },
expiresAt: {
type: 'string',
format: 'date-time',
nullable: true,
example: '2026-05-18T10:00:00.000Z',
},
}),
),
},
'401': {
description: 'Unauthorized.',
content: jsonContent(errorSchema('Unauthorized')),
},
},
},
},
'/api/v1/{path}': {
get: {
summary: 'Web API (read)',
description:
'Public read endpoints: list buckets/objects and download files. See the dashboard for the full reference.',
responses: {
'200': {
description: 'Requested resource.',
},
'404': {
description: 'Not found.',
content: jsonContent(errorSchema('Not found')),
},
},
},
},
'/{bucket}': {
get: {
summary: 'S3 Bucket Operations',
description:
'S3-compatible bucket endpoint (SigV4 auth). Supports ListObjects, versioning queries, and bucket management. Served without rate limiting so Docker registry pushes are not aborted by 429s.',
parameters: [
{
name: 'bucket',
in: 'path',
required: true,
description: 'Bucket name.',
schema: { type: 'string' },
},
],
responses: {
'200': {
description: 'S3 XML response.',
},
'403': {
description: 'Signature mismatch.',
},
},
},
},
'/{bucket}/{key}': {
get: {
summary: 'S3 Object Operations',
description:
'S3-compatible object endpoint (SigV4 auth): GetObject, PutObject, DeleteObject, and multipart uploads. Served without rate limiting so Docker registry pushes are not aborted by 429s.',
parameters: [
{
name: 'bucket',
in: 'path',
required: true,
description: 'Bucket name.',
schema: { type: 'string' },
},
{
name: 'key',
in: 'path',
required: true,
description: 'Object key.',
schema: { type: 'string' },
},
],
responses: {
'200': {
description: 'S3 XML or object bytes.',
},
'403': {
description: 'Signature mismatch.',
},
'404': {
description: 'NoSuchBucket / NoSuchKey.',
},
},
},
},
},
};
+7 -2
View File
@@ -82,11 +82,16 @@ class MetricsCollector {
p95: this.calculatePercentile(this.uploadTimes, 95),
p99: this.calculatePercentile(this.uploadTimes, 99),
},
// Cumulative mean rate since process start (total requests / elapsed
// minutes). Intended for the coarse 5-minute ops log in index.ts, not
// a sliding-window throughput gauge.
uploadThroughput: this.totalRequests > 0 ? this.totalRequests / 60 : 0,
queueSize: 0, // Will be updated by queue
// No upload queue or bot-utilization tracker exists yet — both stay 0
// until one is wired in. Kept in the snapshot shape for compatibility.
queueSize: 0,
errorRate,
cacheHitRate,
botUtilization: 0, // Will be updated by bot tracker
botUtilization: 0,
timestamp: Date.now(),
};
}
+23
View File
@@ -32,6 +32,29 @@ describe('Environment Variables Validation', () => {
expect(typeof config.port).toBe('number');
});
it('startup fails fast when PORT is present but invalid', async () => {
for (const badPort of ['abc', '-5', '0']) {
const proc = Bun.spawn({
cmd: ['bun', '-e', "import('./src/env')"],
cwd: `${import.meta.dir}/..`,
env: {
...process.env,
BOT_TOKENS: '123456:ABC-DEF',
STORAGE_CHANNEL_ID: '-1001234567890',
BASE_URL: 'https://example.com',
DATABASE_URL: 'postgresql://asephs:***@100.121.180.82:6432/test',
PORT: badPort,
},
stdout: 'pipe',
stderr: 'pipe',
});
const exitCode = await proc.exited;
const stderr = await new Response(proc.stderr).text();
expect(exitCode).not.toBe(0);
expect(stderr).toContain('PORT must be a positive integer');
}
});
it("nodeEnv should be 'test' or 'development'", () => {
expect(['test', 'development']).toContain(config.nodeEnv);
});
+132
View File
@@ -0,0 +1,132 @@
import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test';
process.env.NODE_ENV = 'test';
process.env.BOT_TOKEN = '123456:ABC-DEF';
process.env.STORAGE_CHANNEL_ID = '-1001234567890';
process.env.BASE_URL = 'http://localhost:4000';
process.env.DATABASE_URL = 'postgresql://asephs:***@100.121.180.82:6432/test';
process.env.PORT = '4000';
process.env.S3_ACCESS_KEY = 'filedrop-admin';
process.env.S3_SECRET_KEY = 'unit-test-secret';
const bucket = {
id: 'bucket-uuid',
name: 'gitea',
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
mock.module('../src/infrastructure/persistence/repositories/bucket-repository', () => ({
DrizzleBucketRepository: class {
create = () => Promise.resolve(bucket);
findByName = (name: string) => Promise.resolve(name === bucket.name ? bucket : null);
list = () => Promise.resolve([bucket]);
delete = () => Promise.resolve(true);
},
}));
mock.module('../src/infrastructure/persistence/repositories/file-repository', () => ({
DrizzleFileRepository: class {
countByBucket = () => Promise.resolve(0);
findByBucketAndKey = () => Promise.resolve(null);
listByPrefix = () => Promise.resolve({ objects: [], prefixes: [] });
softDelete = () => Promise.resolve(true);
},
}));
mock.module('../src/infrastructure/persistence/repositories/multipart-repository', () => ({
DrizzleMultipartRepository: class {
abort = () => Promise.resolve();
complete = () => Promise.resolve();
create = () => Promise.resolve('upload-id');
findById = () => Promise.resolve(null);
insertPart = () => Promise.resolve();
listParts = () => Promise.resolve([]);
listByBucket = () => Promise.resolve({ uploads: [], isTruncated: false, nextKeyMarker: null });
},
}));
mock.module('../src/infrastructure/telegram/chunked-storage', () => ({
ChunkedStorage: class {
createChunkedObjectResponse = () => Promise.resolve(new Response(''));
storeFileInTelegramChunks = () => Promise.resolve({ fileHash: 'hash' });
},
}));
mock.module('../src/interfaces/s3/auth', () => ({
verifyPresignedUrl: () => Promise.resolve({ isValid: true }),
verifySignature: () => Promise.resolve({ isValid: true }),
verifyBodyHash: () => null,
isS3Request: (headers: Record<string, string>) =>
(headers.authorization || '').startsWith('AWS4-HMAC-SHA256'),
}));
mock.module('../src/infrastructure/telegram/bot-pool', () => ({
botPool: {
forwardToStorage: () =>
Promise.resolve({
telegramFileId: 'mock-tg-id',
telegramFileUniqueId: 'mock-tg-unique',
storageMessageId: 12345,
}),
getFileInfo: () =>
Promise.resolve({
bot_token: '123456:ABC-DEF',
file_path: 'documents/file.txt',
file_size: 100,
mime_type: 'text/plain',
}),
},
}));
const AWS_AUTH =
'AWS4-HMAC-SHA256 Credential=filedrop-admin/20260101/us-east-1/s3/aws4_request, ' +
'SignedHeaders=host;x-amz-date, Signature=abc123';
describe('S3 routing (routes table)', () => {
let routes: typeof import('../src/interfaces/http/routes/index').routes;
beforeAll(async () => {
({ routes } = await import('../src/interfaces/http/routes/index'));
});
afterAll(() => {
mock.restore();
});
it('routes GET / with AWS4 auth headers to S3 (not the home page)', async () => {
const res = await routes['/'].GET(
new Request('http://localhost:4000/', {
headers: { authorization: AWS_AUTH },
}),
);
const contentType = res.headers.get('content-type') || '';
// S3 answers with XML; the home page would be text/html.
expect(contentType).toContain('application/xml');
});
it('serves GET / without S3 headers as the home page (HTML 200)', async () => {
const res = await routes['/'].GET(new Request('http://localhost:4000/'));
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('text/html');
expect(await res.text()).toContain('FileDrop');
});
it('answers OPTIONS /* without S3 headers as a generic 204 CORS preflight (not S3 XML)', async () => {
const res = await routes['/*'].OPTIONS(
new Request('http://localhost:4000/some/path', { method: 'OPTIONS' }),
);
expect(res.status).toBe(204);
expect(res.headers.get('access-control-allow-origin')).toBe('*');
});
it('routes OPTIONS /* with AWS4 auth headers to the S3 handler', async () => {
const res = await routes['/*'].OPTIONS(
new Request('http://localhost:4000/gitea/key', {
method: 'OPTIONS',
headers: { authorization: AWS_AUTH },
}),
);
expect(res.status).toBe(204);
});
});
+18 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'bun:test';
import { handleSwaggerHtml, handleSwaggerJson } from '../src/routes/swagger';
import { handleSwaggerHtml, handleSwaggerJson } from '../src/interfaces/http/swagger';
describe('Swagger Documentation Endpoints', () => {
it('returns OpenAPI specification JSON', async () => {
@@ -36,6 +36,23 @@ describe('Swagger Documentation Endpoints', () => {
expect(downloadResponses['302'].description).toContain('Redirect');
});
it('documents auth, web API, and S3 endpoints with the app version', async () => {
const res = await handleSwaggerJson();
const body = (await res.json()) as {
info: { version: string };
paths: Record<string, object>;
};
expect(body.paths).toHaveProperty('/api/v1/auth/login');
expect(body.paths).toHaveProperty('/api/v1/auth/logout');
expect(body.paths).toHaveProperty('/api/v1/auth/me');
expect(body.paths).toHaveProperty('/api/v1/{path}');
expect(body.paths).toHaveProperty('/{bucket}');
expect(body.paths).toHaveProperty('/{bucket}/{key}');
expect(typeof body.info.version).toBe('string');
expect(body.info.version.length).toBeGreaterThan(0);
});
it('returns Swagger UI HTML page', async () => {
const res = await handleSwaggerHtml();