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:
+26
-2
@@ -1,6 +1,7 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import logger from './shared/logger/index';
|
import logger from './shared/logger/index';
|
||||||
import { TELEGRAM_CHUNK_SIZE_MAX_BYTES } from './shared/utils/validation';
|
import { TELEGRAM_CHUNK_SIZE_MAX_BYTES } from './shared/utils/validation';
|
||||||
|
import { PositiveIntSchema } from './shared/validation/schemas';
|
||||||
|
|
||||||
interface AppConfig {
|
interface AppConfig {
|
||||||
/** Application version (read from package.json, kept in sync by semantic-release prepare.mjs) */
|
/** 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;
|
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[] =>
|
const parseTokens = (value: string | undefined): string[] =>
|
||||||
(value || '')
|
(value || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -172,7 +194,7 @@ export const config: AppConfig = {
|
|||||||
storageChatId: parseInt(process.env.STORAGE_CHANNEL_ID!, 10),
|
storageChatId: parseInt(process.env.STORAGE_CHANNEL_ID!, 10),
|
||||||
baseUrl: process.env.BASE_URL!,
|
baseUrl: process.env.BASE_URL!,
|
||||||
databaseUrl: process.env.DATABASE_URL!,
|
databaseUrl: process.env.DATABASE_URL!,
|
||||||
port: parseInt(process.env.PORT!, 10) || 4000,
|
port: parsePort(process.env.PORT),
|
||||||
nodeEnv: process.env.NODE_ENV || 'development',
|
nodeEnv: process.env.NODE_ENV || 'development',
|
||||||
logLevel: process.env.LOG_LEVEL || 'info',
|
logLevel: process.env.LOG_LEVEL || 'info',
|
||||||
rateLimitWindowMs: parseNumber(process.env.RATE_LIMIT_WINDOW_MS, 60000),
|
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: {
|
||||||
...config,
|
...config,
|
||||||
botTokens: config.botTokens.map(maskSecret),
|
botTokens: config.botTokens.map(maskSecret),
|
||||||
|
|||||||
+32
-21
@@ -49,28 +49,39 @@ const gracefulShutdown = async (signal: string): Promise<void> => {
|
|||||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||||
|
|
||||||
// Periodic maintenance intervals
|
// Periodic maintenance intervals. Timers are unref'd so they never keep the
|
||||||
setInterval(cleanupRateLimitCache, 60000);
|
// process alive on their own (safe no-op where `unref` is unavailable).
|
||||||
setInterval(
|
const unref = (timer: unknown): void => {
|
||||||
() => {
|
if (typeof timer === 'object' && timer !== null && 'unref' in timer) {
|
||||||
const removed = fileInfoCache.cleanup();
|
(timer as { unref?: () => void }).unref?.();
|
||||||
if (removed > 0) {
|
}
|
||||||
logger.info(`Cleaned up ${removed} expired cache entries`);
|
};
|
||||||
}
|
|
||||||
},
|
unref(setInterval(cleanupRateLimitCache, 60000));
|
||||||
5 * 60 * 1000,
|
unref(
|
||||||
|
setInterval(
|
||||||
|
() => {
|
||||||
|
const removed = fileInfoCache.cleanup();
|
||||||
|
if (removed > 0) {
|
||||||
|
logger.info(`Cleaned up ${removed} expired cache entries`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
5 * 60 * 1000,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
setInterval(
|
unref(
|
||||||
() => {
|
setInterval(
|
||||||
const snapshot = metricsCollector.getSnapshot();
|
() => {
|
||||||
logger.info('Metrics snapshot', {
|
const snapshot = metricsCollector.getSnapshot();
|
||||||
uploadLatency: snapshot.uploadLatency,
|
logger.info('Metrics snapshot', {
|
||||||
uploadThroughput: snapshot.uploadThroughput.toFixed(2),
|
uploadLatency: snapshot.uploadLatency,
|
||||||
errorRate: snapshot.errorRate.toFixed(2),
|
uploadThroughput: snapshot.uploadThroughput.toFixed(2),
|
||||||
cacheHitRate: snapshot.cacheHitRate.toFixed(2),
|
errorRate: snapshot.errorRate.toFixed(2),
|
||||||
});
|
cacheHitRate: snapshot.cacheHitRate.toFixed(2),
|
||||||
},
|
});
|
||||||
5 * 60 * 1000,
|
},
|
||||||
|
5 * 60 * 1000,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info('Application running successfully');
|
logger.info('Application running successfully');
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
import type { AuthSession } from '../../../application/dto/auth';
|
||||||
import {
|
import {
|
||||||
type AuthSession,
|
|
||||||
createLoginUseCase,
|
createLoginUseCase,
|
||||||
createLogoutUseCase,
|
createLogoutUseCase,
|
||||||
createMeUseCase,
|
createMeUseCase,
|
||||||
} from '../../../application/use-cases/authenticate';
|
} from '../../../application/use-cases/authenticate';
|
||||||
import { config } from '../../../env';
|
import { config } from '../../../env';
|
||||||
|
import { LoginBodySchema } from '../../../shared/validation/schemas';
|
||||||
import {
|
import {
|
||||||
checkBearerToken,
|
|
||||||
clearSessionCookie,
|
clearSessionCookie,
|
||||||
createSessionCookie,
|
createSessionCookie,
|
||||||
getAuthSession,
|
getAuthSession,
|
||||||
@@ -36,14 +36,17 @@ const notFound = (): Response => json({ error: 'Not found' }, 404);
|
|||||||
/**
|
/**
|
||||||
* Parses the login request body, extracting the `token` field.
|
* 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.
|
* @param req - The incoming HTTP request with a JSON body.
|
||||||
* @returns The login token payload, or `null` when the body is invalid.
|
* @returns The login token payload, or `null` when the body is invalid.
|
||||||
*/
|
*/
|
||||||
const readLoginBody = async (req: Request): Promise<{ token: string } | null> => {
|
const readLoginBody = async (req: Request): Promise<{ token: string } | null> => {
|
||||||
try {
|
try {
|
||||||
const body = (await req.json()) as { token?: unknown };
|
const parsed = LoginBodySchema.safeParse(await req.json());
|
||||||
if (typeof body.token !== 'string' || body.token.length === 0) return null;
|
if (!parsed.success) return null;
|
||||||
return { token: body.token };
|
return { token: parsed.data.token };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -119,8 +122,10 @@ export const handleLogout = async (): Promise<Response> => {
|
|||||||
export const handleMe = async (req: Request): Promise<Response> => {
|
export const handleMe = async (req: Request): Promise<Response> => {
|
||||||
if (!isAuthEnabled()) return notFound();
|
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);
|
const session: AuthSession | null = getAuthSession(req);
|
||||||
if (!session && !checkBearerToken(req.headers.get('authorization'))) {
|
if (!session) {
|
||||||
return json({ error: 'Unauthorized' }, 401);
|
return json({ error: 'Unauthorized' }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,13 +137,7 @@ export const handleMe = async (req: Request): Promise<Response> => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeSession = session ?? {
|
const result = await meUseCase(session);
|
||||||
username: 'admin',
|
|
||||||
expiresAt: null,
|
|
||||||
method: 'bearer' as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await meUseCase(activeSession);
|
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return json({ error: 'Unauthorized' }, 401);
|
return json({ error: 'Unauthorized' }, 401);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { handleSwaggerHtml, handleSwaggerJson } from '../../../routes/swagger';
|
|
||||||
import { getS3RouteBucket, shouldHandleS3 } from '../../../shared/utils/s3-detection';
|
import { getS3RouteBucket, shouldHandleS3 } from '../../../shared/utils/s3-detection';
|
||||||
import { handleLogin, handleLogout, handleMe } from '../controllers/auth-controller';
|
import { handleLogin, handleLogout, handleMe } from '../controllers/auth-controller';
|
||||||
import { handleFileInfo, handleFileRedirect } from '../controllers/file-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 { handleWebApiV1 } from '../controllers/web-api-controller';
|
||||||
import { requireAuth } from '../middleware/auth';
|
import { requireAuth } from '../middleware/auth';
|
||||||
import { withRateLimit } from '../middleware/rate-limit';
|
import { withRateLimit } from '../middleware/rate-limit';
|
||||||
|
import { handleSwaggerHtml, handleSwaggerJson } from '../swagger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatches an S3 request directly, bypassing rate limiting.
|
* 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
|
* S3 API calls (used by Docker registry for blob pushes) must not be
|
||||||
* rate-limited — large concurrent layer uploads would hit the limit and
|
* rate-limited — large concurrent layer uploads would hit the limit and
|
||||||
* fail. The Docker registry client retries on 5xx, not 4xx, so a 429
|
* 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.
|
* @param req - The incoming S3 request.
|
||||||
* @returns The S3 response.
|
* @returns The S3 response.
|
||||||
@@ -25,6 +25,40 @@ const handleS3Direct = (req: Request): Promise<Response> => {
|
|||||||
return handleS3Request(req, getS3RouteBucket(req));
|
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.
|
* Defines all HTTP routes for the application.
|
||||||
*
|
*
|
||||||
@@ -58,7 +92,7 @@ export const routes = {
|
|||||||
},
|
},
|
||||||
'/': {
|
'/': {
|
||||||
GET: (req: Request): Promise<Response> => {
|
GET: (req: Request): Promise<Response> => {
|
||||||
if (shouldHandleS3(req)) return handleS3Direct(req);
|
if (shouldHandleS3(req, Object.fromEntries(req.headers))) return handleS3Direct(req);
|
||||||
return handleHome();
|
return handleHome();
|
||||||
},
|
},
|
||||||
PUT: (req: Request): Promise<Response> => {
|
PUT: (req: Request): Promise<Response> => {
|
||||||
@@ -69,7 +103,7 @@ export const routes = {
|
|||||||
HEAD: handleS3Direct,
|
HEAD: handleS3Direct,
|
||||||
DELETE: handleS3Direct,
|
DELETE: handleS3Direct,
|
||||||
POST: handleS3Direct,
|
POST: handleS3Direct,
|
||||||
OPTIONS: handleS3Direct,
|
OPTIONS: handleCatchAllOptions,
|
||||||
},
|
},
|
||||||
// Catch-all for S3 path-style requests (/{bucket}/{key} ...)
|
// Catch-all for S3 path-style requests (/{bucket}/{key} ...)
|
||||||
// Only intercepts requests with S3 auth headers; others get 404.
|
// 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);
|
if (shouldHandleS3(req, Object.fromEntries(req.headers))) return handleS3Direct(req);
|
||||||
return Promise.resolve(new Response('Not Found', { status: 404 }));
|
return Promise.resolve(new Response('Not Found', { status: 404 }));
|
||||||
},
|
},
|
||||||
OPTIONS: handleS3Direct,
|
OPTIONS: handleCatchAllOptions,
|
||||||
},
|
},
|
||||||
'/api/v1/auth/login': {
|
'/api/v1/auth/login': {
|
||||||
POST: withRateLimit(handleLogin),
|
POST: withRateLimit(handleLogin),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { config } from '../env';
|
import { config } from '../../env';
|
||||||
|
|
||||||
const errorSchema = (example: string) => ({
|
const errorSchema = (example: string) => ({
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -50,8 +50,9 @@ export const handleSwaggerJson = async (): Promise<Response> => {
|
|||||||
openapi: '3.0.0',
|
openapi: '3.0.0',
|
||||||
info: {
|
info: {
|
||||||
title: 'FileDrop API',
|
title: 'FileDrop API',
|
||||||
version: '1.0.0',
|
version: config.appVersion,
|
||||||
description: 'File upload API with stream-based downloads.',
|
description:
|
||||||
|
'File upload API with stream-based downloads, S3-compatible object storage, and admin auth.',
|
||||||
},
|
},
|
||||||
servers: [
|
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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,11 +82,16 @@ class MetricsCollector {
|
|||||||
p95: this.calculatePercentile(this.uploadTimes, 95),
|
p95: this.calculatePercentile(this.uploadTimes, 95),
|
||||||
p99: this.calculatePercentile(this.uploadTimes, 99),
|
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,
|
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,
|
errorRate,
|
||||||
cacheHitRate,
|
cacheHitRate,
|
||||||
botUtilization: 0, // Will be updated by bot tracker
|
botUtilization: 0,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,29 @@ describe('Environment Variables Validation', () => {
|
|||||||
expect(typeof config.port).toBe('number');
|
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'", () => {
|
it("nodeEnv should be 'test' or 'development'", () => {
|
||||||
expect(['test', 'development']).toContain(config.nodeEnv);
|
expect(['test', 'development']).toContain(config.nodeEnv);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'bun:test';
|
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', () => {
|
describe('Swagger Documentation Endpoints', () => {
|
||||||
it('returns OpenAPI specification JSON', async () => {
|
it('returns OpenAPI specification JSON', async () => {
|
||||||
@@ -36,6 +36,23 @@ describe('Swagger Documentation Endpoints', () => {
|
|||||||
expect(downloadResponses['302'].description).toContain('Redirect');
|
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 () => {
|
it('returns Swagger UI HTML page', async () => {
|
||||||
const res = await handleSwaggerHtml();
|
const res = await handleSwaggerHtml();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user