From 340c12d671b01882edbb484794470468dca76b09 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 7 Jul 2026 19:58:15 +0700 Subject: [PATCH] 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 --- .env.example | 11 +- deploy.sh | 6 +- docker-compose.yml | 27 +++-- package.json | 4 +- src/env.ts | 10 +- src/home.html | 99 +++++++++++++++- src/index.ts | 21 +++- src/routes/auth.ts | 64 +++++++++++ src/routes/files.ts | 2 +- src/routes/s3.ts | 24 +++- src/routes/swagger.ts | 6 +- src/routes/upload.ts | 4 +- src/routes/web-api.ts | 2 +- src/utils/auth.ts | 205 ++++++++++++++++++++++++++++++++++ src/utils/logger.ts | 2 +- src/utils/s3/xml.ts | 4 + src/utils/uploadBatcher.ts | 2 +- src/utils/zip.ts | 2 +- test/auth-routes.test.ts | 122 ++++++++++++++++++++ test/auth.test.ts | 183 ++++++++++++++++++++++++++++++ test/bootstrap.test.ts | 10 ++ test/env.test.ts | 9 ++ test/production-e2e.test.ts | 4 +- test/s3-auth.test.ts | 6 +- test/s3-bucket-config.test.ts | 102 +++++++++++++++++ test/s3-sdk.test.ts | 13 ++- test/swagger.test.ts | 2 +- test/zip.test.ts | 4 +- 28 files changed, 898 insertions(+), 52 deletions(-) create mode 100644 src/routes/auth.ts create mode 100644 src/utils/auth.ts create mode 100644 test/auth-routes.test.ts create mode 100644 test/auth.test.ts create mode 100644 test/s3-bucket-config.test.ts diff --git a/.env.example b/.env.example index cab25c4..1ac9bcd 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,7 @@ RATE_LIMIT_MAX_REQUESTS=30 # TRUST_PROXY=true # Uncomment when behind reverse proxy (Traefik, Nginx) for correct client IP detection # S3-compatible API credentials -# S3_ACCESS_KEY=teleuploader-admin +# S3_ACCESS_KEY=filedrop-admin # S3_SECRET_KEY=your-secret-key-here # S3_DEFAULT_REGION=us-east-1 # S3_VHOST_DOMAINS=upload.asepharyana.my.id,upload.asepharyana.web.id @@ -19,4 +19,11 @@ RATE_LIMIT_MAX_REQUESTS=30 # Telegram-safe internal chunking for large stored files # TELEGRAM_CHUNK_SIZE_BYTES=20971520 # COMPRESS_CHUNKED_UPLOADS=true -# CHUNK_COMPRESSION_MIN_SIZE_BYTES=4096 \ No newline at end of file +# 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 \ No newline at end of file diff --git a/deploy.sh b/deploy.sh index 835ee0b..6538e6f 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,5 +1,5 @@ #!/bin/bash -# ─── TeleUploader Deploy Script ────────────────────────────────────────────── +# ─── FileDrop Deploy Script ────────────────────────────────────────────────── # 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, @@ -22,14 +22,14 @@ # VPS_SSH_KEY — path/contents of SSH private key # # 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) # ────────────────────────────────────────────────────────────────────────────── set -eu # ── Config ──────────────────────────────────────────────────────────────────── -APP_NAME="teleuploader" +APP_NAME="filedrop" GITLAB_PROJECT="superaseph%2FTeleUploader" DEPLOY_DIR="${DEPLOY_DIR:-/opt/${APP_NAME}}" COMPOSE_FILE="docker-compose.yml" diff --git a/docker-compose.yml b/docker-compose.yml index 929f44d..8e52258 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: app: build: . - container_name: teleuploader-app + container_name: filedrop-app restart: always environment: - BOT_TOKEN=${BOT_TOKEN} @@ -22,7 +22,10 @@ services: - CHUNK_COMPRESSION_MIN_SIZE_BYTES=${CHUNK_COMPRESSION_MIN_SIZE_BYTES:-4096} - RATE_LIMIT_WINDOW_MS=${RATE_LIMIT_WINDOW_MS:-60000} - 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_DEFAULT_REGION=${S3_DEFAULT_REGION:-us-east-1} - S3_VHOST_DOMAINS=${S3_VHOST_DOMAINS:-upload.asepharyana.my.id,asepharyana.web.id} @@ -52,16 +55,16 @@ services: - app-shared-net labels: - "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.teleuploader.entrypoints=websecure" - - "traefik.http.routers.teleuploader.tls=true" - - "traefik.http.routers.teleuploader.tls.certresolver=cloudflare" - - "traefik.http.services.teleuploader.loadbalancer.server.port=3000" - - "traefik.http.middlewares.teleuploader-rl.ratelimit.average=300" - - "traefik.http.middlewares.teleuploader-rl.ratelimit.burst=100" - - "traefik.http.middlewares.teleuploader-rl.ratelimit.period=1m" - - "traefik.http.middlewares.teleuploader-buf.buffering.maxRequestBodyBytes=2147483648" - - "traefik.http.routers.teleuploader.middlewares=teleuploader-rl@docker,teleuploader-buf@docker" + - "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.filedrop.entrypoints=websecure" + - "traefik.http.routers.filedrop.tls=true" + - "traefik.http.routers.filedrop.tls.certresolver=cloudflare" + - "traefik.http.services.filedrop.loadbalancer.server.port=3000" + - "traefik.http.middlewares.filedrop-rl.ratelimit.average=300" + - "traefik.http.middlewares.filedrop-rl.ratelimit.burst=100" + - "traefik.http.middlewares.filedrop-rl.ratelimit.period=1m" + - "traefik.http.middlewares.filedrop-buf.buffering.maxRequestBodyBytes=2147483648" + - "traefik.http.routers.filedrop.middlewares=filedrop-rl@docker,filedrop-buf@docker" networks: app-shared-net: diff --git a/package.json b/package.json index 14a0d1e..4b7f4de 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "teleuploader", + "name": "filedrop", "version": "1.0.0", "description": "Telegram file uploader backend", "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", "start": "NODE_ENV=production bun dist/index.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-ops": "bun test test/s3-operations.test.ts", "test:web-api": "bun test test/web-api.test.ts", diff --git a/src/env.ts b/src/env.ts index 43b6400..09d874c 100644 --- a/src/env.ts +++ b/src/env.ts @@ -19,6 +19,9 @@ interface AppConfig { telegramChunkSizeBytes: number; compressChunkedUploads: boolean; chunkCompressionMinSizeBytes: number; + adminApiToken: string; + sessionCookieName: string; + sessionMaxAgeMs: number; s3AccessKey: string; s3SecretKey: string; s3DefaultRegion: string; @@ -91,7 +94,10 @@ export const config: AppConfig = { telegramChunkSizeBytes: parseNumber(process.env.TELEGRAM_CHUNK_SIZE_BYTES, 20 * 1024 * 1024), compressChunkedUploads: process.env.COMPRESS_CHUNKED_UPLOADS !== 'false', chunkCompressionMinSizeBytes: parseNumber(process.env.CHUNK_COMPRESSION_MIN_SIZE_BYTES, 4096), - s3AccessKey: process.env.S3_ACCESS_KEY || 'teleuploader-admin', + adminApiToken: process.env.ADMIN_API_TOKEN || '', + sessionCookieName: process.env.SESSION_COOKIE_NAME || 'tu_session', + sessionMaxAgeMs: parseNumber(process.env.SESSION_COOKIE_MAX_AGE_SECONDS, 86400) * 1000, + s3AccessKey: process.env.S3_ACCESS_KEY || 'filedrop-admin', s3SecretKey: process.env.S3_SECRET_KEY || '', s3DefaultRegion: process.env.S3_DEFAULT_REGION || 'us-east-1', proxyS3Get: process.env.PROXY_S3_GET !== 'false', @@ -107,6 +113,8 @@ logger.info('Environment variables loaded', { botToken: maskSecret(config.botToken), additionalBotTokens: config.additionalBotTokens.map(maskSecret), databaseUrl: maskDatabaseUrl(config.databaseUrl), + adminApiToken: maskSecret(config.adminApiToken), + adminApiTokenEnabled: config.adminApiToken.length > 0, s3AccessKey: maskSecret(config.s3AccessKey), s3SecretKey: maskSecret(config.s3SecretKey), }, diff --git a/src/home.html b/src/home.html index ad6ab81..f807e81 100644 --- a/src/home.html +++ b/src/home.html @@ -3,7 +3,7 @@ - TeleUploader · S3 File Manager + FileDrop · S3 File Manager +
+
+

📦 FileDrop

+

Enter admin token to continue.

+ + + +
+
- + + diff --git a/src/index.ts b/src/index.ts index 90c9c50..83ead63 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { serve } from 'bun'; import { startBot } from './bot'; import { config } from './env'; +import { handleLogin, handleLogout, handleMe } from './routes/auth'; import { handleFileInfo, handleFileRedirect } from './routes/files'; import { handleHealth } from './routes/health'; import { handleHome } from './routes/home'; @@ -8,6 +9,7 @@ import { handleS3Request } from './routes/s3'; import { handleSwaggerHtml, handleSwaggerJson } from './routes/swagger'; import { handleUpload } from './routes/upload'; import { handleWebApiV1 } from './routes/web-api'; +import { requireAuth } from './utils/auth'; import { fileInfoCache } from './utils/cache'; import logger from './utils/logger'; import { metricsCollector } from './utils/metrics'; @@ -58,7 +60,7 @@ const server = serve({ port: config.port, routes: { '/api/upload': { - POST: withRateLimit(handleUpload), + POST: withRateLimit(requireAuth(handleUpload)), }, '/f/:public_id': { GET: withRateLimit(handleFileRedirect), @@ -89,11 +91,20 @@ const server = serve({ POST: handleMaybeS3Root, OPTIONS: handleMaybeS3Root, }, + '/api/v1/auth/login': { + POST: withRateLimit(handleLogin), + }, + '/api/v1/auth/logout': { + POST: handleLogout, + }, + '/api/v1/auth/me': { + GET: handleMe, + }, '/api/v1/*': { - GET: handleWebApiV1, - POST: handleWebApiV1, - DELETE: handleWebApiV1, - PUT: handleWebApiV1, + GET: requireAuth(handleWebApiV1), + POST: requireAuth(handleWebApiV1), + DELETE: requireAuth(handleWebApiV1), + PUT: requireAuth(handleWebApiV1), }, }, fetch: async (req: Request) => { diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..ba6b6c2 --- /dev/null +++ b/src/routes/auth.ts @@ -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 = {}): 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 => { + 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 => + json({ success: true }, 200, { + 'set-cookie': clearSessionCookie(), + }); + +export const handleMe = async (req: Request): Promise => { + 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, + }); +}; diff --git a/src/routes/files.ts b/src/routes/files.ts index ce41e04..05a8aa3 100644 --- a/src/routes/files.ts +++ b/src/routes/files.ts @@ -74,7 +74,7 @@ export const handleFileRedirect = async (req: RequestWithParams): Promise => { + const bucket = await findBucketByName(bucketName); + if (!bucket) { + return s3ErrorResponse( + 'NoSuchBucket', + 'The specified bucket does not exist.', + `/${bucketName}`, + 404, + reqId, + ); + } + return s3Response(bucketVersioningConfigurationXml(), 200, reqId, { + 'content-type': 'application/xml', + }); +}; + // ─────── Object Operations ─────── const handleGetObject = async ( @@ -564,7 +584,7 @@ const storeFileToTelegram = async ( contentType: string, reqId: string, ): Promise => { - const tempPath = `/tmp/teleuploader-s3-${nanoid()}`; + const tempPath = `/tmp/filedrop-s3-${nanoid()}`; await Bun.write(tempPath, buffer); const signatureBuffer = buffer.subarray(0, 16); @@ -953,7 +973,7 @@ const handleUploadPart = async ( ); } - const tempPath = `/tmp/teleuploader-mp-${nanoid()}`; + const tempPath = `/tmp/filedrop-mp-${nanoid()}`; await Bun.write(tempPath, buffer); const forwardResult = await forwardToStorage( diff --git a/src/routes/swagger.ts b/src/routes/swagger.ts index 9195de1..0029121 100644 --- a/src/routes/swagger.ts +++ b/src/routes/swagger.ts @@ -49,9 +49,9 @@ export const handleSwaggerJson = async (): Promise => { const spec = { openapi: '3.0.0', info: { - title: 'TeleUploader API', + title: 'FileDrop API', version: '1.0.0', - description: 'Telegram-backed file uploader API with stream-based downloads.', + description: 'File upload API with stream-based downloads.', }, servers: [ { @@ -215,7 +215,7 @@ export const handleSwaggerHtml = async (): Promise => { - TeleUploader API Documentation + FileDrop API Documentation