feat: implement authentication routes with login, logout, and user info retrieval
feat: add S3 bucket versioning support and related XML response handling refactor: rename temporary file paths from 'teleuploader' to 'filedrop' for consistency fix: update Swagger documentation to reflect new API name and descriptions test: add unit tests for authentication routes and utilities test: implement end-to-end tests for S3 bucket configuration and versioning chore: update environment variable defaults for new service name
This commit is contained in:
+8
-1
@@ -11,7 +11,7 @@ RATE_LIMIT_MAX_REQUESTS=30
|
|||||||
# TRUST_PROXY=true # Uncomment when behind reverse proxy (Traefik, Nginx) for correct client IP detection
|
# TRUST_PROXY=true # Uncomment when behind reverse proxy (Traefik, Nginx) for correct client IP detection
|
||||||
|
|
||||||
# S3-compatible API credentials
|
# S3-compatible API credentials
|
||||||
# S3_ACCESS_KEY=teleuploader-admin
|
# S3_ACCESS_KEY=filedrop-admin
|
||||||
# S3_SECRET_KEY=your-secret-key-here
|
# S3_SECRET_KEY=your-secret-key-here
|
||||||
# S3_DEFAULT_REGION=us-east-1
|
# S3_DEFAULT_REGION=us-east-1
|
||||||
# S3_VHOST_DOMAINS=upload.asepharyana.my.id,upload.asepharyana.web.id
|
# S3_VHOST_DOMAINS=upload.asepharyana.my.id,upload.asepharyana.web.id
|
||||||
@@ -20,3 +20,10 @@ RATE_LIMIT_MAX_REQUESTS=30
|
|||||||
# TELEGRAM_CHUNK_SIZE_BYTES=20971520
|
# TELEGRAM_CHUNK_SIZE_BYTES=20971520
|
||||||
# COMPRESS_CHUNKED_UPLOADS=true
|
# COMPRESS_CHUNKED_UPLOADS=true
|
||||||
# CHUNK_COMPRESSION_MIN_SIZE_BYTES=4096
|
# CHUNK_COMPRESSION_MIN_SIZE_BYTES=4096
|
||||||
|
|
||||||
|
# Web dashboard/API auth. If empty, dashboard remains public.
|
||||||
|
ADMIN_API_TOKEN=
|
||||||
|
|
||||||
|
# Optional session cookie settings
|
||||||
|
# SESSION_COOKIE_NAME=tu_session
|
||||||
|
# SESSION_COOKIE_MAX_AGE_SECONDS=86400
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# ─── TeleUploader Deploy Script ──────────────────────────────────────────────
|
# ─── FileDrop Deploy Script ──────────────────────────────────────────────────
|
||||||
# Builds the Bun app locally and deploys to the VPS via Docker.
|
# Builds the Bun app locally and deploys to the VPS via Docker.
|
||||||
#
|
#
|
||||||
# Strategy: build dist locally, ship dist + Docker context to VPS via tar pipe,
|
# Strategy: build dist locally, ship dist + Docker context to VPS via tar pipe,
|
||||||
@@ -22,14 +22,14 @@
|
|||||||
# VPS_SSH_KEY — path/contents of SSH private key
|
# VPS_SSH_KEY — path/contents of SSH private key
|
||||||
#
|
#
|
||||||
# Optional:
|
# Optional:
|
||||||
# DEPLOY_DIR — deploy dir on VPS (default: /opt/teleuploader)
|
# DEPLOY_DIR — deploy dir on VPS (default: /opt/filedrop)
|
||||||
# ADMIN_PASSWORD — verify health after deploy (optional)
|
# ADMIN_PASSWORD — verify health after deploy (optional)
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
# ── Config ────────────────────────────────────────────────────────────────────
|
# ── Config ────────────────────────────────────────────────────────────────────
|
||||||
APP_NAME="teleuploader"
|
APP_NAME="filedrop"
|
||||||
GITLAB_PROJECT="superaseph%2FTeleUploader"
|
GITLAB_PROJECT="superaseph%2FTeleUploader"
|
||||||
DEPLOY_DIR="${DEPLOY_DIR:-/opt/${APP_NAME}}"
|
DEPLOY_DIR="${DEPLOY_DIR:-/opt/${APP_NAME}}"
|
||||||
COMPOSE_FILE="docker-compose.yml"
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
|||||||
+15
-12
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
container_name: teleuploader-app
|
container_name: filedrop-app
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
- BOT_TOKEN=${BOT_TOKEN}
|
- BOT_TOKEN=${BOT_TOKEN}
|
||||||
@@ -22,7 +22,10 @@ services:
|
|||||||
- CHUNK_COMPRESSION_MIN_SIZE_BYTES=${CHUNK_COMPRESSION_MIN_SIZE_BYTES:-4096}
|
- CHUNK_COMPRESSION_MIN_SIZE_BYTES=${CHUNK_COMPRESSION_MIN_SIZE_BYTES:-4096}
|
||||||
- RATE_LIMIT_WINDOW_MS=${RATE_LIMIT_WINDOW_MS:-60000}
|
- RATE_LIMIT_WINDOW_MS=${RATE_LIMIT_WINDOW_MS:-60000}
|
||||||
- RATE_LIMIT_MAX_REQUESTS=${RATE_LIMIT_MAX_REQUESTS:-30}
|
- RATE_LIMIT_MAX_REQUESTS=${RATE_LIMIT_MAX_REQUESTS:-30}
|
||||||
- S3_ACCESS_KEY=${S3_ACCESS_KEY:-teleuploader-admin}
|
- ADMIN_API_TOKEN=${ADMIN_API_TOKEN:-}
|
||||||
|
- SESSION_COOKIE_NAME=${SESSION_COOKIE_NAME:-tu_session}
|
||||||
|
- SESSION_COOKIE_MAX_AGE_SECONDS=${SESSION_COOKIE_MAX_AGE_SECONDS:-86400}
|
||||||
|
- S3_ACCESS_KEY=${S3_ACCESS_KEY:-filedrop-admin}
|
||||||
- S3_SECRET_KEY=${S3_SECRET_KEY}
|
- S3_SECRET_KEY=${S3_SECRET_KEY}
|
||||||
- S3_DEFAULT_REGION=${S3_DEFAULT_REGION:-us-east-1}
|
- S3_DEFAULT_REGION=${S3_DEFAULT_REGION:-us-east-1}
|
||||||
- S3_VHOST_DOMAINS=${S3_VHOST_DOMAINS:-upload.asepharyana.my.id,asepharyana.web.id}
|
- S3_VHOST_DOMAINS=${S3_VHOST_DOMAINS:-upload.asepharyana.my.id,asepharyana.web.id}
|
||||||
@@ -52,16 +55,16 @@ services:
|
|||||||
- app-shared-net
|
- app-shared-net
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.teleuploader.rule=Host(`upload.asepharyana.my.id`) || Host(`upload.asepharyana.web.id`) || Host(`asepharyana.web.id`) || HostRegexp(`{subhost:[a-z0-9][a-z0-9.-]+}.asepharyana.web.id`)"
|
- "traefik.http.routers.filedrop.rule=Host(`upload.asepharyana.my.id`) || Host(`upload.asepharyana.web.id`) || Host(`asepharyana.web.id`) || HostRegexp(`{subhost:[a-z0-9][a-z0-9.-]+}.asepharyana.web.id`)"
|
||||||
- "traefik.http.routers.teleuploader.entrypoints=websecure"
|
- "traefik.http.routers.filedrop.entrypoints=websecure"
|
||||||
- "traefik.http.routers.teleuploader.tls=true"
|
- "traefik.http.routers.filedrop.tls=true"
|
||||||
- "traefik.http.routers.teleuploader.tls.certresolver=cloudflare"
|
- "traefik.http.routers.filedrop.tls.certresolver=cloudflare"
|
||||||
- "traefik.http.services.teleuploader.loadbalancer.server.port=3000"
|
- "traefik.http.services.filedrop.loadbalancer.server.port=3000"
|
||||||
- "traefik.http.middlewares.teleuploader-rl.ratelimit.average=300"
|
- "traefik.http.middlewares.filedrop-rl.ratelimit.average=300"
|
||||||
- "traefik.http.middlewares.teleuploader-rl.ratelimit.burst=100"
|
- "traefik.http.middlewares.filedrop-rl.ratelimit.burst=100"
|
||||||
- "traefik.http.middlewares.teleuploader-rl.ratelimit.period=1m"
|
- "traefik.http.middlewares.filedrop-rl.ratelimit.period=1m"
|
||||||
- "traefik.http.middlewares.teleuploader-buf.buffering.maxRequestBodyBytes=2147483648"
|
- "traefik.http.middlewares.filedrop-buf.buffering.maxRequestBodyBytes=2147483648"
|
||||||
- "traefik.http.routers.teleuploader.middlewares=teleuploader-rl@docker,teleuploader-buf@docker"
|
- "traefik.http.routers.filedrop.middlewares=filedrop-rl@docker,filedrop-buf@docker"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app-shared-net:
|
app-shared-net:
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "teleuploader",
|
"name": "filedrop",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Telegram file uploader backend",
|
"description": "Telegram file uploader backend",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"build": "bun build src/index.ts --target=bun --outfile=dist/index.js && bun build src/db/migrate.ts --target=bun --outfile=dist/migrate.js",
|
"build": "bun build src/index.ts --target=bun --outfile=dist/index.js && bun build src/db/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 test/rateLimit.test.ts && bun test test/file.test.ts && bun test test/telegram.test.ts && bun test test/upload.test.ts && bun test test/files.test.ts && bun test test/health.test.ts && bun test test/db.test.ts && bun test test/bot.test.ts && bun test test/bootstrap.test.ts && bun test test/swagger.test.ts && bun test test/s3-auth.test.ts && bun test test/s3-operations.test.ts && bun test test/web-api.test.ts",
|
"test": "bun test test/rateLimit.test.ts && bun test test/file.test.ts && bun test test/telegram.test.ts && bun test test/upload.test.ts && bun test test/files.test.ts && bun test test/health.test.ts && bun test test/db.test.ts && bun test test/bot.test.ts && bun test test/bootstrap.test.ts && bun test test/swagger.test.ts && bun test test/auth.test.ts && bun test test/auth-routes.test.ts && bun test test/s3-auth.test.ts && bun test test/s3-operations.test.ts && bun test test/s3-bucket-config.test.ts && bun test test/web-api.test.ts",
|
||||||
"test:s3-auth": "bun test test/s3-auth.test.ts",
|
"test:s3-auth": "bun test test/s3-auth.test.ts",
|
||||||
"test:s3-ops": "bun test test/s3-operations.test.ts",
|
"test:s3-ops": "bun test test/s3-operations.test.ts",
|
||||||
"test:web-api": "bun test test/web-api.test.ts",
|
"test:web-api": "bun test test/web-api.test.ts",
|
||||||
|
|||||||
+9
-1
@@ -19,6 +19,9 @@ interface AppConfig {
|
|||||||
telegramChunkSizeBytes: number;
|
telegramChunkSizeBytes: number;
|
||||||
compressChunkedUploads: boolean;
|
compressChunkedUploads: boolean;
|
||||||
chunkCompressionMinSizeBytes: number;
|
chunkCompressionMinSizeBytes: number;
|
||||||
|
adminApiToken: string;
|
||||||
|
sessionCookieName: string;
|
||||||
|
sessionMaxAgeMs: number;
|
||||||
s3AccessKey: string;
|
s3AccessKey: string;
|
||||||
s3SecretKey: string;
|
s3SecretKey: string;
|
||||||
s3DefaultRegion: string;
|
s3DefaultRegion: string;
|
||||||
@@ -91,7 +94,10 @@ export const config: AppConfig = {
|
|||||||
telegramChunkSizeBytes: parseNumber(process.env.TELEGRAM_CHUNK_SIZE_BYTES, 20 * 1024 * 1024),
|
telegramChunkSizeBytes: parseNumber(process.env.TELEGRAM_CHUNK_SIZE_BYTES, 20 * 1024 * 1024),
|
||||||
compressChunkedUploads: process.env.COMPRESS_CHUNKED_UPLOADS !== 'false',
|
compressChunkedUploads: process.env.COMPRESS_CHUNKED_UPLOADS !== 'false',
|
||||||
chunkCompressionMinSizeBytes: parseNumber(process.env.CHUNK_COMPRESSION_MIN_SIZE_BYTES, 4096),
|
chunkCompressionMinSizeBytes: parseNumber(process.env.CHUNK_COMPRESSION_MIN_SIZE_BYTES, 4096),
|
||||||
s3AccessKey: process.env.S3_ACCESS_KEY || 'teleuploader-admin',
|
adminApiToken: process.env.ADMIN_API_TOKEN || '',
|
||||||
|
sessionCookieName: process.env.SESSION_COOKIE_NAME || 'tu_session',
|
||||||
|
sessionMaxAgeMs: parseNumber(process.env.SESSION_COOKIE_MAX_AGE_SECONDS, 86400) * 1000,
|
||||||
|
s3AccessKey: process.env.S3_ACCESS_KEY || 'filedrop-admin',
|
||||||
s3SecretKey: process.env.S3_SECRET_KEY || '',
|
s3SecretKey: process.env.S3_SECRET_KEY || '',
|
||||||
s3DefaultRegion: process.env.S3_DEFAULT_REGION || 'us-east-1',
|
s3DefaultRegion: process.env.S3_DEFAULT_REGION || 'us-east-1',
|
||||||
proxyS3Get: process.env.PROXY_S3_GET !== 'false',
|
proxyS3Get: process.env.PROXY_S3_GET !== 'false',
|
||||||
@@ -107,6 +113,8 @@ logger.info('Environment variables loaded', {
|
|||||||
botToken: maskSecret(config.botToken),
|
botToken: maskSecret(config.botToken),
|
||||||
additionalBotTokens: config.additionalBotTokens.map(maskSecret),
|
additionalBotTokens: config.additionalBotTokens.map(maskSecret),
|
||||||
databaseUrl: maskDatabaseUrl(config.databaseUrl),
|
databaseUrl: maskDatabaseUrl(config.databaseUrl),
|
||||||
|
adminApiToken: maskSecret(config.adminApiToken),
|
||||||
|
adminApiTokenEnabled: config.adminApiToken.length > 0,
|
||||||
s3AccessKey: maskSecret(config.s3AccessKey),
|
s3AccessKey: maskSecret(config.s3AccessKey),
|
||||||
s3SecretKey: maskSecret(config.s3SecretKey),
|
s3SecretKey: maskSecret(config.s3SecretKey),
|
||||||
},
|
},
|
||||||
|
|||||||
+95
-4
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>TeleUploader · S3 File Manager</title>
|
<title>FileDrop · S3 File Manager</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #ffffff; --bg2: #f5f5f5; --text: #1a1a1a;
|
--bg: #ffffff; --bg2: #f5f5f5; --text: #1a1a1a;
|
||||||
@@ -113,16 +113,49 @@
|
|||||||
.modal .buttons .danger { background: var(--danger); color: #fff; border-color: var(--danger); }
|
.modal .buttons .danger { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||||
.empty { text-align: center; padding: 48px 24px; color: var(--text2); }
|
.empty { text-align: center; padding: 48px 24px; color: var(--text2); }
|
||||||
.empty h2 { font-size: 1.2rem; margin-bottom: 8px; }
|
.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; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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">
|
<div class="topbar">
|
||||||
<span class="logo">📦 TeleUploader</span>
|
<span class="logo">📦 FileDrop</span>
|
||||||
<select id="bucketSelect" onchange="window.switchBucket(this.value)">
|
<select id="bucketSelect" onchange="window.switchBucket(this.value)">
|
||||||
<option value="">— Select bucket —</option>
|
<option value="">— Select bucket —</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" onclick="window.showCreateBucketModal()">+ New</button>
|
<button type="button" onclick="window.showCreateBucketModal()">+ New</button>
|
||||||
<button type="button" onclick="window.showCredentialsModal()" title="S3 Credentials">🔑</button>
|
<button type="button" onclick="window.showCredentialsModal()" title="S3 Credentials">🔑</button>
|
||||||
|
<button id="logoutBtn" type="button" onclick="window.logout()" style="display:none">Logout</button>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<div class="search">
|
<div class="search">
|
||||||
<input id="searchInput" type="text" placeholder="Filter prefix..." oninput="window.debouncedSearch()">
|
<input id="searchInput" type="text" placeholder="Filter prefix..." oninput="window.debouncedSearch()">
|
||||||
@@ -146,6 +179,61 @@
|
|||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
let currentBucket = null, currentPrefix = '', currentObjects = [], currentPrefixes = [], allBuckets = [], searchTimer = null;
|
let currentBucket = null, currentPrefix = '', currentObjects = [], currentPrefixes = [], allBuckets = [], searchTimer = null;
|
||||||
|
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';
|
||||||
|
document.getElementById('logoutBtn').style.display = 'none';
|
||||||
|
setTimeout(() => document.getElementById('authTokenInput')?.focus(), 50);
|
||||||
|
};
|
||||||
|
const hideAuthScreen = (showLogout) => {
|
||||||
|
document.getElementById('authScreen').style.display = 'none';
|
||||||
|
document.getElementById('logoutBtn').style.display = showLogout ? 'inline-block' : 'none';
|
||||||
|
};
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/auth/me');
|
||||||
|
if (res.ok) { hideAuthScreen(true); return true; }
|
||||||
|
if (res.status === 401) { showAuthScreen(); return false; }
|
||||||
|
if (res.status === 404) { hideAuthScreen(false); return true; }
|
||||||
|
setAuthError('Unable to verify login status. Please try again.');
|
||||||
|
showAuthScreen(); return false;
|
||||||
|
} catch {
|
||||||
|
setAuthError('Network error while checking login status.');
|
||||||
|
showAuthScreen(); return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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) { hideAuthScreen(true); input.value = ''; 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(() => {});
|
||||||
|
currentBucket = null; currentPrefix = ''; currentObjects = []; currentPrefixes = [];
|
||||||
|
document.getElementById('bucketSelect').innerHTML = '<option value="">— Select bucket —</option>';
|
||||||
|
document.getElementById('fileList').innerHTML = '<div class="empty"><h2>Logged out</h2><p>Enter the admin token to continue.</p></div>';
|
||||||
|
document.getElementById('dropzone').style.display = 'none';
|
||||||
|
showAuthScreen();
|
||||||
|
};
|
||||||
const api = async (path, opts = {}) => {
|
const api = async (path, opts = {}) => {
|
||||||
const res = await fetch(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); }
|
if (!res.ok) { const body = await res.json().catch(() => ({ error: res.statusText })); throw new Error(body.error || res.statusText); }
|
||||||
@@ -240,8 +328,11 @@
|
|||||||
const showCreateBucketModal=()=>{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 showCreateBucketModal=()=>{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()=>{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 createBucket=async()=>{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=()=>{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-weight:600">Access Key</label><input id="s3AccessKey" type="text" readonly onclick="this.select()"><label style="font-size:0.85rem;font-weight:600">Secret Key</label><input id="s3SecretKey" type="password" readonly onclick="this.select()"><div class="buttons"><button type="button" onclick="window.closeModal()">Close</button></div>`);};
|
const showCredentialsModal=()=>{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-weight:600">Access Key</label><input id="s3AccessKey" type="text" readonly onclick="this.select()"><label style="font-size:0.85rem;font-weight:600">Secret Key</label><input id="s3SecretKey" type="password" readonly onclick="this.select()"><div class="buttons"><button type="button" onclick="window.closeModal()">Close</button></div>`);};
|
||||||
Object.assign(window, { switchBucket, navigateTo, debouncedSearch, downloadObject, copyLink, deleteObject, closeModal, showCreateBucketModal, createBucket, showCredentialsModal });
|
const init=async()=>{if(await checkAuth())await loadBuckets();};
|
||||||
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, logout });
|
||||||
|
init();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+16
-5
@@ -1,6 +1,7 @@
|
|||||||
import { serve } from 'bun';
|
import { serve } from 'bun';
|
||||||
import { startBot } from './bot';
|
import { startBot } from './bot';
|
||||||
import { config } from './env';
|
import { config } from './env';
|
||||||
|
import { handleLogin, handleLogout, handleMe } from './routes/auth';
|
||||||
import { handleFileInfo, handleFileRedirect } from './routes/files';
|
import { handleFileInfo, handleFileRedirect } from './routes/files';
|
||||||
import { handleHealth } from './routes/health';
|
import { handleHealth } from './routes/health';
|
||||||
import { handleHome } from './routes/home';
|
import { handleHome } from './routes/home';
|
||||||
@@ -8,6 +9,7 @@ import { handleS3Request } from './routes/s3';
|
|||||||
import { handleSwaggerHtml, handleSwaggerJson } from './routes/swagger';
|
import { handleSwaggerHtml, handleSwaggerJson } from './routes/swagger';
|
||||||
import { handleUpload } from './routes/upload';
|
import { handleUpload } from './routes/upload';
|
||||||
import { handleWebApiV1 } from './routes/web-api';
|
import { handleWebApiV1 } from './routes/web-api';
|
||||||
|
import { requireAuth } from './utils/auth';
|
||||||
import { fileInfoCache } from './utils/cache';
|
import { fileInfoCache } from './utils/cache';
|
||||||
import logger from './utils/logger';
|
import logger from './utils/logger';
|
||||||
import { metricsCollector } from './utils/metrics';
|
import { metricsCollector } from './utils/metrics';
|
||||||
@@ -58,7 +60,7 @@ const server = serve({
|
|||||||
port: config.port,
|
port: config.port,
|
||||||
routes: {
|
routes: {
|
||||||
'/api/upload': {
|
'/api/upload': {
|
||||||
POST: withRateLimit(handleUpload),
|
POST: withRateLimit(requireAuth(handleUpload)),
|
||||||
},
|
},
|
||||||
'/f/:public_id': {
|
'/f/:public_id': {
|
||||||
GET: withRateLimit(handleFileRedirect),
|
GET: withRateLimit(handleFileRedirect),
|
||||||
@@ -89,11 +91,20 @@ const server = serve({
|
|||||||
POST: handleMaybeS3Root,
|
POST: handleMaybeS3Root,
|
||||||
OPTIONS: handleMaybeS3Root,
|
OPTIONS: handleMaybeS3Root,
|
||||||
},
|
},
|
||||||
|
'/api/v1/auth/login': {
|
||||||
|
POST: withRateLimit(handleLogin),
|
||||||
|
},
|
||||||
|
'/api/v1/auth/logout': {
|
||||||
|
POST: handleLogout,
|
||||||
|
},
|
||||||
|
'/api/v1/auth/me': {
|
||||||
|
GET: handleMe,
|
||||||
|
},
|
||||||
'/api/v1/*': {
|
'/api/v1/*': {
|
||||||
GET: handleWebApiV1,
|
GET: requireAuth(handleWebApiV1),
|
||||||
POST: handleWebApiV1,
|
POST: requireAuth(handleWebApiV1),
|
||||||
DELETE: handleWebApiV1,
|
DELETE: requireAuth(handleWebApiV1),
|
||||||
PUT: handleWebApiV1,
|
PUT: requireAuth(handleWebApiV1),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fetch: async (req: Request) => {
|
fetch: async (req: Request) => {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { config } from '../env';
|
||||||
|
import {
|
||||||
|
checkBearerToken,
|
||||||
|
clearSessionCookie,
|
||||||
|
createSessionCookie,
|
||||||
|
getAuthSession,
|
||||||
|
isAuthEnabled,
|
||||||
|
timingSafeCompare,
|
||||||
|
} from '../utils/auth';
|
||||||
|
|
||||||
|
const json = (data: unknown, status = 200, headers: Record<string, string> = {}): Response =>
|
||||||
|
Response.json(data, { status, headers });
|
||||||
|
|
||||||
|
const notFound = (): Response => json({ error: 'Not found' }, 404);
|
||||||
|
|
||||||
|
const readLoginBody = async (req: Request): Promise<{ token: string } | null> => {
|
||||||
|
try {
|
||||||
|
const body = (await req.json()) as { token?: unknown };
|
||||||
|
if (typeof body.token !== 'string' || body.token.length === 0) return null;
|
||||||
|
return { token: body.token };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleLogin = async (req: Request): Promise<Response> => {
|
||||||
|
if (!isAuthEnabled()) return notFound();
|
||||||
|
|
||||||
|
const body = await readLoginBody(req);
|
||||||
|
if (!body) return json({ error: 'Token is required' }, 400);
|
||||||
|
|
||||||
|
if (!timingSafeCompare(body.token, config.adminApiToken)) {
|
||||||
|
return json({ error: 'Invalid token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ username: 'admin' }, 200, {
|
||||||
|
'set-cookie': createSessionCookie('admin'),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleLogout = async (): Promise<Response> =>
|
||||||
|
json({ success: true }, 200, {
|
||||||
|
'set-cookie': clearSessionCookie(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const handleMe = async (req: Request): Promise<Response> => {
|
||||||
|
if (!isAuthEnabled()) return notFound();
|
||||||
|
|
||||||
|
const session = getAuthSession(req);
|
||||||
|
if (!session && !checkBearerToken(req.headers.get('authorization'))) {
|
||||||
|
return json({ error: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSession = session ?? {
|
||||||
|
username: 'admin',
|
||||||
|
expiresAt: null,
|
||||||
|
method: 'bearer' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
return json({
|
||||||
|
username: activeSession.username,
|
||||||
|
expiresAt: activeSession.expiresAt?.toISOString() ?? null,
|
||||||
|
});
|
||||||
|
};
|
||||||
+1
-1
@@ -74,7 +74,7 @@ export const handleFileRedirect = async (req: RequestWithParams): Promise<Respon
|
|||||||
return fail(500, 'Server error');
|
return fail(500, 'Server error');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tempZipPath = `/tmp/teleuploader-dl-${nanoid()}.zip`;
|
const tempZipPath = `/tmp/filedrop-dl-${nanoid()}.zip`;
|
||||||
await Bun.write(tempZipPath, archiveResponse);
|
await Bun.write(tempZipPath, archiveResponse);
|
||||||
|
|
||||||
const loc = await locateZipEntry(tempZipPath, archiveEntryName);
|
const loc = await locateZipEntry(tempZipPath, archiveEntryName);
|
||||||
|
|||||||
+22
-2
@@ -26,6 +26,7 @@ import { S3_CORS_HEADERS, s3Headers } from '../utils/s3/headers';
|
|||||||
import { createGetObjectResponse, type ObjectPartSource } from '../utils/s3/object-stream';
|
import { createGetObjectResponse, type ObjectPartSource } from '../utils/s3/object-stream';
|
||||||
import { parseRangeHeader, unsatisfiedContentRange } from '../utils/s3/range';
|
import { parseRangeHeader, unsatisfiedContentRange } from '../utils/s3/range';
|
||||||
import {
|
import {
|
||||||
|
bucketVersioningConfigurationXml,
|
||||||
completeMultipartUploadXml,
|
completeMultipartUploadXml,
|
||||||
copyObjectResultXml,
|
copyObjectResultXml,
|
||||||
deleteResultXml,
|
deleteResultXml,
|
||||||
@@ -147,6 +148,9 @@ export const handleS3Request = async (
|
|||||||
// Bucket-level operations
|
// Bucket-level operations
|
||||||
if (!key) {
|
if (!key) {
|
||||||
if (method === 'GET') {
|
if (method === 'GET') {
|
||||||
|
if (searchParams.has('versioning')) {
|
||||||
|
return handleGetBucketVersioning(bucket, reqId);
|
||||||
|
}
|
||||||
if (searchParams.has('uploads')) {
|
if (searchParams.has('uploads')) {
|
||||||
return handleListMultipartUploads(bucket, searchParams, reqId);
|
return handleListMultipartUploads(bucket, searchParams, reqId);
|
||||||
}
|
}
|
||||||
@@ -291,6 +295,22 @@ const handleDeleteBucket = async (bucketName: string, reqId: string): Promise<Re
|
|||||||
return s3Response(null, 204, reqId);
|
return s3Response(null, 204, reqId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGetBucketVersioning = async (bucketName: string, reqId: string): Promise<Response> => {
|
||||||
|
const bucket = await findBucketByName(bucketName);
|
||||||
|
if (!bucket) {
|
||||||
|
return s3ErrorResponse(
|
||||||
|
'NoSuchBucket',
|
||||||
|
'The specified bucket does not exist.',
|
||||||
|
`/${bucketName}`,
|
||||||
|
404,
|
||||||
|
reqId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return s3Response(bucketVersioningConfigurationXml(), 200, reqId, {
|
||||||
|
'content-type': 'application/xml',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// ─────── Object Operations ───────
|
// ─────── Object Operations ───────
|
||||||
|
|
||||||
const handleGetObject = async (
|
const handleGetObject = async (
|
||||||
@@ -564,7 +584,7 @@ const storeFileToTelegram = async (
|
|||||||
contentType: string,
|
contentType: string,
|
||||||
reqId: string,
|
reqId: string,
|
||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
const tempPath = `/tmp/teleuploader-s3-${nanoid()}`;
|
const tempPath = `/tmp/filedrop-s3-${nanoid()}`;
|
||||||
await Bun.write(tempPath, buffer);
|
await Bun.write(tempPath, buffer);
|
||||||
|
|
||||||
const signatureBuffer = buffer.subarray(0, 16);
|
const signatureBuffer = buffer.subarray(0, 16);
|
||||||
@@ -953,7 +973,7 @@ const handleUploadPart = async (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tempPath = `/tmp/teleuploader-mp-${nanoid()}`;
|
const tempPath = `/tmp/filedrop-mp-${nanoid()}`;
|
||||||
await Bun.write(tempPath, buffer);
|
await Bun.write(tempPath, buffer);
|
||||||
|
|
||||||
const forwardResult = await forwardToStorage(
|
const forwardResult = await forwardToStorage(
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ export const handleSwaggerJson = async (): Promise<Response> => {
|
|||||||
const spec = {
|
const spec = {
|
||||||
openapi: '3.0.0',
|
openapi: '3.0.0',
|
||||||
info: {
|
info: {
|
||||||
title: 'TeleUploader API',
|
title: 'FileDrop API',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
description: 'Telegram-backed file uploader API with stream-based downloads.',
|
description: 'File upload API with stream-based downloads.',
|
||||||
},
|
},
|
||||||
servers: [
|
servers: [
|
||||||
{
|
{
|
||||||
@@ -215,7 +215,7 @@ export const handleSwaggerHtml = async (): Promise<Response> => {
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>TeleUploader API Documentation</title>
|
<title>FileDrop API Documentation</title>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.17.14/swagger-ui.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.17.14/swagger-ui.css">
|
||||||
<style>
|
<style>
|
||||||
html { box-sizing: border-box; overflow-y: scroll; }
|
html { box-sizing: border-box; overflow-y: scroll; }
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ const rejectOversizedRequest = (req: Request): Response | null => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const streamFileToTemp = async (file: File, maxSizeBytes: number): Promise<PreparedUpload> => {
|
const streamFileToTemp = async (file: File, maxSizeBytes: number): Promise<PreparedUpload> => {
|
||||||
const tempPath = `/tmp/teleuploader-${nanoid()}`;
|
const tempPath = `/tmp/filedrop-${nanoid()}`;
|
||||||
const writer = createWriteStream(tempPath);
|
const writer = createWriteStream(tempPath);
|
||||||
const hasher = new Bun.CryptoHasher('sha256');
|
const hasher = new Bun.CryptoHasher('sha256');
|
||||||
const reader = file.stream().getReader();
|
const reader = file.stream().getReader();
|
||||||
@@ -123,7 +123,7 @@ const streamFileToTemp = async (file: File, maxSizeBytes: number): Promise<Prepa
|
|||||||
};
|
};
|
||||||
|
|
||||||
const writeBufferToTemp = async (fileBuffer: Buffer, fileHash: string): Promise<PreparedUpload> => {
|
const writeBufferToTemp = async (fileBuffer: Buffer, fileHash: string): Promise<PreparedUpload> => {
|
||||||
const tempPath = `/tmp/teleuploader-${nanoid()}`;
|
const tempPath = `/tmp/filedrop-${nanoid()}`;
|
||||||
try {
|
try {
|
||||||
await Bun.write(tempPath, fileBuffer);
|
await Bun.write(tempPath, fileBuffer);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export const handleUploadObjectV1 = async (
|
|||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
const hash = computeHash(buffer);
|
const hash = computeHash(buffer);
|
||||||
|
|
||||||
const tempPath = `/tmp/teleuploader-web-${nanoid()}`;
|
const tempPath = `/tmp/filedrop-web-${nanoid()}`;
|
||||||
await Bun.write(tempPath, buffer);
|
await Bun.write(tempPath, buffer);
|
||||||
|
|
||||||
const signatureBuffer = buffer.subarray(0, 16);
|
const signatureBuffer = buffer.subarray(0, 16);
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
import { config } from '../env';
|
||||||
|
|
||||||
|
const ADMIN_USERNAME = 'admin';
|
||||||
|
const SIGNATURE_SEPARATOR = '.';
|
||||||
|
|
||||||
|
type Handler = (req: Request) => Response | Promise<Response>;
|
||||||
|
|
||||||
|
export interface AuthSession {
|
||||||
|
username: string;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
method: 'cookie' | 'bearer';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CookieOptions {
|
||||||
|
secret?: string;
|
||||||
|
cookieName?: string;
|
||||||
|
maxAgeMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionPayload {
|
||||||
|
u: string;
|
||||||
|
e: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSecret = (secret?: string): string => secret ?? config.adminApiToken;
|
||||||
|
const getCookieName = (cookieName?: string): string => cookieName ?? config.sessionCookieName;
|
||||||
|
const getMaxAgeMs = (maxAgeMs?: number): number => maxAgeMs ?? config.sessionMaxAgeMs;
|
||||||
|
|
||||||
|
const encodePayload = (value: string): string => Buffer.from(value, 'utf8').toString('base64url');
|
||||||
|
|
||||||
|
const decodePayload = (value: string): string | null => {
|
||||||
|
try {
|
||||||
|
return Buffer.from(value, 'base64url').toString('utf8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isAuthEnabled = (secret = config.adminApiToken): boolean => secret.length > 0;
|
||||||
|
|
||||||
|
export const timingSafeCompare = (left: string, right: string): boolean => {
|
||||||
|
const leftBuffer = Buffer.from(left);
|
||||||
|
const rightBuffer = Buffer.from(right);
|
||||||
|
|
||||||
|
if (leftBuffer.length !== rightBuffer.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return timingSafeEqual(leftBuffer, rightBuffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const signCookiePayload = (payload: string, secret: string): string =>
|
||||||
|
createHmac('sha256', secret).update(payload).digest('base64url');
|
||||||
|
|
||||||
|
export const verifyCookieSignature = (cookieValue: string, secret: string): string | null => {
|
||||||
|
const separatorIndex = cookieValue.lastIndexOf(SIGNATURE_SEPARATOR);
|
||||||
|
if (separatorIndex <= 0 || separatorIndex === cookieValue.length - 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = cookieValue.slice(0, separatorIndex);
|
||||||
|
const signature = cookieValue.slice(separatorIndex + 1);
|
||||||
|
const expectedSignature = signCookiePayload(payload, secret);
|
||||||
|
|
||||||
|
if (!timingSafeCompare(signature, expectedSignature)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cookieAttributes = (maxAgeSeconds: number): string =>
|
||||||
|
[`Max-Age=${maxAgeSeconds}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Secure'].join('; ');
|
||||||
|
|
||||||
|
export const createSessionCookie = (
|
||||||
|
username = ADMIN_USERNAME,
|
||||||
|
options: CookieOptions = {},
|
||||||
|
): string => {
|
||||||
|
const secret = getSecret(options.secret);
|
||||||
|
const cookieName = getCookieName(options.cookieName);
|
||||||
|
const maxAgeMs = getMaxAgeMs(options.maxAgeMs);
|
||||||
|
const expiresAt = Date.now() + maxAgeMs;
|
||||||
|
const payload = encodePayload(
|
||||||
|
JSON.stringify({ u: username, e: expiresAt } satisfies SessionPayload),
|
||||||
|
);
|
||||||
|
const signature = signCookiePayload(payload, secret);
|
||||||
|
const maxAgeSeconds = Math.max(1, Math.floor(maxAgeMs / 1000));
|
||||||
|
|
||||||
|
return `${cookieName}=${payload}${SIGNATURE_SEPARATOR}${signature}; ${cookieAttributes(maxAgeSeconds)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearSessionCookie = (cookieName = config.sessionCookieName): string =>
|
||||||
|
`${cookieName}=; ${cookieAttributes(0)}`;
|
||||||
|
|
||||||
|
const findCookieValue = (cookieHeader: string | null, cookieName: string): string | null => {
|
||||||
|
if (!cookieHeader) return null;
|
||||||
|
|
||||||
|
for (const rawCookie of cookieHeader.split(';')) {
|
||||||
|
const cookie = rawCookie.trim();
|
||||||
|
const equalsIndex = cookie.indexOf('=');
|
||||||
|
if (equalsIndex <= 0) continue;
|
||||||
|
|
||||||
|
const name = cookie.slice(0, equalsIndex);
|
||||||
|
if (name === cookieName) {
|
||||||
|
return cookie.slice(equalsIndex + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseSessionFromCookie = (
|
||||||
|
cookieHeader: string | null,
|
||||||
|
options: Pick<CookieOptions, 'secret' | 'cookieName'> = {},
|
||||||
|
): AuthSession | null => {
|
||||||
|
const secret = getSecret(options.secret);
|
||||||
|
const cookieName = getCookieName(options.cookieName);
|
||||||
|
if (!isAuthEnabled(secret)) return null;
|
||||||
|
|
||||||
|
const cookieValue = findCookieValue(cookieHeader, cookieName);
|
||||||
|
if (!cookieValue) return null;
|
||||||
|
|
||||||
|
const encodedPayload = verifyCookieSignature(cookieValue, secret);
|
||||||
|
if (!encodedPayload) return null;
|
||||||
|
|
||||||
|
const rawPayload = decodePayload(encodedPayload);
|
||||||
|
if (!rawPayload) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(rawPayload) as Partial<SessionPayload>;
|
||||||
|
if (payload.u !== ADMIN_USERNAME || typeof payload.e !== 'number') return null;
|
||||||
|
if (!Number.isFinite(payload.e) || payload.e <= Date.now()) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: payload.u,
|
||||||
|
expiresAt: new Date(payload.e),
|
||||||
|
method: 'cookie',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkBearerToken = (
|
||||||
|
authorizationHeader: string | null,
|
||||||
|
secret = config.adminApiToken,
|
||||||
|
): boolean => {
|
||||||
|
if (!isAuthEnabled(secret) || !authorizationHeader) return false;
|
||||||
|
|
||||||
|
const [scheme, ...rest] = authorizationHeader.split(' ');
|
||||||
|
if (scheme !== 'Bearer' || rest.length === 0) return false;
|
||||||
|
|
||||||
|
const token = rest.join(' ').trim();
|
||||||
|
return token.length > 0 && timingSafeCompare(token, secret);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAuthSession = (
|
||||||
|
req: Request,
|
||||||
|
options: Pick<CookieOptions, 'secret' | 'cookieName'> = {},
|
||||||
|
): AuthSession | null => {
|
||||||
|
const secret = getSecret(options.secret);
|
||||||
|
if (!isAuthEnabled(secret)) {
|
||||||
|
return {
|
||||||
|
username: ADMIN_USERNAME,
|
||||||
|
expiresAt: null,
|
||||||
|
method: 'bearer',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieSession = parseSessionFromCookie(req.headers.get('cookie'), options);
|
||||||
|
if (cookieSession) return cookieSession;
|
||||||
|
|
||||||
|
if (checkBearerToken(req.headers.get('authorization'), secret)) {
|
||||||
|
return {
|
||||||
|
username: ADMIN_USERNAME,
|
||||||
|
expiresAt: null,
|
||||||
|
method: 'bearer',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unauthorizedResponse = (): Response =>
|
||||||
|
Response.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
export const requireAuth = (
|
||||||
|
handler: Handler,
|
||||||
|
options: Pick<CookieOptions, 'secret' | 'cookieName'> = {},
|
||||||
|
): ((req: Request) => Promise<Response>) => {
|
||||||
|
return async (req: Request): Promise<Response> => {
|
||||||
|
const secret = getSecret(options.secret);
|
||||||
|
if (!isAuthEnabled(secret)) {
|
||||||
|
return handler(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = getAuthSession(req, options);
|
||||||
|
if (!session) {
|
||||||
|
return unauthorizedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler(req);
|
||||||
|
};
|
||||||
|
};
|
||||||
+1
-1
@@ -7,7 +7,7 @@ const logger = winston.createLogger({
|
|||||||
winston.format.errors({ stack: true }),
|
winston.format.errors({ stack: true }),
|
||||||
winston.format.json(),
|
winston.format.json(),
|
||||||
),
|
),
|
||||||
defaultMeta: { service: 'teleuploader' },
|
defaultMeta: { service: 'filedrop' },
|
||||||
transports: [
|
transports: [
|
||||||
// Write all logs including error logs to file
|
// Write all logs including error logs to file
|
||||||
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
|
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export const listBucketsXml = (
|
|||||||
</Buckets>
|
</Buckets>
|
||||||
</ListAllMyBucketsResult>`;
|
</ListAllMyBucketsResult>`;
|
||||||
|
|
||||||
|
export const bucketVersioningConfigurationXml =
|
||||||
|
(): string => `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"/>`;
|
||||||
|
|
||||||
// ─────── Object listing ───────
|
// ─────── Object listing ───────
|
||||||
|
|
||||||
export const listBucketResultXml = (
|
export const listBucketResultXml = (
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ const flushUploads = async (): Promise<void> => {
|
|||||||
batch.map((item) => ({ tempPath: item.prepared.tempPath, fileName: item.fileName })),
|
batch.map((item) => ({ tempPath: item.prepared.tempPath, fileName: item.fileName })),
|
||||||
);
|
);
|
||||||
zipTempPath = zip.tempPath;
|
zipTempPath = zip.tempPath;
|
||||||
const archiveFileName = `teleuploader-${nanoid()}.zip`;
|
const archiveFileName = `filedrop-${nanoid()}.zip`;
|
||||||
const archiveResult = await forwardToStorage(
|
const archiveResult = await forwardToStorage(
|
||||||
createReadStream(zip.tempPath),
|
createReadStream(zip.tempPath),
|
||||||
archiveFileName,
|
archiveFileName,
|
||||||
|
|||||||
+1
-1
@@ -107,7 +107,7 @@ const calculateFileCrc32 = async (tempPath: string): Promise<number> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createZip = async (files: ZipInputFile[]): Promise<CreatedZip> => {
|
export const createZip = async (files: ZipInputFile[]): Promise<CreatedZip> => {
|
||||||
const tempPath = `/tmp/teleuploader-${nanoid()}.zip`;
|
const tempPath = `/tmp/filedrop-${nanoid()}.zip`;
|
||||||
const writer = createWriteStream(tempPath);
|
const writer = createWriteStream(tempPath);
|
||||||
const hasher = new Bun.CryptoHasher('sha256');
|
const hasher = new Bun.CryptoHasher('sha256');
|
||||||
const entries: ZipEntry[] = [];
|
const entries: ZipEntry[] = [];
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
const defaultEnv = (key: string, value: string) => {
|
||||||
|
process.env[key] ||= value;
|
||||||
|
};
|
||||||
|
const setEnv = (key: string, value: string) => {
|
||||||
|
process.env[key] = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
defaultEnv('BOT_TOKEN', '123456:ABC-DEF');
|
||||||
|
defaultEnv('STORAGE_CHANNEL_ID', '-1001234567890');
|
||||||
|
defaultEnv('BASE_URL', 'https://example.com');
|
||||||
|
defaultEnv('DATABASE_URL', 'postgresql://user:pass@localhost:5432/test');
|
||||||
|
defaultEnv('PORT', '3000');
|
||||||
|
defaultEnv('NODE_ENV', 'test');
|
||||||
|
setEnv('ADMIN_API_TOKEN', 'route-secret-token');
|
||||||
|
setEnv('SESSION_COOKIE_NAME', 'route_session');
|
||||||
|
setEnv('SESSION_COOKIE_MAX_AGE_SECONDS', '3600');
|
||||||
|
|
||||||
|
const { createSessionCookie } = await import('../src/utils/auth');
|
||||||
|
const { handleLogin, handleLogout, handleMe } = await import('../src/routes/auth');
|
||||||
|
|
||||||
|
const jsonBody = async <T>(res: Response): Promise<T> => (await res.json()) as T;
|
||||||
|
|
||||||
|
describe('auth routes', () => {
|
||||||
|
it('logs in with the configured token and sets a session cookie', async () => {
|
||||||
|
const res = await handleLogin(
|
||||||
|
new Request('http://localhost/api/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: 'route-secret-token' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await jsonBody<{ username: string }>(res)).toEqual({ username: 'admin' });
|
||||||
|
expect(res.headers.get('set-cookie')).toContain('route_session=');
|
||||||
|
expect(res.headers.get('set-cookie')).toContain('HttpOnly');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a wrong login token', async () => {
|
||||||
|
const res = await handleLogin(
|
||||||
|
new Request('http://localhost/api/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: 'wrong' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(await jsonBody<{ error: string }>(res)).toEqual({ error: 'Invalid token' });
|
||||||
|
expect(res.headers.get('set-cookie')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing or invalid login body', async () => {
|
||||||
|
const res = await handleLogin(
|
||||||
|
new Request('http://localhost/api/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(await jsonBody<{ error: string }>(res)).toEqual({ error: 'Token is required' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the current user from a valid session cookie', async () => {
|
||||||
|
const setCookie = createSessionCookie('admin');
|
||||||
|
const cookieHeader = setCookie.split(';')[0];
|
||||||
|
|
||||||
|
const res = await handleMe(
|
||||||
|
new Request('http://localhost/api/v1/auth/me', {
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = await jsonBody<{ username: string; expiresAt: string | null }>(res);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(body.username).toBe('admin');
|
||||||
|
if (typeof body.expiresAt !== 'string') throw new Error('Expected expiresAt string');
|
||||||
|
expect(new Date(body.expiresAt).getTime()).toBeGreaterThan(Date.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the current user from a bearer token', async () => {
|
||||||
|
const res = await handleMe(
|
||||||
|
new Request('http://localhost/api/v1/auth/me', {
|
||||||
|
headers: { authorization: 'Bearer route-secret-token' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await jsonBody<{ username: string; expiresAt: null }>(res)).toEqual({
|
||||||
|
username: 'admin',
|
||||||
|
expiresAt: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing and tampered sessions', async () => {
|
||||||
|
const missing = await handleMe(new Request('http://localhost/api/v1/auth/me'));
|
||||||
|
expect(missing.status).toBe(401);
|
||||||
|
|
||||||
|
const validCookie = createSessionCookie('admin').split(';')[0];
|
||||||
|
const tamperedCookie = `${validCookie}x`;
|
||||||
|
const tampered = await handleMe(
|
||||||
|
new Request('http://localhost/api/v1/auth/me', {
|
||||||
|
headers: { cookie: tamperedCookie },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tampered.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the session on logout', async () => {
|
||||||
|
const res = await handleLogout();
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await jsonBody<{ success: boolean }>(res)).toEqual({ success: true });
|
||||||
|
expect(res.headers.get('set-cookie')).toContain('route_session=;');
|
||||||
|
expect(res.headers.get('set-cookie')).toContain('Max-Age=0');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
const defaultEnv = (key: string, value: string) => {
|
||||||
|
process.env[key] ||= value;
|
||||||
|
};
|
||||||
|
const setEnv = (key: string, value: string) => {
|
||||||
|
process.env[key] = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
defaultEnv('BOT_TOKEN', '123456:ABC-DEF');
|
||||||
|
defaultEnv('STORAGE_CHANNEL_ID', '-1001234567890');
|
||||||
|
defaultEnv('BASE_URL', 'https://example.com');
|
||||||
|
defaultEnv('DATABASE_URL', 'postgresql://user:pass@localhost:5432/test');
|
||||||
|
defaultEnv('PORT', '3000');
|
||||||
|
defaultEnv('NODE_ENV', 'test');
|
||||||
|
setEnv('ADMIN_API_TOKEN', 'route-secret-token');
|
||||||
|
setEnv('SESSION_COOKIE_NAME', 'route_session');
|
||||||
|
setEnv('SESSION_COOKIE_MAX_AGE_SECONDS', '3600');
|
||||||
|
|
||||||
|
const auth = await import('../src/utils/auth');
|
||||||
|
|
||||||
|
describe('auth utilities', () => {
|
||||||
|
const secret = 'admin-secret-token';
|
||||||
|
const cookieName = 'test_session';
|
||||||
|
|
||||||
|
it('signs and verifies a cookie payload', () => {
|
||||||
|
const payload = Buffer.from(JSON.stringify({ u: 'admin', e: Date.now() + 60_000 })).toString(
|
||||||
|
'base64url',
|
||||||
|
);
|
||||||
|
const signature = auth.signCookiePayload(payload, secret);
|
||||||
|
|
||||||
|
expect(auth.verifyCookieSignature(`${payload}.${signature}`, secret)).toBe(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects tampered cookie signatures', () => {
|
||||||
|
const payload = Buffer.from(JSON.stringify({ u: 'admin', e: Date.now() + 60_000 })).toString(
|
||||||
|
'base64url',
|
||||||
|
);
|
||||||
|
const signature = auth.signCookiePayload(payload, secret);
|
||||||
|
|
||||||
|
expect(auth.verifyCookieSignature(`${payload}x.${signature}`, secret)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed cookie values', () => {
|
||||||
|
expect(auth.verifyCookieSignature('', secret)).toBeNull();
|
||||||
|
expect(auth.verifyCookieSignature('payload-only', secret)).toBeNull();
|
||||||
|
expect(auth.verifyCookieSignature('.signature', secret)).toBeNull();
|
||||||
|
expect(auth.verifyCookieSignature('payload.', secret)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a secure HttpOnly session cookie', () => {
|
||||||
|
const cookie = auth.createSessionCookie('admin', {
|
||||||
|
secret,
|
||||||
|
cookieName,
|
||||||
|
maxAgeMs: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cookie).toStartWith(`${cookieName}=`);
|
||||||
|
expect(cookie).toContain('Max-Age=60');
|
||||||
|
expect(cookie).toContain('Path=/');
|
||||||
|
expect(cookie).toContain('HttpOnly');
|
||||||
|
expect(cookie).toContain('SameSite=Lax');
|
||||||
|
expect(cookie).toContain('Secure');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears a session cookie', () => {
|
||||||
|
const cookie = auth.clearSessionCookie(cookieName);
|
||||||
|
|
||||||
|
expect(cookie).toStartWith(`${cookieName}=;`);
|
||||||
|
expect(cookie).toContain('Max-Age=0');
|
||||||
|
expect(cookie).toContain('HttpOnly');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a valid signed session cookie', () => {
|
||||||
|
const setCookie = auth.createSessionCookie('admin', {
|
||||||
|
secret,
|
||||||
|
cookieName,
|
||||||
|
maxAgeMs: 60_000,
|
||||||
|
});
|
||||||
|
const cookieHeader = setCookie.split(';')[0];
|
||||||
|
|
||||||
|
const session = auth.parseSessionFromCookie(cookieHeader, { secret, cookieName });
|
||||||
|
|
||||||
|
expect(session?.username).toBe('admin');
|
||||||
|
expect(session?.method).toBe('cookie');
|
||||||
|
expect(session?.expiresAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an expired session cookie', () => {
|
||||||
|
const expiredPayload = Buffer.from(
|
||||||
|
JSON.stringify({ u: 'admin', e: Date.now() - 1_000 }),
|
||||||
|
).toString('base64url');
|
||||||
|
const signature = auth.signCookiePayload(expiredPayload, secret);
|
||||||
|
|
||||||
|
const session = auth.parseSessionFromCookie(`${cookieName}=${expiredPayload}.${signature}`, {
|
||||||
|
secret,
|
||||||
|
cookieName,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects cookies with the wrong name or secret', () => {
|
||||||
|
const setCookie = auth.createSessionCookie('admin', {
|
||||||
|
secret,
|
||||||
|
cookieName,
|
||||||
|
maxAgeMs: 60_000,
|
||||||
|
});
|
||||||
|
const cookieHeader = setCookie.split(';')[0];
|
||||||
|
|
||||||
|
expect(auth.parseSessionFromCookie(cookieHeader, { secret, cookieName: 'other' })).toBeNull();
|
||||||
|
expect(auth.parseSessionFromCookie(cookieHeader, { secret: 'wrong', cookieName })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates bearer tokens', () => {
|
||||||
|
expect(auth.checkBearerToken(`Bearer ${secret}`, secret)).toBe(true);
|
||||||
|
expect(auth.checkBearerToken('Bearer wrong', secret)).toBe(false);
|
||||||
|
expect(auth.checkBearerToken(secret, secret)).toBe(false);
|
||||||
|
expect(auth.checkBearerToken(null, secret)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses cookie and bearer auth for request sessions', () => {
|
||||||
|
const setCookie = auth.createSessionCookie('admin', {
|
||||||
|
secret,
|
||||||
|
cookieName,
|
||||||
|
maxAgeMs: 60_000,
|
||||||
|
});
|
||||||
|
const cookieRequest = new Request('http://localhost/api', {
|
||||||
|
headers: { cookie: setCookie.split(';')[0] },
|
||||||
|
});
|
||||||
|
const bearerRequest = new Request('http://localhost/api', {
|
||||||
|
headers: { authorization: `Bearer ${secret}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(auth.getAuthSession(cookieRequest, { secret, cookieName })?.method).toBe('cookie');
|
||||||
|
expect(auth.getAuthSession(bearerRequest, { secret, cookieName })?.method).toBe('bearer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows protected handlers when auth is disabled', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const handler = (_req: Request) => {
|
||||||
|
calls++;
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
};
|
||||||
|
const protectedHandler = auth.requireAuth(handler, { secret: '', cookieName });
|
||||||
|
|
||||||
|
const res = await protectedHandler(new Request('http://localhost/api'));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects protected handlers without a valid session', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const handler = (_req: Request) => {
|
||||||
|
calls++;
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
};
|
||||||
|
const protectedHandler = auth.requireAuth(handler, { secret, cookieName });
|
||||||
|
|
||||||
|
const res = await protectedHandler(new Request('http://localhost/api'));
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(await res.json()).toEqual({ error: 'Unauthorized' });
|
||||||
|
expect(calls).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes protected handlers with a bearer token', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const handler = (_req: Request) => {
|
||||||
|
calls++;
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
};
|
||||||
|
const protectedHandler = auth.requireAuth(handler, { secret, cookieName });
|
||||||
|
|
||||||
|
const res = await protectedHandler(
|
||||||
|
new Request('http://localhost/api', { headers: { authorization: `Bearer ${secret}` } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -45,6 +45,12 @@ mock.module('../src/routes/health', () => ({
|
|||||||
handleHealth: mock(),
|
handleHealth: mock(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
mock.module('../src/routes/auth', () => ({
|
||||||
|
handleLogin: mock(),
|
||||||
|
handleLogout: mock(),
|
||||||
|
handleMe: mock(),
|
||||||
|
}));
|
||||||
|
|
||||||
mock.module('../src/utils/rateLimit', () => ({
|
mock.module('../src/utils/rateLimit', () => ({
|
||||||
cleanupRateLimitCache: mock(),
|
cleanupRateLimitCache: mock(),
|
||||||
withRateLimit: <T extends Request>(
|
withRateLimit: <T extends Request>(
|
||||||
@@ -76,5 +82,9 @@ describe('Bootstrap Server', () => {
|
|||||||
expect(serveCallArgs.routes).toHaveProperty('/f/:public_id');
|
expect(serveCallArgs.routes).toHaveProperty('/f/:public_id');
|
||||||
expect(serveCallArgs.routes).toHaveProperty('/file/:public_id/info');
|
expect(serveCallArgs.routes).toHaveProperty('/file/:public_id/info');
|
||||||
expect(serveCallArgs.routes).toHaveProperty('/health');
|
expect(serveCallArgs.routes).toHaveProperty('/health');
|
||||||
|
expect(serveCallArgs.routes).toHaveProperty('/api/v1/auth/login');
|
||||||
|
expect(serveCallArgs.routes).toHaveProperty('/api/v1/auth/logout');
|
||||||
|
expect(serveCallArgs.routes).toHaveProperty('/api/v1/auth/me');
|
||||||
|
expect(serveCallArgs.routes).toHaveProperty('/api/v1/*');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ describe('Environment Variables Validation', () => {
|
|||||||
expect(config).toHaveProperty('logLevel');
|
expect(config).toHaveProperty('logLevel');
|
||||||
expect(config).toHaveProperty('rateLimitWindowMs');
|
expect(config).toHaveProperty('rateLimitWindowMs');
|
||||||
expect(config).toHaveProperty('rateLimitMaxRequests');
|
expect(config).toHaveProperty('rateLimitMaxRequests');
|
||||||
|
expect(config).toHaveProperty('adminApiToken');
|
||||||
|
expect(config).toHaveProperty('sessionCookieName');
|
||||||
|
expect(config).toHaveProperty('sessionMaxAgeMs');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('config.botToken should return BOT_TOKEN from process.env', () => {
|
it('config.botToken should return BOT_TOKEN from process.env', () => {
|
||||||
@@ -42,4 +45,10 @@ describe('Environment Variables Validation', () => {
|
|||||||
it('rateLimitMaxRequests should default to 150 when not specified', () => {
|
it('rateLimitMaxRequests should default to 150 when not specified', () => {
|
||||||
expect(config.rateLimitMaxRequests).toBe(150);
|
expect(config.rateLimitMaxRequests).toBe(150);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('auth config should be disabled by default with a 24 hour session', () => {
|
||||||
|
expect(config.adminApiToken).toBe(process.env.ADMIN_API_TOKEN || '');
|
||||||
|
expect(config.sessionCookieName).toBe(process.env.SESSION_COOKIE_NAME || 'tu_session');
|
||||||
|
expect(config.sessionMaxAgeMs).toBe(86400 * 1000);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Tests both the Web API (JSON v1) and S3 (SigV4 XML) interfaces.
|
* Tests both the Web API (JSON v1) and S3 (SigV4 XML) interfaces.
|
||||||
* Requires env vars:
|
* Requires env vars:
|
||||||
* - BASE_URL (default: https://upload.asepharyana.my.id)
|
* - BASE_URL (default: https://upload.asepharyana.my.id)
|
||||||
* - S3_ACCESS_KEY (default: teleuploader-admin)
|
* - S3_ACCESS_KEY (default: filedrop-admin)
|
||||||
* - S3_SECRET_KEY (required for S3 tests)
|
* - S3_SECRET_KEY (required for S3 tests)
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
@@ -15,7 +15,7 @@ import { afterAll, describe, expect, it } from 'bun:test';
|
|||||||
|
|
||||||
// ── Config ───────────────────────────────────────────────────────────────────
|
// ── Config ───────────────────────────────────────────────────────────────────
|
||||||
const BASE_URL = process.env.BASE_URL || 'https://upload.asepharyana.my.id';
|
const BASE_URL = process.env.BASE_URL || 'https://upload.asepharyana.my.id';
|
||||||
const S3_KEY = process.env.S3_ACCESS_KEY || 'teleuploader-admin';
|
const S3_KEY = process.env.S3_ACCESS_KEY || 'filedrop-admin';
|
||||||
const S3_SECRET = process.env.S3_SECRET_KEY || '';
|
const S3_SECRET = process.env.S3_SECRET_KEY || '';
|
||||||
|
|
||||||
const TS = Date.now().toString(36);
|
const TS = Date.now().toString(36);
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ describe('S3 Auth (SigV4)', () => {
|
|||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
it('verifies presigned GET using the public request host', async () => {
|
it('verifies presigned GET using the public request host', async () => {
|
||||||
const accessKey = 'teleuploader-admin';
|
const accessKey = 'filedrop-admin';
|
||||||
const secret = 'unit-test-secret';
|
const secret = 'unit-test-secret';
|
||||||
const host = 'upload.example.test';
|
const host = 'upload.example.test';
|
||||||
const path = '/bucket/key.txt';
|
const path = '/bucket/key.txt';
|
||||||
@@ -152,10 +152,10 @@ describe('S3 Auth (SigV4)', () => {
|
|||||||
|
|
||||||
it('rejects presigned URLs signed for a different host', async () => {
|
it('rejects presigned URLs signed for a different host', async () => {
|
||||||
const result = await verifyPresignedUrl({
|
const result = await verifyPresignedUrl({
|
||||||
url: 'https://wrong.example.test/bucket/key.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=teleuploader-admin%2F20260707%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260707T120000Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=00',
|
url: 'https://wrong.example.test/bucket/key.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=filedrop-admin%2F20260707%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260707T120000Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=00',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { host: 'upload.example.test' },
|
headers: { host: 'upload.example.test' },
|
||||||
s3AccessKey: 'teleuploader-admin',
|
s3AccessKey: 'filedrop-admin',
|
||||||
s3SecretKey: 'unit-test-secret',
|
s3SecretKey: 'unit-test-secret',
|
||||||
region: 'us-east-1',
|
region: 'us-east-1',
|
||||||
now: new Date('2026-07-07T12:05:00Z'),
|
now: new Date('2026-07-07T12:05:00Z'),
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
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:3000';
|
||||||
|
process.env.DATABASE_URL = 'postgresql://localhost/test';
|
||||||
|
process.env.PORT = '3000';
|
||||||
|
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/db/buckets', () => ({
|
||||||
|
createBucket: () => Promise.resolve(bucket),
|
||||||
|
deleteBucket: () => Promise.resolve(true),
|
||||||
|
findBucketByName: (name: string) => Promise.resolve(name === bucket.name ? bucket : null),
|
||||||
|
listBuckets: () => Promise.resolve([bucket]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../src/db/files-ext', () => ({
|
||||||
|
countBucketObjects: () => Promise.resolve(0),
|
||||||
|
findFileByBucketAndKey: () => Promise.resolve(null),
|
||||||
|
listObjectsByPrefix: () => Promise.resolve({ objects: [], prefixes: [] }),
|
||||||
|
softDeleteFile: () => Promise.resolve(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../src/db/multipart', () => ({
|
||||||
|
abortMultipartUpload: () => Promise.resolve(),
|
||||||
|
completeMultipartUpload: () => Promise.resolve(),
|
||||||
|
createMultipartUpload: () => Promise.resolve('upload-id'),
|
||||||
|
findMultipartUpload: () => Promise.resolve(null),
|
||||||
|
insertMultipartPart: () => Promise.resolve(),
|
||||||
|
listMultipartParts: () => Promise.resolve([]),
|
||||||
|
listMultipartUploadsByBucket: () =>
|
||||||
|
Promise.resolve({ uploads: [], isTruncated: false, nextKeyMarker: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../src/utils/chunked-storage', () => ({
|
||||||
|
createChunkedObjectResponse: () => Promise.resolve(new Response('')),
|
||||||
|
storeFileInTelegramChunks: () => Promise.resolve({ fileHash: 'hash' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../src/utils/s3/auth', () => ({
|
||||||
|
verifyPresignedUrl: () => Promise.resolve({ isValid: true }),
|
||||||
|
verifySignature: () => Promise.resolve({ isValid: true }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../src/utils/telegram', () => ({
|
||||||
|
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',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('S3 bucket configuration compatibility', () => {
|
||||||
|
let handleS3Request: typeof import('../src/routes/s3').handleS3Request;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ handleS3Request } = await import('../src/routes/s3'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns VersioningConfiguration for path-style GetBucketVersioning', async () => {
|
||||||
|
const res = await handleS3Request(new Request('http://localhost:3000/gitea?versioning'));
|
||||||
|
const body = await res.text();
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toContain('application/xml');
|
||||||
|
expect(body).toContain('<VersioningConfiguration');
|
||||||
|
expect(body).not.toContain('<ListBucketResult');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns VersioningConfiguration for virtual-hosted GetBucketVersioning', async () => {
|
||||||
|
const res = await handleS3Request(
|
||||||
|
new Request('http://gitea.localhost:3000/?versioning'),
|
||||||
|
'gitea',
|
||||||
|
);
|
||||||
|
const body = await res.text();
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(body).toContain('<VersioningConfiguration');
|
||||||
|
expect(body).not.toContain('<ListBucketResult');
|
||||||
|
});
|
||||||
|
});
|
||||||
+10
-3
@@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* S3 compatibility E2E tests using the official AWS SDK v3.
|
* S3 compatibility E2E tests using the official AWS SDK v3.
|
||||||
*
|
*
|
||||||
* Tests that the TeleUploader S3 gateway is compatible with standard
|
* Tests that the file storage gateway is compatible with standard
|
||||||
* AWS SDK clients. All operations are exercised against the production
|
* AWS SDK clients. All operations are exercised against the production
|
||||||
* endpoint.
|
* endpoint.
|
||||||
*
|
*
|
||||||
* Prerequisites (env vars):
|
* Prerequisites (env vars):
|
||||||
* - S3_ACCESS_KEY (default: teleuploader-admin)
|
* - S3_ACCESS_KEY (default: filedrop-admin)
|
||||||
* - S3_SECRET_KEY (required)
|
* - S3_SECRET_KEY (required)
|
||||||
* - BASE_URL (default: https://upload.asepharyana.my.id)
|
* - BASE_URL (default: https://upload.asepharyana.my.id)
|
||||||
*
|
*
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
DeleteBucketCommand,
|
DeleteBucketCommand,
|
||||||
DeleteObjectCommand,
|
DeleteObjectCommand,
|
||||||
DeleteObjectsCommand,
|
DeleteObjectsCommand,
|
||||||
|
GetBucketVersioningCommand,
|
||||||
GetObjectCommand,
|
GetObjectCommand,
|
||||||
HeadBucketCommand,
|
HeadBucketCommand,
|
||||||
HeadObjectCommand,
|
HeadObjectCommand,
|
||||||
@@ -42,7 +43,7 @@ import {
|
|||||||
|
|
||||||
// ── Config ───────────────────────────────────────────────────────────────────
|
// ── Config ───────────────────────────────────────────────────────────────────
|
||||||
const BASE_URL = process.env.BASE_URL || 'https://upload.asepharyana.my.id';
|
const BASE_URL = process.env.BASE_URL || 'https://upload.asepharyana.my.id';
|
||||||
const S3_KEY = process.env.S3_ACCESS_KEY || 'teleuploader-admin';
|
const S3_KEY = process.env.S3_ACCESS_KEY || 'filedrop-admin';
|
||||||
const S3_SECRET = process.env.S3_SECRET_KEY;
|
const S3_SECRET = process.env.S3_SECRET_KEY;
|
||||||
|
|
||||||
const TS = Date.now().toString(36);
|
const TS = Date.now().toString(36);
|
||||||
@@ -133,6 +134,12 @@ describe('S3 SDK compatibility', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('GetBucketVersioning returns disabled configuration for Gitea compatibility', async () => {
|
||||||
|
const versioning = await s3.send(new GetBucketVersioningCommand({ Bucket: BUCKET }));
|
||||||
|
expect(versioning.Status).toBeUndefined();
|
||||||
|
expect(versioning.MFADelete).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
// ── Object operations ──────────────────────────────────────────────────────
|
// ── Object operations ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('PutObject stores text content', async () => {
|
it('PutObject stores text content', async () => {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ describe('Swagger Documentation Endpoints', () => {
|
|||||||
paths: Record<string, { get?: object; post?: object }>;
|
paths: Record<string, { get?: object; post?: object }>;
|
||||||
};
|
};
|
||||||
expect(body.openapi).toBe('3.0.0');
|
expect(body.openapi).toBe('3.0.0');
|
||||||
expect(body.info.title).toBe('TeleUploader API');
|
expect(body.info.title).toBe('FileDrop API');
|
||||||
expect(body.paths).toHaveProperty('/health');
|
expect(body.paths).toHaveProperty('/health');
|
||||||
expect(body.paths).toHaveProperty('/api/upload');
|
expect(body.paths).toHaveProperty('/api/upload');
|
||||||
expect(body.paths).toHaveProperty('/f/{public_id}');
|
expect(body.paths).toHaveProperty('/f/{public_id}');
|
||||||
|
|||||||
+2
-2
@@ -14,8 +14,8 @@ const cleanup = async (...paths: string[]) => {
|
|||||||
|
|
||||||
describe('ZIP utilities', () => {
|
describe('ZIP utilities', () => {
|
||||||
it('should create a zip and extract entries by name', async () => {
|
it('should create a zip and extract entries by name', async () => {
|
||||||
const firstPath = `/tmp/teleuploader-test-${crypto.randomUUID()}-1.txt`;
|
const firstPath = `/tmp/filedrop-test-${crypto.randomUUID()}-1.txt`;
|
||||||
const secondPath = `/tmp/teleuploader-test-${crypto.randomUUID()}-2.txt`;
|
const secondPath = `/tmp/filedrop-test-${crypto.randomUUID()}-2.txt`;
|
||||||
await writeFile(firstPath, 'hello');
|
await writeFile(firstPath, 'hello');
|
||||||
await writeFile(secondPath, 'world');
|
await writeFile(secondPath, 'world');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user