refactor(fase0): shared scaffold — crypto, file-url, schemas, test splits
- Add zod dep; new src/shared/validation/schemas.ts (BucketName, JsonUploadPayload, LoginBody, DeleteObjects, CompleteMultipart, clampMaxKeys, parseOrNull) + test/validation.test.ts - Dedup timingSafeCompare -> src/shared/utils/crypto.ts (auth middleware, authenticate use-case, s3/auth now import it) - Dedup AuthSession -> single type in dto/auth.ts - Dedup telegram file URL builder + filename sanitizer to shared modules (file-controller now imports the canonical ones) - Remove duplicate controllers/home.html (canonical: src/home.html) - Fix stale bootstrap mocks to real module paths; bootstrap now asserts public GET vs guarded POST separately - Fix health assertion to include version field - package.json: test -> test:unit alias; new test:quarantine for network/live tests; register previously unlisted test files
This commit is contained in:
1 parent
f4b30cef0c
commit
5d01a9405f
16 files changed
+511
-473
No files matched your search
@@ -13,6 +13,7 @@
|
|||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"telegraf": "^4.15.0",
|
"telegraf": "^4.15.0",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
|
"zod": "^4.6.5",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1079.0",
|
"@aws-sdk/client-s3": "^3.1079.0",
|
||||||
@@ -357,6 +358,8 @@
|
|||||||
|
|
||||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||||
|
|
||||||
|
"zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mipsLine truncated
|
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mipsLine truncated
|
||||||
|
|
||||||
"bun-types/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="],
|
"bun-types/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="],
|
||||||
|
|||||||
+6
-3
@@ -8,11 +8,13 @@
|
|||||||
"build": "bun build src/index.ts --target=bun --outfile=dist/index.js && bun build src/infrastructure/persistence/drizzle/migrate.ts --target=bun --outfile=dist/migrate.js",
|
"build": "bun build src/index.ts --target=bun --outfile=dist/index.js && bun build src/infrastructure/persistence/drizzle/migrate.ts --target=bun --outfile=dist/migrate.js",
|
||||||
"start": "NODE_ENV=production bun dist/index.js",
|
"start": "NODE_ENV=production bun dist/index.js",
|
||||||
"db:migrate": "bun dist/migrate.js",
|
"db:migrate": "bun dist/migrate.js",
|
||||||
"test": "bun test --preload ./test/helpers/setup-env.ts test/rateLimit.test.ts && bun test --preload ./test/helpers/setup-env.ts test/file.test.ts && bun test --preload ./test/helpers/setup-env.ts test/telegram.test.ts && bun test --preload ./test/helpers/setup-env.ts test/upload.test.ts && bun test --preload ./test/helpers/setup-env.ts test/files.test.ts && bun test --preload ./test/helpers/setup-env.ts test/health.test.ts && bun test --preload ./test/helpers/setup-env.ts test/db.test.ts &Line truncated
|
"test": "bun run test:unit",
|
||||||
|
"test:unit": "bun test --preload ./test/helpers/setup-env.ts test/rateLimit.test.ts && bun test --preload ./test/helpers/setup-env.ts test/file.test.ts && bun test --preload ./test/helpers/setup-env.ts test/files.test.ts && bun test --preload ./test/helpers/setup-env.ts test/health.test.ts && bun test --preload ./test/helpers/setup-env.ts test/db.test.ts && bun test --preload ./test/helpers/setup-env.ts test/bot-pool.test.ts && bun test --preload ./test/helpers/setup-env.ts test/bot.test.tsLine truncated
|
||||||
|
"test:quarantine": "bun test --preload ./test/helpers/setup-env.ts test/telegram.test.ts && bun test --preload ./test/helpers/setup-env.ts test/upload.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-docker-registry.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-sdk.test.ts && bun test --preload ./test/helpers/setup-env.ts test/production-e2e.test.ts",
|
||||||
"test:s3-auth": "bun test --preload ./test/helpers/setup-env.ts test/s3-auth.test.ts",
|
"test:s3-auth": "bun test --preload ./test/helpers/setup-env.ts test/s3-auth.test.ts",
|
||||||
"test:s3-ops": "bun test --preload ./test/helpers/setup-env.ts test/s3-operations.test.ts",
|
"test:s3-ops": "bun test --preload ./test/helpers/setup-env.ts test/s3-operations.test.ts",
|
||||||
"test:web-api": "bun test --preload ./test/helpers/setup-env.ts test/web-api.test.ts",
|
"test:web-api": "bun test --preload ./test/helpers/setup-env.ts test/web-api.test.ts",
|
||||||
"test:s3": "bun test --preload ./test/helpers/setup-env.ts test/s3-auth.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-auth-edge.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-operations.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-range.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-object-stream.test.ts && bun test --preload ./test/helpers/setup-env.ts test/object-stream-parallel.test.ts && bun test --preload ./Line truncated
|
"test:s3": "bun test --preload ./test/helpers/setup-env.ts test/s3-auth.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-auth-edge.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-operations.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-range.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-object-stream.test.ts && bun test --preload ./test/helpers/setup-env.ts test/object-stream-parallel.test.ts && bun test --preload ./Line truncated
|
||||||
"lint": "bunx biome check src test",
|
"lint": "bunx biome check src test",
|
||||||
"format": "bunx biome format --write src test",
|
"format": "bunx biome format --write src test",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
@@ -25,7 +27,8 @@
|
|||||||
"pg": "^8.11.0",
|
"pg": "^8.11.0",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"telegraf": "^4.15.0",
|
"telegraf": "^4.15.0",
|
||||||
"winston": "^3.11.0"
|
"winston": "^3.11.0",
|
||||||
|
"zod": "^4.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1079.0",
|
"@aws-sdk/client-s3": "^3.1079.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { timingSafeEqual } from 'node:crypto';
|
import { timingSafeCompare } from '../../shared/utils/crypto';
|
||||||
import type {
|
import type {
|
||||||
AuthSession,
|
AuthSession,
|
||||||
LoginInput,
|
LoginInput,
|
||||||
@@ -23,24 +23,6 @@ export interface AuthenticateUseCaseDeps {
|
|||||||
config: AuthUseCaseConfig;
|
config: AuthUseCaseConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Performs a constant-time string comparison to prevent timing attacks.
|
|
||||||
*
|
|
||||||
* @param left - The first string to compare.
|
|
||||||
* @param right - The second string to compare.
|
|
||||||
* @returns `true` if the strings are equal, `false` otherwise.
|
|
||||||
*/
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether authentication is enabled based on the configured token.
|
* Checks whether authentication is enabled based on the configured token.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Builds a Telegram CDN download URL from a file path and bot token.
|
||||||
|
*
|
||||||
|
* Single canonical implementation — previously constructed inline in
|
||||||
|
* `interfaces/http/controllers/file-controller.ts`,
|
||||||
|
* `interfaces/http/controllers/web-api-controller.ts`,
|
||||||
|
* `interfaces/http/controllers/s3-controller.ts`, and
|
||||||
|
* `infrastructure/telegram/chunked-storage.ts`.
|
||||||
|
*
|
||||||
|
* NOTE: URLs produced here embed the bot token. They must only be used
|
||||||
|
* server-side (outbound fetch to the Telegram CDN), never exposed to
|
||||||
|
* clients in redirects or response bodies.
|
||||||
|
*
|
||||||
|
* @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.
|
||||||
|
*/
|
||||||
|
export const buildTelegramFileUrl = (filePath: string, botToken: string): string =>
|
||||||
|
`https://api.telegram.org/file/bot${botToken}/${filePath}`;
|
||||||
@@ -4,6 +4,8 @@ import type { TelegramFileInfo } from '../../../domain/ports/telegram-service';
|
|||||||
import { fileInfoCache } from '../../../infrastructure/cache/index';
|
import { fileInfoCache } from '../../../infrastructure/cache/index';
|
||||||
import { chunkedStorage, fileRepository } from '../../../infrastructure/di';
|
import { chunkedStorage, fileRepository } from '../../../infrastructure/di';
|
||||||
import { botPool } from '../../../infrastructure/telegram/bot-pool';
|
import { botPool } from '../../../infrastructure/telegram/bot-pool';
|
||||||
|
import { buildTelegramFileUrl } from '../../../infrastructure/telegram/file-url';
|
||||||
|
import { sanitizeFilenameHeader } from '../../../shared/http/filename';
|
||||||
import logger from '../../../shared/logger/index';
|
import logger from '../../../shared/logger/index';
|
||||||
import { cleanupTempFile, formatCreatedAt, getErrorMessage } from '../../../shared/utils/file';
|
import { cleanupTempFile, formatCreatedAt, getErrorMessage } from '../../../shared/utils/file';
|
||||||
import { locateZipEntry } from '../../../shared/utils/zip';
|
import { locateZipEntry } from '../../../shared/utils/zip';
|
||||||
@@ -46,26 +48,6 @@ const getTelegramFileInfo = async (
|
|||||||
return fileInfo;
|
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.
|
* Returns a JSON error response with the given status code and message.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,370 +0,0 @@
|
|||||||
<!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; }
|
|
||||||
.readonly-badge {
|
|
||||||
font-size: 0.75rem; color: var(--text2); background: var(--bg2);
|
|
||||||
border: 1px solid var(--border); border-radius: 999px; padding: 2px 10px;
|
|
||||||
}
|
|
||||||
</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 id="newBucketBtn" type="button" onclick="window.showCreateBucketModal()">+ New</button>
|
|
||||||
<button id="credsBtn" type="button" onclick="window.showCredentialsModal()" title="S3 Credentials">🔑</button>
|
|
||||||
<button id="loginBtn" type="button" onclick="window.showAuthScreen()" style="display:none">Login</button>
|
|
||||||
<button id="logoutBtn" type="button" onclick="window.logout()" style="display:none">Logout</button>
|
|
||||||
<span id="readonlyBadge" class="readonly-badge" style="display:none">👀 read-only</span>
|
|
||||||
<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;
|
|
||||||
let isAdmin = false;
|
|
||||||
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';
|
|
||||||
setTimeout(() => document.getElementById('authTokenInput')?.focus(), 50);
|
|
||||||
};
|
|
||||||
const hideAuthScreen = () => {
|
|
||||||
document.getElementById('authScreen').style.display = 'none';
|
|
||||||
};
|
|
||||||
// Applies the admin/read-only UI state based on isAdmin.
|
|
||||||
const applyAdminUI = () => {
|
|
||||||
document.getElementById('newBucketBtn').style.display = isAdmin ? 'inline-block' : 'none';
|
|
||||||
document.getElementById('credsBtn').style.display = isAdmin ? 'inline-block' : 'none';
|
|
||||||
document.getElementById('loginBtn').style.display = isAdmin ? 'none' : 'inline-block';
|
|
||||||
document.getElementById('logoutBtn').style.display = isAdmin ? 'inline-block' : 'none';
|
|
||||||
document.getElementById('readonlyBadge').style.display = isAdmin ? 'none' : 'inline-block';
|
|
||||||
// Dropzone (upload) is admin-only.
|
|
||||||
document.getElementById('dropzone').style.display = isAdmin && currentBucket ? 'block' : 'none';
|
|
||||||
if (currentObjects.length || currentPrefixes.length) renderFileList();
|
|
||||||
};
|
|
||||||
// Non-blocking auth check: read-only visitors still get the file browser.
|
|
||||||
const checkAuth = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/v1/auth/me');
|
|
||||||
if (res.ok) { isAdmin = true; }
|
|
||||||
else if (res.status === 401) { isAdmin = false; }
|
|
||||||
else if (res.status === 404) { isAdmin = true; } // auth disabled — full access
|
|
||||||
else { isAdmin = false; }
|
|
||||||
} catch {
|
|
||||||
isAdmin = false;
|
|
||||||
}
|
|
||||||
hideAuthScreen();
|
|
||||||
applyAdminUI();
|
|
||||||
return isAdmin;
|
|
||||||
};
|
|
||||||
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) { isAdmin = true; hideAuthScreen(); input.value = ''; applyAdminUI(); 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(() => {});
|
|
||||||
isAdmin = false;
|
|
||||||
applyAdminUI();
|
|
||||||
};
|
|
||||||
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(); }
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
// Dropzone (upload) is admin-only.
|
|
||||||
document.getElementById('dropzone').style.display = isAdmin && currentBucket ? 'block' : '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 = isAdmin
|
|
||||||
? '<div class="empty"><h2>This bucket is empty</h2><p>Drop files here to upload.</p></div>'
|
|
||||||
: '<div class="empty"><h2>This bucket is empty</h2></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, '');
|
|
||||||
// Delete is admin-only; download + copy link are always available.
|
|
||||||
const deleteBtn = isAdmin ? `<button onclick="event.stopPropagation();deleteObject('${obj.key}')" title="Delete">🗑</button>` : '';
|
|
||||||
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>${deleteBtn}</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 (!isAdmin) { alert('Read-only mode — login as admin to delete.'); return; }
|
|
||||||
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 (!isAdmin) { alert('Read-only mode — login as admin to upload.'); return; }
|
|
||||||
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=()=>{
|
|
||||||
if (!isAdmin) { alert('Read-only mode — login as admin to create buckets.'); return; }
|
|
||||||
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()=>{
|
|
||||||
if (!isAdmin) { alert('Read-only mode — login as admin to create buckets.'); return; }
|
|
||||||
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=()=>{
|
|
||||||
if (!isAdmin) { alert('Read-only mode — login as admin to view S3 credentials.'); return; }
|
|
||||||
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-weightLine truncated
|
|
||||||
const init=async()=>{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, showAuthScreen, logout });
|
|
||||||
init();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
import { createHmac } from 'node:crypto';
|
||||||
import { config } from '../../../env';
|
import { config } from '../../../env';
|
||||||
|
import { timingSafeCompare } from '../../../shared/utils/crypto';
|
||||||
|
|
||||||
const ADMIN_USERNAME = 'admin';
|
const ADMIN_USERNAME = 'admin';
|
||||||
const SIGNATURE_SEPARATOR = '.';
|
const SIGNATURE_SEPARATOR = '.';
|
||||||
@@ -11,17 +12,7 @@ type Handler = (req: Request) => Response | Promise<Response>;
|
|||||||
* Represents an authenticated user session after successful
|
* Represents an authenticated user session after successful
|
||||||
* authentication via cookie or bearer token.
|
* authentication via cookie or bearer token.
|
||||||
*/
|
*/
|
||||||
export interface AuthSession {
|
export type { AuthSession } from '../../../application/dto/auth';
|
||||||
/** The authenticated username (always "admin" in this implementation). */
|
|
||||||
username: string;
|
|
||||||
/**
|
|
||||||
* Expiration date of the session, or `null` for bearer-token
|
|
||||||
* sessions which do not expire at the session level.
|
|
||||||
*/
|
|
||||||
expiresAt: Date | null;
|
|
||||||
/** The authentication method used to establish this session. */
|
|
||||||
method: 'cookie' | 'bearer';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Options for configuring cookie-based session behaviour. */
|
/** Options for configuring cookie-based session behaviour. */
|
||||||
interface CookieOptions {
|
interface CookieOptions {
|
||||||
@@ -64,24 +55,7 @@ const decodePayload = (value: string): string | null => {
|
|||||||
*/
|
*/
|
||||||
export const isAuthEnabled = (secret = config.adminApiToken): boolean => secret.length > 0;
|
export const isAuthEnabled = (secret = config.adminApiToken): boolean => secret.length > 0;
|
||||||
|
|
||||||
/**
|
export { timingSafeCompare } from '../../../shared/utils/crypto';
|
||||||
* Compares two strings using a timing-safe algorithm to prevent
|
|
||||||
* timing side-channel attacks.
|
|
||||||
*
|
|
||||||
* @param left - First string to compare.
|
|
||||||
* @param right - Second string to compare.
|
|
||||||
* @returns `true` when the strings are equal, `false` otherwise.
|
|
||||||
*/
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Signs an arbitrary payload string with HMAC-SHA256 using the given
|
* Signs an arbitrary payload string with HMAC-SHA256 using the given
|
||||||
|
|||||||
@@ -1,26 +1,4 @@
|
|||||||
import { timingSafeEqual } from 'node:crypto';
|
import { timingSafeCompare } from '../../shared/utils/crypto';
|
||||||
|
|
||||||
/**
|
|
||||||
* Timing-safe string comparison that prevents timing attacks.
|
|
||||||
*
|
|
||||||
* Uses `crypto.timingSafeEqual` which runs in constant time regardless of
|
|
||||||
* where the strings differ. Returns false for mismatched-length inputs
|
|
||||||
* to avoid leaking length information via early return.
|
|
||||||
*
|
|
||||||
* @param left - The first string to compare.
|
|
||||||
* @param right - The second string to compare.
|
|
||||||
* @returns True if both strings are equal.
|
|
||||||
*/
|
|
||||||
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 interface SigV4Result {
|
export interface SigV4Result {
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Sanitises a file name for use in a Content-Disposition header, removing
|
||||||
|
* characters that could enable header injection.
|
||||||
|
*
|
||||||
|
* Single canonical implementation — previously only present in
|
||||||
|
* `interfaces/http/controllers/file-controller.ts` while other download
|
||||||
|
* paths (S3 GET, web-api) did not sanitise at all.
|
||||||
|
*
|
||||||
|
* @param fileName - The raw file name.
|
||||||
|
* @returns The sanitised file name.
|
||||||
|
*/
|
||||||
|
export const sanitizeFilenameHeader = (fileName: string): string =>
|
||||||
|
fileName.replace(/[\\"]/g, '').replace(/[\n\r]/g, '');
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares two strings using a timing-safe algorithm to prevent
|
||||||
|
* timing side-channel attacks.
|
||||||
|
*
|
||||||
|
* Single canonical implementation — replaces the three copies that
|
||||||
|
* previously lived in `interfaces/http/middleware/auth.ts`,
|
||||||
|
* `application/use-cases/authenticate.ts`, and `interfaces/s3/auth.ts`.
|
||||||
|
*
|
||||||
|
* @param left - First string to compare.
|
||||||
|
* @param right - Second string to compare.
|
||||||
|
* @returns `true` when the strings are equal, `false` otherwise.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
};
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boundary validation schemas (zod) for all system entry points.
|
||||||
|
*
|
||||||
|
* Every HTTP/S3/bot boundary parses untrusted input through one of these
|
||||||
|
* schemas before touching domain logic. Controllers map a failed parse to
|
||||||
|
* the transport-appropriate error (400 JSON / S3 XML error) — see
|
||||||
|
* `parseOrNull` usage at each call site.
|
||||||
|
*
|
||||||
|
* @module shared/validation/schemas
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3 bucket name — the single canonical validator.
|
||||||
|
*
|
||||||
|
* Replaces three divergent variants: the inline regex in s3-controller
|
||||||
|
* (`handleCreateBucket`), `BUCKET_NAME_REGEX` in
|
||||||
|
* `application/use-cases/manage-bucket.ts`, and `isValidBucketLabel` in
|
||||||
|
* `interfaces/s3/virtual-host.ts`.
|
||||||
|
*
|
||||||
|
* Rules: 3–63 chars, lowercase letters/digits/dots/hyphens, start/end with
|
||||||
|
* letter-or-digit, plus the stricter M13 rules (no consecutive dots, no IP
|
||||||
|
* format, no `xn--` prefix).
|
||||||
|
*/
|
||||||
|
export const BucketNameSchema = z
|
||||||
|
.string()
|
||||||
|
.min(3)
|
||||||
|
.max(63)
|
||||||
|
.regex(/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/, 'Bucket name has invalid format')
|
||||||
|
.refine((name) => !name.includes('..'), 'Bucket name must not contain consecutive dots')
|
||||||
|
.refine(
|
||||||
|
(name) => !/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name),
|
||||||
|
'Bucket name must not be formatted as an IP address',
|
||||||
|
)
|
||||||
|
.refine((name) => !name.startsWith('xn--'), 'Bucket name must not start with "xn--"');
|
||||||
|
|
||||||
|
/** Inferred bucket-name type. */
|
||||||
|
export type BucketName = z.infer<typeof BucketNameSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON upload endpoint body (`POST /api/upload` with
|
||||||
|
* `Content-Type: application/json`).
|
||||||
|
*
|
||||||
|
* Replaces the bare `as JsonUploadPayload` cast in
|
||||||
|
* `interfaces/http/controllers/upload-controller.ts`.
|
||||||
|
*/
|
||||||
|
export const JsonUploadPayloadSchema = z.object({
|
||||||
|
/** Base64-encoded file data, optionally with a `data:` URI prefix. */
|
||||||
|
file: z.string().min(1, 'Invalid JSON. Must include "file" (base64) and optional "fileName"'),
|
||||||
|
/** Optional file name. */
|
||||||
|
fileName: z.string().default('file'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Inferred JSON-upload payload type. */
|
||||||
|
export type JsonUploadPayload = z.infer<typeof JsonUploadPayloadSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login endpoint body (`POST /api/v1/auth/login`).
|
||||||
|
*
|
||||||
|
* Replaces the manual `typeof token` check in `readLoginBody`
|
||||||
|
* (`interfaces/http/controllers/auth-controller.ts`).
|
||||||
|
*/
|
||||||
|
export const LoginBodySchema = z.object({
|
||||||
|
/** Admin API token. */
|
||||||
|
token: z.string().min(1, 'Token is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Inferred login-body type. */
|
||||||
|
export type LoginBody = z.infer<typeof LoginBodySchema>;
|
||||||
|
|
||||||
|
/** A single key entry in an S3 DeleteObjects (`?delete`) XML body. */
|
||||||
|
export const DeleteObjectKeySchema = z.string().min(1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3 multi-object delete body (`POST /{bucket}?delete`).
|
||||||
|
*
|
||||||
|
* Validates the *parsed* output of `parseDeleteObjectsBody` (`interfaces/s3/xml.ts`).
|
||||||
|
* The regex parser is kept (low-risk), its output is validated here — including
|
||||||
|
* the M11 S3 limit of 1000 keys per request.
|
||||||
|
*/
|
||||||
|
export const DeleteObjectsBodySchema = z.object({
|
||||||
|
/** Object keys to delete. */
|
||||||
|
keys: z.array(DeleteObjectKeySchema).max(1000, 'Max 1000 keys per request'),
|
||||||
|
/** Quiet mode — return only errors. */
|
||||||
|
quiet: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Inferred delete-objects body type. */
|
||||||
|
export type DeleteObjectsBody = z.infer<typeof DeleteObjectsBodySchema>;
|
||||||
|
|
||||||
|
/** A single part entry in a CompleteMultipartUpload XML body. */
|
||||||
|
export const CompletePartSchema = z.object({
|
||||||
|
/** 1-based part number (1–10000 per S3 spec). */
|
||||||
|
partNumber: z.number().int().min(1).max(10000),
|
||||||
|
/** Part ETag as returned by UploadPart (quotes already stripped by the parser). */
|
||||||
|
etag: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3 CompleteMultipartUpload body (`POST /{bucket}/{key}?uploadId=`).
|
||||||
|
*
|
||||||
|
* Validates the *parsed* output of `parseCompleteMultipartBody`
|
||||||
|
* (`interfaces/s3/xml.ts`).
|
||||||
|
*/
|
||||||
|
export const CompleteMultipartBodySchema = z.object({
|
||||||
|
/** Parts in the order submitted by the client. */
|
||||||
|
parts: z.array(CompletePartSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Inferred complete-multipart body type. */
|
||||||
|
export type CompleteMultipartBody = z.infer<typeof CompleteMultipartBodySchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared clamp for paginated S3 listing query params (`max-keys`,
|
||||||
|
* `max-uploads`, `max-parts`).
|
||||||
|
*
|
||||||
|
* Replaces four divergent inline clamps (ListObjectsV1 used `Math.max(1, …)`,
|
||||||
|
* V2/ListUploads/ListParts used bare `Math.min(…, 1000)` which admitted 0,
|
||||||
|
* negatives and NaN). Garbage input now falls back to the S3 default of 1000.
|
||||||
|
*
|
||||||
|
* @param raw - Raw query-param value (may be null when absent).
|
||||||
|
* @param fallback - Default when the value is missing or invalid (default 1000).
|
||||||
|
* @returns An integer in [1, 1000].
|
||||||
|
*/
|
||||||
|
export const clampMaxKeys = (raw: string | null, fallback = 1000): number => {
|
||||||
|
const parsed = z.coerce.number().int().min(1).max(1000).safeParse(raw);
|
||||||
|
if (parsed.success) return parsed.data;
|
||||||
|
if (raw === null) return fallback;
|
||||||
|
return 1000;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3 part number from `?partNumber=` (UploadPart).
|
||||||
|
*
|
||||||
|
* Mirrors the M14 inline check in `handleUploadPart` (integer 1–10000).
|
||||||
|
*/
|
||||||
|
export const PartNumberSchema = z.coerce.number().int().min(1).max(10000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strictly positive integer coercion for numeric env config (e.g. `PORT`).
|
||||||
|
*
|
||||||
|
* Unlike the old `parseNumber` fallback in `src/env.ts` (garbage → silent
|
||||||
|
* default), invalid values fail so startup fails fast instead of running
|
||||||
|
* misconfigured.
|
||||||
|
*/
|
||||||
|
export const PositiveIntSchema = z.coerce.number().int().positive();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses unknown input with a zod schema, returning the typed value or
|
||||||
|
* `null` on failure. Use at transport boundaries where the caller maps
|
||||||
|
* failure to its own error shape (JSON 400 vs S3 XML error).
|
||||||
|
*
|
||||||
|
* @param schema - The zod schema to parse with.
|
||||||
|
* @param input - Untrusted input.
|
||||||
|
* @returns The parsed value, or `null` when invalid.
|
||||||
|
*/
|
||||||
|
export const parseOrNull = <T>(schema: z.ZodType<T>, input: unknown): T | null => {
|
||||||
|
const result = schema.safeParse(input);
|
||||||
|
return result.success ? result.data : null;
|
||||||
|
};
|
||||||
+17
-4
@@ -42,7 +42,7 @@ mock.module('../src/interfaces/bot/handler', () => ({
|
|||||||
startBot: mockStartBot,
|
startBot: mockStartBot,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module('../src/db/migrate', () => ({
|
mock.module('../src/infrastructure/persistence/drizzle/migrate', () => ({
|
||||||
runMigration: mock(() => Promise.resolve()),
|
runMigration: mock(() => Promise.resolve()),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ mock.module('../src/interfaces/http/middleware/auth', () => ({
|
|||||||
requireAuth: mockRequireAuth,
|
requireAuth: mockRequireAuth,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module('../src/utils/rateLimit', () => ({
|
mock.module('../src/interfaces/http/middleware/rate-limit', () => ({
|
||||||
cleanupRateLimitCache: mock(),
|
cleanupRateLimitCache: mock(),
|
||||||
clearRateLimitCache: mock(),
|
clearRateLimitCache: mock(),
|
||||||
checkRateLimit: mock(() => true),
|
checkRateLimit: mock(() => true),
|
||||||
@@ -128,8 +128,21 @@ describe('Bootstrap Server', () => {
|
|||||||
expect(await res.json()).toEqual({ ok: true });
|
expect(await res.json()).toEqual({ ok: true });
|
||||||
expect(mockHandleUpload).toHaveBeenCalledTimes(1);
|
expect(mockHandleUpload).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
const webApiRoute = serveCallArgs.routes?.['/api/v1/*'] as { GET: RouteHandler };
|
// GET /api/v1/* is intentionally public (read endpoints need no auth) —
|
||||||
const protectedRes = await webApiRoute.GET(new Request('http://localhost/api/v1/files'));
|
// it passes through to the raw handler (stubbed here to 404).
|
||||||
|
const webApiRoute = serveCallArgs.routes?.['/api/v1/*'] as {
|
||||||
|
GET: RouteHandler;
|
||||||
|
POST: RouteHandler;
|
||||||
|
};
|
||||||
|
const publicRes = await webApiRoute.GET(new Request('http://localhost/api/v1/files'));
|
||||||
|
|
||||||
|
expect(publicRes.status).toBe(404);
|
||||||
|
expect(await publicRes.json()).toEqual({ error: 'Not Found' });
|
||||||
|
|
||||||
|
// Write endpoints are auth-guarded — POST goes through requireAuth (401 here).
|
||||||
|
const protectedRes = await webApiRoute.POST(
|
||||||
|
new Request('http://localhost/api/v1/files', { method: 'POST' }),
|
||||||
|
);
|
||||||
|
|
||||||
expect(protectedRes.status).toBe(401);
|
expect(protectedRes.status).toBe(401);
|
||||||
expect(await protectedRes.json()).toEqual({ error: 'Unauthorized' });
|
expect(await protectedRes.json()).toEqual({ error: 'Unauthorized' });
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ import { ChunkedStorage } from '../src/infrastructure/telegram/chunked-storage';
|
|||||||
* Tests the real ChunkedStorage class (src/infrastructure/telegram/
|
* Tests the real ChunkedStorage class (src/infrastructure/telegram/
|
||||||
* chunked-storage.ts). uploadFileInTelegramChunks only depends on the injected
|
* chunked-storage.ts). uploadFileInTelegramChunks only depends on the injected
|
||||||
* telegramService, so we stub that and pass no-op repos for the rest.
|
* telegramService, so we stub that and pass no-op repos for the rest.
|
||||||
*
|
|
||||||
* Rewritten from a stale test that imported the old `src/utils/chunked-storage`
|
|
||||||
* layout, which no longer exists after the refactor.
|
|
||||||
*/
|
*/
|
||||||
const makeTelegramStub = (): ITelegramService =>
|
const makeTelegramStub = (): ITelegramService =>
|
||||||
({
|
({
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ describe('Health Route Handler', () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body).toEqual({ status: 'ok' });
|
expect(body).toEqual({ status: 'ok', version: '1.2.2' });
|
||||||
expect(mockExecute).toHaveBeenCalled();
|
expect(mockExecute).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { mock } from 'bun:test';
|
||||||
|
import type { ITelegramService } from '../../src/domain/ports/telegram-service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared test doubles for the TeleUploader unit test suite.
|
||||||
|
*
|
||||||
|
* Consolidates the mock patterns that were previously copy-pasted across
|
||||||
|
* test files (`MockTelegraf` in bot-pool/bot/telegram tests, stub DI repos
|
||||||
|
* in upload/files tests, deterministic nanoid counters in upload tests).
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - Import from this module inside individual test files only — never as a
|
||||||
|
* global preload (avoids cross-file mock pollution; see CLAUDE.md).
|
||||||
|
* - Prefer `mock.module` with these factories over hand-rolled inline mocks
|
||||||
|
* so behavior stays consistent when the doubles evolve.
|
||||||
|
*
|
||||||
|
* @module test/helpers/test-doubles
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Minimal Telegram message result shape used by the MockTelegraf doubles. */
|
||||||
|
export interface MockTelegramMessage {
|
||||||
|
/** Telegram message identifier. */
|
||||||
|
message_id: number;
|
||||||
|
/** Document payload (present for document uploads). */
|
||||||
|
document?: { file_id: string; file_unique_id: string };
|
||||||
|
/** Photo payload (present for photo uploads). */
|
||||||
|
photo?: Array<{ file_id: string; file_unique_id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Telegraf mock payload (`mock.module('telegraf', …)` factory).
|
||||||
|
*
|
||||||
|
* Mirrors the `MockTelegraf` pattern from `bot-pool.test.ts` / `bot.test.ts` /
|
||||||
|
* `telegram.test.ts` with controllable per-method implementations.
|
||||||
|
*
|
||||||
|
* @param overrides - Optional per-method implementations.
|
||||||
|
* @returns A `{ Telegraf }` module shape for `mock.module`.
|
||||||
|
*/
|
||||||
|
export const mockTelegrafModule = (overrides?: {
|
||||||
|
sendDocument?: (chatId: unknown, file: unknown, extra?: unknown) => Promise<MockTelegramMessage>;
|
||||||
|
sendPhoto?: (chatId: unknown, file: unknown, extra?: unknown) => Promise<MockTelegramMessage>;
|
||||||
|
getFile?: (fileId: string) => Promise<{ file_path?: string }>;
|
||||||
|
}) => {
|
||||||
|
const sendDocument =
|
||||||
|
overrides?.sendDocument ??
|
||||||
|
(async () => ({
|
||||||
|
message_id: 54321,
|
||||||
|
document: { file_id: 'document_id', file_unique_id: 'document_unique_id' },
|
||||||
|
}));
|
||||||
|
const sendPhoto =
|
||||||
|
overrides?.sendPhoto ??
|
||||||
|
(async () => ({
|
||||||
|
message_id: 12345,
|
||||||
|
photo: [
|
||||||
|
{ file_id: 'photo_id_low', file_unique_id: 'unique_id_low' },
|
||||||
|
{ file_id: 'photo_id_high', file_unique_id: 'unique_id_high' },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
const getFile = overrides?.getFile ?? (async () => ({ file_path: 'documents/file.dat' }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
Telegraf: class {
|
||||||
|
token: unknown;
|
||||||
|
telegram: {
|
||||||
|
sendDocument: typeof sendDocument;
|
||||||
|
sendPhoto: typeof sendPhoto;
|
||||||
|
getFile: typeof getFile;
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(token: unknown) {
|
||||||
|
this.token = token;
|
||||||
|
this.telegram = { sendDocument, sendPhoto, getFile };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a minimal {@link ITelegramService} stub backed by `bun:test` mocks.
|
||||||
|
*
|
||||||
|
* Mirrors the hand-rolled stub in `chunked-storage.test.ts` (real
|
||||||
|
* `ChunkedStorage` + stub telegram service + no-op repos).
|
||||||
|
*
|
||||||
|
* @param overrides - Optional per-method implementations.
|
||||||
|
* @returns An `ITelegramService` implementation whose methods are mocks.
|
||||||
|
*/
|
||||||
|
export const makeTelegramServiceStub = (
|
||||||
|
overrides?: Partial<ITelegramService>,
|
||||||
|
): ITelegramService & {
|
||||||
|
forwardToStorage: ReturnType<typeof mock>;
|
||||||
|
getFileInfo: ReturnType<typeof mock>;
|
||||||
|
} => {
|
||||||
|
const forwardToStorage = mock(
|
||||||
|
overrides?.forwardToStorage ??
|
||||||
|
(async (_bytes: unknown, fileName: string) => ({
|
||||||
|
telegramFileId: `tg-${fileName}`,
|
||||||
|
telegramFileUniqueId: `tg-unique-${fileName}`,
|
||||||
|
storageMessageId: 1,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const getFileInfo = mock(
|
||||||
|
overrides?.getFileInfo ??
|
||||||
|
(async (telegramFileId: string) => ({
|
||||||
|
file_size: 100,
|
||||||
|
mime_type: 'application/octet-stream',
|
||||||
|
file_path: 'documents/file.dat',
|
||||||
|
bot_token: '123456:ABC-DEF',
|
||||||
|
telegramFileId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
forwardToStorage: forwardToStorage as unknown as ITelegramService['forwardToStorage'],
|
||||||
|
getFileInfo: getFileInfo as unknown as ITelegramService['getFileInfo'],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a deterministic `nanoid` module mock — each call returns
|
||||||
|
* `prefix-1`, `prefix-2`, … instead of random IDs.
|
||||||
|
*
|
||||||
|
* Replaces the ad-hoc `mock.module('nanoid')` counters in `upload.test.ts`.
|
||||||
|
*
|
||||||
|
* @param prefix - Prefix for generated IDs (default `"test-id"`).
|
||||||
|
* @returns A `{ nanoid }` module shape for `mock.module`.
|
||||||
|
*/
|
||||||
|
export const mockNanoidModule = (prefix = 'test-id') => {
|
||||||
|
let counter = 0;
|
||||||
|
return {
|
||||||
|
nanoid: mock(() => `${prefix}-${++counter}`),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a signed S3 test request with valid SigV4 headers.
|
||||||
|
*
|
||||||
|
* Request-signing is covered by `s3-auth.test.ts`; this helper exists for
|
||||||
|
* handler-level tests that mock `../src/interfaces/s3/auth` to accept any
|
||||||
|
* signature and only need a well-formed request object.
|
||||||
|
*
|
||||||
|
* @param url - Request URL (path-style `/bucket/key` or `/`).
|
||||||
|
* @param init - Optional `RequestInit` overrides (method defaults to GET).
|
||||||
|
* @returns A `Request` with stub SigV4 headers.
|
||||||
|
*/
|
||||||
|
export const s3TestRequest = (url: string, init?: RequestInit): Request =>
|
||||||
|
new Request(url, {
|
||||||
|
method: 'GET',
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
authorization: 'AWS4-HMAC-SHA256 Credential=test/20260101/us-east-1/s3/aws4_request',
|
||||||
|
'x-amz-date': '20260101T000000Z',
|
||||||
|
'x-amz-content-sha256': 'UNSIGNED-PAYLOAD',
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 1x1px JPEG fallback binary (offline-safe fixture, no network fetch). */
|
||||||
|
export const TINY_JPEG_HEX =
|
||||||
|
'ffd8ffe000104a46494600010101006000600000ffdb004300080606070605080707070909080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c2837292c30313434341f27393d38323c2e333432ffc0b000080100010101011100ffc4001f0000010501010110000000000000000000000102030405060708ffda000c03010002110311003f00a0ffd9';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the tiny-JPEG fixture as a `Buffer` without any network access.
|
||||||
|
*
|
||||||
|
* Replaces the Wikimedia-fetch-with-fallback pattern in `telegram.test.ts`
|
||||||
|
* and `upload.test.ts` for tests that only need *some* valid image bytes.
|
||||||
|
* Tests that genuinely need a real PNG must stay in quarantine (see
|
||||||
|
* `test:quarantine` in package.json).
|
||||||
|
*
|
||||||
|
* @returns A 1x1px JPEG buffer.
|
||||||
|
*/
|
||||||
|
export const tinyJpegBuffer = (): Buffer => Buffer.from(TINY_JPEG_HEX, 'hex');
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import {
|
||||||
|
BucketNameSchema,
|
||||||
|
CompleteMultipartBodySchema,
|
||||||
|
clampMaxKeys,
|
||||||
|
DeleteObjectsBodySchema,
|
||||||
|
JsonUploadPayloadSchema,
|
||||||
|
LoginBodySchema,
|
||||||
|
parseOrNull,
|
||||||
|
} from '../src/shared/validation/schemas';
|
||||||
|
|
||||||
|
describe('BucketNameSchema (single canonical validator)', () => {
|
||||||
|
it('accepts valid bucket names', () => {
|
||||||
|
expect(BucketNameSchema.safeParse('gitea').success).toBe(true);
|
||||||
|
expect(BucketNameSchema.safeParse('my.bucket-01').success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid names (format, dots, IP, xn--)', () => {
|
||||||
|
expect(BucketNameSchema.safeParse('ab').success).toBe(false);
|
||||||
|
expect(BucketNameSchema.safeParse('UPPERCASE').success).toBe(false);
|
||||||
|
expect(BucketNameSchema.safeParse('a..b').success).toBe(false);
|
||||||
|
expect(BucketNameSchema.safeParse('192.168.1.1').success).toBe(false);
|
||||||
|
expect(BucketNameSchema.safeParse('xn--bcher-kva').success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JsonUploadPayloadSchema', () => {
|
||||||
|
it('accepts file + optional fileName', () => {
|
||||||
|
expect(
|
||||||
|
JsonUploadPayloadSchema.safeParse({ file: 'aGVsbG8=', fileName: 'hi.txt' }).success,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults fileName and rejects missing/empty file', () => {
|
||||||
|
const parsed = JsonUploadPayloadSchema.safeParse({ file: 'aGVsbG8=' });
|
||||||
|
expect(parsed.success && parsed.data.fileName).toBe('file');
|
||||||
|
expect(JsonUploadPayloadSchema.safeParse({}).success).toBe(false);
|
||||||
|
expect(JsonUploadPayloadSchema.safeParse({ file: '' }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LoginBodySchema', () => {
|
||||||
|
it('accepts a non-empty token and rejects the rest', () => {
|
||||||
|
expect(LoginBodySchema.safeParse({ token: 'secret' }).success).toBe(true);
|
||||||
|
expect(LoginBodySchema.safeParse({}).success).toBe(false);
|
||||||
|
expect(LoginBodySchema.safeParse({ token: '' }).success).toBe(false);
|
||||||
|
expect(LoginBodySchema.safeParse({ token: 42 }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DeleteObjectsBodySchema', () => {
|
||||||
|
it('accepts parsed keys and enforces the 1000-key S3 limit', () => {
|
||||||
|
expect(DeleteObjectsBodySchema.safeParse({ keys: ['a', 'b'], quiet: false }).success).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
DeleteObjectsBodySchema.safeParse({ keys: new Array(1001).fill('k'), quiet: true }).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CompleteMultipartBodySchema', () => {
|
||||||
|
it('accepts parsed parts and rejects bad part numbers', () => {
|
||||||
|
expect(
|
||||||
|
CompleteMultipartBodySchema.safeParse({ parts: [{ partNumber: 1, etag: 'abc' }] }).success,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
CompleteMultipartBodySchema.safeParse({ parts: [{ partNumber: 0, etag: 'abc' }] }).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clampMaxKeys (unified listing clamp)', () => {
|
||||||
|
it('clamps into [1, 1000] and falls back to 1000 on garbage', () => {
|
||||||
|
expect(clampMaxKeys(null)).toBe(1000);
|
||||||
|
expect(clampMaxKeys('5')).toBe(5);
|
||||||
|
expect(clampMaxKeys('99999')).toBe(1000);
|
||||||
|
expect(clampMaxKeys('0')).toBe(1000);
|
||||||
|
expect(clampMaxKeys('-3')).toBe(1000);
|
||||||
|
expect(clampMaxKeys('abc')).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseOrNull', () => {
|
||||||
|
it('returns the value on success and null on failure', () => {
|
||||||
|
expect(parseOrNull(LoginBodySchema, { token: 'x' })).toEqual({ token: 'x' });
|
||||||
|
expect(parseOrNull(LoginBodySchema, {})).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user