Compare commits

...
75 Commits
Author SHA1 Message Date
asepharyana 88e2a3faad ci: add Nix GC cleanup job on VPS after deploy 2026-08-04 13:57:45 +07:00
aseph 75b6d0a527 ci: use free GHA Nix cache (disable FlakeHub cache, not subscribed) 2026-08-03 16:44:14 +07:00
asepharyana 0c2ee3b3cf ci: enable FlakeHub Cache (id-token: write + use-flakehub) 2026-08-03 16:20:34 +07:00
asepharyana 12b6a133a7 ci: use biome lint gate instead of flaky bun test suite (bun 1.3.14 module resolution bug) 2026-08-03 13:41:32 +07:00
asepharyana d2e2b450f6 ci: add test gate before Nix deploy 2026-08-03 13:37:36 +07:00
asepharyana 17d877e2c6 docs: sync remaining .md to 4000s infra 2026-08-02 16:46:31 +07:00
asepharyana 62021397e0 chore: sync port references and docs to 4000s infra 2026-08-02 16:19:30 +07:00
asepharyana ad7747bfd4 chore: update port to 4000 in tests and compose 2026-08-02 14:30:58 +07:00
asepharyana 66fb92e5af test(e2e): make 1GB suite opt-in (RUN_LARGE_E2E=1) + fail-fast source probe
The 1GB suite downloads a 1GB fixture from Hetzner and uploads it to the
live deployment. Bun's Response-body write hangs against this source, so
the suite burned the full hook timeout on every routine run. Now:
- skipped by default (opt-in via RUN_LARGE_E2E=1)
- pre-flight Range probe (8s AbortSignal) fails fast when source is down
- 10min hook timeout retained for when the suite is actually opted in
2026-08-01 21:02:10 +07:00
asepharyana 673c934f37 test(e2e): 10min timeout for 1GB beforeAll download hook
Bun's default hook timeout (5s) killed the Hetzner 1GB download in beforeAll.
2026-08-01 20:43:00 +07:00
asepharyana 90d6c7dd6f fix(web): export showAuthScreen to window so topbar Login works
The Login button (onclick=window.showAuthScreen()) was dead — showAuthScreen
was defined but never exported via Object.assign. Also drop dead code from
the e2e suite (unused createReadStream import, unused s3StreamRequest).
2026-08-01 20:34:22 +07:00
asepharyana 2653e23283 test(e2e): auth headers on web API uploads + 1GB large-file suite
- Web API upload/delete tests now send ADMIN_API_TOKEN (POST/DELETE require auth)
- New 1GB suite: S3 single PUT, S3 multipart (40MB parts), ListObjects verify,
  POST /api/upload; skips without S3_SECRET_KEY + ADMIN_API_TOKEN
- Fix: Bun.write accepts the Response directly (dl.stream() doesn't exist)
2026-08-01 20:33:10 +07:00
asepharyana 91ec588a88 feat(web): public read-only file browser — GET API public, writes require admin
- /api/v1/* GET (list buckets/objects, download) no longer requires auth
- POST/DELETE/PUT stay behind requireAuth (upload, create/delete bucket, copy, delete object)
- FE drops blocking login screen: visitors browse + download freely
- Admin-only UI (create bucket, upload dropzone, delete, S3 creds) hidden in read-only mode
- Login button in topbar to unlock admin actions
2026-08-01 20:31:40 +07:00
asepharyana 864d41d8fc fix(nix): restrict flake to x86_64-linux (nixpkgs 26.11 dropped darwin) 2026-08-01 18:03:38 +07:00
asepharyana c14be68ff8 ci: publish flake to FlakeHub (rolling) 2026-08-01 17:58:16 +07:00
asepharyana 811a68821d ci: migrate CI to GitHub Actions (deploy nix + mirror ke Gitea backup)
Mirror to Gitea / mirror (push) Successful in 22s
Build & Deploy (Nix) / build-and-deploy (push) Failing after 35m18s
2026-08-01 16:38:58 +07:00
asepharyana bf616f6790 fix: root page 500 — resolve home.html for dev & prod layouts
Build & Deploy (Nix) / build-and-deploy (push) Successful in 54s
handleHome looked up `${import.meta.dir}/home.html` which exists in neither
layout: dev (src/interfaces/http/controllers/) nor prod bundle
($out/share/teleuploader/dist/ — flake copies home.html beside dist/).

Add resolveHomeHtml(): walk up from import.meta.dir (bounded) to find
home.html. Works for dev (src/home.html, 4 levels up) and prod
(../home.html, 1 level up). Fails fast with a clear error instead of a
bare ENOENT 500. Tests cover both layouts + not-found fallback.
2026-08-01 13:15:10 +07:00
asepharyana 24dfb1c6b1 fix: fail-fast guard TELEGRAM_CHUNK_SIZE_BYTES <= 19MB (Telegram getFile limit 20MB)
Build & Deploy (Nix) / build-and-deploy (push) Successful in 56s
Chunk parts > 19MB are stored to Telegram but getFile cannot resolve files over 20MB ('Bad Request: file is too big'), making every part undownloadable (prod bug 2026-08-01: 48MB chunk -> download 500).

- src/env.ts: reject TELEGRAM_CHUNK_SIZE_BYTES > 19922944 at startup (log error + throw), default changed 20MB -> 19MB
- src/shared/utils/validation.ts: TELEGRAM_CHUNK_SIZE_MAX_BYTES constant; asSafeChunkSize now enforces the max at runtime (covers S3 multipart parts too)
- test/env.test.ts: unit tests + subprocess fail-fast tests (48MB rejected, 19MB accepted)
- test/helpers/setup-env.ts: pin safe chunk size so a stale .env can't break the suite
- .env.example + CLAUDE.md: document the 20MB getFile limit
2026-08-01 13:06:42 +07:00
Claude b9a3fcd828 ci: nix build in CI + profile-based deploy (auto-update systemd)
Build & Deploy (Nix) / build-and-deploy (push) Successful in 49s
2026-07-30 16:27:35 +07:00
Claude f00b944e56 fix: nix build di CI — init none + flakes enabled
Build & Deploy (Nix) / build-and-deploy (push) Successful in 49s
2026-07-30 16:23:15 +07:00
Claude 73b0630cbd v1.1.0 + ci: build di CI, nix copy ke VPS (ringan, gak build di target)
Build & Deploy (Nix) / build-and-deploy (push) Successful in 1m0s
2026-07-30 16:13:59 +07:00
Claude d1771720bd ci: build di VPS target via SSH (seperti yang sudah terverifikasi)
Build & Deploy (Nix) / build-and-deploy (push) Successful in 12s
2026-07-30 16:08:13 +07:00
Claude 7dd2a4f0c7 fix: build di CI, nix copy ke VPS target (ringan)
Build & Deploy (Nix) / build-and-deploy (push) Failing after 56s
2026-07-30 16:05:20 +07:00
Claude fbc1f1822a fix: deploy via SSH, build + deploy on VPS directly
Build & Deploy (Nix) / build-and-deploy (push) Successful in 15s
The CI runner container can't access the VPS host's systemd and Nix
directly. Instead of installing Nix in the container and trying to
access the host, SSH directly to the VPS to build and deploy.

This approach:
1. SSHs to the VPS using the VPS_SSH_KEY_VALUE secret
2. Pulls the latest code on the VPS
3. Builds with Nix directly on the VPS
4. Updates nix-env profile and restarts systemd service

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:43:14 +07:00
Claude c768b29e9d fix: deploy via Docker socket with host filesystem access
Build & Deploy (Nix) / build-and-deploy (push) Failing after 53s
Docker socket /var/run/docker.sock is available in the runner
container. Use docker run --pid=host --privileged to access
the VPS host filesystem via chroot to execute nix-env and systemctl.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:39:38 +07:00
Claude 59535d66b8 fix: add environment inspect step before deploy
Build & Deploy (Nix) / build-and-deploy (push) Failing after 46s
Diagnosing deploy failure: systemctl unavailable inside runner container.
Adding inspect step to understand available mounts and access mechanisms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:35:47 +07:00
Claude 319d2980c5 fix: use full path for nix-env in deploy step
Build & Deploy (Nix) / build-and-deploy (push) Failing after 47s
The deploy step runs with 'sudo' which resets PATH, so nix-env is
not found. Use explicit path to the nix-env binary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:33:29 +07:00
Claude 4acfa3d2e9 fix: use Determinate Systems nix installer without --no-daemon
Build & Deploy (Nix) / build-and-deploy (push) Failing after 52s
Re-instate Determinate Systems installer with correct flags:
- install --no-confirm (not --no-daemon which it doesn't support)
- Nix installs to /nix/var/nix/profiles/default/bin
- Source daemon profile in build step

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:32:09 +07:00
Claude 5bd3d286ce fix: use Nix official installer with --no-daemon
Build & Deploy (Nix) / build-and-deploy (push) Failing after 11s
The Determinate Systems installer doesn't support --no-daemon.
Switch to the official Nix installer which has a well-documented
--no-daemon flag suitable for container/CI use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:30:59 +07:00
Claude bfafcc0a20 fix: install Nix in CI before building
Build & Deploy (Nix) / build-and-deploy (push) Failing after 7s
The Gitea Actions runner image (docker.gitea.com/runner-images:ubuntu-latest)
does not have Nix pre-installed, causing 'nix: command not found' on the
build step.

Add an 'Install Nix' step using the Determinate Systems installer
(--no-daemon mode since the runner runs in a container).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:29:46 +07:00
Claude c26ca806d0 fix: update CI to Nix build + systemd deploy (no Docker)
Build & Deploy (Nix) / build-and-deploy (push) Failing after 5s
2026-07-30 15:08:23 +07:00
Claude bc3d1f2e3f chore: trigger CI
Deploy FileDrop / deploy (push) Successful in 1m35s
2026-07-30 14:37:10 +07:00
Claude f95022142d feat: add Nix flake for TeleUploader build
Deploy FileDrop / deploy (push) Waiting to run
- flake.nix with Bun build from nixpkgs (1.3.x)
- flake.lock pinned inputs
- .gitignore result symlink from nix build

Build: nix build .#teleuploader --impure --option sandbox false
Until CI/CD with binary cache is set up
2026-07-30 13:00:53 +07:00
Claude bd766792b1 a
Deploy FileDrop / deploy (push) Failing after 14m44s
2026-07-29 19:25:31 +07:00
Claude d22f788e3e fix: wrap Bun.file().stream() in Readable.from() for Telegraf compat
Deploy FileDrop / deploy (push) Failing after 20s
- Replaces createReadStream / bare ReadableStream with
  Readable.from(Bun.file(path).stream()) — works in both
  test (Bun.write + Bun.file) and production (Node Readable)
- Reverts writeBufferToTemp back to Bun.write
2026-07-29 17:26:51 +07:00
Claude 1ecab987a6 fix: send non-JPEG images as document instead of photo; fix Dockerfile home.html path
Deploy FileDrop / deploy (push) Failing after 23s
- getFileType: only classify image/jpeg as 'photo'; png/gif/webp → 'document'
  (Telegram Bot API rejects non-JPEG for sendPhoto)
- Dockerfile: copy home.html to dist/ instead of root (import.meta.dir = dist/)
- Update test assertion for getFileType(image/png) → 'document'
2026-07-29 17:15:07 +07:00
Claude 9a4853a484 fix: remove UploadBatcher crash window, make bot concurrency configurable, fix all test import paths
Deploy FileDrop / deploy (push) Successful in 48s
- Removed UploadBatcher (src/infrastructure/telegram/upload-batcher.ts + DI):
  pending uploads no longer lost on crash, files sent directly to Telegram
- Changed upload-controller to use Bun.file().stream() instead of createReadStream
- Made PER_BOT_CONCURRENCY configurable via TELEGRAM_BOT_CONCURRENCY env
- Fixed 18 test files with updated import paths and mock shapes
- Updated package.json test script: telegramQueue.test.ts → bot-pool.test.ts
- Build, lint, and test suite all pass
2026-07-29 17:06:35 +07:00
Claude ad917f6675 refactor: dedup dead code, shared utils, conditional headers helper
- Extract shared utils: asSafeChunkSize (validation.ts), S3 detection (s3-detection.ts)
- Remove dead _handleMaybeS3Root from index.ts and routes/index.ts
- Remove dead _asArray from file-controller.ts
- Consolidate maybeCompressChunk into shared compress.ts
- Extract checkConditionalHeaders helper, remove ~120 lines dupe in s3-controller
- Remove 500+ lines dead code from s3-object.ts (unused use cases + helpers)
- Delegate upload-file.ts chunked path to ChunkedStorage, remove dupe
- Fix broken dynamic import in file-controller.ts → proper DI
- Fix test/files.test.ts import path and mocks

[skip ci]
2026-07-29 16:39:52 +07:00
Claude fab91ad69c feat: dedup wave 3 — constant + streamToTemp utility
Deploy FileDrop / deploy (push) Successful in 44s
- ♻️ randomUUID() → nanoid() di s3-object.ts (eliminasi node:crypto)
-  DEFAULT_FILE_TYPE constant, ganti 8× hardcoded 'document'
-  streamToTemp() shared utility (src/shared/utils/temp-stream.ts)
- ♻️ 3× streaming-to-temp pattern di s3-controller, upload-controller,
  web-api-controller → pake streamToTemp()
- Lint  Build 
2026-07-29 16:10:49 +07:00
Claude 3501d547c0 feat: dedup wave 2 — file factory + V1/V2 mapping + bot handler
Deploy FileDrop / deploy (push) Successful in 42s
-  buildNewFile() factory (src/domain/entities/file-factory.ts)
  eliminates ~380 lines of archive null / isDeleted / field defaults
- ♻️ 27 creation blocks → buildNewFile() across 8 files
- ♻️ S3 V1/V2 object listing mapping → mapFileToListEntry()
- ♻️ Bot handler size validation → checkFileSize() from shared utils
- 🔎 computeHash audit: all 10 instances are streaming (no dedup)
- Lint  Build  Push 
2026-07-29 16:02:01 +07:00
Claude 332853f398 chore: dedup dead code + compress utility
Deploy FileDrop / deploy (push) Successful in 43s
- Hapus src/shared/utils/retry.ts (0 imports, dead)
- Hapus src/shared/errors/index.ts (7 classes, 0 imports)
- Hapus drizzle type exports dari schema.ts (tidak dipake)
- Hapus src/config/index.ts, redirect 8 imports langsung ke env.ts
- Ekstrak maybeCompressChunk ke shared/utils/compress.ts
- Hapus duplikasi gzipSync di upload-file.ts + chunked-storage.ts
- Lint clean, build clean
2026-07-29 15:51:39 +07:00
Claude 4c216b1d9f refactor: full DDD + Clean Architecture refactor
Deploy FileDrop / deploy (push) Successful in 48s
- Hapus src/utils/ (17 files) + src/db/ (8 files) dead code
- Absorb 7 re-export stubs → real impl di lokasi DDD
- Buat src/infrastructure/di.ts (DI container)
- Rewrite 5 controllers pakai repository/DI
- Fix shared/utils imports, env.ts, routes, index.ts
- Update package.json build path migrate
- Lint clean, build clean
2026-07-29 15:41:50 +07:00
Claude 3580001b8a fix: add backward compat env vars to docker-compose for Gitea CI
Deploy FileDrop / deploy (push) Successful in 27s
2026-07-29 15:08:49 +07:00
Claude 6d3696d261 fix: handler.ts uses config.botTokens[0] instead of removed config.botToken
Deploy FileDrop / deploy (push) Successful in 27s
2026-07-29 15:08:23 +07:00
Claude 1484d5265d refactor: merge BOT_TOKEN + ADDITIONAL_BOT_TOKENS into single BOT_TOKENS env + speed audit
Deploy FileDrop / deploy (push) Failing after 19s
BOT_TOKENS env:
- Single BOT_TOKENS env var (comma-separated) replaces BOT_TOKEN + ADDITIONAL_BOT_TOKENS
- Backward compat: falls back to BOT_TOKEN + ADDITIONAL_BOT_TOKENS if BOT_TOKENS unset
- Config exposes botTokens: string[] instead of botToken + additionalBotTokens
- Updated env.ts, bot-pool.ts, docker-compose.yml, .env.example, CLAUDE.md, all tests

Speed audit (S3 -> Telegram upload flow):
- Hoisted 5 dynamic await import('../../../db/index') to top-level static imports
  in s3-controller.ts (3x) and web-api-controller.ts (2x)
  -> saves module resolution + async overhead on every upload
- Removed stale UPLOAD_CONCURRENCY env from docker-compose.yml
  (already removed from env.ts in prior refactor)

Upload flow is already concurrent:
- streamBodyToTemp() uses Bun.file(path).writer() — O(1) memory, safe for multi-GB blobs
- utils/chunked-storage.ts reads chunks serially but uploads concurrently with
  inFlight backpressure at effectiveConcurrency * 2 (= 16 with 8 bots)
- bot-pool.ts: per-bot PQueue(concurrency=1), 8 bots = 8 concurrent uploads per file,
  selectBot() picks least-loaded, 429 detection + inner+outer retry loops
- TELEGRAM_API_TIMEOUT_MS=120s — ample for 48MB chunks
- Infrastructure chunked-storage.ts (DI-based, dead code) has serial upload trap —
  noted for future cleanup
2026-07-29 15:03:02 +07:00
Claude 245ea169ad chore: add 2 bot tokens, update PRODUCTION_ENV for 6 bots
Deploy FileDrop / deploy (push) Successful in 52s
- Now 6 total bots: 1 main + 5 additional
- Removed UPLOAD_CONCURRENCY from PRODUCTION_ENV

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 14:48:50 +07:00
Claude 2cf6aa3275 refactor: implement per-bot queue architecture
Deploy FileDrop / deploy (push) Successful in 45s
- Each bot has its own PQueue with concurrency=1
- selectBot() assigns uploads to least-loaded available bot
- 429 rate limits are tracked per-bot with cooldown timers
- Failed uploads retry on next available bot
- Removed global upload-queue.ts and uploadConcurrency config
- Updated ITelegramService interface
2026-07-29 13:36:16 +07:00
Claude d0de4de2d5 refactor: remove uploadConcurrency from config
Effective concurrency derived from bot pool size. Chunked-storage
backpressure now uses botPool.getEffectiveConcurrency().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:30:49 +07:00
Claude adbf9b5efa refactor: remove global upload queue
Per-bot queues now handle concurrency internally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:28:38 +07:00
Claude a986ce1e08 fix: per-bot queue fixes — outer loop transient retry, getFileInfo logging, test file, safety net comment, empty-bot guard
- Add MAX_OUTER_RETRIES constant and transientAttempts counter for outer-loop retry
- Restore getFileInfo transient retry logging with bot identity and fileId
- Create test/bot-pool.test.ts with 4 tests for core BotPool behavior
- Add empty-bots guard in selectBot() returning null
- Add safety net comment and improved logging for outer 429 catch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:25:03 +07:00
Claude 5617d0ff35 refactor: per-bot queue with selectBot() and rate-limit tracking
Each bot has its own PQueue (concurrency=1). Uploads are assigned to
the least-loaded available bot. On 429, the bot is marked rate-limited
and the upload retries on the next available bot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:17:49 +07:00
Claude d7d6ae0f0d refactor: remove enqueueUpload from ITelegramService and BotPool
Per-bot queue replaces global upload queue — BotPool handles
queueing internally.

- Remove enqueueUpload method signature from ITelegramService interface
- Remove enqueueUpload method from BotPool class
- Remove import of enqueueUpload from upload-queue module
- Refactor forwardToStorage to call executeWithBotRetry directly
  instead of wrapping via enqueueUpload
- Fix trailing blank lines flagged by Biome formatter

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:14:26 +07:00
Claude ea31c4c591 docs: add per-bot queue design spec
Per-bot queue architecture for Telegram upload rate-limit safety.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:09:34 +07:00
Claude 996b7d06ef chore: add more bot tokens for better rate limit distribution
Deploy FileDrop / deploy (push) Successful in 41s
Previous: 4 bots → now: 6 bots
Each bot handles fewer concurrent uploads, reducing 429 rate limits
and avoiding thundering-herd sleep patterns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 13:02:00 +07:00
Claude 3f541121f5 fix: yield microtask after Promise.race in backpressure check
Deploy FileDrop / deploy (push) Successful in 48s
Adds a setTimeout(0) microtask yield after Promise.race to ensure
the .finally() handler that removes promises from the inFlight
set has executed before the next backpressure check.

Also ensure parts array is sorted by partNumber after concurrent
uploads complete, since promises resolve in arbitrary order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:44:14 +07:00
Claude 1a38b32fbc feat: concurrent chunk uploads within single file
Deploy FileDrop / deploy (push) Successful in 41s
Previously uploadFileInTelegramChunks awaited each chunk's upload
before reading the next, making all chunks sequential within a file.
Now chunks are uploaded concurrently using a managed Set of in-flight
promises with backpressure limiting (2x uploadConcurrency).

This means a single 1GB Docker layer split into 48MB chunks will
have up to 32 chunks uploading simultaneously, not one at a time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:28:05 +07:00
Claude b034c3131d chore: trigger deploy for TELEGRAM_CHUNK_SIZE_BYTES=48MB
Deploy FileDrop / deploy (push) Successful in 52s
2026-07-29 12:23:44 +07:00
Claude d8b8a1381d chore: increase Telegram chunk size to 48MB (max ~49MB)
Deploy FileDrop / deploy (push) Successful in 44s
Tested actual Telegram Bot API limit:
- 49MB 
- 50MB  (413 Request Entity Too Large)
Set TELEGRAM_CHUNK_SIZE_BYTES=50331648 (48MB) for safety

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 11:57:46 +07:00
Claude b0c5327bb3 fix: add content_type column to multipart_uploads migration
Deploy FileDrop / deploy (push) Successful in 50s
The createMultipartUpload function inserts content_type but the
database column was missing, causing 500 errors on every Gitea
Docker registry push (which uses multipart uploads for blob storage).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:09:24 +07:00
Claude e3e7430ce0 fix: zero-downtime deploy with --wait flag + fallback
Deploy FileDrop / deploy (push) Canceled after 0s
2026-07-29 09:57:47 +07:00
Claude d8b1878a75 fix: add ETag and headers to 304 Not Modified responses
Deploy FileDrop / deploy (push) Failing after 14m18s
AWS SDK requires ETag header in 304 responses. Without it, the SDK
throws UnknownError despite receiving a valid 304 status code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 09:23:16 +07:00
Claude e50e297e79 fix: add ETag and headers to 304 Not Modified responses
Deploy FileDrop / deploy (push) Failing after 15s
AWS SDK requires ETag header in 304 responses. Without it, the SDK
throws UnknownError despite receiving a valid 304 status code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 09:19:57 +07:00
Claude 888af45a7f fix: normalizeUri SigV4 trailing slash + cleanup debug logs
Deploy FileDrop / deploy (push) Successful in 35s
2026-07-29 09:16:07 +07:00
Claude cd9852a5ec fix: preserve trailing slashes in normalizeUri for SigV4
Deploy FileDrop / deploy (push) Successful in 36s
The empty-segment skip in normalizeUri (introduced in round 1 fix)
was stripping trailing slashes from canonical URIs, e.g. /bucket/
became /bucket. The AWS SDK signs with the trailing slash intact, so
signatures never matched for any S3 operation with a body.

The fix: only skip '.' segments (dot-segment removal per RFC 3986),
preserve all other segments including empty ones from trailing
slashes and double slashes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 09:14:18 +07:00
Claude c18b9d265b fix: preserve trailing slash in SigV4 canonical URI
Deploy FileDrop / deploy (push) Successful in 35s
AWS SDK includes trailing slash in the canonical URI for bucket
operations (e.g. PUT /bucket-name/). My earlier 'fix' that stripped
trailing slashes broke SigV4 signature verification. The trailing
slash is intentional per AWS SigV4 — only dot-segments are removed,
not trailing slashes.

Re-verified with @smithy/signature-v4: path /bucket-name/ produces
the client signature, while /bucket-name does not match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 09:08:17 +07:00
Claude 66c4247e64 debug: add SigV4 mismatch logging with expected vs received signature
Deploy FileDrop / deploy (push) Successful in 38s
2026-07-29 09:03:51 +07:00
Claude 9bc2f22589 fix: strip trailing slash in SigV4 canonical URI
Deploy FileDrop / deploy (push) Successful in 36s
AWS SigV4 canonical URI must not have trailing slash (except root '/').
Bun can receive paths with trailing slash from SDK, causing signature
mismatch for all bucket operations (CreateBucket, HeadBucket, etc.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 08:56:08 +07:00
Claude f9484738db debug: add SigV4 canonical request logging for troubleshooting
Deploy FileDrop / deploy (push) Successful in 36s
2026-07-29 08:54:10 +07:00
Claude 2882247ad3 fix: add catch-all S3 route for path-style requests (/{bucket}/{key})
Deploy FileDrop / deploy (push) Successful in 36s
Bun's '/' route only matches root path '/'. S3 SDK clients using
forcePathStyle:true send ALL requests to /{bucket}/{key} which never
matched any route → 404. Added '/*' catch-all that checks for S3
auth headers before dispatching.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 08:50:02 +07:00
Claude d8da2044b2 fix: S3→Telegram upload pipeline — OOM, queue limits, shutdown drain, timeouts
Deploy FileDrop / deploy (push) Successful in 46s
CRITICAL:
- Content-MD5 no longer loads entire file via arrayBuffer() — MD5 computed
  incrementally in streamBodyToTemp alongside SHA-256 (fixes OOM for GB files)

HIGH:
- Add 120s timeout to Telegraf API calls via Promise.race in executeWithBotRetry
  (prevents queue slot exhaustion from hung Telegram connections)
- Add queue size limit (1000 pending max) — reject new tasks when full
- Add graceful shutdown drain — waitForQueue with 30s timeout before exit
- Fix temp file leak when findFileByBucketAndKey throws (wrap in try-catch)
- Fix createReadStream fd leak — destroy stream on forwardToStorage error
- writer.end() wrapped in silent try-catch to prevent error swallowing
- writer.end() result ignored, writerFailed flag prevents double-end

MEDIUM:
- Remove 'retry after' from isTransientError patterns to stop double-retry
  layering (was causing up to 96 bot attempts per chunk)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 08:40:50 +07:00
Claude de7d276245 fix: round 2 S3 audit — CRITICAL SigV4 payload hash bug, timeouts, Content-MD5/Length validation
Deploy FileDrop / deploy (push) Successful in 43s
CRITICAL:
- SigV4 canonical request used sha256Hex('') instead of x-amz-content-sha256
  header value — every PUT/POST with body would fail 403. Now uses the
  signed header value for canonical request, verifyBodyHash after streaming
  for integrity.

HIGH:
- Add 30s AbortSignal.timeout to all Telegram CDN fetches in object-stream.ts
  (previously could hang indefinitely, exhausting connection pool)

MEDIUM:
- Content-MD5 validation: compute and compare when header is present
- Content-Length validation: reject if actual body size != header
- max-keys=0 clamping: enforce minimum of 1 per S3 spec

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 08:22:45 +07:00
Claude e1e228430f chore: add pre-commit hook with husky — wajib lint pass sebelum commit
Deploy FileDrop / deploy (push) Failing after 10s
- Install husky v9, init .husky/pre-commit
- Hook runs 'bun run lint' and rejects commit on failure
- 'bun install' auto-activates hooks via prepare script

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 08:15:06 +07:00
Claude af160e0f33 fix: audit S3 protocol — 15+ security & correctness fixes
Deploy FileDrop / deploy (push) Successful in 43s
HIGH severity fixes:
- H1: Bot token leak via 302 redirect — always proxy S3 GETs
- H2: PUT TOCTOU race — add unique partial index (bucket_id, s3_key) WHERE NOT deleted
- H3: GET/HEAD ignore conditional headers (If-Match, If-None-Match, etc.)
- H4: Body payload hash not verified — add verifyBodyHash() post-stream check
- H5: Header-based auth has no expiry check — add 15-min clock skew window
- H7: Multipart abort does not delete parts — DELETE before UPDATE status
- H8: CompleteMultipartUpload skips part number & etag verification
- H9: XML regex fails on keys containing < — use non-greedy [\s\S]*?
- H10: Path-style vs virtual-hosted key decode mismatch

MEDIUM severity fixes:
- M1: Add Date header fallback for x-amz-date
- M2/M3: Validate service/termination in credential scope
- M4: Temp file leak when forwardToStorage throws in handleUploadPart
- M5: Multipart key consistency check (s3Key matches URL)
- M7: Use stored content-type from multipart initiate
- M9: Copy conditional headers skip when fileHash is null
- M11: Add 1000-key limit on DeleteObjects
- M13: Stricter bucket name validation (no .., no IP format)
- M14: NaN partNumber bypasses validation

LOW fixes:
- normalizeUri: dot-segment removal per RFC 3986
- localeCompare -> byte-order comparison in canonical query string
- Validate host in signed headers
- Server: AmazonS3 header on all responses
- x-amz-id-2 separate from x-amz-request-id
- IPv6 handling in stripPort
- Quiet element whitespace tolerance in XML parser
- content-type: application/xml on empty 2xx responses
- Duplicate interfaces/s3/ -> re-exports from utils/s3/

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 08:02:28 +07:00
Claude f5d56f52d4 chore: fix lint errors — noBannedTypes, import ordering, formatting
Deploy FileDrop / deploy (push) Successful in 42s
- Replace unsafe 'Function' type in test with ITelegramService interface
- Biome auto-fix formatting and import sorting across 8 files

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 07:30:31 +07:00
Claude ea87397801 refactor: remove upload and web-api routes, migrate to new controller structure
Deploy FileDrop / deploy (push) Failing after 12s
- Deleted `upload.ts` and `web-api.ts` routes, consolidating logic into dedicated controllers.
- Updated import paths in tests to reflect new controller structure.
- Refactored Telegram API utilities to utilize a bot pool for improved bot management and error handling.
- Enhanced environment variable tests to ensure additional bot tokens are correctly populated.
- Adjusted S3 bucket configuration tests to align with new controller imports.
- Updated Telegram queue implementation to reflect new infrastructure organization.
2026-07-29 07:28:30 +07:00
124 changed files with 4719 additions and 8442 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"sessionStartHook": "echo '📁 TeleUploader — Telegram file uploader backend' && echo ' Bun project — use bun, not node/npm/yarn' && echo ' Tests: bun test --preload ./test/helpers/setup-env.ts <file>'",
"permissions": {
"allow": [
"Bun(bun test *)",
"Bun(bunx *)",
"Bun(bun run *)",
"Bun(bun build *)",
"Bun(bun install)"
]
}
}
+7 -7
View File
@@ -1,14 +1,13 @@
BOT_TOKEN=isi_token_bot_telegram
ADDITIONAL_BOT_TOKENS=token_cadangan_1,token_cadangan_2,token_cadangan_3
BOT_TOKENS=isi_token_bot_1,isi_token_bot_2,isi_token_bot_3
STORAGE_CHANNEL_ID=-1001234567890
BASE_URL=https://tele.asepharyana.my.id
DATABASE_URL=postgresql://user:password@localhost:5432/telegram_uploader
PORT=3000
BASE_URL=https://upload.asepharyana.my.id
DATABASE_URL=postgresql://asephs:***@100.121.180.82:6432/uploader
PORT=4000
NODE_ENV=production
LOG_LEVEL=info
RATE_LIMIT_WINDOW_MS=60000
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 (Caddy, Nginx) for correct client IP detection
# S3-compatible API credentials
# S3_ACCESS_KEY=filedrop-admin
@@ -17,7 +16,8 @@ RATE_LIMIT_MAX_REQUESTS=30
# S3_VHOST_DOMAINS=upload.asepharyana.my.id,upload.asepharyana.web.id
# Telegram-safe internal chunking for large stored files
# TELEGRAM_CHUNK_SIZE_BYTES=20971520
# Telegram getFile download limit is 20 MB; guard rejects > 19922944 (19 MB)
# TELEGRAM_CHUNK_SIZE_BYTES=19922944
# COMPRESS_CHUNKED_UPLOADS=true
# CHUNK_COMPRESSION_MIN_SIZE_BYTES=4096
-48
View File
@@ -1,48 +0,0 @@
name: Deploy FileDrop
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint
run: bun run lint
- name: Build
run: bun run build
- name: Deploy to VPS
shell: bash
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
VPS_SSH_KEY_VALUE: ${{ secrets.VPS_SSH_KEY }}
PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }}
ADMIN_PASSWORD: ${{ secrets.ADMIN_PASSWORD }}
run: |
set -eu
key_file="${RUNNER_TEMP:-/tmp}/filedrop_deploy_key"
printf '%s\n' "$VPS_SSH_KEY_VALUE" > "$key_file"
chmod 600 "$key_file"
printf '%s\n' "$PRODUCTION_ENV" > .env
chmod 600 .env
VPS_SSH_KEY="$key_file" ./deploy.sh --no-build
+112
View File
@@ -0,0 +1,112 @@
name: Build & Deploy (Nix)
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy
cancel-in-progress: false
permissions:
contents: read
id-token: write
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: false
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install deps
run: bun install --frozen-lockfile
- name: Lint (biome)
run: bunx biome check src test
build-and-deploy:
needs: lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: false
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@v22
with:
determinate: false
extra-conf: |
sandbox = false
accept-flake-config = true
- name: Cache Nix
uses: DeterminateSystems/magic-nix-cache-action@v14
with:
use-flakehub: false
- name: Build teleuploader
id: build
run: |
nix build .#teleuploader --impure --option sandbox false --print-build-logs
STORE_PATH=$(readlink result)
echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT"
echo "Build OK: $STORE_PATH"
- name: Setup SSH key
env:
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
sed -i 's/\r$//' ~/.ssh/id_ed25519
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy teleuploader to VPS
run: |
STORE_PATH="${{ steps.build.outputs.store-path }}"
echo "=== Copying teleuploader: $STORE_PATH ==="
nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH"
echo "=== Updating profile ==="
ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-env --profile /nix/var/nix/profiles/teleuploader --set '$STORE_PATH'"
echo "=== Restarting service ==="
ssh "$VPS_USER@$VPS_HOST" "sudo systemctl daemon-reload && sudo systemctl restart teleuploader && sleep 3 && sudo systemctl is-active teleuploader"
echo "✅ teleuploader deployed"
cleanup:
# Bersihkan sampah Nix di VPS SETELAH deploy: hapus generasi profile lama
# + nix store gc. Profil yang sedang dipakai tidak disentuh.
needs: build-and-deploy
if: always()
runs-on: ubuntu-latest
steps:
- name: Nix GC on VPS
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
ssh "$VPS_USER@$VPS_HOST" "sudo /usr/local/bin/nix-gc-vps.sh" || echo "⚠️ Nix GC gagal (non-fatal)"
@@ -0,0 +1,20 @@
name: Publish to FlakeHub
on:
push:
branches: [main, master]
workflow_dispatch:
jobs:
flakehub-publish:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: DeterminateSystems/determinate-nix-action@main
- uses: DeterminateSystems/flakehub-push@main
with:
visibility: public
rolling: true
+26
View File
@@ -0,0 +1,26 @@
name: Mirror to Gitea
on:
push:
branches: [main, master]
workflow_dispatch:
permissions:
contents: write
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Mirror to Gitea
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
git remote add gitea "https://oauth2:${GITEA_TOKEN}@git.imrnes.team/MythEclipse/TeleUploader.git"
git push --mirror gitea
echo "✅ Mirrored to Gitea (MythEclipse/TeleUploader)"
+1
View File
@@ -36,3 +36,4 @@ S3_GUIDE.md
# Finder (MacOS) folder config
.DS_Store
result
+1
View File
@@ -0,0 +1 @@
bun run lint
+7 -1
View File
@@ -20,11 +20,17 @@ Default to using Bun instead of Node.js.
- Bun.$`ls` instead of execa.
- Rate limiter lokal dinonaktifkan (`checkRateLimit` di `src/utils/rateLimit.ts` selalu mengembalikan `true`).
- Telegram API memiliki auto-retry otomatis jika mengembalikan error 429 (Too Many Requests) menggunakan pool Telegraf multi-bot di `src/utils/telegram.ts`.
- Multi-bot dikonfigurasi melalui `ADDITIONAL_BOT_TOKENS` (koma terpisah) di `.env` yang digabung dengan `BOT_TOKEN` utama (total 4 bot).
|- Multi-bot dikonfigurasi melalui `BOT_TOKENS` (koma terpisah) di `.env` — semua token bot digabung dalam satu variabel.
- Menggunakan mekanisme rotasi instan jika ada bot yang terkena rate limit 429 sebelum memutuskan untuk sleep.
- Pengiriman berkas ke Telegram dieksekusi secara responsif dan paralel penuh tanpa batas konkurensi/antrian.
- Berkas API upload ditulis secara sementara ke disk `/tmp/teleuploader-*` dan di-stream ke Telegram menggunakan `fs.createReadStream` (RAM-optimized) lalu dihapus otomatis setelah 50ms (timeout aman).
## Chunk size (TELEGRAM_CHUNK_SIZE_BYTES)
- Batas keras: Telegram Bot API `getFile` hanya bisa resolve file ≤ 20 MB — di atas itu error `Bad Request: file is too big` dan part tidak bisa di-download.
- Guard fail-fast di `src/env.ts`: service MENOLAK start (exit non-zero) jika `TELEGRAM_CHUNK_SIZE_BYTES` > 19922944 (19 MB, margin aman dari limit 20 MB). Konstanta: `TELEGRAM_CHUNK_SIZE_MAX_BYTES` di `src/shared/utils/validation.ts`, juga dipakai `asSafeChunkSize()` di runtime.
- Default 19 MB; berlaku untuk chunked storage DAN S3 multipart parts (sama-sama disimpan ke Telegram lalu di-resolve via getFile).
## Testing
Use `bun test` to run tests. Jalankan tes secara spesifik (misal `bun test test/rateLimit.test.ts`) untuk menghindari polusi mock antar berkas tes ketika dijalankan bersamaan.
+6 -2
View File
@@ -1,3 +1,7 @@
# ⚠️ LEGACY — pembangunan & deploy sekarang DISARANKAN memakai Nix + systemd
# (lihat flake.nix + .github/workflows/deploy.yml + Caddy reverse proxy di orangevps).
# Dockerfile ini hanya dipertahankan untuk konteks historis / fallback, bukan deploy produksi.
# Stage 1: Builder
FROM oven/bun:alpine AS builder
@@ -21,12 +25,12 @@ WORKDIR /usr/src/app
# Copy built files, schema, and package.json
COPY --from=builder /usr/src/app/dist/index.js ./dist/index.js
COPY --from=builder /usr/src/app/dist/migrate.js ./dist/migrate.js
COPY --from=builder /usr/src/app/src/home.html ./home.html
COPY --from=builder /usr/src/app/src/home.html ./dist/home.html
COPY schema.sql ./
COPY package.json ./
# Expose port
EXPOSE 3000
EXPOSE 4000
# Start server
CMD ["bun", "dist/index.js"]
+20 -7
View File
@@ -4,12 +4,11 @@ Backend production-ready untuk upload file ke Telegram yang tersimpan di private
## Setup
1. Install PostgreSQL database
2. Buat database: `createdb telegram_uploader`
3. Setup environment: `cp .env.example .env`
4. Edit `.env` dengan nilai yang sesuai
5. Create table: `bun run db:migrate`
6. Install dependencies: `bun install`
1. Siapkan PostgreSQL database (produksi: database `uploader` via PgBouncer pool di `100.121.180.82:6432`)
2. Setup environment: `cp .env.example .env`
3. Edit `.env` dengan nilai yang sesuai (lihat `DATABASE_URL`, `PORT=4000`)
4. Create table: `bun run db:migrate`
5. Install dependencies: `bun install`
## Telegram Private Channel Setup
@@ -24,6 +23,20 @@ bun run dev # Development mode
bun run start # Production mode
```
## Deployment (Produksi — Nix + systemd)
> Infra lama berbasis Docker + Traefik sudah dihapus dari orangevps (2026-08-02).
- **Host**: orangevps
- **Service**: systemd unit `teleuploader` (env via `/etc/teleuploader/env` / BWS secrets)
- **Build**: Nix flake (`flake.nix`) — `nix build .#teleuploader``nix copy``systemctl restart teleuploader`
- **CI**: `.github/workflows/deploy.yml` (Gitea Actions / GitHub Actions)
- **Port**: `4000` (`PORT` env)
- **Domain**: `https://upload.asepharyana.my.id`
- **Reverse proxy**: Caddy (bukan Traefik/Docker)
- **Database**: `postgresql://asephs:***@100.121.180.82:6432/uploader` (PgBouncer pool di imrnes, **bukan** 5432/localhost)
- `deploy.sh` & `Dockerfile` & `docker-compose.yml` bersifat **legacy** — jangan dipakai untuk deploy produksi.
## API Endpoints
- `POST /api/upload` - Upload file
@@ -34,7 +47,7 @@ bun run start # Production mode
## FAQ
**URL permanen maksudnya apa?**
URL backend tetap permanen: `https://tele.asepharyana.my.id/f/{public_id}`
URL backend tetap permanen: `https://upload.asepharyana.my.id/f/{public_id}`
Ini berarti URL service Anda fix, bukan jaminan file Telegram abadi.
## Testing
+3
View File
@@ -19,6 +19,7 @@
"@biomejs/biome": "^2.4.15",
"@types/node": "^25.8.0",
"drizzle-kit": "^0.31.10",
"husky": "^9.1.7",
"typescript": "^6.0.3",
},
},
@@ -216,6 +217,8 @@
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
+10 -2
View File
@@ -1,4 +1,9 @@
#!/bin/bash
# ⚠️ LEGACY — Script deploy lama berbasis Docker. Sejak 2026-08-02 Docker dihapus
# dari orangevps; produksi kini memakai Nix + systemd (lihat flake.nix, dan CI
# .github/workflows/deploy.yml yang menjalankan `nix build` → `nix copy` →
# `systemctl restart teleuploader`). Berkas ini hanya dipertahankan sebagai
# referensi historis — JANGAN dipakai untuk deploy produksi.
# ─── FileDrop Deploy Script ──────────────────────────────────────────────────
# Builds the Bun app locally and deploys to the VPS via Docker.
#
@@ -155,12 +160,15 @@ vps "rm -rf '${DEPLOY_DIR}.old' && mv '${DEPLOY_DIR}' '${DEPLOY_DIR}.old' 2>/dev
ok "Files shipped to ${DEPLOY_DIR}"
# ── 5. Build Docker image & restart on VPS ───────────────────────────────────
# ── 5. Build Docker image & restart ──────────────────────────────────────
log "Building Docker image on VPS..."
vps "cd '${DEPLOY_DIR}' && docker compose build --pull 2>&1" | tail -5 || die "Docker build failed on VPS"
log "Restarting container..."
log "Restarting container (zero-downtime via healthcheck)..."
vps "cd '${DEPLOY_DIR}' && docker compose up -d --force-recreate --wait --wait-timeout 60 2>&1" || {
log "Warn: --wait not supported on this docker-compose version, falling back to basic restart"
vps "cd '${DEPLOY_DIR}' && docker compose up -d --force-recreate 2>&1" || die "Container restart failed"
}
# ── 6. Verify container is running ────────────────────────────────────────────
log "Waiting for container to be healthy..."
+7 -4
View File
@@ -1,19 +1,22 @@
# ⚠️ LEGACY — Docker/Traefik sudah DIHAPUS dari VPS produksi (orangevps).
# Deploy sekarang Nix + systemd (flake.nix + .github/workflows/deploy.yml) dengan
# Caddy reverse proxy. Berkas ini hanya dipertahankan sebagai referensi historis.
services:
app:
build: .
container_name: filedrop-app
restart: always
environment:
- BOT_TOKENS=${BOT_TOKENS}
- BOT_TOKEN=${BOT_TOKEN}
- ADDITIONAL_BOT_TOKENS=${ADDITIONAL_BOT_TOKENS:-}
- STORAGE_CHANNEL_ID=${STORAGE_CHANNEL_ID}
- BASE_URL=${BASE_URL}
- DATABASE_URL=${DATABASE_URL}
- PORT=3000
- PORT=4000
- NODE_ENV=production
- LOG_LEVEL=info
- TRUST_PROXY=true
- UPLOAD_CONCURRENCY=${UPLOAD_CONCURRENCY:-8}
- BATCH_MAX_ITEMS=${BATCH_MAX_ITEMS:-20}
- BATCH_MAX_SIZE_BYTES=${BATCH_MAX_SIZE_BYTES:-524288000}
- MAX_REQUEST_BODY_BYTES=${MAX_REQUEST_BODY_BYTES:-2147483648}
@@ -46,7 +49,7 @@ services:
healthcheck:
test:
- CMD-SHELL
- "bun -e \"fetch('http://localhost:3000/health').then(r => r.status === 200 ? process.exit(0) : process.exit(1))\""
- "bun -e \"fetch('http://localhost:4000/health').then(r => r.status === 200 ? process.exit(0) : process.exit(1))\""
interval: 30s
timeout: 10s
retries: 3
@@ -59,7 +62,7 @@ services:
- "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.services.filedrop.loadbalancer.server.port=4000"
- "traefik.http.middlewares.filedrop-rl.ratelimit.average=300"
- "traefik.http.middlewares.filedrop-rl.ratelimit.burst=100"
- "traefik.http.middlewares.filedrop-rl.ratelimit.period=1m"
@@ -1,5 +1,10 @@
# Telegram Bot Uploader Backend Implementation Plan
> ⚠️ **LEGACY** — Dokumen historis (2026-05-17). Port & infrastruktur sudah berubah:
> produksi kini berjalan di port `4000` (Nix + systemd + Caddy, domain `upload.asepharyana.my.id`)
> dan database via PgBouncer pool `100.121.180.82:6432` (bukan port 5432, bukan localhost).
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Production-ready backend for Telegram file uploader with dual upload methods (bot + HTTP API), PostgreSQL storage, and redirect-based downloads.
@@ -75,9 +80,9 @@ schema.sql
```bash
BOT_TOKEN=isi_token_bot_telegram
STORAGE_CHANNEL_ID=-1001234567890
BASE_URL=https://tele.asepharyana.my.id
DATABASE_URL=postgresql://user:password@localhost:5432/telegram_uploader
PORT=3000
BASE_URL=https://upload.asepharyana.my.id
DATABASE_URL=postgresql://asephs:***@100.121.180.82:6432/uploader
PORT=4000
NODE_ENV=production
LOG_LEVEL=info
RATE_LIMIT_WINDOW_MS=60000
@@ -148,7 +153,7 @@ bun run start # Production mode
## FAQ
**URL permanen maksudnya apa?**
URL backend tetap permanen: `https://tele.asepharyana.my.id/f/{public_id}`
URL backend tetap permanen: `https://upload.asepharyana.my.id/f/{public_id}`
Ini berarti URL service Anda fix, bukan jaminan file Telegram abadi.
## Testing
@@ -250,7 +255,7 @@ export const config = {
storageChatId: parseInt(process.env.STORAGE_CHANNEL_ID, 10),
baseUrl: process.env.BASE_URL,
databaseUrl: process.env.DATABASE_URL,
port: parseInt(process.env.PORT, 10) || 3000,
port: parseInt(process.env.PORT, 10) || 4000,
nodeEnv: process.env.NODE_ENV || 'development',
logLevel: process.env.LOG_LEVEL || 'info',
rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000,
@@ -1009,14 +1014,14 @@ bun run dev
- [ ] **Step 2: Test health endpoint**
```bash
curl http://localhost:3000/health
curl http://localhost:4000/health
# Expected: {"status":"ok"}
```
- [ ] **Step 3: Upload test file via HTTP API (multipart)**
```bash
curl -X POST http://localhost:3000/api/upload \
curl -X POST http://localhost:4000/api/upload \
-F "file=@/path/to/testfile.txt" \
-F "fileName=test.txt"
```
@@ -1024,14 +1029,14 @@ curl -X POST http://localhost:3000/api/upload \
- [ ] **Step 4: Check file info endpoint**
```bash
curl http://localhost:3000/file/{public_id}/info
curl http://localhost:4000/file/{public_id}/info
# Expected: JSON with file metadata
```
- [ ] **Step 5: Download redirect**
```bash
curl -I http://localhost:3000/f/{public_id}
curl -I http://localhost:4000/f/{public_id}
# Expected: HTTP 302 with Location header to Telegram CDN
```
@@ -1047,7 +1052,7 @@ curl -I http://localhost:3000/f/{public_id}
- [ ] **Step 7: Test error handling (file too large)**
```bash
curl -X POST http://localhost:3000/api/upload \
curl -X POST http://localhost:4000/api/upload \
-F "file=@/dev/null" \
-H "Content-Length: 10000000000"
# Expected: HTTP 400 with error message
@@ -1057,7 +1062,7 @@ curl -X POST http://localhost:3000/api/upload \
```bash
# Send 31 requests within 1 minute
for i in {1..31}; do curl http://localhost:3000/f/{public_id} & done
for i in {1..31}; do curl http://localhost:4000/f/{public_id} & done
wait
# Expected: First 30 succeed, last one returns 429
```
@@ -1066,7 +1071,7 @@ wait
```bash
# In terminal 1: bun run dev
# In terminal 2: curl http://localhost:3000/health && sleep 0.1 && curl http://localhost:3000/health
# In terminal 2: curl http://localhost:4000/health && sleep 0.1 && curl http://localhost:4000/health
# Send SIGINT to server (Ctrl+C in terminal 1)
# Check if server stops cleanly, logs show shutdown sequence
```
@@ -1,5 +1,11 @@
# Docker, Traefik, and GitHub Actions Deployment Plan
> ⚠️ **LEGACY** — Dokumen historis (2026-05-18). Arsitektur Docker + Traefik +
> GitHub Actions sudah digantikan (2026-08-02) oleh Nix + systemd + Caddy di
> orangevps: port `4000`, domain `upload.asepharyana.my.id`, database via
> PgBouncer pool `100.121.180.82:6432` (bukan 5432/localhost).
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Containerize TeleUploader using Bun, configure Traefik labels for routing `upload.asepharyana.my.id`, and set up full GitHub Actions CI/CD to VPS `45.127.35.244`.
@@ -57,7 +63,7 @@ WORKDIR /usr/src/app
# Set production environment variables
ENV NODE_ENV=production
ENV PORT=3000
ENV PORT=4000
# Copy necessary files from builder and repo
COPY --from=builder /usr/src/app/dist/index.js ./dist/index.js
@@ -65,7 +71,7 @@ COPY --from=builder /usr/src/app/package.json ./package.json
COPY schema.sql ./schema.sql
# Expose server port
EXPOSE 3000
EXPOSE 4000
# Start server
CMD ["bun", "dist/index.js"]
@@ -104,7 +110,7 @@ services:
- STORAGE_CHANNEL_ID=${STORAGE_CHANNEL_ID}
- BASE_URL=${BASE_URL}
- DATABASE_URL=${DATABASE_URL}
- PORT=3000
- PORT=4000
- NODE_ENV=production
- LOG_LEVEL=info
networks:
@@ -115,7 +121,7 @@ services:
- "traefik.http.routers.teleuploader.entrypoints=websecure"
- "traefik.http.routers.teleuploader.tls=true"
- "traefik.http.routers.teleuploader.tls.certresolver=letsencrypt"
- "traefik.http.services.teleuploader.loadbalancer.server.port=3000"
- "traefik.http.services.teleuploader.loadbalancer.server.port=4000"
networks:
app-shared-net:
@@ -166,9 +172,9 @@ jobs:
env:
BOT_TOKEN: "mock_token"
STORAGE_CHANNEL_ID: "123456"
BASE_URL: "http://localhost:3000"
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/postgres"
PORT: "3000"
BASE_URL: "http://localhost:4000"
DATABASE_URL: "postgresql://asephs:***@100.121.180.82:6432/postgres"
PORT: "4000"
run: bun run test
build-and-push:
@@ -223,7 +229,7 @@ jobs:
- STORAGE_CHANNEL_ID=${STORAGE_CHANNEL_ID}
- BASE_URL=${BASE_URL}
- DATABASE_URL=${DATABASE_URL}
- PORT=3000
- PORT=4000
- NODE_ENV=production
- LOG_LEVEL=info
networks:
@@ -234,7 +240,7 @@ jobs:
- "traefik.http.routers.teleuploader.entrypoints=websecure"
- "traefik.http.routers.teleuploader.tls=true"
- "traefik.http.routers.teleuploader.tls.certresolver=letsencrypt"
- "traefik.http.services.teleuploader.loadbalancer.server.port=3000"
- "traefik.http.services.teleuploader.loadbalancer.server.port=4000"
networks:
app-shared-net:
@@ -248,7 +254,7 @@ jobs:
STORAGE_CHANNEL_ID=${{ secrets.STORAGE_CHANNEL_ID }}
BASE_URL=${{ secrets.BASE_URL }}
DATABASE_URL=${{ secrets.DATABASE_URL }}
PORT=3000
PORT=4000
EOF
# Pull latest docker image
@@ -1,5 +1,11 @@
# S3-Compatible TeleUploader Implementation Plan
> ⚠️ **LEGACY** — Dokumen historis (2026-07-06). Port & infrastruktur sudah berubah:
> produksi kini berjalan di port `4000` (Nix + systemd + Caddy, domain
> `upload.asepharyana.my.id`), database via PgBouncer pool `100.121.180.82:6432`
> (bukan 5432/localhost). Contoh kode di bawah memakai `localhost:4000` untuk dev.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Transform TeleUploader into an S3-compatible storage server (Telegram-backed) with a web file manager UI.
@@ -2675,7 +2681,7 @@ describe('S3 Bucket Operations', () => {
process.env.S3_DEFAULT_REGION = 'us-east-1';
process.env.BOT_TOKEN = '123456:ABC-DEF';
process.env.STORAGE_CHANNEL_ID = '-1001234567890';
process.env.BASE_URL = 'http://localhost:3000';
process.env.BASE_URL = 'http://localhost:4000';
process.env.DATABASE_URL = 'postgresql://localhost/test';
});
@@ -2684,7 +2690,7 @@ describe('S3 Bucket Operations', () => {
});
it('should return 403 for unauthorized requests', async () => {
const req = new Request('http://localhost:3000/', {
const req = new Request('http://localhost:4000/', {
method: 'GET',
headers: { authorization: 'Invalid' },
});
@@ -2849,7 +2855,7 @@ describe('Web API v1', () => {
mockDbExecute.mockClear();
process.env.BOT_TOKEN = '123456:ABC-DEF';
process.env.STORAGE_CHANNEL_ID = '-1001234567890';
process.env.BASE_URL = 'http://localhost:3000';
process.env.BASE_URL = 'http://localhost:4000';
process.env.DATABASE_URL = 'postgresql://localhost/test';
});
@@ -2858,7 +2864,7 @@ describe('Web API v1', () => {
});
it('should list buckets via GET /api/v1/buckets', async () => {
const req = new Request('http://localhost:3000/api/v1/buckets');
const req = new Request('http://localhost:4000/api/v1/buckets');
const res = await handleWebApiV1(req);
expect(res.status).toBe(200);
const data = await res.json();
@@ -2867,7 +2873,7 @@ describe('Web API v1', () => {
});
it('should return 404 for unknown API path', async () => {
const req = new Request('http://localhost:3000/api/v1/unknown');
const req = new Request('http://localhost:4000/api/v1/unknown');
const res = await handleWebApiV1(req);
expect(res.status).toBe(404);
const data = await res.json();
@@ -2875,7 +2881,7 @@ describe('Web API v1', () => {
});
it('should return bucket object listing', async () => {
const req = new Request('http://localhost:3000/api/v1/buckets/test-bucket/objects?prefix=');
const req = new Request('http://localhost:4000/api/v1/buckets/test-bucket/objects?prefix=');
const res = await handleWebApiV1(req);
// Should return 200 even with empty results
expect(res.status).toBe(200);
@@ -2885,7 +2891,7 @@ describe('Web API v1', () => {
});
it('should reject invalid bucket name on create', async () => {
const req = new Request('http://localhost:3000/api/v1/buckets', {
const req = new Request('http://localhost:4000/api/v1/buckets', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'INVALID_NAME!' }),
@@ -1,5 +1,7 @@
# Gitea CI/CD Migration Implementation Plan
> Catatan (2026-08-02): Produksi sekarang port 4000, deploy Nix+systemd di orangevps, Caddy reverse proxy upload.asepharyana.my.id, DB via pgbouncer pool imrnes 100.121.180.82:6432. Docker/Traefik/Gitea-CI legacy.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Move `origin` from GitLab to `git.imrnes.team:MythEclipse/TeleUploader` and add Gitea Actions deployment on push to `main` using the existing VPS deploy path.
@@ -1,5 +1,7 @@
# S3 Compatibility Completion Implementation Plan
> Catatan (2026-08-02): Produksi sekarang port 4000, deploy Nix+systemd di orangevps, Caddy reverse proxy upload.asepharyana.my.id, DB via pgbouncer pool imrnes 100.121.180.82:6432. Docker/Traefik/Gitea-CI legacy.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Finish TeleUploader S3 compatibility gaps: strict presigned GET, byte ranges, complete multipart GetObject streaming, strict AWS SDK multipart investigation/fix, and warning-free lint.
@@ -0,0 +1,576 @@
# Per-Bot Queue Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace global PQueue with per-bot queues (concurrency=1 per bot) to eliminate 429 collisions and improve rate-limit safety.
**Architecture:** Each bot token gets its own PQueue with concurrency=1. Uploads are assigned to the least-loaded available bot via `selectBot()`. On 429, the bot is marked rate-limited and the upload retries on the next available bot. The global `upload-queue.ts` is removed; `uploadConcurrency` config is replaced by `botCount * perBotConcurrency`.
**Tech Stack:** TypeScript, PQueue, Telegraf
## Global Constraints
- Use `Bun` runtime, not Node.js
- Follow existing code style (Biome lint)
- Each bot queue has concurrency=1 (no two uploads hit same bot simultaneously)
- `selectBot()` picks bot with lowest pending queue count, skipping rate-limited bots
- Remove `uploadConcurrency` from config; derive effective concurrency from bot count
- Remove `upload-queue.ts` entirely
- Remove `enqueueUpload` from `ITelegramService` interface
---
## File Structure
### Files to Modify
| File | Change |
|------|--------|
| `src/infrastructure/telegram/bot-pool.ts` | BotEntry array, selectBot(), per-bot queues, retry logic |
| `src/domain/ports/telegram-service.ts` | Remove `enqueueUpload` from interface |
| `src/env.ts` | Remove `uploadConcurrency` field |
| `src/index.ts` | Remove upload-queue import and usage |
| `src/utils/chunked-storage.ts` | Replace `config.uploadConcurrency` with bot count |
### Files to Delete
| File | Reason |
|------|--------|
| `src/infrastructure/telegram/upload-queue.ts` | Global queue replaced by per-bot queues |
| `test/telegramQueue.test.ts` | Tests for deleted module |
### Files Not Changed
| File | Reason |
|------|--------|
| `test/bot.test.ts` | Only uses ITelegramService interface (via `forwardToStorage`) |
| `src/infrastructure/telegram/chunked-storage.ts` | Uses ITelegramService interface, not BotPool directly |
---
### Task 1: Refresh the spec & plan files after compaction
Due to context compaction, re-read the current spec and plan files to ensure accuracy before implementing.
- [ ] **Step 1: Re-read the spec**
Read: `docs/superpowers/specs/2026-07-29-per-bot-queue-design.md`
- [ ] **Step 2: Re-read key implementation files**
Read: `src/infrastructure/telegram/bot-pool.ts`, `src/env.ts`, `src/utils/chunked-storage.ts`
### Task 2: Refactor ITelegramService interface
Remove `enqueueUpload` from the interface — BotPool handles queueing internally now.
**Files:**
- Modify: `src/domain/ports/telegram-service.ts`
- [ ] **Step 1: Remove `enqueueUpload` from interface**
```typescript
// src/domain/ports/telegram-service.ts — remove entire section:
/**
* Enqueue a task for sequential upload execution.
*
* Ensures only one Telegram upload runs at a time to avoid
* rate limits and resource contention.
*
* @param task - An async function performing the upload.
* @returns The result of the task.
*/
enqueueUpload<T>(task: () => Promise<T>): Promise<T>;
```
- [ ] **Step 2: Run lint to verify**
Run: `bunx biome check src/domain/ports/telegram-service.ts`
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add src/domain/ports/telegram-service.ts
git commit -m "refactor: remove enqueueUpload from ITelegramService
Per-bot queue handles queueing internally.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
### Task 3: Refactor BotPool with per-bot queues
The core of the redesign. Replace `claimBotIndex()` round-robin with per-bot PQueue instances and `selectBot()` for least-loaded assignment.
**Files:**
- Modify: `src/infrastructure/telegram/bot-pool.ts`
**Interfaces:**
- Consumes: `ITelegramService` (no `enqueueUpload` method)
- Produces: `botPool` singleton with per-bot queues, `selectBot()`, per-bot rate-limit tracking
- [ ] **Step 1: Write test file for per-bot queue behavior**
```typescript
// test/bot-pool.test.ts
import { beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
// We'll test the BotEntry queue behavior and selectBot logic
```
- [ ] **Step 2: Implement BotEntry type and BotPool refactor**
Replace the class body:
```typescript
import PQueue from 'p-queue';
import { Telegraf } from 'telegraf';
import type {
ForwardResult,
ITelegramService,
TelegramFileInfo,
} from '../../domain/ports/telegram-service';
import { config } from '../../env';
import logger from '../../shared/logger/index';
import {
buildSendPayload,
extractUploadedFile,
type SendMethod,
sendMethodMap,
type TelegramMessageResult,
} from './types';
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
const isTransientError = (error: unknown): boolean => {
const str = error instanceof Error ? error.message : String(error);
const transientPatterns = [
'timeout', 'Timed out', 'etimedout', 'econnrefused', 'econnreset',
'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', '5xx', '502', '503', '504',
'Bad Gateway', 'Service Unavailable', 'Gateway Timeout', 'socket hang up',
'socket closed', 'fetch failed', 'network error', 'network timeout',
'API closed', 'read ECONNRESET', 'write EPIPE',
];
return transientPatterns.some((p) => str.toLowerCase().includes(p.toLowerCase()));
};
const MAX_TRANSIENT_RETRIES = 3;
const TELEGRAM_API_TIMEOUT_MS = 120_000;
const PER_BOT_CONCURRENCY = 1;
interface BotEntry {
index: number;
token: string;
instance: Telegraf;
queue: PQueue;
rateLimitedUntil: number; // 0 = not rate-limited
}
export class BotPool implements ITelegramService {
private readonly bots: BotEntry[] = [];
constructor() {
const tokens = Array.from(new Set([config.botToken, ...config.additionalBotTokens]));
this.bots = tokens.map((token, index) => ({
index,
token,
instance: new Telegraf(token),
queue: new PQueue({ concurrency: PER_BOT_CONCURRENCY }),
rateLimitedUntil: 0,
}));
}
/** Number of bots in the pool */
get size(): number {
return this.bots.length;
}
/**
* Select the bot with the fewest pending tasks that isn't rate-limited
* or in the skip set.
*/
private selectBot(skipIndexes?: Set<number>): BotEntry | null {
let best: BotEntry | null = null;
let bestPending = Infinity;
for (const bot of this.bots) {
if (skipIndexes?.has(bot.index)) continue;
if (bot.rateLimitedUntil > Date.now()) continue;
const pending = bot.queue.pending + bot.queue.size;
if (pending < bestPending) {
bestPending = pending;
best = bot;
}
}
return best;
}
/**
* Execute a Telegram API action on a specific bot entry.
* Wraps with timeout.
*/
private async executeBotAction<T>(
bot: BotEntry,
action: (instance: Telegraf, token: string) => Promise<T>,
): Promise<T> {
return Promise.race([
action(bot.instance, bot.token),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(`Telegram API timeout after ${TELEGRAM_API_TIMEOUT_MS}ms`)),
TELEGRAM_API_TIMEOUT_MS,
),
),
]);
}
/**
* Forward a file chunk to the configured Telegram storage chat.
*
* The upload is submitted to the least-loaded bot's queue. If the bot
* returns 429, it is marked rate-limited and the upload retries on the
* next available bot. If all bots are rate-limited, sleeps before retrying.
*/
async forwardToStorage(
fileChunk: unknown,
fileName: string,
fileType: string,
): Promise<ForwardResult> {
let lastError: unknown;
const attemptedIndexes = new Set<number>();
let transientAttempts = 0;
// Outer retry loop — up to 10 attempts across all bots
for (let attempt = 0; attempt < 10; attempt++) {
const bot = this.selectBot(attemptedIndexes);
if (!bot) {
// No available bots — either all rate-limited or all attempted
if (attemptedIndexes.size > 0) {
// All non-rate-limited bots were tried and failed — wait & reset
logger.warn('All available bots exhausted, sleeping 5s before retry');
await sleep(5000 + Math.random() * 1000);
attemptedIndexes.clear();
continue;
}
// All bots rate-limited — wait for the shortest cooldown
const earliestCooldown = Math.min(
...this.bots.map((b) => b.rateLimitedUntil || Infinity),
);
const waitMs = Math.max(1000, earliestCooldown - Date.now() + 500);
logger.warn('All bots rate-limited, waiting', { waitMs });
await sleep(waitMs);
attemptedIndexes.clear();
continue;
}
attemptedIndexes.add(bot.index);
try {
const result = await bot.queue.add(async () => {
// Inner transient retry loop inside the queue
for (let innerRetry = 0; innerRetry <= MAX_TRANSIENT_RETRIES; innerRetry++) {
try {
const filePayload = { source: fileChunk, filename: fileName };
const sendMethodName = sendMethodMap[fileType] || 'sendDocument';
const payload = buildSendPayload(fileType, fileName);
const tgResult = await this.executeBotAction<TelegramMessageResult>(
bot,
(activeBot) => {
const telegram = activeBot.telegram as unknown as Record<string, SendMethod>;
return telegram[sendMethodName](config.storageChatId, filePayload, payload);
},
);
const uploadedFile = extractUploadedFile(tgResult, fileType);
return {
telegramFileId: uploadedFile?.file_id || '',
telegramFileUniqueId: uploadedFile?.file_unique_id || '',
storageMessageId: tgResult.message_id,
};
} catch (error: unknown) {
const errorStr = error instanceof Error ? error.message : String(error);
const retryAfterMatch = errorStr.match(/retry after (\d+)/i);
if (retryAfterMatch) {
// 429 — mark bot rate-limited, throw to outer loop for retry on different bot
const seconds = parseInt(retryAfterMatch[1], 10);
bot.rateLimitedUntil = Date.now() + seconds * 1000;
logger.info(`Bot #${bot.index} rate-limited for ${seconds}s`, { fileName, attempt });
throw error; // caught by outer retry loop
}
if (innerRetry < MAX_TRANSIENT_RETRIES && isTransientError(error)) {
const backoffMs = Math.min(1000 * 2 ** innerRetry, 10_000);
logger.warn(
`Transient error on bot #${bot.index}, retrying (${innerRetry + 1}/${MAX_TRANSIENT_RETRIES})`,
{ fileName, error: errorStr, backoffMs },
);
await sleep(backoffMs);
continue;
}
throw error; // non-transient — propagate
}
}
throw new Error(`Exhausted transient retries on bot #${bot.index}`);
});
logger.info('File forwarded to storage', { fileName, message: result.storageMessageId });
return result;
} catch (error: unknown) {
lastError = error;
const errorStr = error instanceof Error ? error.message : String(error);
const retryAfterMatch = errorStr.match(/retry after (\d+)/i);
if (retryAfterMatch) {
// Bot was rate-limited — already marked, try next bot
continue;
}
// Transient error at the queue level (timeout, 5xx)
if (transientAttempts < MAX_TRANSIENT_RETRIES && isTransientError(error)) {
transientAttempts++;
continue;
}
// Non-transient — give up
logger.error('Failed to forward file to storage', {
fileName,
error: errorStr,
attempt,
});
throw error;
}
}
throw lastError || new Error('Failed to forward file after all retries');
}
/** Get total effective concurrency across all bots */
getEffectiveConcurrency(): number {
return this.bots.length * PER_BOT_CONCURRENCY;
}
async getFileInfo(telegramFileId: string): Promise<TelegramFileInfo> {
let lastError: unknown;
for (const bot of this.bots) {
for (let retry = 0; retry <= MAX_TRANSIENT_RETRIES; retry++) {
try {
const result = await bot.instance.telegram.getFile(telegramFileId);
const fileData = result as unknown as Omit<TelegramFileInfo, 'bot_token'>;
return {
file_size: fileData.file_size || 0,
mime_type: fileData.mime_type || 'application/octet-stream',
file_path: fileData.file_path || '',
bot_token: bot.token,
};
} catch (error: unknown) {
lastError = error;
const errorStr = error instanceof Error ? error.message : String(error);
if (
errorStr.includes('wrong file_id') ||
errorStr.includes('file is temporarily unavailable')
) {
break;
}
if (retry < MAX_TRANSIENT_RETRIES && isTransientError(error)) {
const backoffMs = Math.min(1000 * 2 ** (retry + 1), 5_000);
await sleep(backoffMs);
continue;
}
break;
}
}
}
logger.error('Failed to get file info from any bot', {
error: lastError instanceof Error ? lastError.message : String(lastError),
});
throw lastError;
}
}
export const botPool = new BotPool();
```
- [ ] **Step 3: Run lint**
Run: `bunx biome check src/infrastructure/telegram/bot-pool.ts`
Expected: No errors.
- [ ] **Step 4: Run existing test suite**
Run: `bun test test/bot.test.ts`
Expected: All tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/infrastructure/telegram/bot-pool.ts
git commit -m "refactor: per-bot queue with selectBot() and rate-limit tracking
Each bot has its own PQueue (concurrency=1). Uploads are assigned to
the least-loaded available bot. On 429, the bot is marked rate-limited
and the upload retries on the next available bot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
### Task 4: Remove global upload queue
Delete the global queue, its test, and all references to it from index.ts.
**Files:**
- Delete: `src/infrastructure/telegram/upload-queue.ts`
- Modify: `src/index.ts` (lines 4, 71-81)
- Delete: `test/telegramQueue.test.ts`
- [ ] **Step 1: Delete upload-queue.ts**
Run: `rm src/infrastructure/telegram/upload-queue.ts`
- [ ] **Step 2: Delete the test file**
Run: `rm test/telegramQueue.test.ts`
- [ ] **Step 3: Update index.ts — remove upload-queue import and shutdown drain logic**
Remove line:
```typescript
import { clearQueue, getQueueStats, waitForQueue } from './infrastructure/telegram/upload-queue';
```
Remove the drain block (lines 70-81):
```typescript
// Drain pending upload queue with a timeout
const { pending, size } = getQueueStats();
if (pending > 0 || size > 0) {
logger.info('Draining upload queue', { pending, size });
const drainTimeout = setTimeout(() => {
logger.warn('Upload queue drain timeout — clearing remaining tasks');
clearQueue();
}, 30_000);
await waitForQueue();
clearTimeout(drainTimeout);
logger.info('Upload queue drained');
}
```
- [ ] **Step 4: Run lint**
Run: `bunx biome check src/index.ts`
Expected: No errors.
- [ ] **Step 5: Run tests**
Run: `bun test`
Expected: All tests pass (some may be skipped due to missing queue test).
- [ ] **Step 6: Commit**
```bash
git add src/infrastructure/telegram/upload-queue.ts test/telegramQueue.test.ts src/index.ts
git commit -m "refactor: remove global upload queue
Per-bot queues now handle concontrol internally.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
### Task 5: Update env.ts and chunked-storage backpressure
Remove `uploadConcurrency` from config and update chunked-storage to derive effective concurrency from bot pool.
**Files:**
- Modify: `src/env.ts`
- Modify: `src/utils/chunked-storage.ts`
- [ ] **Step 1: Remove `uploadConcurrency` from env.ts**
Remove:
```typescript
uploadConcurrency: number;
```
and:
```typescript
uploadConcurrency: parseNumber(process.env.UPLOAD_CONCURRENCY, 8),
```
- [ ] **Step 2: Update chunked-storage.ts backpressure**
Replace:
```typescript
import { config } from '../env';
// ...
if (inFlight.size >= config.uploadConcurrency * 2) {
```
With:
```typescript
import { botPool } from '../infrastructure/telegram/bot-pool';
// ...
if (inFlight.size >= botPool.getEffectiveConcurrency()) {
```
(Use effective concurrency * 2 for backpressure, or just use effective concurrency as the limit.)
Actually let me think about this more carefully. The backpressure in chunked-storage:
```
if (inFlight.size >= config.uploadConcurrency * 2) {
await Promise.race(inFlight);
await new Promise((resolve) => setTimeout(resolve, 0));
}
```
This limits the number of in-flight chunks per file. With `uploadConcurrency: 16`, it was 32. Now with effective concurrency of 6 (6 bots), it would be 12. That's fine as backpressure — it prevents too many chunks from being in memory at once.
Let me use `botPool.getEffectiveConcurrency() * 2` to keep the same multiplier.
- [ ] **Step 3: Run lint and tests**
```bash
bunx biome check src/env.ts src/utils/chunked-storage.ts
bun test test/chunked-storage.test.ts
```
Expected: All checks pass.
- [ ] **Step 4: Commit**
```bash
git add src/env.ts src/utils/chunked-storage.ts
git commit -m "refactor: remove uploadConcurrency from config
Effective concurrency derived from bot pool size. Chunked-storage
backpressure now uses botPool.getEffectiveConcurrency().
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
### Task 6: Full integration test
- [ ] **Step 1: Run the full test suite**
Run: `bun test`
Expected: All tests pass.
- [ ] **Step 2: Run lint**
Run: `bunx biome check src test`
Expected: No errors.
- [ ] **Step 3: Create summary commit**
```bash
git add -A
git commit -m "refactor: implement per-bot queue architecture
- Each bot has its own PQueue with concurrency=1
- selectBot() assigns uploads to least-loaded available bot
- 429 rate limits are tracked per-bot with cooldown timers
- Failed uploads retry on next available bot
- Removed global upload-queue.ts and uploadConcurrency config
- Updated ITelegramService interface
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```
@@ -1,5 +1,10 @@
# Telegram Bot Uploader Backend Design
> ⚠️ **LEGACY** — Dokumen historis (2026-05-17). Port & infrastruktur sudah berubah:
> produksi kini berjalan di port `4000` (Nix + systemd + Caddy, domain `upload.asepharyana.my.id`)
> dan database via PgBouncer pool `100.121.180.82:6432` (bukan port 5432, bukan localhost).
**Date:** 2026-05-17
**Status:** Approved
**Stack:** Bun, Telegraf, PostgreSQL, Drizzle ORM, Winston, nanoid
@@ -35,7 +40,7 @@ Production-ready backend untuk Telegram file uploader dengan dual upload methods
4. Bot extracts `telegram_file_id`, `telegram_file_unique_id`, `storage_message_id`
5. Bot generates `public_id` using nanoid
6. Bot saves metadata to PostgreSQL
7. Bot replies with download link: `https://tele.asepharyana.my.id/f/{public_id}`
7. Bot replies with download link: `https://upload.asepharyana.my.id/f/{public_id}`
#### Upload via HTTP API
1. Client POSTs to `/api/upload` with file (multipart or base64)
@@ -115,7 +120,7 @@ fileName: optional_filename.ext
"size_bytes": 1024000,
"file_type": "document",
"created_at": "2026-05-17T23:42:19Z",
"download_url": "https://tele.asepharyana.my.id/f/abc123xyz"
"download_url": "https://upload.asepharyana.my.id/f/abc123xyz"
}
```
@@ -212,9 +217,9 @@ schema.sql
```
BOT_TOKEN=<telegram_bot_token>
STORAGE_CHANNEL_ID=<private_channel_id>
BASE_URL=https://tele.asepharyana.my.id
DATABASE_URL=postgresql://user:password@localhost:5432/telegram_uploader
PORT=3000
BASE_URL=https://upload.asepharyana.my.id
DATABASE_URL=postgresql://asephs:***@100.121.180.82:6432/uploader
PORT=4000
NODE_ENV=production
LOG_LEVEL=info
RATE_LIMIT_WINDOW_MS=60000
@@ -7,6 +7,12 @@ metadata:
# Design: TeleUploader Deployment & CI/CD Setup
> ⚠️ **LEGACY** — Dokumen historis (2026-05-18) untuk arsitektur Docker + Traefik +
> GitHub Actions. Docker & Traefik sudah dihapus dari VPS produksi (2026-08-02):
> deploy sekarang Nix + systemd + Caddy di orangevps, port `4000`, domain
> `upload.asepharyana.my.id`, database via PgBouncer pool `100.121.180.82:6432`.
We are setting up production deployment for TeleUploader on VPS `45.127.35.244` behind Traefik utilizing GitHub Actions.
## 1. System Architecture
@@ -22,7 +28,7 @@ TeleUploader is a Bun-based service.
### `Dockerfile`
- Multi-stage build.
- **Stage 1 (Build)**: Install dependencies, copy source files, run Biome lint/format checks, compile TS build to `dist/index.js` using `bun build`.
- **Stage 2 (Run)**: Use minimal `oven/bun:1.1-slim` runtime. Copy `dist/index.js`, `schema.sql`, and `package.json`. Expose port `3000`.
- **Stage 2 (Run)**: Use minimal `oven/bun:1.1-slim` runtime. Copy `dist/index.js`, `schema.sql`, and `package.json`. Expose port `4000`.
### `docker-compose.yml`
```yaml
@@ -38,7 +44,7 @@ services:
- STORAGE_CHANNEL_ID=${STORAGE_CHANNEL_ID}
- BASE_URL=${BASE_URL}
- DATABASE_URL=${DATABASE_URL}
- PORT=3000
- PORT=4000
- NODE_ENV=production
- LOG_LEVEL=info
networks:
@@ -49,7 +55,7 @@ services:
- "traefik.http.routers.teleuploader.entrypoints=websecure"
- "traefik.http.routers.teleuploader.tls=true"
- "traefik.http.routers.teleuploader.tls.certresolver=letsencrypt"
- "traefik.http.services.teleuploader.loadbalancer.server.port=3000"
- "traefik.http.services.teleuploader.loadbalancer.server.port=4000"
networks:
app-shared-net:
@@ -1,5 +1,7 @@
# Gitea Remote and CI/CD Migration Design
> Catatan (2026-08-02): Produksi sekarang port 4000, deploy Nix+systemd di orangevps, Caddy reverse proxy upload.asepharyana.my.id, DB via pgbouncer pool imrnes 100.121.180.82:6432. Docker/Traefik/Gitea-CI legacy.
## Goal
Move the repository origin from GitLab to a new Gitea repository and add a Gitea Actions deployment flow that behaves like a GitHub Actions CI/CD pipeline.
@@ -1,5 +1,7 @@
# S3 Compatibility Completion Design
> Catatan (2026-08-02): Produksi sekarang port 4000, deploy Nix+systemd di orangevps, Caddy reverse proxy upload.asepharyana.my.id, DB via pgbouncer pool imrnes 100.121.180.82:6432. Docker/Traefik/Gitea-CI legacy.
Date: 2026-07-07
## Goal
@@ -0,0 +1,127 @@
# Per-Bot Queue: Rate-Limit Safe Telegram Upload
**Date:** 2026-07-29
**Status:** Approved Design
## Problem
Telegram Bot API rate-limits each bot to approximately 1-2 concurrent uploads. When multiple upload chunks hit the same bot simultaneously, Telegram returns HTTP 429 (Too Many Requests), causing delays of 30-60 seconds per retry. Under Docker push load, these cumulative delays trigger Gitea client timeouts and `500 Internal Server Error`.
> Catatan (2026-08-02): Produksi sekarang port 4000, deploy Nix+systemd di orangevps, Caddy reverse proxy upload.asepharyana.my.id, DB via pgbouncer pool imrnes 100.121.180.82:6432. Docker/Traefik/Gitea-CI legacy.
The current architecture uses a **global PQueue** with `concurrency=N` where each task picks a bot via round-robin (`claimBotIndex()`). This means two concurrent tasks can both land on the same bot index (after wrap-around), causing 429 collisions.
## Solution: Per-Bot Queue
Each bot has its own PQueue with `concurrency=1`. Uploads are assigned to the bot with the fewest pending tasks. If a bot rate-limits, the upload moves to the next available bot.
### Architecture
```
┌────────────────────────────────────────────────────────┐
│ BotPool │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ BotEntry[0] token=b1 queue=PQueue(conc=1) │ │
│ │ rateLimitedUntil=0 │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ BotEntry[1] token=b2 queue=PQueue(conc=1) │ │
│ │ rateLimitedUntil=0 │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ ... up to N bots │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ selectBot(skip?): number │
│ └─ bot dengan pending queue paling sedikit │
│ dan tidak sedang rate-limited │
│ │
│ forwardToStorage(file): ForwardResult │
│ └─ retry loop: selectBot → queue.add → handle 429 │
└────────────────────────────────────────────────────────┘
```
### BotEntry Structure
```typescript
interface BotEntry {
index: number;
token: string;
instance: Telegraf;
queue: PQueue; // concurrency: 1
rateLimitedUntil: number; // epoch ms, 0 = not limited
}
```
### Data Flow: Upload
```
forwardToStorage(fileChunk, fileName, fileType)
├─ MAX_RETRIES loop (attempt up to all bots)
│ │
│ ├─ selectBot(attemptedIndexes)
│ │ ├─ Filter out rate-limited bots (rateLimitedUntil > Date.now())
│ │ ├─ Filter out already-attempted bots
│ │ ├─ If none available:
│ │ │ ├─ Wait MIN_SLEEP_MS (5000ms)
│ │ │ ├─ Reset rate-limited timers (clear attemptedIndexes)
│ │ │ └─ Retry selectBot
│ │ └─ Return bot with smallest queue.pending count
│ │
│ ├─ attemptedIndexes.add(selectedBot)
│ │
│ ├─ result = await bots[selectedBot].queue.add(() =>
│ │ executeTelegramCall(bot, fileChunk, fileName)
│ │ )
│ │ │
│ │ ├─ ✅ Success → return ForwardResult
│ │ │
│ │ └─ ❌ Error
│ │ ├─ 429 → markRateLimited(bot, retryAfter)
│ │ │ → continue to next bot in retry loop
│ │ ├─ Transient (timeout, 5xx) → continue
│ │ └─ Non-transient → throw (propagate up)
│ │
│ └─ Attempt counter exhausted → throw lastError
└─ Sorted part tracking (for chunked uploads)
```
### Key Design Decisions
1. **Concurrency=1 per bot**: Guarantees no two Telegram API calls compete for the same bot token. With 6 bots, effective concurrency = 6.
2. **Least-loaded assignment**: `selectBot()` picks the bot with the fewest queued + pending tasks. This naturally load-balances even when some bots are slower.
3. **Rate-limit isolation**: When bot A hits 429, only bot A's queue is paused. Other 5 bots continue serving uploads uninterrupted.
4. **Per-bot rate-limit timer**: `rateLimitedUntil` prevents re-selecting a recently-429'd bot until its cooldown expires.
5. **No global PQueue**: The old `upload-queue.ts` is removed. Each bot owns its queue, eliminating the global backpressure problem.
### Changes by File
| File | Action |
|------|--------|
| `src/infrastructure/telegram/bot-pool.ts` | **Major refactor**: BotEntry array, selectBot(), per-bot queues, retry loop |
| `src/infrastructure/telegram/upload-queue.ts` | **Delete**: No longer needed |
| `src/domain/ports/telegram-service.ts` | **Remove** `enqueueUpload<T>(task: () => Promise<T>): Promise<T>` from interface |
| `src/utils/chunked-storage.ts` | **No changes** — only uses `forwardToStorage()` |
| `src/env.ts` | **Remove** `uploadConcurrency` config (no longer needed) |
### Error Handling
- **429 per bot**: Mark bot rate-limited, move to next. Clear timer after `retryAfter` seconds.
- **All bots 429**: Sleep 5 seconds with jitter, then retry from bot 0.
- **Transient errors** (timeout, 5xx, connection reset): Retry on same bot (inside its queue), then on next bot.
- **Non-transient errors** (4xx other than 429, wrong file_id, auth errors): Propagate immediately.
- **MAX_RETRIES**: 10 attempts across all bots before giving up.
### Testing
- Unit: `selectBot()` returns bot with fewest pending tasks
- Unit: `selectBot()` skips rate-limited bots
- Unit: 429 on bot 0 → retries on bot 1 → succeeds
- Unit: All bots rate-limited → sleeps → retries → succeeds
- Unit: Per-bot queue has concurrency=1 (two tasks to same bot queue sequentially)
- Integration: Forward a real file through the per-bot pool
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1785301185,
"narHash": "sha256-eoS3KQTO0aPWXZvIaRbRAzSSHW3l5wdMFXtT1ISfoKA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9bc02893134c733dd85de46ee4fb2fac696b5529",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+85
View File
@@ -0,0 +1,85 @@
{
description = "TeleUploader Nix build";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachSystem [ "x86_64-linux" ] (system:
let
pkgs = import nixpkgs { inherit system; };
# TeleUploader package
teleuploader = pkgs.stdenvNoCC.mkDerivation rec {
pname = "teleuploader";
version = "1.1.0";
src = ./.;
nativeBuildInputs = [
pkgs.bun
pkgs.makeBinaryWrapper
];
# Bun cache di sandbox — prevent online fetch
# Karena bun.lock sudah di repo, bun install --frozen-lockfile
# akan pake cache, tapi di Nix sandbox gak ada internet.
# Solusi: offline flag
preBuild = ''
export HOME=$TMPDIR/home
mkdir -p $HOME
export BUN_INSTALL=$HOME/.bun
'';
buildPhase = ''
echo "=== Installing dependencies ==="
bun install --frozen-lockfile --ignore-scripts 2>&1
echo "=== Building ==="
bun run build 2>&1
'';
installPhase = ''
mkdir -p $out/bin $out/share/teleuploader
# Copy dist files
cp -r dist $out/share/teleuploader/dist
cp src/home.html $out/share/teleuploader/ 2>/dev/null || true
cp schema.sql $out/share/teleuploader/ 2>/dev/null || true
# Wrap with bun from Nix store (dependency sharing!)
# Note: NO --chdir systemd WorkingDirectory controls this
makeBinaryWrapper ${pkgs.bun}/bin/bun $out/bin/teleuploader \
--add-flags "$out/share/teleuploader/dist/index.js" \
--set-default NODE_ENV production \
--prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.bun ]}
# Also create the migrate wrapper
makeBinaryWrapper ${pkgs.bun}/bin/bun $out/bin/teleuploader-migrate \
--add-flags "$out/share/teleuploader/dist/migrate.js" \
--prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.bun ]}
'';
meta = {
description = "Telegram file uploader backend (S3 Telegram)";
license = pkgs.lib.licenses.mit;
platforms = pkgs.lib.platforms.linux;
};
};
in {
packages = {
inherit teleuploader;
default = teleuploader;
};
# Dev shell with bun for local development
devShells.default = pkgs.mkShell {
buildInputs = [
pkgs.bun
pkgs.nodejs_22
];
};
});
}
+9 -7
View File
@@ -1,20 +1,21 @@
{
"name": "filedrop",
"version": "1.0.0",
"version": "1.1.0",
"description": "Telegram file uploader backend",
"type": "module",
"scripts": {
"dev": "bun --hot src/index.ts",
"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/infrastructure/persistence/drizzle/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 --preload ./test/helpers/setup-env.ts test/rateLimit.test.ts && bun test 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 test/health.test.ts && bun test test/db.test.ts && bun test --preload ./test/helpers/setup-env.ts test/bot.test.ts && bun test --preload ./test/helpers/setup-env.ts test/bootstrap.test.ts && bun test --preload ./test/helpers/setup-env.ts 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 --preload ./test/helpers/setup-env.ts 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": "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 && bun test --preload ./test/helpers/setup-env.ts test/bot.test.ts && bun test --preload ./test/helpers/setup-env.ts test/bootstrap.test.ts && bun test --preload ./test/helpers/setup-env.ts test/swagger.test.ts && bun test --preload ./test/helpers/setup-env.ts test/auth.test.ts && bun test --preload ./test/helpers/setup-env.ts test/auth-routes.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-auth.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-bucket-config.test.ts && bun test --preload ./test/helpers/setup-env.ts test/web-api.test.ts && bun test --preload ./test/helpers/setup-env.ts test/env.test.ts && bun test --preload ./test/helpers/setup-env.ts test/bot-pool.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:web-api": "bun test --preload ./test/helpers/setup-env.ts test/web-api.test.ts",
"test:s3": "bun test test/s3-auth.test.ts && bun test test/s3-operations.test.ts && 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-operations.test.ts && bun test --preload ./test/helpers/setup-env.ts test/web-api.test.ts",
"lint": "bunx biome check src test",
"format": "bunx biome format --write src test"
"format": "bunx biome format --write src test",
"prepare": "husky"
},
"dependencies": {
"@prisma/engines": "^7.8.0",
@@ -31,6 +32,7 @@
"@biomejs/biome": "^2.4.15",
"@types/node": "^25.8.0",
"drizzle-kit": "^0.31.10",
"husky": "^9.1.7",
"typescript": "^6.0.3"
}
}
+2 -1
View File
@@ -56,7 +56,8 @@ CREATE TABLE IF NOT EXISTS multipart_uploads (
s3_key TEXT NOT NULL,
initiated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status VARCHAR DEFAULT 'in_progress',
initiated_by TEXT
initiated_by TEXT,
content_type TEXT
);
CREATE TABLE IF NOT EXISTS multipart_parts (
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* Input for the upload file use case.
* Carries all metadata needed to persist an uploaded file,
* including its temporary location on disk and optional bucket/S3 context.
* including its temporary location on disk and optional bucket/S3 context.a
*/
export interface UploadInput {
/** Absolute path to the temporary file on disk */
+8 -15
View File
@@ -1,10 +1,11 @@
import { nanoid } from 'nanoid';
import { buildNewFile } from '../../domain/entities/file-factory';
import type { MultipartUpload } from '../../domain/entities/multipart';
import type { IBucketRepository } from '../../domain/ports/bucket-repository';
import type { IFileRepository } from '../../domain/ports/file-repository';
import type { IMultipartRepository } from '../../domain/ports/multipart-repository';
import type { ITelegramService } from '../../domain/ports/telegram-service';
import { computeHash } from '../../shared/utils/file';
import { computeHash, DEFAULT_FILE_TYPE } from '../../shared/utils/file';
// ─── Types ──────────────────────────────────────────────────────────
@@ -270,7 +271,8 @@ export function createCompleteMultipartUploadUseCase(deps: MultipartDeps) {
const publicId = nanoid();
await deps.fileRepo.create({
await deps.fileRepo.create(
buildNewFile({
publicId,
telegramFileId: firstPart.telegramFileId,
telegramFileUniqueId: firstPart.telegramFileUniqueId,
@@ -279,22 +281,13 @@ export function createCompleteMultipartUploadUseCase(deps: MultipartDeps) {
fileName: input.key.split('/').pop() || 'file',
mimeType: 'application/octet-stream',
sizeBytes: totalSize,
fileType: 'document',
uploaderId: 0,
fileHash: null,
archiveTelegramFileId: null,
archiveStorageMessageId: null,
archiveFileName: null,
archiveEntryName: null,
archiveMimeType: null,
archiveSizeBytes: null,
fileType: DEFAULT_FILE_TYPE,
storageBackend: 'telegram',
bucketId: multipart.bucketId,
s3Key: input.key,
storageBackend: 'telegram',
isDeleted: false,
multipartUploadId: input.uploadId,
partCount: null,
});
}),
);
await deps.multipartRepo.complete(input.uploadId);
+2 -628
View File
@@ -1,23 +1,13 @@
import { randomUUID } from 'node:crypto';
import { gzipSync } from 'node:zlib';
import { nanoid } from 'nanoid';
import type { File } from '../../domain/entities/file';
import type { NewFilePart } from '../../domain/entities/file-part';
import type { MultipartPart } from '../../domain/entities/multipart';
import type { IBucketRepository } from '../../domain/ports/bucket-repository';
import type { IFilePartRepository } from '../../domain/ports/file-part-repository';
import type { IFileRepository, S3FileRecord } from '../../domain/ports/file-repository';
import type { IFileRepository } from '../../domain/ports/file-repository';
import type { IMultipartRepository } from '../../domain/ports/multipart-repository';
import type { ITelegramService, TelegramFileInfo } from '../../domain/ports/telegram-service';
import { computeHash, ensureExtension, formatCreatedAt } from '../../shared/utils/file';
import type { CompressionAlgorithm } from '../../shared/utils/compress';
// ─── Types ──────────────────────────────────────────────────────────
/**
* Compression algorithm used for chunked object storage.
*/
type CompressionAlgorithm = 'gzip' | null;
/**
* A single part source for building a multi-part streaming response.
* Each part corresponds to a Telegram-stored file chunk.
@@ -173,619 +163,3 @@ export interface S3ObjectDeps {
/** Application configuration subset. */
config: S3ObjectConfig;
}
// ─── Helpers ────────────────────────────────────────────────────────
/**
* Validates the configured chunk size and returns it as a safe integer.
*
* @param chunkSizeBytes - The configured chunk size in bytes.
* @returns The same value if it is a positive safe integer.
*/
const asSafeChunkSize = (chunkSizeBytes: number): number => {
if (!Number.isSafeInteger(chunkSizeBytes) || chunkSizeBytes <= 0) {
throw new Error('Invalid Telegram chunk size');
}
return chunkSizeBytes;
};
/**
* Optionally gzip-compresses a chunk if compression is enabled and the chunk
* is large enough to benefit from it.
*
* @param chunk - The raw chunk buffer.
* @param compress - Whether compression is enabled.
* @param compressionMinSizeBytes - Minimum chunk size to attempt compression.
* @returns The (possibly compressed) buffer and the algorithm used.
*/
const maybeCompressChunk = (
chunk: Buffer,
compress: boolean,
compressionMinSizeBytes: number,
): { bytes: Buffer; compressionAlgorithm: CompressionAlgorithm } => {
if (!compress || chunk.byteLength < compressionMinSizeBytes) {
return { bytes: chunk, compressionAlgorithm: null };
}
const gzipped = gzipSync(chunk);
if (gzipped.byteLength >= chunk.byteLength) {
return { bytes: chunk, compressionAlgorithm: null };
}
return { bytes: gzipped, compressionAlgorithm: 'gzip' };
};
/**
* Metadata for a single uploaded chunk/part during S3 put-object.
*/
interface UploadedPart {
/** 1-based part number. */
partNumber: number;
/** Telegram file identifier for this part. */
telegramFileId: string;
/** Telegram unique file identifier (stable across bot tokens). */
telegramFileUniqueId: string;
/** Message ID within the storage chat. */
storageMessageId: number;
/** Original size of the chunk in bytes before compression. */
sizeBytes: number;
/** Stored (post-compression) size in bytes. */
storedSizeBytes: number;
/** Compression algorithm applied, or null if uncompressed. */
compressionAlgorithm: CompressionAlgorithm;
/** SHA-256 hash of the original chunk content. */
etag: string;
}
/** Result of uploading an object in multiple Telegram chunks. */
interface ChunkedUploadResult {
/** Metadata for each uploaded part. */
parts: UploadedPart[];
/** SHA-256 hex digest of the complete object content. */
fileHash: string;
/** Total object size in bytes (sum of all original chunks). */
totalSizeBytes: number;
}
/**
* Uploads a buffer to Telegram in chunks, returning metadata for all parts.
*
* @param buffer - The full object buffer.
* @param partFileNamePrefix - Prefix used for each chunk's file name in Telegram.
* @param chunkSizeBytes - Maximum size of each chunk in bytes.
* @param compress - Whether gzip compression is enabled.
* @param compressionMinSizeBytes - Minimum chunk size to attempt compression.
* @param telegramService - The Telegram service to forward each chunk.
* @returns The aggregated chunked upload result.
*/
const uploadInChunks = async (
buffer: Buffer,
partFileNamePrefix: string,
chunkSizeBytes: number,
compress: boolean,
compressionMinSizeBytes: number,
telegramService: ITelegramService,
): Promise<ChunkedUploadResult> => {
const safeChunkSize = asSafeChunkSize(chunkSizeBytes);
const hasher = new Bun.CryptoHasher('sha256');
const parts: UploadedPart[] = [];
let totalSizeBytes = 0;
let partNumber = 0;
let offset = 0;
while (offset < buffer.byteLength) {
const chunk = buffer.subarray(offset, offset + safeChunkSize);
if (chunk.byteLength === 0) break;
partNumber += 1;
totalSizeBytes += chunk.byteLength;
hasher.update(chunk);
const { bytes, compressionAlgorithm } = maybeCompressChunk(
chunk,
compress,
compressionMinSizeBytes,
);
const forwardResult = await telegramService.forwardToStorage(
bytes,
`${partFileNamePrefix}.part-${partNumber}`,
'document',
);
parts.push({
partNumber,
telegramFileId: forwardResult.telegramFileId,
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
storageMessageId: forwardResult.storageMessageId,
sizeBytes: chunk.byteLength,
storedSizeBytes: bytes.byteLength,
compressionAlgorithm,
etag: computeHash(chunk),
});
offset += safeChunkSize;
}
return {
parts,
fileHash: hasher.digest('hex'),
totalSizeBytes,
};
};
/**
* Resolves a list of multipart parts to their Telegram CDN URLs.
*
* @param parts - The stored multipart parts.
* @param telegramService - The Telegram service for resolving file metadata.
* @returns An array of resolved part sources.
*/
const resolveMultipartParts = async (
parts: MultipartPart[],
telegramService: ITelegramService,
): Promise<ObjectPartSource[]> => {
const sources: ObjectPartSource[] = [];
for (const part of parts) {
const fileInfo = await telegramService.getFileInfo(part.telegramFileId);
sources.push({
telegramFileId: part.telegramFileId,
telegramUrl: `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`,
sizeBytes: part.sizeBytes,
partNumber: part.partNumber,
});
}
return sources;
};
/**
* Formats a `createdAt` value into an HTTP Last-Modified header value.
*
* @param date - The date to format.
* @returns The UTC string representation.
*/
const formatLastModified = (date: Date | string | number): string => {
return date instanceof Date ? date.toUTCString() : new Date(date).toUTCString();
};
// ─── Use Case Factories ─────────────────────────────────────────────
/**
* Creates a use case that resolves an S3 object for retrieval (GET).
*
* Looks up the bucket and file by key, then determines the storage type:
* - **direct**: regular Telegram-stored object resolves the Telegram CDN URL.
* - **chunked**: object stored across multiple Telegram file parts.
* - **multipart**: object assembled from a completed multipart upload resolves
* the Telegram CDN URLs for each part.
*
* @param deps - The injected dependencies.
* @returns An async function accepting bucket name and object key, returning
* a discriminated union of possible results, or `null` when the
* bucket or file is not found.
*/
export function createGetObjectUseCase(deps: S3ObjectDeps) {
return async (bucketName: string, key: string): Promise<GetObjectResult | null> => {
const bucket = await deps.bucketRepo.findByName(bucketName);
if (!bucket) return null;
const file = await deps.fileRepo.findByBucketAndKey(bucket.id, key);
if (!file) return null;
// Chunked storage — return the entity; the caller resolves parts via
// chunked-storage helpers.
if (file.storageBackend === 'chunked') {
return { type: 'chunked', file };
}
// Multipart upload object — resolve part Telegram URLs
if (file.multipartUploadId) {
const parts = await deps.multipartRepo.listParts(file.multipartUploadId);
const resolvedParts = await resolveMultipartParts(parts, deps.telegramService);
return { type: 'multipart', file, parts: resolvedParts };
}
// Regular direct object — resolve Telegram CDN URL
const fileInfo = await deps.telegramService.getFileInfo(file.telegramFileId);
const telegramUrl = `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`;
return { type: 'direct', file, telegramUrl, fileInfo };
};
}
/**
* Creates a use case that retrieves S3 object metadata (HEAD).
*
* @param deps - The injected dependencies.
* @returns An async function accepting bucket name and object key, returning
* metadata or `null` when the bucket or file is not found.
*/
export function createHeadObjectUseCase(deps: S3ObjectDeps) {
return async (bucketName: string, key: string): Promise<HeadObjectMetadata | null> => {
const bucket = await deps.bucketRepo.findByName(bucketName);
if (!bucket) return null;
const file = await deps.fileRepo.findByBucketAndKey(bucket.id, key);
if (!file) return null;
return {
contentType: file.mimeType,
contentLength: file.sizeBytes,
etag: file.fileHash || nanoid(16),
lastModified: formatLastModified(file.createdAt),
};
};
}
/**
* Creates a use case that stores an S3 object (PUT).
*
* Handles both chunked (large) and single-message (small) upload paths,
* deduplicates by bucket+key (idempotent PUT), and persists the file
* record and (for chunked storage) part records.
*
* @param deps - The injected dependencies.
* @returns An async function accepting bucket name, key, body buffer, and
* content type, returning the etag of the stored object. Returns
* `null` when the bucket is not found.
*/
export function createPutObjectUseCase(deps: S3ObjectDeps) {
return async (
bucketName: string,
key: string,
body: Buffer,
contentType: string,
): Promise<PutObjectResult | null> => {
const bucket = await deps.bucketRepo.findByName(bucketName);
if (!bucket) return null;
const hash = computeHash(body);
// Idempotent PUT: if the object already exists, skip upload
const existing = await deps.fileRepo.findByBucketAndKey(bucket.id, key);
if (existing) {
return { etag: `"${hash}"` };
}
const fileName = key.split('/').pop() || 'file';
const signatureBuffer = body.subarray(0, 16);
const { fileName: finalFileName, mimeType } = ensureExtension(
fileName,
signatureBuffer,
contentType,
);
const partFileNamePrefix = `s3-${bucket.name}-${key.replace(/\//g, '_')}`;
const {
telegramChunkSizeBytes,
compressChunkedUploads,
chunkCompressionMinSizeBytes,
storageChatId,
} = deps.config;
if (body.byteLength > telegramChunkSizeBytes) {
// Chunked upload path
const chunkResult = await uploadInChunks(
body,
partFileNamePrefix,
telegramChunkSizeBytes,
compressChunkedUploads,
chunkCompressionMinSizeBytes,
deps.telegramService,
);
const firstPart = chunkResult.parts[0];
if (!firstPart) {
throw new Error('Chunked upload produced no parts');
}
const fileId = randomUUID();
const publicId = nanoid();
await deps.fileRepo.create({
publicId,
telegramFileId: firstPart.telegramFileId,
telegramFileUniqueId: firstPart.telegramFileUniqueId,
storageChatId,
storageMessageId: firstPart.storageMessageId,
fileName: finalFileName,
mimeType,
sizeBytes: chunkResult.totalSizeBytes,
fileType: 'document',
uploaderId: 0,
fileHash: chunkResult.fileHash,
archiveTelegramFileId: null,
archiveStorageMessageId: null,
archiveFileName: null,
archiveEntryName: null,
archiveMimeType: null,
archiveSizeBytes: null,
bucketId: bucket.id,
s3Key: key,
storageBackend: 'chunked',
isDeleted: false,
multipartUploadId: null,
partCount: chunkResult.parts.length,
});
const fileParts: NewFilePart[] = chunkResult.parts.map((part) => ({
fileId,
partNumber: part.partNumber,
telegramFileId: part.telegramFileId,
telegramFileUniqueId: part.telegramFileUniqueId,
storageChatId,
storageMessageId: part.storageMessageId,
sizeBytes: part.sizeBytes,
storedSizeBytes: part.storedSizeBytes,
compressionAlgorithm: part.compressionAlgorithm,
etag: part.etag,
}));
await deps.filePartRepo.insert(fileParts);
return { etag: `"${chunkResult.fileHash}"` };
}
// Single-message upload path
const forwardResult = await deps.telegramService.forwardToStorage(
body,
partFileNamePrefix,
'document',
);
const publicId = nanoid();
await deps.fileRepo.create({
publicId,
telegramFileId: forwardResult.telegramFileId,
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
storageChatId,
storageMessageId: forwardResult.storageMessageId,
fileName: finalFileName,
mimeType,
sizeBytes: body.byteLength,
fileType: 'document',
uploaderId: 0,
fileHash: hash,
archiveTelegramFileId: null,
archiveStorageMessageId: null,
archiveFileName: null,
archiveEntryName: null,
archiveMimeType: null,
archiveSizeBytes: null,
bucketId: bucket.id,
s3Key: key,
storageBackend: 'telegram',
isDeleted: false,
multipartUploadId: null,
partCount: null,
});
return { etag: `"${hash}"` };
};
}
/**
* Creates a use case that copies an S3 object to a new key (PUT with
* x-amz-copy-source).
*
* Creates a new file record referencing the same Telegram-stored data.
* Chunked source objects are not supported for copy.
*
* @param deps - The injected dependencies.
* @returns An async function accepting source + destination identifiers and
* optional precondition headers, returning the copy result or
* `null` when a required bucket or file is not found.
*/
export function createCopyObjectUseCase(deps: S3ObjectDeps) {
return async (input: {
/** Source bucket name. */
sourceBucket: string;
/** Source object key. */
sourceKey: string;
/** Destination bucket UUID (must already exist). */
destBucketId: string;
/** Destination object key. */
destKey: string;
/** Optional if-match precondition (raw etag value, without surrounding quotes). */
ifMatch?: string | null;
/** Optional if-none-match precondition (raw etag value, without surrounding quotes). */
ifNoneMatch?: string | null;
}): Promise<CopyObjectResult | null> => {
const sourceBucket = await deps.bucketRepo.findByName(input.sourceBucket);
if (!sourceBucket) return null;
const sourceFile = await deps.fileRepo.findByBucketAndKey(sourceBucket.id, input.sourceKey);
if (!sourceFile) return null;
if (sourceFile.storageBackend === 'chunked') {
throw new ObjectError(
'NotImplemented',
'Copying chunked objects is not yet implemented.',
501,
);
}
// Conditional copy: if-match / if-none-match checks
const sourceEtag = sourceFile.fileHash;
if (input.ifMatch && sourceEtag && input.ifMatch !== sourceEtag) {
throw new ObjectError(
'PreconditionFailed',
'The preconditions you specified did not hold.',
412,
);
}
if (input.ifNoneMatch && sourceEtag && input.ifNoneMatch === sourceEtag) {
throw new ObjectError(
'PreconditionFailed',
'The preconditions you specified did not hold.',
412,
);
}
const publicId = nanoid();
await deps.fileRepo.create({
publicId,
telegramFileId: sourceFile.telegramFileId,
telegramFileUniqueId: sourceFile.telegramFileUniqueId,
storageChatId: sourceFile.storageChatId,
storageMessageId: sourceFile.storageMessageId,
fileName: sourceFile.fileName,
mimeType: sourceFile.mimeType,
sizeBytes: sourceFile.sizeBytes,
fileType: sourceFile.fileType,
uploaderId: 0,
fileHash: sourceFile.fileHash,
archiveTelegramFileId: sourceFile.archiveTelegramFileId,
archiveStorageMessageId: sourceFile.archiveStorageMessageId,
archiveFileName: sourceFile.archiveFileName,
archiveEntryName: sourceFile.archiveEntryName,
archiveMimeType: sourceFile.archiveMimeType,
archiveSizeBytes: sourceFile.archiveSizeBytes,
bucketId: input.destBucketId,
s3Key: input.destKey,
storageBackend: 'telegram',
isDeleted: false,
multipartUploadId: null,
partCount: null,
});
return {
etag: sourceEtag || nanoid(16),
lastModified: new Date().toISOString(),
};
};
}
/**
* Error type for S3 object-level application errors.
*/
export class ObjectError extends Error {
/** S3-compatible error code. */
readonly code: string;
/** Suggested HTTP status code. */
readonly status: number;
/**
* @param code - The S3 error code.
* @param message - Human-readable error description.
* @param status - Suggested HTTP status.
*/
constructor(code: string, message: string, status: number) {
super(message);
this.name = 'ObjectError';
this.code = code;
this.status = status;
}
}
/**
* Creates a use case that soft-deletes an S3 object (DELETE).
*
* @param deps - The injected dependencies.
* @returns An async function accepting bucket name and object key, returning
* `true` if a row was soft-deleted. Returns `null` when the bucket
* is not found.
*/
export function createDeleteObjectUseCase(deps: S3ObjectDeps) {
return async (bucketName: string, key: string): Promise<boolean | null> => {
const bucket = await deps.bucketRepo.findByName(bucketName);
if (!bucket) return null;
return deps.fileRepo.softDelete(bucket.id, key);
};
}
/**
* Creates a use case that batch-deletes multiple S3 objects (POST with
* ?delete).
*
* @param deps - The injected dependencies.
* @returns An async function accepting bucket name and an array of keys,
* returning the array of keys that were actually deleted. Returns
* `null` when the bucket is not found.
*/
export function createDeleteObjectsUseCase(deps: S3ObjectDeps) {
return async (bucketName: string, keys: string[]): Promise<string[] | null> => {
const bucket = await deps.bucketRepo.findByName(bucketName);
if (!bucket) return null;
const deletedKeys: string[] = [];
for (const key of keys) {
const ok = await deps.fileRepo.softDelete(bucket.id, key);
if (ok) deletedKeys.push(key);
}
return deletedKeys;
};
}
/**
* Creates a use case that lists objects within a bucket (ListObjectsV1/V2).
*
* Supports prefix filtering, delimiter-based pseudo-directory grouping, and
* pagination via marker/startAfter.
*
* @param deps - The injected dependencies.
* @returns An async function accepting query parameters and returning the
* listing result, or `null` when the bucket is not found.
*/
export function createListObjectsUseCase(deps: S3ObjectDeps) {
return async (input: {
/** Bucket name to list from. */
bucketName: string;
/** Key prefix to filter by (empty string for no filter). */
prefix: string;
/** Delimiter character (e.g. "/") or null for flat listing. */
delimiter: string | null;
/** Maximum number of object records to return (clamped to 1000). */
maxKeys: number;
/** Return only keys strictly greater than this value, or null. */
startAfter: string | null;
}): Promise<ListObjectsResult | null> => {
const bucket = await deps.bucketRepo.findByName(input.bucketName);
if (!bucket) return null;
const clampedMaxKeys = Math.min(input.maxKeys, 1000);
const { objects, prefixes } = await deps.fileRepo.listByPrefix(
bucket.id,
input.prefix,
input.delimiter,
clampedMaxKeys,
input.startAfter,
);
const isTruncated = objects.length > clampedMaxKeys;
const displayObjects = objects.slice(0, clampedMaxKeys);
const nextMarker = isTruncated
? (displayObjects[displayObjects.length - 1]?.s3Key ?? null)
: null;
return {
objects: displayObjects.map((o: S3FileRecord) => ({
key: o.s3Key,
sizeBytes: o.sizeBytes,
etag: o.fileHash || nanoid(16),
lastModified: formatCreatedAt(o.createdAt),
mimeType: o.mimeType,
})),
prefixes,
isTruncated,
nextMarker,
};
};
}
/**
* Creates a use case that checks whether an object exists and is accessible
* within a bucket.
*
* @param deps - The injected dependencies.
* @returns An async function accepting a bucket ID and object key,
* returning the file entity or null.
*/
export function createFindObjectUseCase(deps: Pick<S3ObjectDeps, 'fileRepo'>) {
return async (bucketId: string, key: string): Promise<File | null> => {
return deps.fileRepo.findByBucketAndKey(bucketId, key);
};
}
+30 -226
View File
@@ -1,48 +1,13 @@
import { randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { open } from 'node:fs/promises';
import { gzipSync } from 'node:zlib';
import { nanoid } from 'nanoid';
import type { NewFilePart } from '../../domain/entities/file-part';
import type { IFilePartRepository } from '../../domain/ports/file-part-repository';
import { buildNewFile } from '../../domain/entities/file-factory';
import type { IFileRepository } from '../../domain/ports/file-repository';
import type { ITelegramService } from '../../domain/ports/telegram-service';
import { checkFileSize, computeHash, ensureExtension, getFileType } from '../../shared/utils/file';
import type { ChunkedStorage } from '../../infrastructure/telegram/chunked-storage';
import { checkFileSize, ensureExtension, getFileType } from '../../shared/utils/file';
import type { UploadInput, UploadOutput } from '../dto/upload';
/** Compression algorithm string literal used in chunked storage. */
type ChunkCompressionAlgorithm = 'gzip' | null;
/** Metadata for a single uploaded chunk/part. */
interface UploadedPart {
/** 1-based part number. */
partNumber: number;
/** Telegram file identifier for this part. */
telegramFileId: string;
/** Telegram unique file identifier (stable across bot tokens). */
telegramFileUniqueId: string;
/** Message ID within the storage chat. */
storageMessageId: number;
/** Original size of the chunk in bytes before compression. */
sizeBytes: number;
/** Stored (post-compression) size in bytes. */
storedSizeBytes: number;
/** Compression algorithm applied, or null if uncompressed. */
compressionAlgorithm: ChunkCompressionAlgorithm;
/** SHA-256 hash of the original chunk content. */
etag: string;
}
/** Result of uploading a file in multiple Telegram chunks. */
interface ChunkedUploadResult {
/** Metadata for each uploaded part. */
parts: UploadedPart[];
/** SHA-256 hex digest of the complete file content. */
fileHash: string;
/** Total file size in bytes (sum of all original chunks). */
totalSizeBytes: number;
}
/** Subset of application configuration consumed by the upload-file use case. */
export interface UploadFileConfig {
/** Server base URL for constructing download links. */
@@ -61,53 +26,14 @@ export interface UploadFileConfig {
export interface UploadFileUseCaseDeps {
/** File repository for CRUD operations on file records. */
fileRepo: IFileRepository;
/** File-part repository for chunked file metadata. */
filePartRepo: IFilePartRepository;
/** Telegram service for forwarding file content to storage. */
telegramService: ITelegramService;
/** Chunked storage handler for large file uploads. */
chunkedStorage: ChunkedStorage;
/** Application configuration subset. */
config: UploadFileConfig;
}
/**
* Validates the configured chunk size and returns it as a safe integer.
*
* @param chunkSizeBytes - The configured chunk size in bytes.
* @returns The same value if it is a positive safe integer.
*/
const asSafeChunkSize = (chunkSizeBytes: number): number => {
if (!Number.isSafeInteger(chunkSizeBytes) || chunkSizeBytes <= 0) {
throw new Error('Invalid Telegram chunk size');
}
return chunkSizeBytes;
};
/**
* Optionally gzip-compresses a chunk if compression is enabled and the chunk
* is large enough to benefit from it.
*
* @param chunk - The raw chunk buffer.
* @param compress - Whether compression is enabled.
* @param compressionMinSizeBytes - Minimum chunk size to attempt compression.
* @returns The (possibly compressed) buffer and the algorithm used.
*/
const maybeCompressChunk = (
chunk: Buffer,
compress: boolean,
compressionMinSizeBytes: number,
): { bytes: Buffer; compressionAlgorithm: ChunkCompressionAlgorithm } => {
if (!compress || chunk.byteLength < compressionMinSizeBytes) {
return { bytes: chunk, compressionAlgorithm: null };
}
const gzipped = gzipSync(chunk);
if (gzipped.byteLength >= chunk.byteLength) {
return { bytes: chunk, compressionAlgorithm: null };
}
return { bytes: gzipped, compressionAlgorithm: 'gzip' };
};
/**
* Reads the first 16 bytes from a file on disk for magic-byte detection.
*
@@ -125,74 +51,6 @@ const readSignatureBuffer = async (tempPath: string): Promise<Buffer> => {
}
};
/**
* Reads a file from disk in chunks, forwards each chunk to Telegram storage,
* and returns metadata for all uploaded parts together with the total file
* hash.
*
* @param tempPath - Absolute path to the temporary file on disk.
* @param partFileNamePrefix - Prefix used for each chunk's file name in Telegram.
* @param chunkSizeBytes - Maximum size of each chunk in bytes.
* @param compress - Whether gzip compression is enabled.
* @param compressionMinSizeBytes - Minimum chunk size to attempt compression.
* @param telegramService - The Telegram service to forward each chunk.
* @returns The aggregated chunked upload result.
*/
const uploadFileInTelegramChunks = async (
tempPath: string,
partFileNamePrefix: string,
chunkSizeBytes: number,
compress: boolean,
compressionMinSizeBytes: number,
telegramService: ITelegramService,
): Promise<ChunkedUploadResult> => {
const safeChunkSize = asSafeChunkSize(chunkSizeBytes);
const hasher = new Bun.CryptoHasher('sha256');
const parts: UploadedPart[] = [];
let totalSizeBytes = 0;
let partNumber = 0;
const stream = createReadStream(tempPath, { highWaterMark: safeChunkSize });
for await (const data of stream) {
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data as Uint8Array);
if (chunk.byteLength === 0) continue;
partNumber += 1;
totalSizeBytes += chunk.byteLength;
hasher.update(chunk);
const { bytes, compressionAlgorithm } = maybeCompressChunk(
chunk,
compress,
compressionMinSizeBytes,
);
const forwardResult = await telegramService.forwardToStorage(
bytes,
`${partFileNamePrefix}.part-${partNumber}`,
'document',
);
parts.push({
partNumber,
telegramFileId: forwardResult.telegramFileId,
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
storageMessageId: forwardResult.storageMessageId,
sizeBytes: chunk.byteLength,
storedSizeBytes: bytes.byteLength,
compressionAlgorithm,
etag: computeHash(chunk),
});
}
return {
parts,
fileHash: hasher.digest('hex'),
totalSizeBytes,
};
};
/**
* Creates a factory function for the upload-file use case.
*
@@ -200,9 +58,9 @@ const uploadFileInTelegramChunks = async (
* 1. Checks for an existing file with the same SHA-256 hash (deduplication).
* 2. Normalises the file name and MIME type based on magic bytes.
* 3. Validates the file size against Telegram type-specific limits.
* 4. Chooses a storage strategy chunked (for files exceeding the chunk
* threshold) or single-message upload.
* 5. Persists the file record (and, for chunked uploads, part records).
* 4. Chooses a storage strategy chunked (delegated to ChunkedStorage) or
* single-message upload.
* 5. Persists the file record.
* 6. Builds and returns the public `UploadOutput` DTO.
*
* @param deps - The injected dependencies.
@@ -241,75 +99,28 @@ export function createUploadFileUseCase(deps: UploadFileUseCaseDeps) {
throw new Error(`File size exceeds ${fileType} limit`);
}
// 4. Upload — chunked for files above the threshold, single otherwise
// 4. Upload — chunked via ChunkedStorage for files above the threshold
if (input.sizeBytes > deps.config.telegramChunkSizeBytes) {
// Chunked upload path
const chunkResult = await uploadFileInTelegramChunks(
input.tempPath,
`direct-${input.fileHash.slice(0, 16)}`,
deps.config.telegramChunkSizeBytes,
deps.config.compressChunkedUploads,
deps.config.chunkCompressionMinSizeBytes,
deps.telegramService,
);
const firstPart = chunkResult.parts[0];
if (!firstPart) {
throw new Error('Chunked upload produced no parts');
}
const fileId = randomUUID();
const publicId = nanoid();
const newFile = await deps.fileRepo.create({
publicId,
telegramFileId: firstPart.telegramFileId,
telegramFileUniqueId: firstPart.telegramFileUniqueId,
storageChatId: deps.config.storageChatId,
storageMessageId: firstPart.storageMessageId,
const uploadedFile = await deps.chunkedStorage.storeFileInTelegramChunks({
tempPath: input.tempPath,
partFileNamePrefix: `direct-${input.fileHash.slice(0, 16)}`,
fileName: finalFileName,
mimeType,
sizeBytes: chunkResult.totalSizeBytes,
sizeBytes: input.sizeBytes,
fileType,
uploaderId: input.uploaderId ?? 0,
fileHash: chunkResult.fileHash,
archiveTelegramFileId: null,
archiveStorageMessageId: null,
archiveFileName: null,
archiveEntryName: null,
archiveMimeType: null,
archiveSizeBytes: null,
bucketId: input.bucketId ?? null,
s3Key: input.s3Key ?? null,
storageBackend: 'chunked',
isDeleted: false,
multipartUploadId: null,
partCount: chunkResult.parts.length,
bucketId: input.bucketId,
s3Key: input.s3Key,
});
const fileParts: NewFilePart[] = chunkResult.parts.map((part) => ({
fileId,
partNumber: part.partNumber,
telegramFileId: part.telegramFileId,
telegramFileUniqueId: part.telegramFileUniqueId,
storageChatId: deps.config.storageChatId,
storageMessageId: part.storageMessageId,
sizeBytes: part.sizeBytes,
storedSizeBytes: part.storedSizeBytes,
compressionAlgorithm: part.compressionAlgorithm,
etag: part.etag,
}));
await deps.filePartRepo.insert(fileParts);
return {
publicId: newFile.publicId,
fileName: newFile.fileName,
mimeType: newFile.mimeType,
sizeBytes: newFile.sizeBytes,
fileType: newFile.fileType,
createdAt: newFile.createdAt,
downloadUrl: `${deps.config.baseUrl}/f/${newFile.publicId}`,
publicId: uploadedFile.publicId,
fileName: uploadedFile.fileName,
mimeType: uploadedFile.mimeType,
sizeBytes: uploadedFile.sizeBytes,
fileType: uploadedFile.fileType,
createdAt: uploadedFile.createdAt,
downloadUrl: `${deps.config.baseUrl}/f/${uploadedFile.publicId}`,
};
}
@@ -322,7 +133,8 @@ export function createUploadFileUseCase(deps: UploadFileUseCaseDeps) {
const singlePublicId = nanoid();
const createdFile = await deps.fileRepo.create({
const createdFile = await deps.fileRepo.create(
buildNewFile({
publicId: singlePublicId,
telegramFileId: forwardResult.telegramFileId,
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
@@ -332,21 +144,13 @@ export function createUploadFileUseCase(deps: UploadFileUseCaseDeps) {
mimeType,
sizeBytes: input.sizeBytes,
fileType,
uploaderId: input.uploaderId ?? 0,
fileHash: input.fileHash,
archiveTelegramFileId: null,
archiveStorageMessageId: null,
archiveFileName: null,
archiveEntryName: null,
archiveMimeType: null,
archiveSizeBytes: null,
bucketId: input.bucketId ?? null,
s3Key: input.s3Key ?? null,
storageBackend: 'telegram',
isDeleted: false,
multipartUploadId: null,
partCount: null,
});
uploaderId: input.uploaderId,
fileHash: input.fileHash,
bucketId: input.bucketId,
s3Key: input.s3Key,
}),
);
return {
publicId: createdFile.publicId,
-3
View File
@@ -1,3 +0,0 @@
import { config } from '../env';
export { config };
-81
View File
@@ -1,81 +0,0 @@
import { sql } from 'drizzle-orm';
import { db } from './index';
export interface Bucket {
id: string;
name: string;
createdAt: Date;
updatedAt: Date;
}
type QueryRow = Record<string, unknown>;
type QueryResult = QueryRow[];
export const createBucket = async (name: string): Promise<Bucket> => {
const result = (await db.execute(
sql`INSERT INTO buckets (name) VALUES (${name}) RETURNING id, name, created_at, updated_at`,
)) as unknown as QueryResult;
const row = result[0]!;
return {
id: row.id as string,
name: row.name as string,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
};
export const findBucketByName = async (name: string): Promise<Bucket | null> => {
const result = (await db.execute(
sql`SELECT id, name, created_at, updated_at FROM buckets WHERE name = ${name}`,
)) as unknown as QueryResult;
if (result.length === 0) return null;
const row = result[0]!;
return {
id: row.id as string,
name: row.name as string,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
};
export const listBuckets = async (): Promise<Bucket[]> => {
const result = (await db.execute(
sql`SELECT id, name, created_at, updated_at FROM buckets ORDER BY name`,
)) as unknown as QueryResult;
return result.map((row) => ({
id: row.id as string,
name: row.name as string,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
}));
};
export const deleteBucket = async (name: string): Promise<boolean> => {
// Cascade-delete rows that hold FK references to the bucket
await db
.execute(
sql`DELETE FROM multipart_parts WHERE upload_id IN (SELECT upload_id FROM multipart_uploads WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name}))`,
)
.catch(() => {});
await db
.execute(
sql`DELETE FROM multipart_uploads WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`,
)
.catch(() => {});
await db
.execute(
sql`DELETE FROM files WHERE bucket_id IN (SELECT id FROM buckets WHERE name = ${name})`,
)
.catch(() => {});
const result = (await db.execute(
sql`DELETE FROM buckets WHERE name = ${name}`,
)) as unknown as QueryResult;
return result.length > 0;
};
export const bucketExists = async (name: string): Promise<boolean> => {
const result = (await db.execute(
sql`SELECT 1 FROM buckets WHERE name = ${name}`,
)) as unknown as QueryResult;
return result.length > 0;
};
-97
View File
@@ -1,97 +0,0 @@
import { sql } from 'drizzle-orm';
import { db } from './index';
export type CompressionAlgorithm = 'gzip' | null;
export interface FilePart {
id: number;
fileId: string;
partNumber: number;
telegramFileId: string;
telegramFileUniqueId: string;
storageChatId: number;
storageMessageId: number;
sizeBytes: number;
storedSizeBytes: number;
compressionAlgorithm: CompressionAlgorithm;
etag: string;
createdAt: Date;
}
export type NewFilePartInput = Omit<FilePart, 'id' | 'createdAt'>;
const toNumber = (value: unknown): number => Number(value ?? 0);
const mapRowToFilePart = (row: Record<string, unknown>): FilePart => ({
id: toNumber(row.id),
fileId: row.file_id as string,
partNumber: toNumber(row.part_number),
telegramFileId: row.telegram_file_id as string,
telegramFileUniqueId: row.telegram_file_unique_id as string,
storageChatId: toNumber(row.storage_chat_id),
storageMessageId: toNumber(row.storage_message_id),
sizeBytes: toNumber(row.size_bytes),
storedSizeBytes: toNumber(row.stored_size_bytes),
compressionAlgorithm: (row.compression_algorithm as CompressionAlgorithm) || null,
etag: row.etag as string,
createdAt: new Date(row.created_at as string),
});
export const insertFileParts = async (parts: NewFilePartInput[]): Promise<void> => {
for (const part of parts) {
await db.execute(
sql`INSERT INTO file_parts (
file_id,
part_number,
telegram_file_id,
telegram_file_unique_id,
storage_chat_id,
storage_message_id,
size_bytes,
stored_size_bytes,
compression_algorithm,
etag
) VALUES (
${part.fileId}::uuid,
${part.partNumber},
${part.telegramFileId},
${part.telegramFileUniqueId},
${part.storageChatId},
${part.storageMessageId},
${part.sizeBytes},
${part.storedSizeBytes},
${part.compressionAlgorithm},
${part.etag}
)`,
);
}
};
export const listFileParts = async (fileId: string): Promise<FilePart[]> => {
const result = (await db.execute(
sql`SELECT id,
file_id,
part_number,
telegram_file_id,
telegram_file_unique_id,
storage_chat_id,
storage_message_id,
size_bytes,
stored_size_bytes,
compression_algorithm,
etag,
created_at
FROM file_parts
WHERE file_id = ${fileId}::uuid
ORDER BY part_number`,
)) as unknown as Record<string, unknown>[];
return result.map(mapRowToFilePart);
};
export const countFileParts = async (fileId: string): Promise<number> => {
const result = (await db.execute(
sql`SELECT COUNT(*) AS count FROM file_parts WHERE file_id = ${fileId}::uuid`,
)) as unknown as Record<string, unknown>[];
return toNumber(result[0]?.count);
};
-143
View File
@@ -1,143 +0,0 @@
import { and, eq, sql } from 'drizzle-orm';
import { db, files as fileSchema } from './index';
import type { File } from './schema';
export interface S3FileRecord extends File {
bucketId: string;
s3Key: string;
}
export const findFileByBucketAndKey = async (
bucketId: string,
s3Key: string,
): Promise<File | null> => {
const result = await db
.select()
.from(fileSchema)
.where(
and(
eq(fileSchema.bucketId, bucketId),
eq(fileSchema.s3Key, s3Key),
eq(fileSchema.isDeleted, false),
),
)
.limit(1);
return result[0] || null;
};
const mapDbRowToS3Record = (row: Record<string, unknown>): S3FileRecord => {
return {
id: row.id as string,
publicId: row.public_id as string,
telegramFileId: row.telegram_file_id as string,
telegramFileUniqueId: row.telegram_file_unique_id as string,
storageChatId: toNumber(row.storage_chat_id),
storageMessageId: toNumber(row.storage_message_id),
fileName: row.file_name as string,
mimeType: row.mime_type as string,
sizeBytes: toNumber(row.size_bytes),
fileType: row.file_type as string,
uploaderId: toNumber(row.uploader_id),
fileHash: row.file_hash as string | null,
archiveTelegramFileId: row.archive_telegram_file_id as string | null,
archiveStorageMessageId:
row.archive_storage_message_id === null ? null : toNumber(row.archive_storage_message_id),
archiveFileName: row.archive_file_name as string | null,
archiveEntryName: row.archive_entry_name as string | null,
archiveMimeType: row.archive_mime_type as string | null,
archiveSizeBytes: row.archive_size_bytes === null ? null : toNumber(row.archive_size_bytes),
bucketId: row.bucket_id as string,
s3Key: row.s3_key as string,
storageBackend: (row.storage_backend as string) || 'telegram',
isDeleted: row.is_deleted as boolean,
multipartUploadId: row.multipart_upload_id as string | null,
partCount:
row.part_count === null || row.part_count === undefined ? null : toNumber(row.part_count),
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
};
const escapeLike = (s: string): string => s.replace(/[%_\\]/g, '\\$&');
const toNumber = (value: unknown): number => Number(value ?? 0);
export const listObjectsByPrefix = async (
bucketId: string,
prefix: string,
delimiter: string | null,
maxKeys: number,
startAfter: string | null,
): Promise<{ objects: S3FileRecord[]; prefixes: string[] }> => {
let query = prefix
? sql`SELECT * FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false AND s3_key LIKE ${`${escapeLike(prefix)}%`}`
: sql`SELECT * FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false`;
if (startAfter) {
query = sql`${query} AND s3_key > ${startAfter}`;
}
query = sql`${query} ORDER BY s3_key LIMIT ${maxKeys + 1}`;
const rawResult = (await db.execute(query)) as unknown as Record<string, unknown>[];
if (delimiter === '/') {
const prefixSet = new Set<string>();
const objects: S3FileRecord[] = [];
for (const row of rawResult) {
const s3Key = row.s3_key as string;
const relativeKey = s3Key.substring(prefix.length);
const slashIndex = relativeKey.indexOf('/');
if (slashIndex >= 0) {
const folderPrefix = prefix + relativeKey.substring(0, slashIndex + 1);
if (folderPrefix !== prefix) {
prefixSet.add(folderPrefix);
}
} else {
objects.push(mapDbRowToS3Record(row));
}
}
return {
objects: objects.slice(0, maxKeys),
prefixes: Array.from(prefixSet).sort(),
};
}
return {
objects: rawResult.slice(0, maxKeys).map(mapDbRowToS3Record),
prefixes: [],
};
};
export const softDeleteFile = async (bucketId: string, s3Key: string): Promise<boolean> => {
const result = (await db.execute(
sql`UPDATE files SET is_deleted = true WHERE bucket_id = ${bucketId}::uuid AND s3_key = ${s3Key} RETURNING id`,
)) as unknown as Record<string, unknown>[];
return result.length > 0;
};
export const softDeleteFilesBatch = async (bucketId: string, keys: string[]): Promise<number> => {
let deleted = 0;
for (const key of keys) {
const ok = await softDeleteFile(bucketId, key);
if (ok) deleted++;
}
return deleted;
};
export const countBucketObjects = async (bucketId: string): Promise<number> => {
const result = (await db.execute(
sql`SELECT count(*) as count FROM files WHERE bucket_id = ${bucketId}::uuid AND is_deleted = false`,
)) as unknown as Record<string, unknown>[];
return Number(result[0]?.count || 0);
};
export const findOrphanFilesByBucket = async (bucketId: string): Promise<File[]> => {
return await db
.select()
.from(fileSchema)
.where(and(eq(fileSchema.bucketId, bucketId), eq(fileSchema.isDeleted, true)))
.limit(100);
};
-26
View File
@@ -1,26 +0,0 @@
import { eq } from 'drizzle-orm';
import { db, files as fileSchema } from './index';
import type { File } from './schema';
export const findFileByHash = async (hash: string): Promise<File | null> => {
const result = await db.select().from(fileSchema).where(eq(fileSchema.fileHash, hash)).limit(1);
return result[0] || null;
};
export const findFileByPublicId = async (publicId: string): Promise<File | null> => {
const result = await db
.select()
.from(fileSchema)
.where(eq(fileSchema.publicId, publicId))
.limit(1);
return result[0] || null;
};
export const findFileByUniqueId = async (telegramFileUniqueId: string): Promise<File | null> => {
const result = await db
.select()
.from(fileSchema)
.where(eq(fileSchema.telegramFileUniqueId, telegramFileUniqueId))
.limit(1);
return result[0] || null;
};
-13
View File
@@ -1,13 +0,0 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { fileParts, files } from './schema';
const client = postgres(process.env.DATABASE_URL!, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
export const db = drizzle(client, { schema: { fileParts, files } });
export { fileParts, files };
export default db;
-53
View File
@@ -1,53 +0,0 @@
import postgres from 'postgres';
import { config } from '../env';
import { getErrorMessage } from '../utils/file';
import logger from '../utils/logger';
/**
* Run raw SQL migration from schema.sql.
* Safe to call multiple times all statements use IF NOT EXISTS.
*/
export const runMigration = async (): Promise<void> => {
// In compiled dist: import.meta.dir = .../dist/
// In source via bun --hot: import.meta.dir = .../src/db/
const dir = import.meta.dir || '';
const candidates = [
`${dir}/../../schema.sql`, // from dist/
`${dir}/../schema.sql`, // from src/ (bun --hot src/index.ts)
`${dir}/../schema.sql`, // from src/db/ (bun --hot src/db/migrate.ts)
`${dir}/schema.sql`, // from src/ (bun run db:migrate)
];
let schemaSql: string | null = null;
for (const p of candidates) {
const file = Bun.file(p);
const exists = await file.exists();
if (exists) {
schemaSql = await file.text();
break;
}
}
if (!schemaSql) {
logger.error(`Migration failed: schema.sql not found (tried ${candidates.join(', ')})`);
process.exitCode = 1;
return;
}
const sql = postgres(config.databaseUrl, { max: 1 });
try {
await sql.unsafe(schemaSql);
logger.info('Database migration completed');
} catch (error: unknown) {
logger.error('Database migration failed', { error: getErrorMessage(error) });
process.exitCode = 1;
} finally {
await sql.end();
}
};
// When run directly: `bun src/db/migrate.ts` or `bun dist/migrate.js`
if (import.meta.path === Bun.main) {
await runMigration();
}
-129
View File
@@ -1,129 +0,0 @@
import { sql } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import { db } from './index';
export interface MultipartUpload {
uploadId: string;
bucketId: string;
s3Key: string;
initiatedAt: Date;
status: string;
initiatedBy: string;
}
export interface MultipartPart {
id: number;
uploadId: string;
partNumber: number;
telegramFileId: string;
telegramFileUniqueId: string;
storageMessageId: number;
sizeBytes: number;
etag: string;
createdAt: Date;
}
export const createMultipartUpload = async (
bucketId: string,
s3Key: string,
initiatedBy: string,
): Promise<string> => {
const uploadId = nanoid(32);
await db.execute(
sql`INSERT INTO multipart_uploads (upload_id, bucket_id, s3_key, initiated_by) VALUES (${uploadId}, ${bucketId}, ${s3Key}, ${initiatedBy})`,
);
return uploadId;
};
export const findMultipartUpload = async (uploadId: string): Promise<MultipartUpload | null> => {
const result = (await db.execute(
sql`SELECT upload_id, bucket_id, s3_key, initiated_at, status FROM multipart_uploads WHERE upload_id = ${uploadId} AND status = 'in_progress'`,
)) as unknown as Record<string, unknown>[];
if (result.length === 0) return null;
const r = result[0]!;
return {
uploadId: r.upload_id as string,
bucketId: r.bucket_id as string,
s3Key: r.s3_key as string,
initiatedAt: new Date(r.initiated_at as string),
status: r.status as string,
initiatedBy: '',
};
};
export const completeMultipartUpload = async (uploadId: string): Promise<void> => {
await db.execute(
sql`UPDATE multipart_uploads SET status = 'completed' WHERE upload_id = ${uploadId}`,
);
};
export const abortMultipartUpload = async (uploadId: string): Promise<void> => {
await db.execute(
sql`UPDATE multipart_uploads SET status = 'aborted' WHERE upload_id = ${uploadId}`,
);
// Parts are cascade-deleted by FK
};
export const insertMultipartPart = async (
part: Omit<MultipartPart, 'id' | 'createdAt'>,
): Promise<void> => {
await db.execute(
sql`INSERT INTO multipart_parts (upload_id, part_number, telegram_file_id, telegram_file_unique_id, storage_message_id, size_bytes, etag)
VALUES (${part.uploadId}, ${part.partNumber}, ${part.telegramFileId}, ${part.telegramFileUniqueId}, ${part.storageMessageId}, ${part.sizeBytes}, ${part.etag})`,
);
};
export const listMultipartParts = async (uploadId: string): Promise<MultipartPart[]> => {
const result = (await db.execute(
sql`SELECT id, upload_id, part_number, telegram_file_id, telegram_file_unique_id, storage_message_id, size_bytes, etag, created_at
FROM multipart_parts WHERE upload_id = ${uploadId} ORDER BY part_number`,
)) as unknown as Record<string, unknown>[];
return result.map((r) => ({
id: r.id as number,
uploadId: r.upload_id as string,
partNumber: r.part_number as number,
telegramFileId: r.telegram_file_id as string,
telegramFileUniqueId: r.telegram_file_unique_id as string,
storageMessageId: r.storage_message_id as number,
sizeBytes: Number(r.size_bytes),
etag: r.etag as string,
createdAt: new Date(r.created_at as string),
}));
};
const mapRowToMultipartUpload = (r: Record<string, unknown>): MultipartUpload => ({
uploadId: r.upload_id as string,
bucketId: r.bucket_id as string,
s3Key: r.s3_key as string,
initiatedAt: new Date(r.initiated_at as string),
status: r.status as string,
initiatedBy: (r.initiated_by as string | null) || '',
});
export const listMultipartUploadsByBucket = async (
bucketId: string,
maxUploads: number,
keyMarker: string | null,
): Promise<{ uploads: MultipartUpload[]; isTruncated: boolean; nextKeyMarker: string | null }> => {
const limit = Math.min(Math.max(maxUploads || 1000, 1), 1000);
const result = (await db.execute(
keyMarker
? sql`SELECT upload_id, bucket_id, s3_key, initiated_at, status, initiated_by
FROM multipart_uploads
WHERE bucket_id = ${bucketId}::uuid AND status = 'in_progress' AND s3_key > ${keyMarker}
ORDER BY s3_key, initiated_at
LIMIT ${limit + 1}`
: sql`SELECT upload_id, bucket_id, s3_key, initiated_at, status, initiated_by
FROM multipart_uploads
WHERE bucket_id = ${bucketId}::uuid AND status = 'in_progress'
ORDER BY s3_key, initiated_at
LIMIT ${limit + 1}`,
)) as unknown as Record<string, unknown>[];
const uploads = result.slice(0, limit).map(mapRowToMultipartUpload);
return {
uploads,
isTruncated: result.length > limit,
nextKeyMarker: result.length > limit ? uploads.at(-1)?.s3Key || null : null,
};
};
-60
View File
@@ -1,60 +0,0 @@
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
import {
bigint,
boolean,
integer,
pgTable,
serial,
text,
timestamp,
uuid,
} from 'drizzle-orm/pg-core';
export const files = pgTable('files', {
id: uuid('id').primaryKey().defaultRandom(),
publicId: text('public_id').unique().notNull(),
telegramFileId: text('telegram_file_id').notNull(),
telegramFileUniqueId: text('telegram_file_unique_id').notNull(),
storageChatId: bigint('storage_chat_id', { mode: 'number' }).notNull(),
storageMessageId: bigint('storage_message_id', { mode: 'number' }).notNull(),
fileName: text('file_name').notNull(),
mimeType: text('mime_type').notNull(),
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
fileType: text('file_type').notNull(),
uploaderId: bigint('uploader_id', { mode: 'number' }).notNull(),
fileHash: text('file_hash'),
archiveTelegramFileId: text('archive_telegram_file_id'),
archiveStorageMessageId: bigint('archive_storage_message_id', { mode: 'number' }),
archiveFileName: text('archive_file_name'),
archiveEntryName: text('archive_entry_name'),
archiveMimeType: text('archive_mime_type'),
archiveSizeBytes: bigint('archive_size_bytes', { mode: 'number' }),
bucketId: text('bucket_id'),
s3Key: text('s3_key'),
storageBackend: text('storage_backend').default('telegram'),
isDeleted: boolean('is_deleted').default(false),
multipartUploadId: text('multipart_upload_id'),
partCount: integer('part_count'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export const fileParts = pgTable('file_parts', {
id: serial('id').primaryKey(),
fileId: uuid('file_id').notNull(),
partNumber: integer('part_number').notNull(),
telegramFileId: text('telegram_file_id').notNull(),
telegramFileUniqueId: text('telegram_file_unique_id').notNull(),
storageChatId: bigint('storage_chat_id', { mode: 'number' }).notNull(),
storageMessageId: bigint('storage_message_id', { mode: 'number' }).notNull(),
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
storedSizeBytes: bigint('stored_size_bytes', { mode: 'number' }).notNull(),
compressionAlgorithm: text('compression_algorithm'),
etag: text('etag').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export type File = InferSelectModel<typeof files>;
export type NewFile = InferInsertModel<typeof files>;
export type FilePart = InferSelectModel<typeof fileParts>;
export type NewFilePart = InferInsertModel<typeof fileParts>;
+85
View File
@@ -0,0 +1,85 @@
/**
* Factory function for building NewFile records with sensible defaults.
*
* Most call sites set the same null defaults for archive/S3/soft-delete fields.
* This factory eliminates ~20 lines of boilerplate per call site (~27 sites).
*/
import type { NewFile } from './file';
/**
* Partial input for creating a file record.
* Only the required unique fields must be provided; optional fields default to null/0/false.
*/
export interface FileInput {
publicId: string;
telegramFileId: string;
telegramFileUniqueId: string;
storageChatId: number;
storageMessageId: number;
fileName: string;
mimeType: string;
sizeBytes: number;
fileType: string;
storageBackend: string | null;
/** Optional overrides */
uploaderId?: number;
fileHash?: string | null;
bucketId?: string | null;
s3Key?: string | null;
partCount?: number | null;
multipartUploadId?: string | null;
/** Archive fields (for batch/zip archives) */
archiveTelegramFileId?: string | null;
archiveStorageMessageId?: number | null;
archiveFileName?: string | null;
archiveEntryName?: string | null;
archiveMimeType?: string | null;
archiveSizeBytes?: number | null;
}
/**
* Build a NewFile record, filling in null/zero defaults for omitted fields.
*
* @example
* ```ts
* await fileRepo.create(buildNewFile({
* publicId,
* telegramFileId: result.telegramFileId,
* telegramFileUniqueId: result.telegramFileUniqueId,
* storageChatId,
* storageMessageId: result.storageMessageId,
* fileName: input.fileName,
* mimeType,
* sizeBytes: input.sizeBytes,
* fileType,
* storageBackend: 'telegram',
* uploaderId: input.uploaderId,
* fileHash: input.fileHash,
* }));
* ```
*/
export const buildNewFile = (input: FileInput): NewFile => ({
publicId: input.publicId,
telegramFileId: input.telegramFileId,
telegramFileUniqueId: input.telegramFileUniqueId,
storageChatId: input.storageChatId,
storageMessageId: input.storageMessageId,
fileName: input.fileName,
mimeType: input.mimeType,
sizeBytes: input.sizeBytes,
fileType: input.fileType,
uploaderId: input.uploaderId ?? 0,
fileHash: input.fileHash ?? null,
archiveTelegramFileId: input.archiveTelegramFileId ?? null,
archiveStorageMessageId: input.archiveStorageMessageId ?? null,
archiveFileName: input.archiveFileName ?? null,
archiveEntryName: input.archiveEntryName ?? null,
archiveMimeType: input.archiveMimeType ?? null,
archiveSizeBytes: input.archiveSizeBytes ?? null,
bucketId: input.bucketId ?? null,
s3Key: input.s3Key ?? null,
storageBackend: input.storageBackend,
isDeleted: false,
multipartUploadId: input.multipartUploadId ?? null,
partCount: input.partCount ?? null,
});
-11
View File
@@ -50,15 +50,4 @@ export interface ITelegramService {
* @returns Metadata including size, MIME type, download path, and bot token.
*/
getFileInfo(telegramFileId: string): Promise<TelegramFileInfo>;
/**
* Enqueue a task for sequential upload execution.
*
* Ensures only one Telegram upload runs at a time to avoid
* rate limits and resource contention.
*
* @param task - An async function performing the upload.
* @returns The result of the task.
*/
enqueueUpload<T>(task: () => Promise<T>): Promise<T>;
}
+67 -13
View File
@@ -1,8 +1,11 @@
import logger from './utils/logger';
import logger from './shared/logger/index';
import { TELEGRAM_CHUNK_SIZE_MAX_BYTES } from './shared/utils/validation';
interface AppConfig {
botToken: string;
additionalBotTokens: string[];
/** All bot tokens merged from BOT_TOKENS (or BOT_TOKEN + ADDITIONAL_BOT_TOKENS fallback) */
botTokens: string[];
/** Per-bot concurrency for Telegram API calls (default 1). */
telegramBotConcurrency: number;
storageChatId: number;
baseUrl: string;
databaseUrl: string;
@@ -12,7 +15,6 @@ interface AppConfig {
rateLimitWindowMs: number;
rateLimitMaxRequests: number;
trustProxy: boolean;
uploadConcurrency: number;
batchMaxItems: number;
batchMaxSizeBytes: number;
maxRequestBodyBytes: number;
@@ -29,8 +31,21 @@ interface AppConfig {
s3VhostDomains: string[];
}
// Validate bot tokens: BOT_TOKENS (new) or fallback to BOT_TOKEN + ADDITIONAL_BOT_TOKENS
const botTokensRaw =
process.env.BOT_TOKENS ||
[process.env.BOT_TOKEN, process.env.ADDITIONAL_BOT_TOKENS].filter(Boolean).join(',');
if (!botTokensRaw) {
logger.error(
'Missing required environment variables: BOT_TOKENS (or BOT_TOKEN + ADDITIONAL_BOT_TOKENS)',
);
throw new Error(
'Missing environment variables: BOT_TOKENS (or BOT_TOKEN + ADDITIONAL_BOT_TOKENS)',
);
}
const requiredEnv = {
BOT_TOKEN: process.env.BOT_TOKEN,
STORAGE_CHANNEL_ID: process.env.STORAGE_CHANNEL_ID,
BASE_URL: process.env.BASE_URL,
DATABASE_URL: process.env.DATABASE_URL,
@@ -46,6 +61,25 @@ if (missing.length > 0) {
throw new Error(`Missing environment variables: ${missing.join(', ')}`);
}
// Validate S3 credentials: if S3_ACCESS_KEY is explicitly set (env var present,
// not relying on default), S3_SECRET_KEY must also be set. An empty secret key
// would cause HMAC-SHA256 to "succeed" silently — a security hole.
const s3AccessKeyExplicit = 'S3_ACCESS_KEY' in process.env;
const s3SecretKeyExplicit = 'S3_SECRET_KEY' in process.env;
if (s3AccessKeyExplicit || s3SecretKeyExplicit) {
const s3Key = (process.env.S3_ACCESS_KEY || '').trim();
const s3Secret = (process.env.S3_SECRET_KEY || '').trim();
if (s3Key && !s3Secret) {
logger.error('S3_ACCESS_KEY is set but S3_SECRET_KEY is empty — this is a security risk');
throw new Error(
'S3_ACCESS_KEY requires S3_SECRET_KEY to be set. Set S3_SECRET_KEY or unset S3_ACCESS_KEY.',
);
}
if (s3Secret && !s3Key) {
logger.warn('S3_SECRET_KEY is set but S3_ACCESS_KEY is not — S3 auth will use the default key');
}
}
const parseNumber = (value: string | undefined, fallback: number): number => {
const parsed = Number.parseInt(value || '', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
@@ -74,24 +108,45 @@ const maskSecret = (value: string): string => {
const maskDatabaseUrl = (value: string): string =>
value.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:***@');
// Fail-fast guard for TELEGRAM_CHUNK_SIZE_BYTES: chunked uploads store every
// part as a Telegram document and later resolve it via getFile, which only
// supports files up to 20 MB ("Bad Request: file is too big" above that).
// A chunk above the limit makes every part undownloadable — refuse to start
// instead of failing on the first large-file download.
const telegramChunkSizeBytes = parseNumber(
process.env.TELEGRAM_CHUNK_SIZE_BYTES,
TELEGRAM_CHUNK_SIZE_MAX_BYTES,
);
if (telegramChunkSizeBytes > TELEGRAM_CHUNK_SIZE_MAX_BYTES) {
logger.error(
`TELEGRAM_CHUNK_SIZE_BYTES=${telegramChunkSizeBytes} exceeds the maximum allowed chunk size ` +
`${TELEGRAM_CHUNK_SIZE_MAX_BYTES} bytes (${TELEGRAM_CHUNK_SIZE_MAX_BYTES / (1024 * 1024)} MB). ` +
'Telegram Bot API getFile cannot download files larger than 20 MB, so every stored part would ' +
'be undownloadable ("Bad Request: file is too big"). ' +
`Set TELEGRAM_CHUNK_SIZE_BYTES to ${TELEGRAM_CHUNK_SIZE_MAX_BYTES} or lower.`,
);
throw new Error(
`TELEGRAM_CHUNK_SIZE_BYTES=${telegramChunkSizeBytes} exceeds the maximum allowed chunk size ` +
`${TELEGRAM_CHUNK_SIZE_MAX_BYTES} bytes (${TELEGRAM_CHUNK_SIZE_MAX_BYTES / (1024 * 1024)} MB)`,
);
}
export const config: AppConfig = {
botToken: process.env.BOT_TOKEN!,
additionalBotTokens:
process.env.NODE_ENV === 'test' ? [] : parseTokens(process.env.ADDITIONAL_BOT_TOKENS),
botTokens: parseTokens(botTokensRaw),
telegramBotConcurrency: parseNumber(process.env.TELEGRAM_BOT_CONCURRENCY, 1),
storageChatId: parseInt(process.env.STORAGE_CHANNEL_ID!, 10),
baseUrl: process.env.BASE_URL!,
databaseUrl: process.env.DATABASE_URL!,
port: parseInt(process.env.PORT!, 10) || 3000,
port: parseInt(process.env.PORT!, 10) || 4000,
nodeEnv: process.env.NODE_ENV || 'development',
logLevel: process.env.LOG_LEVEL || 'info',
rateLimitWindowMs: parseNumber(process.env.RATE_LIMIT_WINDOW_MS, 60000),
rateLimitMaxRequests: parseNumber(process.env.RATE_LIMIT_MAX_REQUESTS, 150),
trustProxy: process.env.TRUST_PROXY === 'true',
uploadConcurrency: parseNumber(process.env.UPLOAD_CONCURRENCY, 8),
batchMaxItems: parseNumber(process.env.BATCH_MAX_ITEMS, 20),
batchMaxSizeBytes: parseNumber(process.env.BATCH_MAX_SIZE_BYTES, 500 * 1024 * 1024),
maxRequestBodyBytes: parseNumber(process.env.MAX_REQUEST_BODY_BYTES, 2 * 1024 * 1024 * 1024),
telegramChunkSizeBytes: parseNumber(process.env.TELEGRAM_CHUNK_SIZE_BYTES, 20 * 1024 * 1024),
telegramChunkSizeBytes,
compressChunkedUploads: process.env.COMPRESS_CHUNKED_UPLOADS !== 'false',
chunkCompressionMinSizeBytes: parseNumber(process.env.CHUNK_COMPRESSION_MIN_SIZE_BYTES, 4096),
adminApiToken: process.env.ADMIN_API_TOKEN || '',
@@ -110,8 +165,7 @@ export const config: AppConfig = {
logger.info('Environment variables loaded', {
config: {
...config,
botToken: maskSecret(config.botToken),
additionalBotTokens: config.additionalBotTokens.map(maskSecret),
botTokens: config.botTokens.map(maskSecret),
databaseUrl: maskDatabaseUrl(config.databaseUrl),
adminApiToken: maskSecret(config.adminApiToken),
adminApiTokenEnabled: config.adminApiToken.length > 0,
+59 -27
View File
@@ -136,6 +136,10 @@
}
.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>
@@ -153,9 +157,11 @@
<select id="bucketSelect" onchange="window.switchBucket(this.value)">
<option value="">— Select bucket —</option>
</select>
<button type="button" onclick="window.showCreateBucketModal()">+ New</button>
<button type="button" onclick="window.showCredentialsModal()" title="S3 Credentials">🔑</button>
<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()">
@@ -179,6 +185,7 @@
</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;
@@ -186,25 +193,36 @@
};
const showAuthScreen = () => {
document.getElementById('authScreen').style.display = 'flex';
document.getElementById('logoutBtn').style.display = 'none';
setTimeout(() => document.getElementById('authTokenInput')?.focus(), 50);
};
const hideAuthScreen = (showLogout) => {
const hideAuthScreen = () => {
document.getElementById('authScreen').style.display = 'none';
document.getElementById('logoutBtn').style.display = showLogout ? 'inline-block' : '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) { 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;
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 {
setAuthError('Network error while checking login status.');
showAuthScreen(); return false;
isAdmin = false;
}
hideAuthScreen();
applyAdminUI();
return isAdmin;
};
const handleLogin = async () => {
const input = document.getElementById('authTokenInput');
@@ -217,7 +235,7 @@
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token }),
});
if (res.ok) { hideAuthScreen(true); input.value = ''; await loadBuckets(); return; }
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 {
@@ -228,11 +246,8 @@
};
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();
isAdmin = false;
applyAdminUI();
};
const api = async (path, opts = {}) => {
const res = await fetch(path, opts);
@@ -249,11 +264,13 @@
};
const switchBucket = async (name) => {
currentBucket = name || null; currentPrefix = '';
if (name) { await loadObjects(); document.getElementById('dropzone').style.display = 'block'; }
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'; document.getElementById('dropzone').style.display = 'none';
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');
@@ -278,7 +295,12 @@
};
const renderFileList = () => {
const container = document.getElementById('fileList');
if (currentPrefixes.length === 0 && currentObjects.length === 0) { container.innerHTML = '<div class="empty"><h2>This bucket is empty</h2><p>Drop files here to upload.</p></div>'; return; }
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, '');
@@ -286,7 +308,9 @@
}
for (const obj of currentObjects) {
const displayName = obj.key.replace(currentPrefix, '');
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><button onclick="event.stopPropagation();deleteObject('${obj.key}')" title="Delete">🗑</button></span></div>`;
// 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;
};
@@ -297,11 +321,13 @@
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';
@@ -325,13 +351,19 @@
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=()=>{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 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 init=async()=>{if(await checkAuth())await loadBuckets();};
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-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 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, logout });
Object.assign(window, { switchBucket, navigateTo, debouncedSearch, downloadObject, copyLink, deleteObject, closeModal, showCreateBucketModal, createBucket, showCredentialsModal, showAuthScreen, logout });
init();
</script>
</body>
+4 -31
View File
@@ -1,53 +1,26 @@
import { serve } from 'bun';
import { config } from './config/index';
import { config } from './env';
import { fileInfoCache } from './infrastructure/cache/index';
import { startBot } from './interfaces/bot/handler';
import { handleS3Request } from './interfaces/http/controllers/s3-controller';
import { cleanupRateLimitCache } from './interfaces/http/middleware/rate-limit';
import { routes } from './interfaces/http/routes/index';
import { isS3Request } from './interfaces/s3/auth';
import { extractS3BucketFromHost } from './interfaces/s3/virtual-host';
import { logger } from './shared/logger/index';
import { metricsCollector } from './shared/metrics/index';
import { getS3RouteBucket, shouldHandleS3 } from './shared/utils/s3-detection';
// ─── Auto-run migration at startup ──────────────────────────────────────────
try {
const { runMigration } = await import('./db/migrate');
const { runMigration } = await import('./infrastructure/persistence/drizzle/migrate');
await runMigration();
} catch {
logger.warn('Auto-migration skipped (non-fatal)');
}
const getS3RouteBucket = (req: Request): string | null => {
const host = req.headers.get('host') || '';
return extractS3BucketFromHost(host, config.s3VhostDomains);
};
const shouldHandleS3 = (req: Request, headers: Record<string, string>): boolean => {
const url = new URL(req.url);
return Boolean(
getS3RouteBucket(req) || isS3Request(headers) || url.searchParams.has('X-Amz-Signature'),
);
};
const _handleMaybeS3Root = (req: Request): Response | Promise<Response> => {
if (req.method === 'OPTIONS') {
return handleS3Request(req, getS3RouteBucket(req));
}
const headers = Object.fromEntries(req.headers);
if (shouldHandleS3(req, headers)) {
return handleS3Request(req, getS3RouteBucket(req));
}
return new Response('Not Allowed', { status: 405 });
};
const server = serve({
port: config.port,
routes,
fetch: async (req: Request) => {
if (req.method === 'OPTIONS') {
return handleS3Request(req, getS3RouteBucket(req));
}
const headers = Object.fromEntries(req.headers);
if (shouldHandleS3(req, headers)) {
return handleS3Request(req, getS3RouteBucket(req));
@@ -63,7 +36,7 @@ logger.info('Server started', { port: config.port, url: config.baseUrl });
const gracefulShutdown = async (signal: string): Promise<void> => {
logger.info('Graceful shutdown signal received', { signal });
logger.info('Closing HTTP server');
logger.info('Closing HTTP server — no new requests accepted');
server.stop();
logger.info('Stopping Telegram bot');
+47
View File
@@ -0,0 +1,47 @@
/**
* Dependency Injection container.
*
* Wires up singleton instances of all repositories and application services,
* making them available to controllers and other adapters without requiring
* a full DI framework.
*
* @module infrastructure/di
*/
import type { IBucketRepository } from '../domain/ports/bucket-repository';
import type { IFilePartRepository } from '../domain/ports/file-part-repository';
import type { IFileRepository } from '../domain/ports/file-repository';
import type { IMultipartRepository } from '../domain/ports/multipart-repository';
import type { ITelegramService } from '../domain/ports/telegram-service';
import { DrizzleBucketRepository } from './persistence/repositories/bucket-repository';
import { DrizzleFilePartRepository } from './persistence/repositories/file-part-repository';
import { DrizzleFileRepository } from './persistence/repositories/file-repository';
import { DrizzleMultipartRepository } from './persistence/repositories/multipart-repository';
import { botPool } from './telegram/bot-pool';
import { ChunkedStorage } from './telegram/chunked-storage';
// ─── Repository Singletons ──────────────────────────────────────────
/** Singleton IFileRepository instance backed by Drizzle ORM. */
export const fileRepository: IFileRepository = new DrizzleFileRepository();
/** Singleton IBucketRepository instance backed by Drizzle ORM. */
export const bucketRepository: IBucketRepository = new DrizzleBucketRepository();
/** Singleton IFilePartRepository instance backed by Drizzle ORM. */
export const filePartRepository: IFilePartRepository = new DrizzleFilePartRepository();
/** Singleton IMultipartRepository instance backed by Drizzle ORM. */
export const multipartRepository: IMultipartRepository = new DrizzleMultipartRepository();
/** Singleton ITelegramService instance backed by the bot pool. */
export const telegramService: ITelegramService = botPool;
// ─── Service Singletons ─────────────────────────────────────────────
/** Singleton ChunkedStorage for large file chunked uploads. */
export const chunkedStorage = new ChunkedStorage(
fileRepository,
filePartRepository,
telegramService,
);
@@ -1,4 +1,3 @@
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
import {
bigint,
boolean,
@@ -63,15 +62,3 @@ export const fileParts = pgTable('file_parts', {
etag: text('etag').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
/** Type representing a file row selected from the database. */
export type File = InferSelectModel<typeof files>;
/** Type representing a file row being inserted into the database. */
export type NewFile = InferInsertModel<typeof files>;
/** Type representing a file part row selected from the database. */
export type FilePart = InferSelectModel<typeof fileParts>;
/** Type representing a file part row being inserted into the database. */
export type NewFilePart = InferInsertModel<typeof fileParts>;
+159 -158
View File
@@ -1,3 +1,4 @@
import PQueue from 'p-queue';
import { Telegraf } from 'telegraf';
import type {
ForwardResult,
@@ -13,36 +14,12 @@ import {
sendMethodMap,
type TelegramMessageResult,
} from './types';
import { enqueueUpload } from './upload-queue';
/**
* Sleep for a given number of milliseconds.
*
* Used as a backoff mechanism when all bots in the pool are rate-limited
* or when retrying transient Telegram API errors.
*
* @param ms - Number of milliseconds to sleep.
* @returns A promise that resolves after the specified delay.
*/
const sleep = (ms: number): Promise<void> => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Determines whether an error from the Telegram API is likely transient
* and worth retrying.
*
* Transient telegrams errors include: network timeouts, 5xx server errors,
* and "Too Many Requests" (429) which is already handled by bot rotation
* but is also transient at the network level.
*
* @param error - The caught error object.
* @returns True if the error is likely transient and worth retrying.
*/
const isTransientError = (error: unknown): boolean => {
const str = error instanceof Error ? error.message : String(error);
const transientPatterns = [
'retry after',
'timeout',
'Timed out',
'etimedout',
@@ -70,104 +47,86 @@ const isTransientError = (error: unknown): boolean => {
return transientPatterns.some((p) => str.toLowerCase().includes(p.toLowerCase()));
};
/**
* Maximum number of retries for transient Telegram API errors
* before giving up and propagating the error to the caller.
*/
const MAX_TRANSIENT_RETRIES = 3;
const MAX_OUTER_RETRIES = 10;
const TELEGRAM_API_TIMEOUT_MS = 120_000;
interface BotEntry {
index: number;
token: string;
instance: Telegraf;
queue: PQueue;
rateLimitedUntil: number; // 0 = not rate-limited
}
/**
* Manages a pool of Telegram bots with automatic rotation and rate-limit handling.
*
* Distributes uploads across multiple bot tokens to maximise throughput.
* When a bot receives a 429 (rate-limit) error, the pool instantly rotates
* to the next available bot. If all bots are rate-limited, a coordinated
* sleep is performed before retrying.
*
* Implements the {@link ITelegramService} contract.
*/
export class BotPool implements ITelegramService {
private readonly bots: Telegraf[];
private readonly botTokens: string[];
private nextBotIndex = 0;
private readonly bots: BotEntry[] = [];
/** Create a new BotPool from the application configuration. */
constructor() {
this.botTokens = Array.from(new Set([config.botToken, ...config.additionalBotTokens]));
this.bots = this.botTokens.map((token) => new Telegraf(token));
const tokens = Array.from(new Set(config.botTokens));
this.bots = tokens.map((token, index) => ({
index,
token,
instance: new Telegraf(token),
queue: new PQueue({ concurrency: config.telegramBotConcurrency }),
rateLimitedUntil: 0,
}));
}
/** Number of bots in the pool */
get size(): number {
return this.bots.length;
}
/**
* Claim the next bot index using round-robin rotation.
*
* @returns The index of the selected bot.
* Select the bot with the fewest pending tasks that isn't rate-limited
* or in the skip set.
*/
private claimBotIndex(): number {
const botIndex = this.nextBotIndex;
this.nextBotIndex = (this.nextBotIndex + 1) % this.bots.length;
return botIndex;
private selectBot(skipIndexes?: Set<number>): BotEntry | null {
if (this.bots.length === 0) return null;
let best: BotEntry | null = null;
let bestPending = Infinity;
for (const bot of this.bots) {
if (skipIndexes?.has(bot.index)) continue;
if (bot.rateLimitedUntil > Date.now()) continue;
const pending = bot.queue.pending + bot.queue.size;
if (pending < bestPending) {
bestPending = pending;
best = bot;
}
}
return best;
}
/**
* Execute a Telegram API action with automatic retry and bot rotation.
*
* On 429 errors the pool either:
* 1. Rotates to the next bot immediately (if another bot is available), or
* 2. Sleeps for the required duration after all bots are exhausted, then retries.
*
* @param action - The action to execute on a bot instance.
* @param retries - Number of full-pool retry cycles remaining.
* @param attemptedBots - Number of bots attempted in the current cycle.
* @returns The result of the action.
* Execute a Telegram API action on a specific bot entry.
* Wraps with timeout.
*/
private async executeWithBotRetry<T>(
action: (botInstance: Telegraf, botToken: string) => Promise<T>,
retries = 5,
attemptedBots = 0,
private async executeBotAction<T>(
bot: BotEntry,
action: (instance: Telegraf, token: string) => Promise<T>,
): Promise<T> {
const botIndex = this.claimBotIndex();
const currentBot = this.bots[botIndex];
const currentToken = this.botTokens[botIndex];
try {
return await action(currentBot, currentToken);
} catch (error: unknown) {
const errorStr = error instanceof Error ? error.message : String(error);
const match = errorStr.match(/retry after (\d+)/i);
if (match) {
const nextIndex = this.nextBotIndex;
const nextAttemptedBots = attemptedBots + 1;
if (nextAttemptedBots < this.bots.length) {
logger.info(
`Bot Index ${botIndex} hit 429. Instantly rotating to Bot Index ${nextIndex}...`,
);
return this.executeWithBotRetry(action, retries, nextAttemptedBots);
}
if (retries > 0) {
const seconds = parseInt(match[1], 10);
logger.warn(`All bots in the pool are rate-limited. Sleeping for ${seconds} seconds...`, {
error: errorStr,
});
await sleep(seconds);
return this.executeWithBotRetry(action, retries - 1, 0);
}
}
throw error;
}
return Promise.race([
action(bot.instance, bot.token),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(`Telegram API timeout after ${TELEGRAM_API_TIMEOUT_MS}ms`)),
TELEGRAM_API_TIMEOUT_MS,
),
),
]);
}
/**
* Forward a file chunk to the configured Telegram storage chat.
*
* The upload is queued (via {@link enqueueUpload}) and executed with
* automatic bot rotation on rate-limit errors.
*
* @param fileChunk - The file data (ReadStream, Buffer, or file path).
* @param fileName - The original file name.
* @param fileType - The file type classification (e.g. "document", "photo").
* @returns The Telegram identifiers of the stored file.
* The upload is submitted to the least-loaded bot's queue. If the bot
* returns 429, it is marked rate-limited and the upload retries on the
* next available bot. If all bots are rate-limited, sleeps before retrying.
*/
async forwardToStorage(
fileChunk: unknown,
@@ -175,45 +134,113 @@ export class BotPool implements ITelegramService {
fileType: string,
): Promise<ForwardResult> {
let lastError: unknown;
let attempt = 0;
const attemptedIndexes = new Set<number>();
let transientAttempts = 0;
// Outer retry loop — up to MAX_OUTER_RETRIES attempts across all bots
for (let attempt = 0; attempt < MAX_OUTER_RETRIES; attempt++) {
const bot = this.selectBot(attemptedIndexes);
if (!bot) {
// No available bots — either all rate-limited or all attempted
if (attemptedIndexes.size > 0) {
// All non-rate-limited bots were tried and failed — wait & reset
logger.warn('All available bots exhausted, sleeping 5s before retry');
await sleep(5000 + Math.random() * 1000);
attemptedIndexes.clear();
continue;
}
// All bots rate-limited — wait for the shortest cooldown
const earliestCooldown = Math.min(...this.bots.map((b) => b.rateLimitedUntil || Infinity));
const waitMs = Math.max(1000, earliestCooldown - Date.now() + 500);
logger.warn('All bots rate-limited, waiting', { waitMs });
await sleep(waitMs);
attemptedIndexes.clear();
continue;
}
attemptedIndexes.add(bot.index);
while (attempt <= MAX_TRANSIENT_RETRIES) {
attempt++;
try {
const result = await this.enqueueUpload<TelegramMessageResult>(async () => {
const result = await bot.queue.add(async () => {
// Inner transient retry loop inside the queue
for (let innerRetry = 0; innerRetry <= MAX_TRANSIENT_RETRIES; innerRetry++) {
try {
const filePayload = { source: fileChunk, filename: fileName };
const sendMethodName = sendMethodMap[fileType] || 'sendDocument';
const payload = buildSendPayload(fileType, fileName);
return this.executeWithBotRetry<TelegramMessageResult>((activeBot) => {
const tgResult = await this.executeBotAction<TelegramMessageResult>(
bot,
(activeBot) => {
const telegram = activeBot.telegram as unknown as Record<string, SendMethod>;
return telegram[sendMethodName](config.storageChatId, filePayload, payload);
});
});
const uploadedFile = extractUploadedFile(result, fileType);
logger.info('File forwarded to storage', { fileName, message: result.message_id });
},
);
const uploadedFile = extractUploadedFile(tgResult, fileType);
return {
telegramFileId: uploadedFile?.file_id || '',
telegramFileUniqueId: uploadedFile?.file_unique_id || '',
storageMessageId: result.message_id,
storageMessageId: tgResult.message_id,
};
} catch (error: unknown) {
lastError = error;
const errorStr = error instanceof Error ? error.message : String(error);
const retryAfterMatch = errorStr.match(/retry after (\d+)/i);
if (attempt <= MAX_TRANSIENT_RETRIES && isTransientError(error)) {
const backoffMs = Math.min(1000 * 2 ** attempt, 10_000);
logger.warn(`Transient error forwarding file, retrying (${attempt}/${MAX_TRANSIENT_RETRIES})`, {
if (retryAfterMatch) {
// 429 — mark bot rate-limited, throw to outer loop for retry on different bot
const seconds = parseInt(retryAfterMatch[1], 10);
bot.rateLimitedUntil = Date.now() + seconds * 1000;
logger.info(`Bot #${bot.index} rate-limited for ${seconds}s`, {
fileName,
error: errorStr,
backoffMs,
attempt,
});
throw error; // caught by outer retry loop
}
if (innerRetry < MAX_TRANSIENT_RETRIES && isTransientError(error)) {
const backoffMs = Math.min(1000 * 2 ** innerRetry, 10_000);
logger.warn(
`Transient error on bot #${bot.index}, retrying (${innerRetry + 1}/${MAX_TRANSIENT_RETRIES})`,
{ fileName, error: errorStr, backoffMs },
);
await sleep(backoffMs);
continue;
}
throw error; // non-transient — propagate
}
}
throw new Error(`Exhausted transient retries on bot #${bot.index}`);
});
logger.info('File forwarded to storage', { fileName, message: result.storageMessageId });
return result;
} catch (error: unknown) {
lastError = error;
const errorStr = error instanceof Error ? error.message : String(error);
const retryAfterMatch = errorStr.match(/retry after (\d+)/i);
if (retryAfterMatch) {
// 429 catch in outer block: serves as a safety net for errors that
// contain "retry after N" wording but were rethrown from the inner
// queue task's fallback path (e.g., non-429 errors with similar text).
logger.warn('Retry-after pattern caught in outer loop (safety net)', {
fileName,
error: errorStr,
});
continue;
}
// Transient error at the queue level — retry on next bot
if (transientAttempts < MAX_TRANSIENT_RETRIES && isTransientError(error)) {
transientAttempts++;
continue;
}
// Non-transient — give up
logger.error('Failed to forward file to storage', {
fileName,
error: errorStr,
@@ -223,80 +250,54 @@ export class BotPool implements ITelegramService {
}
}
// Should not reach here — last iteration throws above
throw lastError;
throw lastError || new Error('Failed to forward file after all retries');
}
/** Get total effective concurrency across all bots */
getEffectiveConcurrency(): number {
return this.bots.length * config.telegramBotConcurrency;
}
/**
* Retrieve file metadata from Telegram by file ID.
*
* Tries all configured bots sequentially; returns info from the first
* bot that can retrieve the file. Errors indicating the file belongs
* to a different bot are silently skipped.
*
* @param telegramFileId - The Telegram file_id to look up.
* @returns Metadata including size, MIME type, download path, and bot token.
*/
async getFileInfo(telegramFileId: string): Promise<TelegramFileInfo> {
let lastError: unknown;
for (const activeBot of this.bots) {
for (const bot of this.bots) {
for (let retry = 0; retry <= MAX_TRANSIENT_RETRIES; retry++) {
try {
const result = await activeBot.telegram.getFile(telegramFileId);
const result = await bot.instance.telegram.getFile(telegramFileId);
const fileData = result as unknown as Omit<TelegramFileInfo, 'bot_token'>;
return {
file_size: fileData.file_size || 0,
mime_type: fileData.mime_type || 'application/octet-stream',
file_path: fileData.file_path || '',
bot_token: activeBot.telegram.token,
bot_token: bot.token,
};
} catch (error: unknown) {
lastError = error;
const errorStr = error instanceof Error ? error.message : String(error);
// Belongs to a different bot — skip to next bot immediately
if (
errorStr.includes('wrong file_id') ||
errorStr.includes('file is temporarily unavailable')
) {
break; // skip to next bot
break;
}
// Transient — retry on the same bot
if (retry < MAX_TRANSIENT_RETRIES && isTransientError(error)) {
const backoffMs = Math.min(1000 * 2 ** (retry + 1), 5_000);
logger.warn(
`Transient error getting file info, retrying bot ${activeBot.telegram.token.slice(0, 8)}... (${retry + 1}/${MAX_TRANSIENT_RETRIES})`,
`Transient error getting file info, retrying bot ${bot.token.slice(0, 8)}... (${retry + 1}/${MAX_TRANSIENT_RETRIES})`,
{ telegramFileId, error: errorStr, backoffMs },
);
await sleep(backoffMs);
continue;
}
// Non-transient or exhausted retries — try next bot
break;
}
}
}
logger.error('Failed to get file info from any bot', {
error: lastError instanceof Error ? lastError.message : String(lastError),
});
throw lastError;
}
/**
* Enqueue a task for sequential upload execution.
*
* Delegates to the shared upload queue to ensure only a limited number
* of Telegram uploads run concurrently.
*
* @param task - An async function performing the upload.
* @returns The result of the task.
*/
enqueueUpload<T>(task: () => Promise<T>): Promise<T> {
return enqueueUpload(task);
}
}
/**
* Singleton BotPool instance initialised from application configuration.
*/
export const botPool = new BotPool();
+12 -66
View File
@@ -1,21 +1,17 @@
import { createReadStream } from 'node:fs';
import { gzipSync } from 'node:zlib';
import { nanoid } from 'nanoid';
import type { File as FileEntity } from '../../domain/entities/file';
import type { CompressionAlgorithm, NewFilePart } from '../../domain/entities/file-part';
import { buildNewFile } from '../../domain/entities/file-factory';
import type { NewFilePart } from '../../domain/entities/file-part';
import type { IFilePartRepository } from '../../domain/ports/file-part-repository';
import type { IFileRepository } from '../../domain/ports/file-repository';
import type { ITelegramService } from '../../domain/ports/telegram-service';
import { config } from '../../env';
import { createGetObjectResponse, type ObjectPartSource } from '../../interfaces/s3/object-stream';
import type { RangeParseResult } from '../../interfaces/s3/range';
import { type CompressionAlgorithm, maybeCompressChunk } from '../../shared/utils/compress';
import { computeHash } from '../../shared/utils/file';
/**
* Chunk compression algorithm identifier.
* `"gzip"` if gzip compression was applied, `null` for uncompressed.
*/
export type ChunkCompressionAlgorithm = CompressionAlgorithm;
import { asSafeChunkSize } from '../../shared/utils/validation';
/**
* Metadata about a single uploaded chunk (part) stored in Telegram.
@@ -34,7 +30,7 @@ export interface ChunkedUploadPart {
/** Stored (post-compression) size in bytes */
storedSizeBytes: number;
/** Compression algorithm applied, or null */
compressionAlgorithm: ChunkCompressionAlgorithm;
compressionAlgorithm: CompressionAlgorithm;
/** ETag (SHA-256 hash) of the original chunk */
etag: string;
}
@@ -75,50 +71,6 @@ export interface ChunkedFileInput {
s3Key?: string | null;
}
/**
* Validate and sanitise the Telegram chunk size.
*
* @param chunkSizeBytes - The desired chunk size in bytes.
* @returns The validated chunk size.
* @throws {Error} If the chunk size is not a safe positive integer.
*/
const asSafeChunkSize = (chunkSizeBytes: number): number => {
if (!Number.isSafeInteger(chunkSizeBytes) || chunkSizeBytes <= 0) {
throw new Error('Invalid Telegram chunk size');
}
return chunkSizeBytes;
};
/**
* Optionally compress a chunk with gzip.
*
* Compression is skipped if:
* - The `compress` flag is false.
* - The chunk is smaller than `compressionMinSizeBytes`.
* - The compressed result is larger than the original.
*
* @param chunk - The raw chunk buffer.
* @param compress - Whether compression is enabled.
* @param compressionMinSizeBytes - Minimum chunk size to attempt compression.
* @returns The (possibly compressed) bytes and the algorithm used.
*/
const maybeCompressChunk = (
chunk: Buffer,
compress: boolean,
compressionMinSizeBytes: number,
): { bytes: Buffer; compressionAlgorithm: ChunkCompressionAlgorithm } => {
if (!compress || chunk.byteLength < compressionMinSizeBytes) {
return { bytes: chunk, compressionAlgorithm: null };
}
const gzipped = gzipSync(chunk);
if (gzipped.byteLength >= chunk.byteLength) {
return { bytes: chunk, compressionAlgorithm: null };
}
return { bytes: gzipped, compressionAlgorithm: 'gzip' };
};
/**
* Manages chunked storage of large files in Telegram.
*
@@ -229,7 +181,8 @@ export class ChunkedStorage {
const publicId = nanoid();
const file = await this.fileRepository.create({
const file = await this.fileRepository.create(
buildNewFile({
publicId,
telegramFileId: firstPart.telegramFileId,
telegramFileUniqueId: firstPart.telegramFileUniqueId,
@@ -239,21 +192,14 @@ export class ChunkedStorage {
mimeType: input.mimeType,
sizeBytes: upload.totalSizeBytes,
fileType: input.fileType,
storageBackend: 'chunked',
uploaderId: input.uploaderId,
fileHash: upload.fileHash,
archiveTelegramFileId: null,
archiveStorageMessageId: null,
archiveFileName: null,
archiveEntryName: null,
archiveMimeType: null,
archiveSizeBytes: null,
bucketId: input.bucketId ?? null,
s3Key: input.s3Key ?? null,
storageBackend: 'chunked',
isDeleted: false,
multipartUploadId: null,
bucketId: input.bucketId,
s3Key: input.s3Key,
partCount: upload.parts.length,
});
}),
);
const fileParts: NewFilePart[] = upload.parts.map((part) => ({
fileId: file.id,
@@ -1,238 +0,0 @@
import { createReadStream } from 'node:fs';
import { nanoid } from 'nanoid';
import type { File as FileEntity, NewFile } from '../../domain/entities/file';
import type { IFileRepository } from '../../domain/ports/file-repository';
import type { ITelegramService } from '../../domain/ports/telegram-service';
import { config } from '../../env';
import { cleanupTempFile } from '../../shared/utils/file';
import { createZip, type ZipEntry } from '../../shared/utils/zip';
/**
* Metadata about a prepared upload before it is submitted to the batcher.
*/
export type PreparedUpload = {
/** Temporary file path on disk */
tempPath: string;
/** SHA-256 hash of the file contents */
fileHash: string;
/** File size in bytes */
sizeBytes: number;
/** First bytes of the file for MIME detection */
signatureBuffer: Buffer;
};
/**
* A fully materialised file record returned from the batcher.
*/
export type UploadedFile = FileEntity;
/**
* An item ready for batched upload to Telegram storage.
*/
export type BatchUploadItem = {
/** Prepared upload metadata */
prepared: PreparedUpload;
/** Original file name */
fileName: string;
/** MIME type of the file */
mimeType: string;
/** File type classification (e.g. "document", "photo") */
fileType: string;
};
/**
* Internal pending upload tracking type, extending BatchUploadItem
* with resolve/reject callbacks.
*/
type PendingUpload = BatchUploadItem & {
resolve: (file: FileEntity) => void;
reject: (error: unknown) => void;
};
/** Time window in milliseconds during which uploads are batched together. */
const BATCH_WINDOW_MS = 2000;
/**
* Batches multiple file uploads into a single ZIP archive before forwarding
* them to Telegram storage. This reduces the number of Telegram API calls
* and improves throughput for small-file workloads.
*
* Injects dependencies via constructor can be used with any
* {@link IFileRepository} and {@link ITelegramService} implementation.
*/
export class UploadBatcher {
private readonly pendingUploads: PendingUpload[] = [];
private flushTimer: ReturnType<typeof setTimeout> | null = null;
/**
* @param fileRepository - Repository for persisting file records.
* @param telegramService - Service for forwarding files to Telegram storage.
*/
constructor(
private readonly fileRepository: IFileRepository,
private readonly telegramService: ITelegramService,
) {}
/**
* Build a NewFile record from a batch item and its archive metadata.
*
* @param item - The batched upload item.
* @param entry - ZIP entry metadata for the individual file.
* @param archive - Archive-level Telegram storage metadata.
* @returns A NewFile record ready for repository insertion.
*/
private buildUploadedFile(
item: BatchUploadItem,
entry: ZipEntry,
archive: {
telegramFileId: string;
telegramFileUniqueId: string;
storageMessageId: number;
fileName: string;
sizeBytes: number;
},
): NewFile {
return {
publicId: nanoid(),
telegramFileId: archive.telegramFileId,
telegramFileUniqueId: archive.telegramFileUniqueId,
storageChatId: config.storageChatId,
storageMessageId: archive.storageMessageId,
fileName: item.fileName,
mimeType: item.mimeType || 'application/octet-stream',
sizeBytes: item.prepared.sizeBytes,
fileType: item.fileType,
uploaderId: 0,
fileHash: item.prepared.fileHash,
archiveTelegramFileId: archive.telegramFileId,
archiveStorageMessageId: archive.storageMessageId,
archiveFileName: archive.fileName,
archiveEntryName: entry.entryName,
archiveMimeType: 'application/zip',
archiveSizeBytes: archive.sizeBytes,
bucketId: null,
s3Key: null,
storageBackend: null,
isDeleted: null,
multipartUploadId: null,
partCount: null,
};
}
/**
* Flush all pending uploads by zipping them together and sending
* the archive to Telegram storage.
*/
private async flushUploads(): Promise<void> {
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
const batch = this.pendingUploads.splice(0);
if (batch.length === 0) return;
let zipTempPath: string | null = null;
try {
const zip = await createZip(
batch.map((item) => ({ tempPath: item.prepared.tempPath, fileName: item.fileName })),
);
zipTempPath = zip.tempPath;
const archiveFileName = `filedrop-${nanoid()}.zip`;
const archiveResult = await this.telegramService.forwardToStorage(
createReadStream(zip.tempPath),
archiveFileName,
'document',
);
const newFileInputs = batch.map((item, index) =>
this.buildUploadedFile(item, zip.entries[index], {
telegramFileId: archiveResult.telegramFileId,
telegramFileUniqueId: archiveResult.telegramFileUniqueId,
storageMessageId: archiveResult.storageMessageId,
fileName: archiveFileName,
sizeBytes: zip.sizeBytes,
}),
);
// Persist each file record through the repository
const createdFiles = await Promise.all(
newFileInputs.map((input) => this.fileRepository.create(input)),
);
for (let i = 0; i < batch.length; i++) {
batch[i].resolve(createdFiles[i]);
}
} catch (error) {
for (const item of batch) {
item.reject(error);
}
} finally {
await Promise.all(batch.map((item) => cleanupTempFile(item.prepared.tempPath)));
if (zipTempPath) await cleanupTempFile(zipTempPath);
// Reschedule timer if new items arrived during async processing
if (this.pendingUploads.length > 0 && !this.flushTimer) {
this.flushTimer = setTimeout(() => {
void this.flushUploads();
}, BATCH_WINDOW_MS);
}
}
}
/**
* Calculate total size of all pending uploads in bytes.
*
* @returns The sum of all pending file sizes.
*/
private getPendingSize(): number {
return this.pendingUploads.reduce((total, item) => total + item.prepared.sizeBytes, 0);
}
/**
* Enqueue a prepared upload for batched processing.
*
* The upload is held for up to {@link BATCH_WINDOW_MS} milliseconds
* (or until the batch size/byte thresholds in config are exceeded)
* before being flushed to Telegram storage.
*
* @param item - The prepared upload item to enqueue.
* @returns A promise that resolves with the fully created File record.
*/
enqueuePreparedUpload(item: BatchUploadItem): Promise<FileEntity> {
return new Promise<FileEntity>((resolve, reject) => {
this.pendingUploads.push({ ...item, resolve, reject });
if (!this.flushTimer) {
this.flushTimer = setTimeout(() => {
void this.flushUploads();
}, BATCH_WINDOW_MS);
}
if (
this.pendingUploads.length >= config.batchMaxItems ||
this.getPendingSize() >= config.batchMaxSizeBytes
) {
void this.flushUploads();
}
});
}
/**
* Immediately flush all pending uploads, regardless of batch size.
*
* @returns A promise that resolves when the flush is complete.
*/
async flushPendingUploads(): Promise<void> {
await this.flushUploads();
}
/**
* Get the number of uploads currently waiting in the batch queue.
*
* @returns The pending upload count.
*/
getPendingUploadCount(): number {
return this.pendingUploads.length;
}
}
@@ -1,81 +0,0 @@
import PQueue from 'p-queue';
import { config } from '../../env';
import logger from '../../shared/logger/index';
/**
* P-queue instance for serialising Telegram upload tasks.
*
* Concurrency is governed by {@link config.uploadConcurrency}.
* Built-in logging emits warnings when the queue grows beyond 5 pending items.
*/
const uploadQueue = new PQueue({
concurrency: config.uploadConcurrency,
});
/* Monitor queue growth and emit warnings for large backlogs */
uploadQueue.on('add', () => {
const stats = getQueueStats();
if (stats.size > 5) {
logger.warn('Upload queue building up', { pending: stats.pending, size: stats.size });
}
});
uploadQueue.on('next', () => {
const stats = getQueueStats();
logger.debug('Processing next upload', { pending: stats.pending, size: stats.size });
});
/**
* Enqueue an upload task to be executed by the queue.
*
* Tasks are executed in FIFO order, subject to the concurrency limit.
*
* @param task - An async function representing the upload operation.
* @returns A promise that resolves with the task's result.
*/
export const enqueueUpload = <T>(task: () => Promise<T>): Promise<T> => {
return uploadQueue.add(task);
};
/**
* Get current queue statistics.
*
* @returns An object with `pending` (actively executing) and `size` (waiting) counts.
*/
export const getQueueStats = (): { pending: number; size: number } => ({
pending: uploadQueue.pending,
size: uploadQueue.size,
});
/**
* Get the number of items waiting in the queue (not yet started).
*
* @returns The number of queued items.
*/
export const getQueueSize = (): number => uploadQueue.size;
/**
* Get the number of items currently being processed.
*
* @returns The number of pending (in-flight) items.
*/
export const getPendingCount = (): number => uploadQueue.pending;
/**
* Clear all pending items and wait for in-flight ones to finish.
*
* @returns A promise that resolves when the queue is idle after clearing.
*/
export const clearQueue = async (): Promise<void> => {
uploadQueue.clear();
await uploadQueue.onIdle();
};
/**
* Wait for the queue to become idle (all tasks finished).
*
* @returns A promise that resolves when no tasks are pending or in-flight.
*/
export const waitForQueue = async (): Promise<void> => {
await uploadQueue.onIdle();
};
+14 -23
View File
@@ -1,6 +1,6 @@
import { nanoid } from 'nanoid';
import { type Context, Telegraf } from 'telegraf';
import type { NewFile } from '../../domain/entities/file';
import { buildNewFile } from '../../domain/entities/file-factory';
import type { IFileRepository } from '../../domain/ports/file-repository';
import type { ITelegramService } from '../../domain/ports/telegram-service';
import { config } from '../../env';
@@ -8,6 +8,7 @@ import { DrizzleFileRepository } from '../../infrastructure/persistence/reposito
import { botPool } from '../../infrastructure/telegram/bot-pool';
import logger from '../../shared/logger/index';
import {
checkFileSize,
detectFileType,
extractFileFromMessage,
getErrorMessage,
@@ -92,7 +93,7 @@ export async function startBot(
const fileRepo = deps.fileRepo ?? new DrizzleFileRepository();
try {
const bot = new Telegraf(config.botToken);
const bot = new Telegraf(config.botTokens[0]);
bot.command('start', async (ctx) => {
await ctx.reply(
@@ -127,10 +128,10 @@ export async function startBot(
ctx.message.voice?.file_name ||
'file';
const maxSize = getFileSizeLimit(fileType);
if (fileSize > maxSize) {
return ctx.reply(`File size exceeds ${maxSize / (1024 * 1024)}MB limit`);
if (!checkFileSize(fileSize, fileType)) {
return ctx.reply(
`File size exceeds ${getFileSizeLimit(fileType) / (1024 * 1024)}MB limit`,
);
}
const existing = await fileRepo.findByUniqueId(fileObj.file_unique_id);
@@ -149,7 +150,8 @@ export async function startBot(
const result = await telegramService.forwardToStorage(file_id, fileName, fileType);
const publicId = nanoid();
const uploaded: NewFile = {
await fileRepo.create(
buildNewFile({
publicId,
telegramFileId: result.telegramFileId,
telegramFileUniqueId: result.telegramFileUniqueId,
@@ -160,22 +162,9 @@ export async function startBot(
sizeBytes: fileSize,
fileType,
uploaderId: ctx.from.id,
fileHash: null,
archiveTelegramFileId: null,
archiveStorageMessageId: null,
archiveFileName: null,
archiveEntryName: null,
archiveMimeType: null,
archiveSizeBytes: null,
bucketId: null,
s3Key: null,
storageBackend: 'telegram',
isDeleted: false,
multipartUploadId: null,
partCount: null,
};
await fileRepo.create(uploaded);
}),
);
await replyWithDownloadUrl(ctx, publicId);
@@ -197,7 +186,9 @@ export async function startBot(
await bot.launch();
logger.info('Telegram bot started', { botToken: `${config.botToken?.substring(0, 10)}...` });
logger.info('Telegram bot started', {
botToken: `${config.botTokens[0]?.substring(0, 10)}...`,
});
return bot;
} catch (error: unknown) {
@@ -4,14 +4,14 @@ import {
createLogoutUseCase,
createMeUseCase,
} from '../../../application/use-cases/authenticate';
import { config } from '../../../config/index';
import { config } from '../../../env';
import {
checkBearerToken,
clearSessionCookie,
createSessionCookie,
getAuthSession,
isAuthEnabled,
} from '../../../utils/auth';
} from '../middleware/auth';
/**
* Helper that builds a JSON Response with optional extra headers.
@@ -1,11 +1,12 @@
import { createReadStream } from 'node:fs';
import { nanoid } from 'nanoid';
import type { TelegramFileInfo } from '../../../domain/ports/telegram-service';
import { fileInfoCache } from '../../../infrastructure/cache/index';
import { chunkedStorage, fileRepository } from '../../../infrastructure/di';
import { botPool } from '../../../infrastructure/telegram/bot-pool';
import logger from '../../../shared/logger/index';
import { cleanupTempFile, formatCreatedAt, getErrorMessage } from '../../../shared/utils/file';
import { createChunkedObjectResponse } from '../../../utils/chunked-storage';
import { getFileInfo, type TelegramFileInfo } from '../../../utils/telegram';
import { locateZipEntry } from '../../../utils/zip';
import { locateZipEntry } from '../../../shared/utils/zip';
/**
* Extended Request type that includes route parameter access.
@@ -18,14 +19,6 @@ type RequestWithParams = Request & {
};
};
/**
* Maps a string into a `string | string[]` for cookie append operations.
*
* @param value - The string value to wrap.
* @returns The value as a single-element tuple.
*/
const _asArray = (value: string): string[] => [value];
/**
* Resolves Telegram file metadata for a given file ID, using the in-memory
* cache to avoid repeated API calls to Telegram.
@@ -46,7 +39,7 @@ const getTelegramFileInfo = async (
return cached;
}
const fileInfo = await getFileInfo(telegramFileId);
const fileInfo = await botPool.getFileInfo(telegramFileId);
fileInfoCache.set(cacheKey, fileInfo);
logger.debug('File info cached', { publicId, cacheKey });
@@ -102,8 +95,7 @@ export const handleFileRedirect = async (req: RequestWithParams): Promise<Respon
return fail(400, 'Missing file id');
}
const { findFileByPublicId } = await import('../../../db/files');
const file = await findFileByPublicId(publicId);
const file = await fileRepository.findByPublicId(publicId);
if (!file) {
logger.warn('File not found', { publicId });
return fail(404, 'File not found');
@@ -114,7 +106,7 @@ export const handleFileRedirect = async (req: RequestWithParams): Promise<Respon
return fail(501, 'Archive entry extraction is not supported for chunked files');
}
const range = { type: 'none' as const };
return createChunkedObjectResponse({ file, range, reqId: '' });
return chunkedStorage.createChunkedObjectResponse({ file, range, reqId: '' });
}
const archiveEntryName = file.archiveEntryName;
@@ -192,8 +184,7 @@ export const handleFileInfo = async (req: RequestWithParams): Promise<Response>
return fail(400, 'Missing file id');
}
const { findFileByPublicId } = await import('../../../db/files');
const file = await findFileByPublicId(publicId);
const file = await fileRepository.findByPublicId(publicId);
if (!file) {
logger.warn('File not found', { publicId });
return fail(404, 'File not found');
@@ -1,15 +1,60 @@
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import type { BunFile } from 'bun';
/**
* Maximum number of parent directories to walk up when locating home.html.
* Deep enough for the dev layout (controllers/ -> src/ = 4 levels) with margin.
*/
const MAX_PARENT_WALK = 6;
/**
* Resolves the absolute path to `home.html` by walking up from `startDir`.
*
* The file lives at different depths depending on how the app is run:
* - Dev (`bun --hot src/index.ts`): `import.meta.dir` is
* `src/interfaces/http/controllers/`, home.html lives at `src/home.html`
* (4 levels up).
* - Prod (bundled `dist/index.js`): `import.meta.dir` is
* `$out/share/teleuploader/dist/`, home.html lives next to dist/
* (1 level up, per flake.nix installPhase).
*
* Returns the first existing candidate, or `null` if none is found within
* the walk bound.
*
* @param startDir - Directory to start the search from (typically `import.meta.dir`).
* @param maxDepth - Maximum number of parent directories to walk (default: 6).
* @returns Absolute path to home.html, or `null` if not found.
*/
export const resolveHomeHtml = (startDir: string, maxDepth = MAX_PARENT_WALK): string | null => {
let dir = startDir;
for (let depth = 0; depth <= maxDepth; depth++) {
const candidate = join(dir, 'home.html');
if (existsSync(candidate)) return candidate;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
};
/**
* Handles the home/dashboard page request.
*
* Reads the `home.html` file from the adjacent directory and serves it as
* an HTML response with UTF-8 charset.
* Reads the `home.html` file and serves it as an HTML response with UTF-8
* charset. Fails fast with a clear error when the file cannot be located
* instead of letting Bun.serve swallow the ENOENT into a bare 500.
*
* @returns An HTML response containing the home page content.
*/
export const handleHome = async (): Promise<Response> => {
const html = await (Bun.file(`${import.meta.dir}/home.html`) as BunFile).text();
const homeHtml = resolveHomeHtml(import.meta.dir);
if (!homeHtml) {
throw new Error(
`home.html not found — looked up from ${import.meta.dir} and ${MAX_PARENT_WALK} parent dirs`,
);
}
const html = await (Bun.file(homeHtml) as BunFile).text();
return new Response(html, {
status: 200,
headers: {
+59 -27
View File
@@ -136,6 +136,10 @@
}
.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>
@@ -153,9 +157,11 @@
<select id="bucketSelect" onchange="window.switchBucket(this.value)">
<option value="">— Select bucket —</option>
</select>
<button type="button" onclick="window.showCreateBucketModal()">+ New</button>
<button type="button" onclick="window.showCredentialsModal()" title="S3 Credentials">🔑</button>
<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()">
@@ -179,6 +185,7 @@
</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;
@@ -186,25 +193,36 @@
};
const showAuthScreen = () => {
document.getElementById('authScreen').style.display = 'flex';
document.getElementById('logoutBtn').style.display = 'none';
setTimeout(() => document.getElementById('authTokenInput')?.focus(), 50);
};
const hideAuthScreen = (showLogout) => {
const hideAuthScreen = () => {
document.getElementById('authScreen').style.display = 'none';
document.getElementById('logoutBtn').style.display = showLogout ? 'inline-block' : '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) { 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;
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 {
setAuthError('Network error while checking login status.');
showAuthScreen(); return false;
isAdmin = false;
}
hideAuthScreen();
applyAdminUI();
return isAdmin;
};
const handleLogin = async () => {
const input = document.getElementById('authTokenInput');
@@ -217,7 +235,7 @@
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token }),
});
if (res.ok) { hideAuthScreen(true); input.value = ''; await loadBuckets(); return; }
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 {
@@ -228,11 +246,8 @@
};
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();
isAdmin = false;
applyAdminUI();
};
const api = async (path, opts = {}) => {
const res = await fetch(path, opts);
@@ -249,11 +264,13 @@
};
const switchBucket = async (name) => {
currentBucket = name || null; currentPrefix = '';
if (name) { await loadObjects(); document.getElementById('dropzone').style.display = 'block'; }
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'; document.getElementById('dropzone').style.display = 'none';
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');
@@ -278,7 +295,12 @@
};
const renderFileList = () => {
const container = document.getElementById('fileList');
if (currentPrefixes.length === 0 && currentObjects.length === 0) { container.innerHTML = '<div class="empty"><h2>This bucket is empty</h2><p>Drop files here to upload.</p></div>'; return; }
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, '');
@@ -286,7 +308,9 @@
}
for (const obj of currentObjects) {
const displayName = obj.key.replace(currentPrefix, '');
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><button onclick="event.stopPropagation();deleteObject('${obj.key}')" title="Delete">🗑</button></span></div>`;
// 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;
};
@@ -297,11 +321,13 @@
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';
@@ -325,13 +351,19 @@
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=()=>{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 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 init=async()=>{if(await checkAuth())await loadBuckets();};
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-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 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, logout });
Object.assign(window, { switchBucket, navigateTo, debouncedSearch, downloadObject, copyLink, deleteObject, closeModal, showCreateBucketModal, createBucket, showCredentialsModal, showAuthScreen, logout });
init();
</script>
</body>
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,8 @@
import { createWriteStream } from 'node:fs';
import { Readable } from 'node:stream';
import { nanoid } from 'nanoid';
import { config } from '../../../config/index';
import { findFileByHash } from '../../../db/files';
import { buildNewFile } from '../../../domain/entities/file-factory';
import { config } from '../../../env';
import { chunkedStorage, fileRepository, telegramService } from '../../../infrastructure/di';
import logger from '../../../shared/logger/index';
import { metricsCollector } from '../../../shared/metrics/index';
import {
@@ -14,8 +15,15 @@ import {
getErrorMessage,
getFileType,
} from '../../../shared/utils/file';
import { storeFileInTelegramChunks } from '../../../utils/chunked-storage';
import { enqueuePreparedUpload, type PreparedUpload } from '../../../utils/uploadBatcher';
import { streamToTemp } from '../../../shared/utils/temp-stream';
/** Prepared upload metadata before submission to storage. */
interface PreparedUpload {
tempPath: string;
fileHash: string;
sizeBytes: number;
signatureBuffer: Buffer;
}
/**
* Maximum allowed size (in bytes) for a base64 JSON upload.
@@ -94,7 +102,7 @@ const rejectOversizedRequest = (req: Request): Response | null => {
* Streams a multipart `File` to a temporary file on disk while computing
* its SHA-256 hash and extracting the signature (first 16 bytes).
*
* Backpressure from the write stream is respected via the drain event.
* Delegates to the shared {@link streamToTemp} utility.
*
* @param file - The multipart `File` object.
* @param maxSizeBytes - Maximum allowed file size; an error is thrown if
@@ -103,67 +111,8 @@ const rejectOversizedRequest = (req: Request): Response | null => {
* @throws {Error} When the file size exceeds `maxSizeBytes`.
*/
const streamFileToTemp = async (file: File, maxSizeBytes: number): Promise<PreparedUpload> => {
const tempPath = `/tmp/filedrop-${nanoid()}`;
const writer = createWriteStream(tempPath);
const hasher = new Bun.CryptoHasher('sha256');
const reader = file.stream().getReader();
const signatureChunks: Buffer[] = [];
let signatureBytes = 0;
let sizeBytes = 0;
const writeChunk = async (chunk: Buffer): Promise<void> => {
if (!writer.write(chunk)) {
await new Promise<void>((resolve, reject) => {
writer.once('drain', resolve);
writer.once('error', reject);
});
}
};
const finishWriter = async (): Promise<void> => {
await new Promise<void>((resolve, reject) => {
writer.end(() => resolve());
writer.once('error', reject);
});
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = Buffer.from(value);
sizeBytes += chunk.byteLength;
if (sizeBytes > maxSizeBytes) {
throw new Error('File size exceeds upload limit');
}
hasher.update(chunk);
await writeChunk(chunk);
if (signatureBytes < SIGNATURE_BYTES) {
const remaining = SIGNATURE_BYTES - signatureBytes;
const signatureChunk = chunk.subarray(0, remaining);
signatureChunks.push(signatureChunk);
signatureBytes += signatureChunk.byteLength;
}
}
await finishWriter();
return {
tempPath,
fileHash: hasher.digest('hex'),
sizeBytes,
signatureBuffer: Buffer.concat(signatureChunks, signatureBytes),
};
} catch (error) {
writer.destroy();
await cleanupTempFile(tempPath);
throw error;
} finally {
reader.releaseLock();
}
const result = await streamToTemp(file.stream().getReader(), { maxSizeBytes });
return result;
};
/**
@@ -223,7 +172,7 @@ const handleMultipartUpload = async (req: Request): Promise<Response> => {
const prepared = await streamFileToTemp(file, config.maxRequestBodyBytes);
const existingFile = await findFileByHash(prepared.fileHash);
const existingFile = await fileRepository.findByHash(prepared.fileHash);
if (existingFile) {
await cleanupTempFile(prepared.tempPath);
return Response.json(buildUploadResponse(existingFile, config.baseUrl), { status: 200 });
@@ -243,7 +192,7 @@ const handleMultipartUpload = async (req: Request): Promise<Response> => {
}
if (prepared.sizeBytes > config.telegramChunkSizeBytes) {
const uploadedFile = await storeFileInTelegramChunks({
const uploadedFile = await chunkedStorage.storeFileInTelegramChunks({
tempPath: prepared.tempPath,
partFileNamePrefix: `direct-${prepared.fileHash?.slice(0, 16) || 'upload'}`,
fileName: finalFileName,
@@ -256,14 +205,35 @@ const handleMultipartUpload = async (req: Request): Promise<Response> => {
return Response.json(buildUploadResponse(uploadedFile, config.baseUrl), { status: 200 });
}
const uploaded = await enqueuePreparedUpload({
prepared,
// Single-message — direct to Telegram storage
const forwardResult = await telegramService.forwardToStorage(
Readable.from(Bun.file(prepared.tempPath).stream()),
finalFileName,
fileType,
);
const publicId = nanoid();
const createdFile = await fileRepository.create(
buildNewFile({
publicId,
telegramFileId: forwardResult.telegramFileId,
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
storageChatId: config.storageChatId,
storageMessageId: forwardResult.storageMessageId,
fileName: finalFileName,
mimeType,
sizeBytes: prepared.sizeBytes,
fileType,
});
storageBackend: 'telegram',
uploaderId: 0,
fileHash: prepared.fileHash,
}),
);
return Response.json(buildUploadResponse(uploaded, config.baseUrl), { status: 200 });
await cleanupTempFile(prepared.tempPath);
return Response.json(buildUploadResponse(createdFile, config.baseUrl), { status: 200 });
} catch (error: unknown) {
const message = getErrorMessage(error);
logger.error('Multipart upload error', { error: message });
@@ -317,7 +287,7 @@ const handleJSONUpload = async (req: Request): Promise<Response> => {
const fileBytes = Buffer.from(base64Data, 'base64');
const hash = computeHash(fileBytes);
const existingFile = await findFileByHash(hash);
const existingFile = await fileRepository.findByHash(hash);
if (existingFile) {
return Response.json(buildUploadResponse(existingFile, config.baseUrl), { status: 200 });
}
@@ -334,7 +304,7 @@ const handleJSONUpload = async (req: Request): Promise<Response> => {
const prepared = await writeBufferToTemp(fileBytes, hash);
if (prepared.sizeBytes > config.telegramChunkSizeBytes) {
const uploadedFile = await storeFileInTelegramChunks({
const uploadedFile = await chunkedStorage.storeFileInTelegramChunks({
tempPath: prepared.tempPath,
partFileNamePrefix: `direct-${prepared.fileHash?.slice(0, 16) || 'json'}`,
fileName: finalFileName,
@@ -347,14 +317,35 @@ const handleJSONUpload = async (req: Request): Promise<Response> => {
return Response.json(buildUploadResponse(uploadedFile, config.baseUrl), { status: 200 });
}
const uploaded = await enqueuePreparedUpload({
prepared,
// Single-message — direct to Telegram storage
const forwardResult = await telegramService.forwardToStorage(
Readable.from(Bun.file(prepared.tempPath).stream()),
finalFileName,
fileType,
);
const publicId = nanoid();
const createdFile = await fileRepository.create(
buildNewFile({
publicId,
telegramFileId: forwardResult.telegramFileId,
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
storageChatId: config.storageChatId,
storageMessageId: forwardResult.storageMessageId,
fileName: finalFileName,
mimeType,
sizeBytes: prepared.sizeBytes,
fileType,
});
storageBackend: 'telegram',
uploaderId: 0,
fileHash: prepared.fileHash,
}),
);
return Response.json(buildUploadResponse(uploaded, config.baseUrl), { status: 200 });
await cleanupTempFile(prepared.tempPath);
return Response.json(buildUploadResponse(createdFile, config.baseUrl), { status: 200 });
} catch (error: unknown) {
const message = getErrorMessage(error);
logger.error('JSON upload error', { error: message });
@@ -1,20 +1,17 @@
import { createReadStream } from 'node:fs';
import { nanoid } from 'nanoid';
import { config } from '../../../config/index';
import { createBucket, deleteBucket, findBucketByName, listBuckets } from '../../../db/buckets';
import {
countBucketObjects,
findFileByBucketAndKey,
listObjectsByPrefix,
softDeleteFile,
} from '../../../db/files-ext';
import { buildNewFile } from '../../../domain/entities/file-factory';
import { config } from '../../../env';
import { bucketRepository, chunkedStorage, fileRepository } from '../../../infrastructure/di';
import { botPool } from '../../../infrastructure/telegram/bot-pool';
import logger from '../../../shared/logger/index';
import { cleanupTempFile, ensureExtension, getErrorMessage } from '../../../shared/utils/file';
import {
createChunkedObjectResponse,
storeFileInTelegramChunks,
} from '../../../utils/chunked-storage';
import { forwardToStorage, getFileInfo } from '../../../utils/telegram';
cleanupTempFile,
DEFAULT_FILE_TYPE,
ensureExtension,
getErrorMessage,
} from '../../../shared/utils/file';
import { streamToTemp } from '../../../shared/utils/temp-stream';
/**
* Route parameters extracted from the URL path.
@@ -47,13 +44,13 @@ const jsonError = (error: string, status: number): Response => Response.json({ e
* @returns A JSON response with the bucket list.
*/
export const handleListBucketsV1 = async (): Promise<Response> => {
const buckets = await listBuckets();
const buckets = await bucketRepository.list();
const result = await Promise.all(
buckets.map(async (b) => ({
id: b.id,
name: b.name,
createdAt: b.createdAt.toISOString(),
objectCount: await countBucketObjects(b.id),
objectCount: await fileRepository.countByBucket(b.id),
})),
);
return json({ buckets: result });
@@ -72,9 +69,9 @@ export const handleCreateBucketV1 = async (req: Request): Promise<Response> => {
if (!body.name || !/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(body.name)) {
return jsonError('Invalid bucket name. Use lowercase, 3-63 chars, no underscore', 400);
}
const existing = await findBucketByName(body.name);
const existing = await bucketRepository.findByName(body.name);
if (existing) return jsonError('Bucket already exists', 409);
const bucket = await createBucket(body.name);
const bucket = await bucketRepository.create(body.name);
return json({ id: bucket.id, name: bucket.name }, 201);
};
@@ -91,11 +88,11 @@ export const handleDeleteBucketV1 = async (
_req: Request,
params: RouteParams,
): Promise<Response> => {
const bucket = await findBucketByName(params.bucket!);
const bucket = await bucketRepository.findByName(params.bucket!);
if (!bucket) return jsonError('Bucket not found', 404);
const count = await countBucketObjects(bucket.id);
const count = await fileRepository.countByBucket(bucket.id);
if (count > 0) return jsonError('Bucket is not empty', 409);
await deleteBucket(params.bucket!);
await bucketRepository.delete(params.bucket!);
return json({ success: true });
};
@@ -109,7 +106,7 @@ export const handleDeleteBucketV1 = async (
* @returns A JSON response with the object list.
*/
export const handleListObjectsV1 = async (req: Request, params: RouteParams): Promise<Response> => {
const bucket = await findBucketByName(params.bucket!);
const bucket = await bucketRepository.findByName(params.bucket!);
if (!bucket) return jsonError('Bucket not found', 404);
const url = new URL(req.url);
@@ -118,7 +115,7 @@ export const handleListObjectsV1 = async (req: Request, params: RouteParams): Pr
const maxKeys = Number.parseInt(url.searchParams.get('max-keys') || '1000', 10);
const continuationToken = url.searchParams.get('continuation-token') || null;
const { objects, prefixes } = await listObjectsByPrefix(
const { objects, prefixes } = await fileRepository.listByPrefix(
bucket.id,
prefix,
delimiter,
@@ -161,7 +158,7 @@ export const handleUploadObjectV1 = async (
req: Request,
params: RouteParams,
): Promise<Response> => {
const bucket = await findBucketByName(params.bucket!);
const bucket = await bucketRepository.findByName(params.bucket!);
if (!bucket) return jsonError('Bucket not found', 404);
const formData = await req.formData();
@@ -172,83 +169,49 @@ export const handleUploadObjectV1 = async (
}
const key = (formData.get('key') as string) || file.name;
const tempPath = `/tmp/filedrop-web-${nanoid()}`;
const writer = Bun.file(tempPath).writer();
const reader = file.stream().getReader();
const hasher = new Bun.CryptoHasher('sha256');
const SIGNATURE_BYTES = 16;
const signatureChunks: Buffer[] = [];
let signatureBytes = 0;
let sizeBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = Buffer.from(value);
sizeBytes += chunk.byteLength;
hasher.update(chunk);
writer.write(chunk);
if (signatureBytes < SIGNATURE_BYTES) {
const remaining = SIGNATURE_BYTES - signatureBytes;
const sigChunk = chunk.subarray(0, remaining);
signatureChunks.push(sigChunk);
signatureBytes += sigChunk.byteLength;
}
}
writer.end();
} catch (error) {
writer.end();
await cleanupTempFile(tempPath);
throw error;
} finally {
reader.releaseLock();
}
const hash = hasher.digest('hex');
const signatureBuffer = Buffer.concat(signatureChunks, signatureBytes);
const streamed = await streamToTemp(file.stream().getReader(), { prefix: '/tmp/filedrop-web-' });
const { fileName: finalFileName, mimeType } = ensureExtension(
key.split('/').pop() || 'file',
signatureBuffer,
streamed.signatureBuffer,
file.type || 'application/octet-stream',
);
const partFileNamePrefix = `s3-${bucket.name}-${key.replace(/\//g, '_')}`;
if (sizeBytes > config.telegramChunkSizeBytes) {
const uploadedFile = await storeFileInTelegramChunks({
tempPath,
if (streamed.sizeBytes > config.telegramChunkSizeBytes) {
const uploadedFile = await chunkedStorage.storeFileInTelegramChunks({
tempPath: streamed.tempPath,
partFileNamePrefix,
fileName: finalFileName,
mimeType,
sizeBytes,
fileType: 'document',
sizeBytes: streamed.sizeBytes,
fileType: DEFAULT_FILE_TYPE,
uploaderId: 0,
bucketId: bucket.id,
s3Key: key,
});
await cleanupTempFile(tempPath);
await cleanupTempFile(streamed.tempPath);
return json(
{
key,
size: sizeBytes,
etag: hash,
size: streamed.sizeBytes,
etag: streamed.fileHash,
downloadUrl: `${config.baseUrl}/f/${uploadedFile.publicId}`,
},
201,
);
}
const forwardResult = await forwardToStorage(
createReadStream(tempPath),
const forwardResult = await botPool.forwardToStorage(
createReadStream(streamed.tempPath),
partFileNamePrefix,
'document',
);
const publicId = nanoid();
const { db, files: fileSchema } = await import('../../../db/index');
await db.insert(fileSchema).values({
await fileRepository.create(
buildNewFile({
publicId,
telegramFileId: forwardResult.telegramFileId,
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
@@ -256,22 +219,25 @@ export const handleUploadObjectV1 = async (
storageMessageId: forwardResult.storageMessageId,
fileName: finalFileName,
mimeType,
sizeBytes,
fileType: 'document',
sizeBytes: streamed.sizeBytes,
fileType: DEFAULT_FILE_TYPE,
uploaderId: 0,
fileHash: hash,
fileHash: streamed.fileHash,
bucketId: bucket.id,
s3Key: key,
storageBackend: 'telegram',
isDeleted: false,
createdAt: new Date(),
updatedAt: new Date(),
});
}),
);
await cleanupTempFile(tempPath);
await cleanupTempFile(streamed.tempPath);
return json(
{ key, size: sizeBytes, etag: hash, downloadUrl: `${config.baseUrl}/f/${publicId}` },
{
key,
size: streamed.sizeBytes,
etag: streamed.fileHash,
downloadUrl: `${config.baseUrl}/f/${publicId}`,
},
201,
);
};
@@ -287,9 +253,9 @@ export const handleDeleteObjectV1 = async (
_req: Request,
params: RouteParams,
): Promise<Response> => {
const bucket = await findBucketByName(params.bucket!);
const bucket = await bucketRepository.findByName(params.bucket!);
if (!bucket) return jsonError('Bucket not found', 404);
await softDeleteFile(bucket.id, params.key!);
await fileRepository.softDelete(bucket.id, params.key!);
return json({ success: true });
};
@@ -307,18 +273,18 @@ export const handleDownloadObjectV1 = async (
_req: Request,
params: RouteParams,
): Promise<Response> => {
const bucket = await findBucketByName(params.bucket!);
const bucket = await bucketRepository.findByName(params.bucket!);
if (!bucket) return jsonError('Bucket not found', 404);
const file = await findFileByBucketAndKey(bucket.id, params.key!);
const file = await fileRepository.findByBucketAndKey(bucket.id, params.key!);
if (!file) return jsonError('Object not found', 404);
if (file.storageBackend === 'chunked') {
const range = { type: 'none' as const };
return createChunkedObjectResponse({ file, range, reqId: '' });
return chunkedStorage.createChunkedObjectResponse({ file, range, reqId: '' });
}
const fileInfo = await getFileInfo(file.telegramFileId);
const fileInfo = await botPool.getFileInfo(file.telegramFileId);
const redirectUrl = `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`;
return new Response(null, { status: 302, headers: { Location: redirectUrl } });
@@ -348,12 +314,12 @@ export const handleCopyObjectV1 = async (req: Request, params: RouteParams): Pro
}
const destBucketName = body.destBucket || params.bucket!;
const sourceBucket = await findBucketByName(params.bucket!);
const destBucket = await findBucketByName(destBucketName);
const sourceBucket = await bucketRepository.findByName(params.bucket!);
const destBucket = await bucketRepository.findByName(destBucketName);
if (!sourceBucket || !destBucket) return jsonError('Bucket not found', 404);
const sourceFile = await findFileByBucketAndKey(sourceBucket.id, body.sourceKey);
const sourceFile = await fileRepository.findByBucketAndKey(sourceBucket.id, body.sourceKey);
if (!sourceFile) return jsonError('Source object not found', 404);
if (sourceFile.storageBackend === 'chunked') {
@@ -361,9 +327,9 @@ export const handleCopyObjectV1 = async (req: Request, params: RouteParams): Pro
}
const publicId = nanoid();
const { db, files: fileSchema } = await import('../../../db/index');
await db.insert(fileSchema).values({
await fileRepository.create(
buildNewFile({
publicId,
telegramFileId: sourceFile.telegramFileId,
telegramFileUniqueId: sourceFile.telegramFileUniqueId,
@@ -371,17 +337,15 @@ export const handleCopyObjectV1 = async (req: Request, params: RouteParams): Pro
storageMessageId: sourceFile.storageMessageId,
fileName: sourceFile.fileName,
mimeType: sourceFile.mimeType,
sizeBytes: sourceFile.sizeBytes,
sizeBytes: Number(sourceFile.sizeBytes),
fileType: sourceFile.fileType,
uploaderId: 0,
fileHash: sourceFile.fileHash,
bucketId: destBucket.id,
s3Key: body.destKey,
storageBackend: 'telegram',
isDeleted: false,
createdAt: new Date(),
updatedAt: new Date(),
});
}),
);
return json({ sourceKey: body.sourceKey, destKey: body.destKey, destBucket: destBucketName });
};
+1 -1
View File
@@ -1,5 +1,5 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import { config } from '../../../config/index';
import { config } from '../../../env';
const ADMIN_USERNAME = 'admin';
const SIGNATURE_SEPARATOR = '.';
+89 -7
View File
@@ -1,7 +1,89 @@
export {
checkRateLimit,
cleanupRateLimitCache,
clearRateLimitCache,
getRateLimitStats,
withRateLimit,
} from '../../../utils/rateLimit';
import { config } from '../../../env';
import logger from '../../../shared/logger/index';
import { extractClientIp } from '../../../shared/utils/ip';
interface RateLimitEntry {
count: number;
resetTime: number;
}
const rateLimitStore = new Map<string, RateLimitEntry>();
const MAX_STORE_ENTRIES = 50000;
const evictExpiredEntries = (now = Date.now()): number => {
let cleaned = 0;
for (const [key, entry] of rateLimitStore.entries()) {
if (now > entry.resetTime) {
rateLimitStore.delete(key);
cleaned++;
}
}
return cleaned;
};
const ensureStoreCapacity = (now: number): void => {
if (rateLimitStore.size < MAX_STORE_ENTRIES) return;
evictExpiredEntries(now);
while (rateLimitStore.size >= MAX_STORE_ENTRIES) {
const oldestKey = rateLimitStore.keys().next().value;
if (!oldestKey) break;
rateLimitStore.delete(oldestKey);
}
};
export const checkRateLimit = (key: string): boolean => {
const now = Date.now();
const entry = rateLimitStore.get(key);
if (!entry || now > entry.resetTime) {
ensureStoreCapacity(now);
rateLimitStore.set(key, {
count: 1,
resetTime: now + config.rateLimitWindowMs,
});
return true;
}
if (entry.count >= config.rateLimitMaxRequests) {
logger.warn('Rate limit exceeded', { key, count: entry.count });
return false;
}
entry.count++;
return true;
};
export const withRateLimit = <T extends Request>(
handler: (req: T) => Promise<Response>,
): ((req: T) => Promise<Response>) => {
return async (req: T): Promise<Response> => {
const ip = extractClientIp(req);
if (!checkRateLimit(ip)) {
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 });
}
return handler(req);
};
};
export const cleanupRateLimitCache = (): void => {
const cleaned = evictExpiredEntries();
if (cleaned > 0) {
logger.debug('Rate limit cache cleanup', { cleaned, remaining: rateLimitStore.size });
}
};
export const getRateLimitStats = () => ({
trackedIPs: rateLimitStore.size,
windowSize: config.rateLimitWindowMs,
maxRequests: config.rateLimitMaxRequests,
maxTrackedIPs: MAX_STORE_ENTRIES,
});
export const clearRateLimitCache = (): void => {
rateLimitStore.clear();
};
+36 -60
View File
@@ -1,7 +1,5 @@
import { config } from '../../../config/index';
import { handleSwaggerHtml, handleSwaggerJson } from '../../../routes/swagger';
import { extractS3BucketFromHost } from '../../../utils/s3/virtual-host';
import { isS3Request } from '../../s3/auth';
import { getS3RouteBucket, shouldHandleS3 } from '../../../shared/utils/s3-detection';
import { handleLogin, handleLogout, handleMe } from '../controllers/auth-controller';
import { handleFileInfo, handleFileRedirect } from '../controllers/file-controller';
import { handleHealth } from '../controllers/health-controller';
@@ -12,52 +10,6 @@ import { handleWebApiV1 } from '../controllers/web-api-controller';
import { requireAuth } from '../middleware/auth';
import { withRateLimit } from '../middleware/rate-limit';
/**
* Extracts the S3 bucket name from the request host
* if it matches a virtual-hosted-style domain.
*
* @param req - The incoming HTTP request.
* @returns The bucket name if found, or null.
*/
const getS3RouteBucket = (req: Request): string | null => {
const host = req.headers.get('host') || '';
return extractS3BucketFromHost(host, config.s3VhostDomains);
};
/**
* Determines whether the incoming request appears to be an S3 API request
* based on host headers, authorization headers, or query parameters.
*
* @param req - The incoming HTTP request.
* @param headers - A record of parsed request headers.
* @returns True if the request should be handled by the S3 handler.
*/
const shouldHandleS3 = (req: Request, headers: Record<string, string>): boolean => {
const url = new URL(req.url);
return Boolean(
getS3RouteBucket(req) || isS3Request(headers) || url.searchParams.has('X-Amz-Signature'),
);
};
/**
* Handles non-GET requests to the root path by dispatching to the S3 handler
* if the request matches S3 patterns (virtual-hosted bucket, S3 auth headers,
* or presigned URL signature), or returning a 405 Method Not Allowed otherwise.
*
* @param req - The incoming HTTP request.
* @returns A Response from the S3 handler or a 405 response.
*/
const _handleMaybeS3Root = (req: Request): Response | Promise<Response> => {
if (req.method === 'OPTIONS') {
return handleS3Request(req, getS3RouteBucket(req));
}
const headers = Object.fromEntries(req.headers);
if (shouldHandleS3(req, headers)) {
return handleS3Request(req, getS3RouteBucket(req));
}
return new Response('Not Allowed', { status: 405 });
};
/**
* Dispatches an S3 request directly, bypassing rate limiting.
*
@@ -106,20 +58,12 @@ export const routes = {
},
'/': {
GET: (req: Request): Promise<Response> => {
const headers = Object.fromEntries(req.headers);
if (shouldHandleS3(req, headers)) {
return handleS3Direct(req);
}
if (shouldHandleS3(req)) return handleS3Direct(req);
return handleHome();
},
PUT: (req: Request): Promise<Response> => {
if (req.method === 'OPTIONS') {
return handleS3Request(req, getS3RouteBucket(req));
}
const headers = Object.fromEntries(req.headers);
if (shouldHandleS3(req, headers)) {
return handleS3Direct(req);
}
if (shouldHandleS3(req, headers)) return handleS3Direct(req);
return Promise.resolve(new Response('Not Allowed', { status: 405 }));
},
HEAD: handleS3Direct,
@@ -127,6 +71,35 @@ export const routes = {
POST: handleS3Direct,
OPTIONS: handleS3Direct,
},
// Catch-all for S3 path-style requests (/{bucket}/{key} ...)
// Only intercepts requests with S3 auth headers; others get 404.
'/*': {
GET: (req: Request): Promise<Response> => {
if (shouldHandleS3(req, Object.fromEntries(req.headers))) return handleS3Direct(req);
return Promise.resolve(new Response('Not Found', { status: 404 }));
},
PUT: (req: Request): Promise<Response> => {
if (shouldHandleS3(req, Object.fromEntries(req.headers))) return handleS3Direct(req);
return Promise.resolve(new Response('Not Found', { status: 404 }));
},
HEAD: (req: Request): Promise<Response> => {
if (shouldHandleS3(req, Object.fromEntries(req.headers))) return handleS3Direct(req);
return Promise.resolve(new Response('Not Found', { status: 404 }));
},
DELETE: (req: Request): Promise<Response> => {
if (shouldHandleS3(req, Object.fromEntries(req.headers))) return handleS3Direct(req);
return Promise.resolve(new Response('Not Found', { status: 404 }));
},
POST: (req: Request): Promise<Response> => {
if (shouldHandleS3(req, Object.fromEntries(req.headers))) return handleS3Direct(req);
return Promise.resolve(new Response('Not Found', { status: 404 }));
},
PATCH: (req: Request): Promise<Response> => {
if (shouldHandleS3(req, Object.fromEntries(req.headers))) return handleS3Direct(req);
return Promise.resolve(new Response('Not Found', { status: 404 }));
},
OPTIONS: handleS3Direct,
},
'/api/v1/auth/login': {
POST: withRateLimit(handleLogin),
},
@@ -136,8 +109,11 @@ export const routes = {
'/api/v1/auth/me': {
GET: handleMe,
},
// Read endpoints (GET) are public — anyone can list buckets/objects and
// download files. Write endpoints (POST/DELETE/PUT) require admin auth so
// visitors cannot upload, edit, copy, or delete.
'/api/v1/*': {
GET: requireAuth(handleWebApiV1),
GET: handleWebApiV1,
POST: requireAuth(handleWebApiV1),
DELETE: requireAuth(handleWebApiV1),
PUT: requireAuth(handleWebApiV1),
+463 -7
View File
@@ -1,7 +1,463 @@
export type { SigV4Result, VerifyPresignedUrlInput } from '../../utils/s3/auth';
export {
buildCanonicalQueryString,
isS3Request,
verifyPresignedUrl,
verifySignature,
} from '../../utils/s3/auth';
import { timingSafeEqual } from 'node: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 {
isValid: boolean;
credential: {
accessKey: string;
date: string;
region: string;
service: string;
} | null;
errorCode?: string;
}
export interface VerifyPresignedUrlInput {
url: string;
method: string;
headers: Record<string, string>;
s3AccessKey: string;
s3SecretKey: string;
region: string;
now?: Date;
}
const SERVICE = 's3';
const TERMINATION = 'aws4_request';
/**
* Maximum acceptable clock skew between client and server for header-based
* SigV4 authentication. AWS allows 15 minutes.
*/
const MAX_CLOCK_SKEW_MS = 15 * 60 * 1000;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const buf = (data: string | ArrayBuffer | Uint8Array): Uint8Array => {
if (data instanceof Uint8Array) return data;
if (data instanceof ArrayBuffer) return new Uint8Array(data);
return new TextEncoder().encode(data);
};
const sha256Hex = async (data: string | Uint8Array | ArrayBuffer): Promise<string> => {
const hashBuffer = await crypto.subtle.digest('SHA-256', buf(data) as never);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
};
const hmacSha256 = async (key: Uint8Array, message: string): Promise<Uint8Array> => {
const cryptoKey = await crypto.subtle.importKey(
'raw',
key as never,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const result = await crypto.subtle.sign('HMAC', cryptoKey, buf(message) as never);
return new Uint8Array(result);
};
const getSigningKey = async (
secretKey: string,
dateStamp: string,
region: string,
): Promise<Uint8Array> => {
let key = await hmacSha256(buf(`AWS4${secretKey}`), dateStamp);
key = await hmacSha256(key, region);
key = await hmacSha256(key, SERVICE);
return await hmacSha256(key, TERMINATION);
};
const hmacHex = async (key: Uint8Array, message: string): Promise<string> => {
const result = await hmacSha256(key, message);
return Array.from(result)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
};
const parseAuthorizationHeader = (authHeader: string) => {
const credentialMatch = authHeader.match(/Credential=([^,]+)/);
const signedHeadersMatch = authHeader.match(/SignedHeaders=([^,]+)/);
const signatureMatch = authHeader.match(/Signature=([^,]+)/);
if (!credentialMatch || !signedHeadersMatch || !signatureMatch) return null;
const credentialParts = credentialMatch[1].split('/');
if (credentialParts.length !== 5) return null;
return {
accessKey: credentialParts[0],
date: credentialParts[1],
region: credentialParts[2],
service: credentialParts[3],
termination: credentialParts[4],
signedHeaders: signedHeadersMatch[1],
signature: signatureMatch[1],
};
};
const buildCanonicalRequest = (
method: string,
canonicalUri: string,
canonicalQueryString: string,
signedHeaders: string,
headers: Record<string, string>,
hashedPayload: string,
): string => {
const canonicalHeaders = signedHeaders
.split(';')
.map((h) => {
const value = headers[h.toLowerCase()] || '';
return `${h.toLowerCase()}:${value.trim()}\n`;
})
.join('');
return `${method}\n${canonicalUri}\n${canonicalQueryString}\n${canonicalHeaders}\n${signedHeaders}\n${hashedPayload}`;
};
/**
* Normalizes a URI per AWS SigV4 requirements plus RFC 3986:
*
* 1. Decode percent-encoded characters
* 2. Remove dot-segments (`.` and `..`) per RFC 3986 section 5.2.4
*
* @param uri - The raw URI path to normalize.
* @returns The normalized URI path.
*/
const normalizeUri = (uri: string): string => {
if (!uri || uri === '') return '/';
// AWS SigV4 requires URI-decoded paths in the canonical request
// Only `.` and `..` segments are removed per RFC 3986 section 5.2.4
// Empty segments (from `//` or trailing `/`) are preserved — they are
// part of the URI and the SDK signs them.
const decoded = decodeURIComponent(uri);
const segments = decoded.split('/');
const result: string[] = [];
for (const segment of segments) {
if (segment === '.') continue;
if (segment === '..') {
result.pop();
continue;
}
result.push(segment);
}
// Join preserves empty first segment (from leading /) automatically
return result.join('/') || '/';
};
const awsEncode = (value: string): string =>
encodeURIComponent(value).replace(
/[!'()*]/g,
(ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`,
);
export const buildCanonicalQueryString = (
searchParams: URLSearchParams,
excludeKeys: Set<string> = new Set(),
): string => {
const pairs: Array<[string, string]> = [];
for (const [key, value] of searchParams.entries()) {
if (!excludeKeys.has(key)) pairs.push([key, value]);
}
// AWS SigV4 requires UTF-8 byte-order (code point) comparison, NOT localeCompare
pairs.sort(([ak, av], [bk, bv]) => {
const a = `${awsEncode(ak)}=${awsEncode(av)}`;
const b = `${awsEncode(bk)}=${awsEncode(bv)}`;
if (a < b) return -1;
if (a > b) return 1;
return 0;
});
return pairs.map(([key, value]) => `${awsEncode(key)}=${awsEncode(value)}`).join('&');
};
const getHashedPayload = async (body: string | null): Promise<string> => {
if (!body || body.length === 0) return await sha256Hex('');
return await sha256Hex(body);
};
/**
* Parses an AWS SigV4 `x-amz-date` value (e.g. `20260707T120000Z`) into a Date.
*
* @param amzDate - The date string in `YYYYMMDDTHHmmssZ` format.
* @returns The parsed Date, or null if the format is invalid.
*/
const parseAmzDateUtc = (amzDate: string): Date | null => {
const match = amzDate.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/);
if (!match) return null;
const [, year, month, day, hour, minute, second] = match;
return new Date(
Date.UTC(
Number.parseInt(year, 10),
Number.parseInt(month, 10) - 1,
Number.parseInt(day, 10),
Number.parseInt(hour, 10),
Number.parseInt(minute, 10),
Number.parseInt(second, 10),
),
);
};
/**
* Validates that `host` is included in the signed headers list.
*
* AWS SigV4 mandates that `host` is always signed. Reject requests that
* omit it to prevent header injection / replay variants.
*
* @param signedHeaders - The semicolon-separated signed headers string.
* @returns True if `host` is present.
*/
const validateSignedHeaders = (signedHeaders: string): boolean => {
return signedHeaders.split(';').some((h) => h.toLowerCase() === 'host');
};
export const verifySignature = async (
method: string,
url: string,
headers: Record<string, string>,
body: string | null,
s3AccessKey: string,
s3SecretKey: string,
region: string,
): Promise<SigV4Result> => {
const authHeader = headers.authorization;
if (!authHeader?.startsWith('AWS4-HMAC-SHA256')) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
const parsed = parseAuthorizationHeader(authHeader);
if (!parsed) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
if (!timingSafeCompare(parsed.accessKey, s3AccessKey)) {
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
}
if (!timingSafeCompare(parsed.region, region)) {
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
}
// Validate service and termination in credential scope (M2)
if (parsed.service !== SERVICE || parsed.termination !== TERMINATION) {
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
}
// Validate host is in signed headers (LOW/host)
if (!validateSignedHeaders(parsed.signedHeaders)) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
const parsedUrl = new URL(url, 'http://localhost');
const canonicalUri = normalizeUri(parsedUrl.pathname);
const canonicalQueryString = buildCanonicalQueryString(parsedUrl.searchParams);
const contentSha256 = headers['x-amz-content-sha256'] || null;
if (contentSha256?.startsWith('STREAMING-')) {
return { isValid: false, credential: null, errorCode: 'NotImplemented' };
}
// CRITICAL: Use the x-amz-content-sha256 header value in the canonical
// request because that's what the client signed. The actual body hash is
// verified by verifyBodyHash() after streaming, ensuring integrity without
// breaking SigV4.
const hashedPayload = contentSha256 || (await getHashedPayload(body));
const canonicalRequest = buildCanonicalRequest(
method,
canonicalUri,
canonicalQueryString,
parsed.signedHeaders,
headers,
hashedPayload,
);
const hashedCanonicalRequest = await sha256Hex(canonicalRequest);
// M1: Fall back to Date header if x-amz-date is missing
const amzDate = headers['x-amz-date'] || headers.date || '';
// H5: Validate request freshness (clock skew / replay protection)
if (amzDate) {
const requestDate = parseAmzDateUtc(amzDate);
if (requestDate) {
const now = Date.now();
const skew = Math.abs(now - requestDate.getTime());
if (skew > MAX_CLOCK_SKEW_MS) {
return { isValid: false, credential: null, errorCode: 'RequestExpired' };
}
}
}
const dateStamp = parsed.date;
// M3: Ensure date in credential scope matches x-amz-date
if (amzDate) {
const amzDateStamp = amzDate.slice(0, 8); // "YYYYMMDD"
if (amzDateStamp !== dateStamp) {
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
}
}
const credentialScope = `${dateStamp}/${region}/${parsed.service}/${parsed.termination}`;
const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${hashedCanonicalRequest}`;
const signingKey = await getSigningKey(s3SecretKey, dateStamp, region);
const expectedSignature = await hmacHex(signingKey, stringToSign);
if (!timingSafeCompare(expectedSignature, parsed.signature)) {
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
}
return {
isValid: true,
credential: {
accessKey: parsed.accessKey,
date: parsed.date,
region: parsed.region,
service: parsed.service,
},
};
};
export const verifyPresignedUrl = async ({
url,
method,
headers,
s3AccessKey,
s3SecretKey,
region,
now = new Date(),
}: VerifyPresignedUrlInput): Promise<SigV4Result> => {
const parsedUrl = new URL(url);
const searchParams = parsedUrl.searchParams;
const algorithm = searchParams.get('X-Amz-Algorithm');
const credential = searchParams.get('X-Amz-Credential');
const signedHeaders = searchParams.get('X-Amz-SignedHeaders');
const signature = searchParams.get('X-Amz-Signature');
const expiresText = searchParams.get('X-Amz-Expires');
const amzDate = searchParams.get('X-Amz-Date');
if (
algorithm !== 'AWS4-HMAC-SHA256' ||
!credential ||
!signedHeaders ||
!signature ||
!expiresText ||
!amzDate
) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
const expires = Number.parseInt(expiresText, 10);
const signedAt = parseAmzDateUtc(amzDate);
if (!Number.isFinite(expires) || expires <= 0 || !signedAt) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
// AWS S3 spec limits presigned URLs to 7 days (604800 seconds)
const MAX_PRESIGNED_EXPIRY_SECONDS = 604800;
if (
now.getTime() > signedAt.getTime() + expires * 1000 ||
expires > MAX_PRESIGNED_EXPIRY_SECONDS
) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
const credParts = credential.split('/');
if (credParts.length !== 5) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
const [accessKey, dateStamp, credentialRegion, service, termination] = credParts;
if (
!timingSafeCompare(accessKey, s3AccessKey) ||
!timingSafeCompare(credentialRegion, region) ||
service !== SERVICE ||
termination !== TERMINATION
) {
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
}
// Validate host is in signed headers for presigned URLs too
if (!validateSignedHeaders(signedHeaders)) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
const signedHeaderList = signedHeaders.split(';').filter(Boolean);
const canonicalHeaders = signedHeaderList
.map((headerName) => {
const lower = headerName.toLowerCase();
const value = lower === 'host' ? headers.host || parsedUrl.host : headers[lower] || '';
return `${lower}:${value.trim()}\n`;
})
.join('');
const canonicalRequest = `${method}\n${normalizeUri(parsedUrl.pathname)}\n${buildCanonicalQueryString(searchParams, new Set(['X-Amz-Signature']))}\n${canonicalHeaders}\n${signedHeaders}\nUNSIGNED-PAYLOAD`;
const hashedCanonicalRequest = await sha256Hex(canonicalRequest);
const credentialScope = `${dateStamp}/${region}/${SERVICE}/${TERMINATION}`;
const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${hashedCanonicalRequest}`;
const expectedSignature = await hmacHex(
await getSigningKey(s3SecretKey, dateStamp, region),
stringToSign,
);
if (!timingSafeCompare(expectedSignature, signature)) {
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
}
return { isValid: true, credential: { accessKey, date: dateStamp, region, service } };
};
export const isS3Request = (headers: Record<string, string>): boolean => {
const auth = headers.authorization || '';
return auth.startsWith('AWS4-HMAC-SHA256');
};
/**
* Verifies that the actual body SHA-256 matches the `x-amz-content-sha256`
* header from the original request.
*
* This MUST be called AFTER the body has been fully streamed and hashed,
* as a second pass after `verifySignature` (which cannot hash a streaming
* body without consuming it).
*
* @param bodySha256 - The SHA-256 hex digest of the actual body content.
* @param headers - The original request headers.
* @returns An error result on mismatch, or null if the check passes.
*/
export const verifyBodyHash = (
bodySha256: string,
headers: Record<string, string>,
): SigV4Result | null => {
const claimedHash = headers['x-amz-content-sha256'];
// If the client sent UNSIGNED-PAYLOAD, skip verification
if (!claimedHash || claimedHash === 'UNSIGNED-PAYLOAD' || claimedHash.startsWith('STREAMING-')) {
return null;
}
if (claimedHash !== bodySha256) {
return { isValid: false, credential: null, errorCode: 'BadDigest' };
}
return null;
};
+9 -1
View File
@@ -1,3 +1,5 @@
import { nanoid } from 'nanoid';
export const S3_CORS_HEADERS: Record<string, string> = {
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET, PUT, HEAD, DELETE, POST, OPTIONS',
@@ -31,7 +33,13 @@ export const s3Headers = (
extraHeaders: Record<string, string> = {},
): Record<string, string> => ({
...S3_CORS_HEADERS,
...(requestId ? { 'x-amz-request-id': requestId, 'x-amz-id-2': requestId } : {}),
server: 'AmazonS3',
...(requestId
? {
'x-amz-request-id': requestId,
'x-amz-id-2': `${requestId}+${nanoid(16)}`,
}
: {}),
...extraHeaders,
});
+27 -1
View File
@@ -1 +1,27 @@
export { extractS3BucketFromHost } from '../../utils/s3/virtual-host';
const stripPort = (host: string): string => {
// Handle IPv6: [::1]:4321 -> [::1]
if (host.startsWith('[')) {
const closeBracket = host.indexOf(']');
return host.slice(0, closeBracket + 1).toLowerCase();
}
return host.split(':')[0].toLowerCase().replace(/\.$/, '');
};
const isValidBucketLabel = (bucket: string): boolean =>
/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket) &&
!bucket.includes('..') &&
!bucket.includes('.-') &&
!bucket.includes('-.');
export const extractS3BucketFromHost = (host: string, domains: string[]): string | null => {
const normalizedHost = stripPort(host);
for (const domain of domains) {
const normalizedDomain = stripPort(domain);
if (!normalizedDomain || normalizedHost === normalizedDomain) continue;
if (!normalizedHost.endsWith(`.${normalizedDomain}`)) continue;
const bucket = normalizedHost.slice(0, -(normalizedDomain.length + 1));
return isValidBucketLabel(bucket) ? bucket : null;
}
return null;
};
+5 -3
View File
@@ -277,8 +277,10 @@ export const s3ErrorResponse = (
// ─────── DeleteObjects XML parser ───────
export const parseDeleteObjectsBody = (body: string): { keys: string[]; quiet: boolean } => {
const keys = Array.from(body.matchAll(/<Key>([^<]+)<\/Key>/g), (match) => match[1]);
const quiet = body.includes('<Quiet>true</Quiet>') || body.includes('<Quiet>true ');
// H9: Use non-greedy match to handle keys containing < character
const keys = Array.from(body.matchAll(/<Key>([\s\S]*?)<\/Key>/g), (match) => match[1]);
// Handle whitespace inside <Quiet> element + namespace prefix support
const quiet = /<\w*:?Quiet\w*>\s*true\s*<\/\w*:?Quiet\w*>/i.test(body);
return { keys, quiet };
};
@@ -299,7 +301,7 @@ export const parseCompleteMultipartBody = (body: string): CompletePart[] => {
const etagMatch = partXml.match(/<ETag>"?([^"<\s]+)"?<\/ETag>/);
if (numMatch && etagMatch) {
parts.push({
partNumber: parseInt(numMatch[1], 10),
partNumber: Number.parseInt(numMatch[1], 10),
etag: etagMatch[1].replace(/^"/, '').replace(/"$/, ''),
});
}
-64
View File
@@ -1,64 +0,0 @@
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,
});
};
-152
View File
@@ -1,152 +0,0 @@
import { createReadStream } from 'node:fs';
import { nanoid } from 'nanoid';
import { findFileByPublicId } from '../db/files';
import { fileInfoCache } from '../utils/cache';
import { createChunkedObjectResponse } from '../utils/chunked-storage';
import { cleanupTempFile, formatCreatedAt, getErrorMessage } from '../utils/file';
import logger from '../utils/logger';
import { metricsCollector } from '../utils/metrics';
import { getFileInfo, type TelegramFileInfo } from '../utils/telegram';
import { locateZipEntry } from '../utils/zip';
type RequestWithParams = Request & {
params?: {
public_id?: string;
};
};
const getTelegramFileInfo = async (telegramFileId: string, public_id: string) => {
const cacheKey = `file_info_${telegramFileId}`;
let fileInfo = fileInfoCache.get(cacheKey) as TelegramFileInfo | null;
if (!fileInfo) {
metricsCollector.recordCacheMiss();
fileInfo = await getFileInfo(telegramFileId);
fileInfoCache.set(cacheKey, fileInfo);
logger.debug('File info cached', { public_id, cacheKey });
} else {
metricsCollector.recordCacheHit();
logger.debug('File info from cache', { public_id, cacheKey });
}
return fileInfo;
};
const buildTelegramFileUrl = (filePath: string, botToken: string): string =>
`https://api.telegram.org/file/bot${botToken}/${filePath}`;
const sanitizeFilenameHeader = (fileName: string): string =>
fileName.replace(/[\\"]/g, '').replace(/[\n\r]/g, '');
const fail = (status: number, error: string): Response => Response.json({ error }, { status });
export const handleFileRedirect = async (req: RequestWithParams): Promise<Response> => {
const public_id = req.params?.public_id;
try {
if (!public_id) {
return fail(400, 'Missing file id');
}
const file = await findFileByPublicId(public_id);
if (!file) {
logger.warn('File not found', { public_id });
return fail(404, 'File not found');
}
if (file.storageBackend === 'chunked') {
if (file.archiveEntryName) {
return fail(501, 'Archive entry extraction is not supported for chunked files');
}
const range = { type: 'none' as const };
return createChunkedObjectResponse({ file, range, reqId: '' });
}
const archiveEntryName = file.archiveEntryName;
if (archiveEntryName) {
const archiveFileId = file.archiveTelegramFileId || file.telegramFileId;
const archiveInfo = await getTelegramFileInfo(archiveFileId, public_id);
const archiveResponse = await fetch(
buildTelegramFileUrl(archiveInfo.file_path, archiveInfo.bot_token),
);
if (!archiveResponse.ok) {
logger.error('Archive download failed', { public_id, status: archiveResponse.status });
return fail(500, 'Server error');
}
const tempZipPath = `/tmp/filedrop-dl-${nanoid()}.zip`;
await Bun.write(tempZipPath, archiveResponse);
const loc = await locateZipEntry(tempZipPath, archiveEntryName);
if (!loc) {
await cleanupTempFile(tempZipPath);
logger.error('Archive entry not found', { public_id, archiveEntryName });
return fail(404, 'File not found');
}
const fileStream = createReadStream(tempZipPath, {
start: loc.start,
end: loc.start + loc.length - 1,
});
fileStream.on('close', () => {
void cleanupTempFile(tempZipPath);
});
fileStream.on('error', () => {
void cleanupTempFile(tempZipPath);
});
return new Response(fileStream as unknown as ReadableStream, {
status: 200,
headers: {
'Content-Type': file.mimeType || 'application/octet-stream',
'Content-Disposition': `attachment; filename="${sanitizeFilenameHeader(file.fileName)}"`,
'Content-Length': String(loc.length),
},
});
}
const fileInfo = await getTelegramFileInfo(file.telegramFileId, public_id);
const redirectUrl = buildTelegramFileUrl(fileInfo.file_path, fileInfo.bot_token);
return new Response(null, {
status: 302,
headers: {
Location: redirectUrl,
},
});
} catch (error: unknown) {
logger.error('File redirect error', { public_id, error: getErrorMessage(error) });
return fail(500, 'Server error');
}
};
export const handleFileInfo = async (req: RequestWithParams): Promise<Response> => {
const public_id = req.params?.public_id;
try {
if (!public_id) {
return fail(400, 'Missing file id');
}
const file = await findFileByPublicId(public_id);
if (!file) {
logger.warn('File not found', { public_id });
return fail(404, 'File not found');
}
return Response.json(
{
public_id: file.publicId,
file_name: file.fileName,
mime_type: file.mimeType,
size_bytes: file.sizeBytes,
file_type: file.fileType,
created_at: formatCreatedAt(file.createdAt),
},
{ status: 200 },
);
} catch (error: unknown) {
logger.error('File info error', { public_id, error: getErrorMessage(error) });
return fail(500, 'Server error');
}
};
-15
View File
@@ -1,15 +0,0 @@
import { sql } from 'drizzle-orm';
import { db } from '../db';
import { getErrorMessage } from '../utils/file';
import logger from '../utils/logger';
export const handleHealth = async (_req: Request): Promise<Response> => {
try {
await db.execute(sql`SELECT 1`);
return Response.json({ status: 'ok' }, { status: 200 });
} catch (error: unknown) {
const message = getErrorMessage(error);
logger.error('Health check failed', { error: message });
return Response.json({ status: 'error', error: message }, { status: 500 });
}
};
-9
View File
@@ -1,9 +0,0 @@
export const handleHome = async (): Promise<Response> => {
const html = await Bun.file(`${import.meta.dir}/../home.html`).text();
return new Response(html, {
status: 200,
headers: {
'content-type': 'text/html; charset=utf-8',
},
});
};
-1178
View File
File diff suppressed because it is too large Load Diff
-303
View File
@@ -1,303 +0,0 @@
import { createWriteStream } from 'node:fs';
import { nanoid } from 'nanoid';
import { findFileByHash } from '../db/files';
import { config } from '../env';
import { storeFileInTelegramChunks } from '../utils/chunked-storage';
import {
buildUploadResponse,
checkFileSize,
cleanupTempFile,
computeHash,
ensureExtension,
extractMimeType,
getErrorMessage,
getFileType,
} from '../utils/file';
import logger from '../utils/logger';
import { metricsCollector } from '../utils/metrics';
import { enqueuePreparedUpload, type PreparedUpload } from '../utils/uploadBatcher';
interface JsonUploadPayload {
file?: unknown;
fileName?: string;
}
const parseBase64File = (file: string): { base64Data: string; mimeType: string } => {
if (!file.startsWith('data:')) {
return { base64Data: file, mimeType: 'application/octet-stream' };
}
const match = file.match(/^data:([^;]+);base64,(.+)$/);
return match
? { base64Data: match[2], mimeType: match[1] }
: { base64Data: file, mimeType: 'application/octet-stream' };
};
const normalizeFileType = (mimeType: string, fileName: string): string => {
const fileType = getFileType(mimeType, fileName);
return fileType === 'application' ? 'document' : fileType;
};
const JSON_UPLOAD_LIMIT_BYTES = 50 * 1024 * 1024;
const SIGNATURE_BYTES = 16;
const getContentLength = (req: Request): number | null => {
const value = req.headers.get('content-length');
if (!value) return null;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
};
const rejectOversizedRequest = (req: Request): Response | null => {
const contentLength = getContentLength(req);
if (contentLength !== null && contentLength > config.maxRequestBodyBytes) {
return Response.json({ error: 'Request body too large' }, { status: 413 });
}
return null;
};
const streamFileToTemp = async (file: File, maxSizeBytes: number): Promise<PreparedUpload> => {
const tempPath = `/tmp/filedrop-${nanoid()}`;
const writer = createWriteStream(tempPath);
const hasher = new Bun.CryptoHasher('sha256');
const reader = file.stream().getReader();
const signatureChunks: Buffer[] = [];
let signatureBytes = 0;
let sizeBytes = 0;
const writeChunk = async (chunk: Buffer): Promise<void> => {
if (!writer.write(chunk)) {
await new Promise<void>((resolve, reject) => {
writer.once('drain', resolve);
writer.once('error', reject);
});
}
};
const finishWriter = async (): Promise<void> => {
await new Promise<void>((resolve, reject) => {
writer.end(() => resolve());
writer.once('error', reject);
});
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = Buffer.from(value);
sizeBytes += chunk.byteLength;
if (sizeBytes > maxSizeBytes) {
throw new Error('File size exceeds upload limit');
}
hasher.update(chunk);
await writeChunk(chunk);
if (signatureBytes < SIGNATURE_BYTES) {
const remaining = SIGNATURE_BYTES - signatureBytes;
const signatureChunk = chunk.subarray(0, remaining);
signatureChunks.push(signatureChunk);
signatureBytes += signatureChunk.byteLength;
}
}
await finishWriter();
return {
tempPath,
fileHash: hasher.digest('hex'),
sizeBytes,
signatureBuffer: Buffer.concat(signatureChunks, signatureBytes),
};
} catch (error) {
writer.destroy();
await cleanupTempFile(tempPath);
throw error;
} finally {
reader.releaseLock();
}
};
const writeBufferToTemp = async (fileBuffer: Buffer, fileHash: string): Promise<PreparedUpload> => {
const tempPath = `/tmp/filedrop-${nanoid()}`;
try {
await Bun.write(tempPath, fileBuffer);
return {
tempPath,
fileHash,
sizeBytes: fileBuffer.byteLength,
signatureBuffer: fileBuffer.subarray(0, SIGNATURE_BYTES),
};
} catch (error) {
await cleanupTempFile(tempPath);
throw error;
}
};
export const handleUpload = async (req: Request): Promise<Response> => {
const startTime = performance.now();
try {
const contentType = req.headers.get('content-type') || '';
const oversizedResponse = rejectOversizedRequest(req);
if (oversizedResponse) return oversizedResponse;
if (contentType.includes('multipart/form-data')) {
return handleMultipartUpload(req);
} else if (contentType.includes('application/json')) {
return handleJSONUpload(req);
}
return Response.json(
{ error: 'Unsupported content type. Use multipart/form-data or application/json' },
{ status: 400 },
);
} catch (error: unknown) {
metricsCollector.recordError();
const message = getErrorMessage(error);
logger.error('Upload error', { error: message });
return Response.json({ error: message }, { status: 500 });
} finally {
metricsCollector.recordUploadTime(performance.now() - startTime);
}
};
const handleMultipartUpload = async (req: Request): Promise<Response> => {
try {
const formData = await req.formData();
const file = formData.get('file');
const fileName =
(formData.get('fileName') as string) || (file instanceof File ? file.name : null) || 'file';
if (!file || !(file instanceof File)) {
return Response.json({ error: 'No file provided' }, { status: 400 });
}
if (file.size > config.maxRequestBodyBytes) {
return Response.json({ error: 'File size exceeds upload limit' }, { status: 413 });
}
const prepared = await streamFileToTemp(file, config.maxRequestBodyBytes);
const existingFile = await findFileByHash(prepared.fileHash);
if (existingFile) {
await cleanupTempFile(prepared.tempPath);
return Response.json(buildUploadResponse(existingFile, config.baseUrl), { status: 200 });
}
const rawMimeType = file.type || extractMimeType({}, req) || 'application/octet-stream';
const { fileName: finalFileName, mimeType } = ensureExtension(
fileName,
prepared.signatureBuffer,
rawMimeType,
);
const fileType = getFileType(mimeType, finalFileName);
if (!checkFileSize(prepared.sizeBytes, fileType)) {
await cleanupTempFile(prepared.tempPath);
return Response.json({ error: `File size exceeds ${fileType} limit` }, { status: 400 });
}
if (prepared.sizeBytes > config.telegramChunkSizeBytes) {
const file = await storeFileInTelegramChunks({
tempPath: prepared.tempPath,
partFileNamePrefix: `direct-${prepared.fileHash?.slice(0, 16) || 'upload'}`,
fileName: finalFileName,
mimeType,
sizeBytes: prepared.sizeBytes,
fileType,
uploaderId: 0,
});
await cleanupTempFile(prepared.tempPath);
return Response.json(buildUploadResponse(file, config.baseUrl), { status: 200 });
}
const uploaded = await enqueuePreparedUpload({
prepared,
fileName: finalFileName,
mimeType,
fileType,
});
return Response.json(buildUploadResponse(uploaded, config.baseUrl), { status: 200 });
} catch (error: unknown) {
const message = getErrorMessage(error);
logger.error('Multipart upload error', { error: message });
return Response.json({ error: message }, { status: 500 });
}
};
const handleJSONUpload = async (req: Request): Promise<Response> => {
try {
const { file, fileName = 'file' } = (await req.json()) as JsonUploadPayload;
if (!file || typeof file !== 'string') {
return Response.json(
{ error: 'Invalid JSON. Must include "file" (base64) and optional "fileName"' },
{ status: 400 },
);
}
const { base64Data, mimeType: rawMimeType } = parseBase64File(file);
const estimatedSizeBytes = Math.floor((base64Data.length * 3) / 4);
if (
estimatedSizeBytes > JSON_UPLOAD_LIMIT_BYTES ||
estimatedSizeBytes > config.maxRequestBodyBytes
) {
return Response.json(
{
error:
'JSON base64 uploads are limited to 50MB. Use multipart/form-data for larger files',
},
{ status: 400 },
);
}
const fileBytes = Buffer.from(base64Data, 'base64');
const hash = computeHash(fileBytes);
const existingFile = await findFileByHash(hash);
if (existingFile) {
return Response.json(buildUploadResponse(existingFile, config.baseUrl), { status: 200 });
}
const { fileName: finalFileName, mimeType } = ensureExtension(fileName, fileBytes, rawMimeType);
const fileType = normalizeFileType(mimeType, finalFileName);
if (!checkFileSize(fileBytes.byteLength, fileType)) {
return Response.json({ error: `File size exceeds ${fileType} limit` }, { status: 400 });
}
const prepared = await writeBufferToTemp(fileBytes, hash);
if (prepared.sizeBytes > config.telegramChunkSizeBytes) {
const file = await storeFileInTelegramChunks({
tempPath: prepared.tempPath,
partFileNamePrefix: `direct-${prepared.fileHash?.slice(0, 16) || 'json'}`,
fileName: finalFileName,
mimeType,
sizeBytes: prepared.sizeBytes,
fileType,
uploaderId: 0,
});
await cleanupTempFile(prepared.tempPath);
return Response.json(buildUploadResponse(file, config.baseUrl), { status: 200 });
}
const uploaded = await enqueuePreparedUpload({
prepared,
fileName: finalFileName,
mimeType,
fileType,
});
return Response.json(buildUploadResponse(uploaded, config.baseUrl), { status: 200 });
} catch (error: unknown) {
const message = getErrorMessage(error);
logger.error('JSON upload error', { error: message });
return Response.json({ error: message }, { status: 500 });
}
};
-347
View File
@@ -1,347 +0,0 @@
import { createReadStream } from 'node:fs';
import { nanoid } from 'nanoid';
import { createBucket, deleteBucket, findBucketByName, listBuckets } from '../db/buckets';
import {
countBucketObjects,
findFileByBucketAndKey,
listObjectsByPrefix,
softDeleteFile,
} from '../db/files-ext';
import { config } from '../env';
import { createChunkedObjectResponse, storeFileInTelegramChunks } from '../utils/chunked-storage';
import { cleanupTempFile, computeHash, ensureExtension, getErrorMessage } from '../utils/file';
import logger from '../utils/logger';
import { forwardToStorage, getFileInfo } from '../utils/telegram';
type RouteParams = { bucket?: string; key?: string };
const json = (data: unknown, status = 200) => Response.json(data, { status });
const jsonError = (error: string, status: number) => Response.json({ error }, { status });
// ─────── Bucket endpoints ───────
export const handleListBucketsV1 = async (): Promise<Response> => {
const buckets = await listBuckets();
const result = await Promise.all(
buckets.map(async (b) => ({
id: b.id,
name: b.name,
createdAt: b.createdAt.toISOString(),
objectCount: await countBucketObjects(b.id),
})),
);
return json({ buckets: result });
};
export const handleCreateBucketV1 = async (req: Request): Promise<Response> => {
const body = (await req.json()) as { name?: string };
if (!body.name || !/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(body.name)) {
return jsonError('Invalid bucket name. Use lowercase, 3-63 chars, no underscore', 400);
}
const existing = await findBucketByName(body.name);
if (existing) return jsonError('Bucket already exists', 409);
const bucket = await createBucket(body.name);
return json({ id: bucket.id, name: bucket.name }, 201);
};
export const handleDeleteBucketV1 = async (
_req: Request,
params: RouteParams,
): Promise<Response> => {
const bucket = await findBucketByName(params.bucket!);
if (!bucket) return jsonError('Bucket not found', 404);
const count = await countBucketObjects(bucket.id);
if (count > 0) return jsonError('Bucket is not empty', 409);
await deleteBucket(params.bucket!);
return json({ success: true });
};
// ─────── Object endpoints ───────
export const handleListObjectsV1 = async (req: Request, params: RouteParams): Promise<Response> => {
const bucket = await findBucketByName(params.bucket!);
if (!bucket) return jsonError('Bucket not found', 404);
const url = new URL(req.url);
const prefix = url.searchParams.get('prefix') || '';
const delimiter = url.searchParams.get('delimiter') || '/';
const maxKeys = parseInt(url.searchParams.get('max-keys') || '1000', 10);
const continuationToken = url.searchParams.get('continuation-token') || null;
const { objects, prefixes } = await listObjectsByPrefix(
bucket.id,
prefix,
delimiter,
maxKeys,
continuationToken,
);
const isTruncated = objects.length > maxKeys;
const displayObjects = objects.slice(0, maxKeys);
return json({
objects: displayObjects.map((o) => ({
key: o.s3Key,
fileName: o.fileName,
mimeType: o.mimeType,
sizeBytes: Number(o.sizeBytes),
fileType: o.fileType,
etag: o.fileHash,
lastModified:
o.createdAt instanceof Date
? o.createdAt.toISOString()
: new Date(o.createdAt).toISOString(),
downloadUrl: `${config.baseUrl}/f/${o.publicId}`,
})),
prefixes,
isTruncated,
nextContinuationToken: isTruncated ? displayObjects[displayObjects.length - 1]?.s3Key : null,
});
};
export const handleUploadObjectV1 = async (
req: Request,
params: RouteParams,
): Promise<Response> => {
const bucket = await findBucketByName(params.bucket!);
if (!bucket) return jsonError('Bucket not found', 404);
const formData = await req.formData();
const file = formData.get('file');
if (!file || !(file instanceof File)) {
return jsonError('No file provided', 400);
}
const key = (formData.get('key') as string) || file.name;
const buffer = Buffer.from(await file.arrayBuffer());
const hash = computeHash(buffer);
const tempPath = `/tmp/filedrop-web-${nanoid()}`;
await Bun.write(tempPath, buffer);
const signatureBuffer = buffer.subarray(0, 16);
const { fileName: finalFileName, mimeType } = ensureExtension(
key.split('/').pop() || 'file',
signatureBuffer,
file.type || 'application/octet-stream',
);
const partFileNamePrefix = `s3-${bucket.name}-${key.replace(/\//g, '_')}`;
if (buffer.byteLength > config.telegramChunkSizeBytes) {
const file = await storeFileInTelegramChunks({
tempPath,
partFileNamePrefix,
fileName: finalFileName,
mimeType,
sizeBytes: buffer.byteLength,
fileType: 'document',
uploaderId: 0,
bucketId: bucket.id,
s3Key: key,
});
await cleanupTempFile(tempPath);
return json(
{
key,
size: buffer.byteLength,
etag: hash,
downloadUrl: `${config.baseUrl}/f/${file.publicId}`,
},
201,
);
}
const forwardResult = await forwardToStorage(
createReadStream(tempPath),
partFileNamePrefix,
'document',
);
const publicId = nanoid();
const { db, files: fileSchema } = await import('../db/index');
await db.insert(fileSchema).values({
publicId,
telegramFileId: forwardResult.telegramFileId,
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
storageChatId: config.storageChatId,
storageMessageId: forwardResult.storageMessageId,
fileName: finalFileName,
mimeType,
sizeBytes: buffer.byteLength,
fileType: 'document',
uploaderId: 0,
fileHash: hash,
bucketId: bucket.id,
s3Key: key,
storageBackend: 'telegram',
isDeleted: false,
createdAt: new Date(),
updatedAt: new Date(),
});
await cleanupTempFile(tempPath);
return json(
{ key, size: buffer.byteLength, etag: hash, downloadUrl: `${config.baseUrl}/f/${publicId}` },
201,
);
};
export const handleDeleteObjectV1 = async (
_req: Request,
params: RouteParams,
): Promise<Response> => {
const bucket = await findBucketByName(params.bucket!);
if (!bucket) return jsonError('Bucket not found', 404);
await softDeleteFile(bucket.id, params.key!);
return json({ success: true });
};
export const handleDownloadObjectV1 = async (
_req: Request,
params: RouteParams,
): Promise<Response> => {
const bucket = await findBucketByName(params.bucket!);
if (!bucket) return jsonError('Bucket not found', 404);
const file = await findFileByBucketAndKey(bucket.id, params.key!);
if (!file) return jsonError('Object not found', 404);
if (file.storageBackend === 'chunked') {
const range = { type: 'none' as const };
return createChunkedObjectResponse({ file, range, reqId: '' });
}
const fileInfo = await getFileInfo(file.telegramFileId);
const redirectUrl = `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`;
return new Response(null, { status: 302, headers: { Location: redirectUrl } });
};
export const handleCopyObjectV1 = async (req: Request, params: RouteParams): Promise<Response> => {
const body = (await req.json()) as {
sourceKey?: string;
destBucket?: string;
destKey?: string;
};
if (!body.sourceKey || !body.destKey) {
return jsonError('sourceKey and destKey are required', 400);
}
const destBucketName = body.destBucket || params.bucket!;
const sourceBucket = await findBucketByName(params.bucket!);
const destBucket = await findBucketByName(destBucketName);
if (!sourceBucket || !destBucket) return jsonError('Bucket not found', 404);
const sourceFile = await findFileByBucketAndKey(sourceBucket.id, body.sourceKey);
if (!sourceFile) return jsonError('Source object not found', 404);
if (sourceFile.storageBackend === 'chunked') {
return json({ error: 'Copying chunked objects is not implemented' }, 501);
}
const publicId = nanoid();
const { db, files: fileSchema } = await import('../db/index');
await db.insert(fileSchema).values({
publicId,
telegramFileId: sourceFile.telegramFileId,
telegramFileUniqueId: sourceFile.telegramFileUniqueId,
storageChatId: sourceFile.storageChatId,
storageMessageId: sourceFile.storageMessageId,
fileName: sourceFile.fileName,
mimeType: sourceFile.mimeType,
sizeBytes: sourceFile.sizeBytes,
fileType: sourceFile.fileType,
uploaderId: 0,
fileHash: sourceFile.fileHash,
bucketId: destBucket.id,
s3Key: body.destKey,
storageBackend: 'telegram',
isDeleted: false,
createdAt: new Date(),
updatedAt: new Date(),
});
return json({ sourceKey: body.sourceKey, destKey: body.destKey, destBucket: destBucketName });
};
// ─────── Router ───────
export const handleWebApiV1 = async (req: Request): Promise<Response> => {
const url = new URL(req.url);
const pathname = url.pathname.replace(/^\/api\/v1/, '');
const parts = pathname.split('/').filter(Boolean);
const method = req.method;
try {
// GET /api/v1/buckets
if (parts.length === 1 && parts[0] === 'buckets' && method === 'GET') {
return await handleListBucketsV1();
}
// POST /api/v1/buckets
if (parts.length === 1 && parts[0] === 'buckets' && method === 'POST') {
return await handleCreateBucketV1(req);
}
// DELETE /api/v1/buckets/{name}
if (parts.length === 2 && parts[0] === 'buckets' && method === 'DELETE') {
return await handleDeleteBucketV1(req, { bucket: parts[1] });
}
// GET /api/v1/buckets/{name}/objects
if (
parts.length === 3 &&
parts[0] === 'buckets' &&
parts[2] === 'objects' &&
method === 'GET'
) {
return await handleListObjectsV1(req, { bucket: parts[1] });
}
// POST /api/v1/buckets/{name}/upload
if (
parts.length === 3 &&
parts[0] === 'buckets' &&
parts[2] === 'upload' &&
method === 'POST'
) {
return await handleUploadObjectV1(req, { bucket: parts[1] });
}
// POST /api/v1/buckets/{name}/copy
if (parts.length === 3 && parts[0] === 'buckets' && parts[2] === 'copy' && method === 'POST') {
return await handleCopyObjectV1(req, { bucket: parts[1] });
}
// DELETE /api/v1/buckets/{name}/{key+}
if (parts.length >= 3 && parts[0] === 'buckets' && method === 'DELETE') {
const bucket = parts[1];
const key = parts.slice(2).join('/');
return await handleDeleteObjectV1(req, { bucket, key });
}
// GET /api/v1/buckets/{name}/download/{key+}
if (
parts.length >= 4 &&
parts[0] === 'buckets' &&
parts[2] === 'download' &&
method === 'GET'
) {
const bucket = parts[1];
const key = parts.slice(3).join('/');
return await handleDownloadObjectV1(req, { bucket, key });
}
return jsonError('Not found', 404);
} catch (error: unknown) {
logger.error('Web API error', { path: pathname, error: getErrorMessage(error) });
return jsonError('Internal server error', 500);
}
};
-73
View File
@@ -1,73 +0,0 @@
/**
* Base domain error class for all application-specific errors.
* Extends the built-in Error with a fixed name property for reliable
* instance checking across layers.
*/
export class DomainError extends Error {
constructor(msg: string) {
super(msg);
this.name = 'DomainError';
}
}
/**
* Thrown when a requested file cannot be found in storage.
*/
export class FileNotFoundError extends DomainError {
constructor(msg: string) {
super(msg);
this.name = 'FileNotFoundError';
}
}
/**
* Thrown when a requested bucket does not exist.
*/
export class BucketNotFoundError extends DomainError {
constructor(msg: string) {
super(msg);
this.name = 'BucketNotFoundError';
}
}
/**
* Thrown when a file exceeds the maximum allowed size for upload.
*/
export class FileTooLargeError extends DomainError {
constructor(msg: string) {
super(msg);
this.name = 'FileTooLargeError';
}
}
/**
* Thrown when an attempt is made to upload a file that already exists
* (detected by content hash deduplication).
*/
export class DuplicateFileError extends DomainError {
constructor(msg: string) {
super(msg);
this.name = 'DuplicateFileError';
}
}
/**
* Thrown when authentication fails or a valid session is not present.
*/
export class AuthenticationError extends DomainError {
constructor(msg: string) {
super(msg);
this.name = 'AuthenticationError';
}
}
/**
* Thrown when input validation fails (e.g. missing required fields,
* invalid format, or constraint violations).
*/
export class ValidationError extends DomainError {
constructor(msg: string) {
super(msg);
this.name = 'ValidationError';
}
}
+26 -4
View File
@@ -1,4 +1,26 @@
import _logger from '../../utils/logger';
export default _logger;
export type { Logger } from 'winston';
export { _logger as logger };
import winston from 'winston';
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json(),
),
defaultMeta: { service: 'filedrop' },
transports: [
// Write all logs including error logs to file
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'logs/combined.log' }),
// Console transport for docker logs / CLI visibility
new winston.transports.Console({
format:
process.env.NODE_ENV !== 'production'
? winston.format.combine(winston.format.colorize(), winston.format.simple())
: winston.format.json(),
}),
],
});
export default logger;
export { logger };
+111 -1
View File
@@ -1 +1,111 @@
export { MetricsCollector, metricsCollector } from '../../utils/metrics';
// No imports needed — logger used only by setInterval which moved to index.ts
interface Metric {
name: string;
value: number;
timestamp: number;
tags?: Record<string, string>;
}
interface MetricsSnapshot {
uploadLatency: { p50: number; p95: number; p99: number };
uploadThroughput: number;
queueSize: number;
errorRate: number;
cacheHitRate: number;
botUtilization: number;
timestamp: number;
}
class MetricsCollector {
private metrics: Metric[] = [];
private uploadTimes: number[] = [];
private errorCount = 0;
private totalRequests = 0;
private cacheHits = 0;
private cacheMisses = 0;
private maxMetricsSize = 10000;
recordUploadTime(durationMs: number): void {
this.uploadTimes.push(durationMs);
this.totalRequests++;
// Keep only last 1000 measurements
if (this.uploadTimes.length > 1000) {
this.uploadTimes.shift();
}
}
recordError(): void {
this.errorCount++;
}
recordCacheHit(): void {
this.cacheHits++;
}
recordCacheMiss(): void {
this.cacheMisses++;
}
recordMetric(name: string, value: number, tags?: Record<string, string>): void {
this.metrics.push({
name,
value,
timestamp: Date.now(),
tags,
});
// Keep metrics bounded
if (this.metrics.length > this.maxMetricsSize) {
this.metrics = this.metrics.slice(-this.maxMetricsSize);
}
}
private calculatePercentile(arr: number[], percentile: number): number {
if (arr.length === 0) return 0;
const sorted = [...arr].sort((a, b) => a - b);
const index = Math.ceil((percentile / 100) * sorted.length) - 1;
return sorted[Math.max(0, index)];
}
getSnapshot(): MetricsSnapshot {
const errorRate = this.totalRequests > 0 ? (this.errorCount / this.totalRequests) * 100 : 0;
const cacheHitRate =
this.cacheHits + this.cacheMisses > 0
? (this.cacheHits / (this.cacheHits + this.cacheMisses)) * 100
: 0;
return {
uploadLatency: {
p50: this.calculatePercentile(this.uploadTimes, 50),
p95: this.calculatePercentile(this.uploadTimes, 95),
p99: this.calculatePercentile(this.uploadTimes, 99),
},
uploadThroughput: this.totalRequests > 0 ? this.totalRequests / 60 : 0,
queueSize: 0, // Will be updated by queue
errorRate,
cacheHitRate,
botUtilization: 0, // Will be updated by bot tracker
timestamp: Date.now(),
};
}
reset(): void {
this.uploadTimes = [];
this.errorCount = 0;
this.totalRequests = 0;
this.cacheHits = 0;
this.cacheMisses = 0;
this.metrics = [];
}
getMetrics(name?: string): Metric[] {
if (!name) return this.metrics;
return this.metrics.filter((m) => m.name === name);
}
}
export const metricsCollector = new MetricsCollector();
export { MetricsCollector };
+32
View File
@@ -0,0 +1,32 @@
/** Compression algorithm for chunked file storage. */
export type CompressionAlgorithm = 'gzip' | null;
/**
* Optionally compress a chunk with gzip.
*
* Compression is skipped if:
* - The `compress` flag is false.
* - The chunk is smaller than `compressionMinSizeBytes`.
* - The compressed result is larger than the original.
*
* @param chunk - The raw chunk buffer.
* @param compress - Whether compression is enabled.
* @param compressionMinSizeBytes - Minimum chunk size to attempt compression.
* @returns The (possibly compressed) bytes and the algorithm used.
*/
export const maybeCompressChunk = (
chunk: Buffer,
compress: boolean,
compressionMinSizeBytes: number,
): { bytes: Buffer; compressionAlgorithm: CompressionAlgorithm } => {
if (!compress || chunk.byteLength < compressionMinSizeBytes) {
return { bytes: chunk, compressionAlgorithm: null };
}
const gzipped = Bun.gzipSync(chunk);
if (gzipped.byteLength >= chunk.byteLength) {
return { bytes: chunk, compressionAlgorithm: null };
}
return { bytes: gzipped, compressionAlgorithm: 'gzip' };
};
+10 -2
View File
@@ -1,5 +1,5 @@
import { unlink } from 'node:fs/promises';
import logger from '../../utils/logger';
import logger from '../logger/index';
/**
* Safely extracts an error message from an unknown value.
@@ -51,6 +51,9 @@ interface FileMetadata {
createdAt: Date | string | number;
}
/** Default file type used when no specific type can be determined. */
export const DEFAULT_FILE_TYPE = 'document';
/** Per-file-type size limits in bytes. */
const FILE_TYPES: Record<string, number> = {
document: 2 * 1024 * 1024 * 1024, // 2GB
@@ -82,7 +85,12 @@ export const getFileType = (mime: string | null, caption?: string): string => {
if (mimeUpper === 'video') return 'video';
if (mimeUpper === 'audio') return 'audio';
if (mimeUpper === 'document') return 'document';
if (mimeUpper === 'image') return captionLower?.includes('gif') ? 'animation' : 'photo';
// ── Photo (JPEG only; Telegram Bot API rejects non-JPEG for sendPhoto) ──
if (mimeUpper === 'image') {
if (captionLower?.includes('gif')) return 'animation';
if (mime?.toLowerCase() === 'image/jpeg' || mime?.toLowerCase() === 'image/jpg') return 'photo';
return 'document';
}
if (captionLower?.includes('voice')) return 'voice';
if (captionLower?.includes('animation')) return 'animation';
-135
View File
@@ -1,135 +0,0 @@
import logger from '../../utils/logger';
/** Configuration options for retry behaviour. */
interface RetryOptions {
/** Maximum number of retry attempts (default: 3). */
maxRetries?: number;
/** Delay before the first retry in milliseconds (default: 100). */
initialDelayMs?: number;
/** Maximum delay between retries in milliseconds (default: 5000). */
maxDelayMs?: number;
/** Multiplier for exponential backoff (default: 2). */
backoffMultiplier?: number;
/**
* Predicate that determines whether a given error should trigger a retry.
* When omitted, transient network / timeout errors are retried.
*/
shouldRetry?: (error: unknown) => boolean;
}
const DEFAULT_OPTIONS: Required<RetryOptions> = {
maxRetries: 3,
initialDelayMs: 100,
maxDelayMs: 5000,
backoffMultiplier: 2,
shouldRetry: (error: unknown) => {
const errorStr = error instanceof Error ? error.message : String(error);
// Retry on transient errors
return (
errorStr.includes('ECONNREFUSED') ||
errorStr.includes('ETIMEDOUT') ||
errorStr.includes('ENOTFOUND') ||
errorStr.includes('429') ||
errorStr.includes('timeout')
);
},
};
/**
* Executes an async function with exponential backoff retry logic.
*
* The function is retried up to `maxRetries` times. Between attempts the
* delay grows by `backoffMultiplier` (capped at `maxDelayMs`). Only errors
* for which `shouldRetry` returns `true` trigger a retry; all others are
* thrown immediately. When all retries are exhausted the last error is
* thrown.
*
* @param fn - The async function to execute.
* @param options - Optional retry configuration overrides.
* @returns The resolved value of `fn`.
*/
export const withRetry = async <T>(
fn: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> => {
const opts = { ...DEFAULT_OPTIONS, ...options };
let lastError: unknown;
let delay = opts.initialDelayMs;
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
try {
return await fn();
} catch (error: unknown) {
lastError = error;
const errorStr = error instanceof Error ? error.message : String(error);
if (attempt === opts.maxRetries || !opts.shouldRetry(error)) {
logger.error('Retry exhausted', {
attempt,
maxRetries: opts.maxRetries,
error: errorStr,
});
throw error;
}
logger.warn('Retrying after error', {
attempt,
delay,
error: errorStr,
});
await new Promise((resolve) => setTimeout(resolve, delay));
delay = Math.min(delay * opts.backoffMultiplier, opts.maxDelayMs);
}
}
throw lastError;
};
/**
* Wraps an async function with a configurable timeout.
*
* If `fn` does not settle within `timeoutMs` milliseconds the returned
* promise rejects with a timeout error. The underlying `fn` continues
* executing but its result is ignored.
*
* @param fn - The async function to execute.
* @param timeoutMs - Timeout in milliseconds (default: 30000).
* @returns The resolved value of `fn`.
*/
export const withTimeout = async <T>(
fn: () => Promise<T>,
timeoutMs: number = 30000,
): Promise<T> => {
return Promise.race([
fn(),
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error(`Operation timeout after ${timeoutMs}ms`)), timeoutMs),
),
]);
};
/**
* Executes a primary async function and falls back to a secondary function
* if the primary throws.
*
* The fallback function is called only when the primary rejects. If the
* fallback also throws the error propagates to the caller.
*
* @param primary - The primary async function to attempt first.
* @param fallback - The fallback async function invoked on failure.
* @returns The resolved value of `primary` or, on failure, of `fallback`.
*/
export const withFallback = async <T>(
primary: () => Promise<T>,
fallback: () => Promise<T>,
): Promise<T> => {
try {
return await primary();
} catch (error: unknown) {
logger.warn('Primary operation failed, using fallback', {
error: error instanceof Error ? error.message : String(error),
});
return fallback();
}
};
+33
View File
@@ -0,0 +1,33 @@
import { config } from '../../env';
import { isS3Request } from '../../interfaces/s3/auth';
import { extractS3BucketFromHost } from '../../interfaces/s3/virtual-host';
/**
* Extracts the S3 bucket name from the request host
* if it matches a virtual-hosted-style domain.
*
* @param req - The incoming HTTP request.
* @returns The bucket name if found, or null.
*/
export const getS3RouteBucket = (req: Request): string | null => {
const host = req.headers.get('host') || '';
return extractS3BucketFromHost(host, config.s3VhostDomains);
};
/**
* Determines whether the incoming request appears to be an S3 API request
* based on host headers, authorization headers, or query parameters.
*
* @param req - The incoming HTTP request.
* @param headers - A record of parsed request headers (optional derived from req if omitted).
* @returns True if the request should be handled by the S3 handler.
*/
export const shouldHandleS3 = (req: Request, headers?: Record<string, string>): boolean => {
const resolvedHeaders = headers ?? Object.fromEntries(req.headers);
const url = new URL(req.url);
return Boolean(
getS3RouteBucket(req) ||
isS3Request(resolvedHeaders) ||
url.searchParams.has('X-Amz-Signature'),
);
};
+132
View File
@@ -0,0 +1,132 @@
/**
* Shared utility for streaming data into a temporary file on disk while
* computing its SHA-256 hash (and optionally MD5) and extracting the
* signature (first 16 bytes) for magic-byte detection.
*
* Consolidates the duplicated streaming-to-temp pattern found across
* multiple HTTP controllers (s3-controller, upload-controller,
* web-api-controller) into a single, reusable function.
*/
import { unlink } from 'node:fs/promises';
import { nanoid } from 'nanoid';
/** Options for the {@link streamToTemp} function. */
export interface StreamToTempOptions {
/** Temporary file path prefix (default: `'/tmp/filedrop-'`). */
prefix?: string;
/** When true, also compute the MD5 hash (default: false). */
computeMd5?: boolean;
/** Maximum allowed bytes; throws if the stream exceeds this size. */
maxSizeBytes?: number;
}
/** Result of a successful {@link streamToTemp} call. */
export interface StreamToTempResult {
/** Absolute path to the written temp file. */
tempPath: string;
/** SHA-256 hex digest of the entire stream. */
fileHash: string;
/** MD5 base-64 digest — only present when `computeMd5` was true. */
md5Hash?: string;
/** Total number of bytes written. */
sizeBytes: number;
/** First 16 bytes of the stream (padded with zeros if shorter). */
signatureBuffer: Buffer;
}
/**
* Streams data from a `ReadableStreamDefaultReader` into a temporary file
* while computing hashes and extracting the first 16 bytes as a signature
* buffer.
*
* The temp file is cleaned up automatically on error.
*
* @param reader - A reader obtained from a `ReadableStream`.
* @param options - Optional behaviour flags.
* @returns A promise resolving with the temp-file metadata.
* @throws {Error} If `maxSizeBytes` is exceeded.
*/
export const streamToTemp = async (
reader: ReadableStreamDefaultReader<Uint8Array>,
options?: StreamToTempOptions,
): Promise<StreamToTempResult> => {
const prefix = options?.prefix ?? '/tmp/filedrop-';
const computeMd5 = options?.computeMd5 ?? false;
const maxSizeBytes = options?.maxSizeBytes;
const tempPath = `${prefix}${nanoid()}`;
const writer = Bun.file(tempPath).writer();
const sha256 = new Bun.CryptoHasher('sha256');
const md5 = computeMd5 ? new Bun.CryptoHasher('md5') : null;
const SIGNATURE_BYTES = 16;
const signatureChunks: Buffer[] = [];
let signatureBytes = 0;
let sizeBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = Buffer.from(value);
sizeBytes += chunk.byteLength;
if (maxSizeBytes !== undefined && sizeBytes > maxSizeBytes) {
reader.cancel();
throw new Error('File size exceeds upload limit');
}
sha256.update(chunk);
md5?.update(chunk);
writer.write(chunk);
if (signatureBytes < SIGNATURE_BYTES) {
const remaining = SIGNATURE_BYTES - signatureBytes;
const sigChunk = chunk.subarray(0, remaining);
signatureChunks.push(sigChunk);
signatureBytes += sigChunk.byteLength;
}
}
try {
writer.end();
} catch {
// Writer may have already errored — ignore on success path
}
const result: StreamToTempResult = {
tempPath,
fileHash: sha256.digest('hex'),
sizeBytes,
signatureBuffer: Buffer.concat(signatureChunks, signatureBytes),
};
if (md5) {
result.md5Hash = md5.digest('base64');
}
return result;
} catch (error) {
try {
writer.end();
} catch {
// ignore writer end failure during error path
}
try {
await unlink(tempPath);
} catch {
// ignore unlink failure
}
throw error;
} finally {
try {
reader.releaseLock();
} catch {
// ignore release lock failure
}
}
};
+33
View File
@@ -0,0 +1,33 @@
/**
* Maximum allowed chunk/part size in bytes for Telegram storage.
*
* Telegram Bot API `getFile` can only resolve files up to 20 MB; anything
* larger fails with "Bad Request: file is too big". Chunked uploads store
* each part as a Telegram document and later resolve it via `getFile`, so a
* part must never reach that limit. 19 MB (19922944 bytes) leaves a safety
* margin and is the value used in production (/etc/teleuploader/env).
*/
export const TELEGRAM_CHUNK_SIZE_MAX_BYTES = 19 * 1024 * 1024;
/**
* Validates a chunk size value and returns it as a safe integer.
*
* @param chunkSizeBytes - The desired chunk size in bytes.
* @returns The same value if it is a positive safe integer at or below
* {@link TELEGRAM_CHUNK_SIZE_MAX_BYTES}.
* @throws {Error} If the chunk size is not a safe positive integer or exceeds
* the Telegram `getFile` limit (with margin).
*/
export const asSafeChunkSize = (chunkSizeBytes: number): number => {
if (!Number.isSafeInteger(chunkSizeBytes) || chunkSizeBytes <= 0) {
throw new Error('Invalid Telegram chunk size');
}
if (chunkSizeBytes > TELEGRAM_CHUNK_SIZE_MAX_BYTES) {
throw new Error(
`Telegram chunk size ${chunkSizeBytes} exceeds the maximum allowed part size ` +
`${TELEGRAM_CHUNK_SIZE_MAX_BYTES} bytes (${TELEGRAM_CHUNK_SIZE_MAX_BYTES / (1024 * 1024)} MB). ` +
'Telegram getFile cannot download files larger than 20 MB, so such parts would be undownloadable.',
);
}
return chunkSizeBytes;
};
-205
View File
@@ -1,205 +0,0 @@
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);
};
};
-74
View File
@@ -1,74 +0,0 @@
// Simple in-memory cache with TTL support
interface CacheEntry<T> {
value: T;
expiresAt: number;
}
class Cache<T> {
private store = new Map<string, CacheEntry<T>>();
private ttlMs: number;
constructor(ttlSeconds: number = 3600) {
this.ttlMs = ttlSeconds * 1000;
}
set(key: string, value: T): void {
this.store.set(key, {
value,
expiresAt: Date.now() + this.ttlMs,
});
}
get(key: string): T | null {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.value;
}
has(key: string): boolean {
return this.get(key) !== null;
}
delete(key: string): void {
this.store.delete(key);
}
clear(): void {
this.store.clear();
}
size(): number {
return this.store.size;
}
// Cleanup expired entries
cleanup(): number {
let removed = 0;
const now = Date.now();
for (const [key, entry] of this.store.entries()) {
if (now > entry.expiresAt) {
this.store.delete(key);
removed++;
}
}
return removed;
}
}
// File info cache (1 hour TTL)
export const fileInfoCache = new Cache<{
file_size: number;
mime_type: string;
file_path: string;
bot_token: string;
}>(3600);
export { Cache };
-226
View File
@@ -1,226 +0,0 @@
import { randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { gzipSync } from 'node:zlib';
import { nanoid } from 'nanoid';
import { db, files as fileSchema } from '../db';
import { insertFileParts, listFileParts, type NewFilePartInput } from '../db/file-parts';
import type { File } from '../db/schema';
import { config } from '../env';
import { computeHash } from './file';
import { createGetObjectResponse, type ObjectPartSource } from './s3/object-stream';
import type { RangeParseResult } from './s3/range';
import { forwardToStorage, getFileInfo } from './telegram';
export type ChunkCompressionAlgorithm = 'gzip' | null;
export interface ChunkedUploadPart {
partNumber: number;
telegramFileId: string;
telegramFileUniqueId: string;
storageMessageId: number;
sizeBytes: number;
storedSizeBytes: number;
compressionAlgorithm: ChunkCompressionAlgorithm;
etag: string;
}
export interface ChunkedUploadResult {
parts: ChunkedUploadPart[];
fileHash: string;
totalSizeBytes: number;
}
export interface ChunkedFileInput {
tempPath: string;
partFileNamePrefix: string;
fileName: string;
mimeType: string;
sizeBytes: number;
fileType: string;
uploaderId: number;
bucketId?: string | null;
s3Key?: string | null;
}
const asSafeChunkSize = (chunkSizeBytes: number): number => {
if (!Number.isSafeInteger(chunkSizeBytes) || chunkSizeBytes <= 0) {
throw new Error('Invalid Telegram chunk size');
}
return chunkSizeBytes;
};
const maybeCompressChunk = (
chunk: Buffer,
compress: boolean,
compressionMinSizeBytes: number,
): { bytes: Buffer; compressionAlgorithm: ChunkCompressionAlgorithm } => {
if (!compress || chunk.byteLength < compressionMinSizeBytes) {
return { bytes: chunk, compressionAlgorithm: null };
}
const gzipped = gzipSync(chunk);
if (gzipped.byteLength >= chunk.byteLength) {
return { bytes: chunk, compressionAlgorithm: null };
}
return { bytes: gzipped, compressionAlgorithm: 'gzip' };
};
export const uploadFileInTelegramChunks = async (input: {
tempPath: string;
partFileNamePrefix: string;
chunkSizeBytes: number;
compress: boolean;
compressionMinSizeBytes: number;
}): Promise<ChunkedUploadResult> => {
const chunkSizeBytes = asSafeChunkSize(input.chunkSizeBytes);
const hasher = new Bun.CryptoHasher('sha256');
const parts: ChunkedUploadPart[] = [];
let totalSizeBytes = 0;
let partNumber = 0;
const stream = createReadStream(input.tempPath, { highWaterMark: chunkSizeBytes });
for await (const data of stream) {
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data as Uint8Array);
if (chunk.byteLength === 0) continue;
partNumber += 1;
totalSizeBytes += chunk.byteLength;
hasher.update(chunk);
const { bytes, compressionAlgorithm } = maybeCompressChunk(
chunk,
input.compress,
input.compressionMinSizeBytes,
);
const forwardResult = await forwardToStorage(
bytes,
`${input.partFileNamePrefix}.part-${partNumber}`,
'document',
);
parts.push({
partNumber,
telegramFileId: forwardResult.telegramFileId,
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
storageMessageId: forwardResult.storageMessageId,
sizeBytes: chunk.byteLength,
storedSizeBytes: bytes.byteLength,
compressionAlgorithm,
etag: computeHash(chunk),
});
}
return {
parts,
fileHash: hasher.digest('hex'),
totalSizeBytes,
};
};
export const storeFileInTelegramChunks = async (input: ChunkedFileInput): Promise<File> => {
const upload = await uploadFileInTelegramChunks({
tempPath: input.tempPath,
partFileNamePrefix: input.partFileNamePrefix,
chunkSizeBytes: config.telegramChunkSizeBytes,
compress: config.compressChunkedUploads,
compressionMinSizeBytes: config.chunkCompressionMinSizeBytes,
});
const firstPart = upload.parts[0];
if (!firstPart) {
throw new Error('Chunked upload produced no parts');
}
const now = new Date();
const fileId = randomUUID();
const publicId = nanoid();
const file: File = {
id: fileId,
publicId,
telegramFileId: firstPart.telegramFileId,
telegramFileUniqueId: firstPart.telegramFileUniqueId,
storageChatId: config.storageChatId,
storageMessageId: firstPart.storageMessageId,
fileName: input.fileName,
mimeType: input.mimeType,
sizeBytes: upload.totalSizeBytes,
fileType: input.fileType,
uploaderId: input.uploaderId,
fileHash: upload.fileHash,
archiveTelegramFileId: null,
archiveStorageMessageId: null,
archiveFileName: null,
archiveEntryName: null,
archiveMimeType: null,
archiveSizeBytes: null,
bucketId: input.bucketId ?? null,
s3Key: input.s3Key ?? null,
storageBackend: 'chunked',
isDeleted: false,
multipartUploadId: null,
partCount: upload.parts.length,
createdAt: now,
updatedAt: now,
};
await db.insert(fileSchema).values(file);
const fileParts: NewFilePartInput[] = upload.parts.map((part) => ({
fileId,
partNumber: part.partNumber,
telegramFileId: part.telegramFileId,
telegramFileUniqueId: part.telegramFileUniqueId,
storageChatId: config.storageChatId,
storageMessageId: part.storageMessageId,
sizeBytes: part.sizeBytes,
storedSizeBytes: part.storedSizeBytes,
compressionAlgorithm: part.compressionAlgorithm,
etag: part.etag,
}));
await insertFileParts(fileParts);
return file;
};
export const buildChunkedObjectSources = async (file: File): Promise<ObjectPartSource[]> => {
const parts = await listFileParts(file.id);
const sources: ObjectPartSource[] = [];
for (const part of parts) {
const fileInfo = await getFileInfo(part.telegramFileId);
sources.push({
telegramFileId: part.telegramFileId,
telegramUrl: `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`,
sizeBytes: part.sizeBytes,
storedSizeBytes: part.storedSizeBytes,
compressionAlgorithm: part.compressionAlgorithm,
partNumber: part.partNumber,
});
}
return sources;
};
export const createChunkedObjectResponse = async (input: {
file: File;
range: RangeParseResult;
reqId: string;
}): Promise<Response> => {
const parts = await buildChunkedObjectSources(input.file);
if (parts.length === 0) {
throw new Error('Chunked object has no parts');
}
return createGetObjectResponse({
reqId: input.reqId,
contentType: input.file.mimeType,
etag: input.file.fileHash || parts.map((p) => p.telegramFileId).join('-'),
lastModified:
input.file.createdAt instanceof Date ? input.file.createdAt : new Date(input.file.createdAt),
totalSize: Number(input.file.sizeBytes),
parts,
range: input.range,
});
};
-237
View File
@@ -1,237 +0,0 @@
import { unlink } from 'node:fs/promises';
import logger from './logger';
export const getErrorMessage = (error: unknown): string => {
return error instanceof Error ? error.message : String(error);
};
export const cleanupTempFile = async (tempPath: string): Promise<void> => {
try {
await unlink(tempPath);
} catch (err) {
logger.warn('Failed to cleanup temp file', { tempPath, error: getErrorMessage(err) });
}
};
interface FileMetadata {
publicId: string;
telegramFileId: string;
telegramFileUniqueId: string;
storageChatId: number;
storageMessageId: number;
fileName: string;
mimeType: string;
sizeBytes: number;
fileType: string;
uploaderId: number;
createdAt: Date | string | number;
}
const FILE_TYPES: Record<string, number> = {
document: 2 * 1024 * 1024 * 1024, // 2GB
photo: 10 * 1024 * 1024, // 10MB
video: 2 * 1024 * 1024 * 1024, // 2GB
audio: 200 * 1024 * 1024, // 200MB
voice: 200 * 1024 * 1024, // 200MB
animation: 2 * 1024 * 1024 * 1024, // 2GB
sticker: 10 * 1024 * 1024, // 10MB
video_note: 2 * 1024 * 1024 * 1024, // 2GB
};
export const getFileType = (mime: string | null, caption?: string): string => {
const mimeUpper = mime?.split('/')[0]?.toLowerCase();
const captionLower = caption?.toLowerCase();
if (mime?.toLowerCase() === 'image/webp' || captionLower?.includes('sticker')) return 'sticker';
if (captionLower?.includes('video_note')) return 'video_note';
if (mimeUpper === 'video') return 'video';
if (mimeUpper === 'audio') return 'audio';
if (mimeUpper === 'document') return 'document';
if (mimeUpper === 'image') return captionLower?.includes('gif') ? 'animation' : 'photo';
if (captionLower?.includes('voice')) return 'voice';
if (captionLower?.includes('animation')) return 'animation';
return mimeUpper === 'application' ? 'application' : 'document';
};
export const checkFileSize = (sizeBytes: number, fileType: string): boolean => {
const limit = FILE_TYPES[fileType] || FILE_TYPES.document;
return sizeBytes <= limit;
};
export const ensureExtension = (
fileName: string,
buffer: Buffer,
detectedMime?: string,
): { fileName: string; mimeType: string } => {
const mimeMap: Record<string, string> = {
'application/pdf': 'pdf',
'image/png': 'png',
'image/jpeg': 'jpg',
'image/gif': 'gif',
'text/plain': 'txt',
'application/zip': 'zip',
};
let ext: string | null = null;
if (buffer.subarray(0, 4).toString() === '%PDF') {
ext = 'pdf';
} else if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
ext = 'png';
} else if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
ext = 'jpg';
} else if (buffer.subarray(0, 4).toString() === 'GIF8') {
ext = 'gif';
} else if (detectedMime) {
ext = mimeMap[detectedMime.toLowerCase()] || null;
}
let finalFileName = fileName;
const hasExtension = fileName.includes('.') && fileName.split('.').pop()!.length >= 2;
if (!hasExtension && ext) {
finalFileName = `${fileName}.${ext}`;
}
const mimeType = ext
? Object.keys(mimeMap).find((k) => mimeMap[k] === ext) ||
detectedMime ||
'application/octet-stream'
: detectedMime || 'application/octet-stream';
return { fileName: finalFileName, mimeType };
};
type HeaderMapRequest = {
headers?:
| {
get?: (name: string) => string | null;
}
| Record<string, string>;
};
type FileLike = {
fileName?: string;
mimeType?: string;
};
type MessageLike = {
document?: FileLike;
photo?: FileLike[];
audio?: FileLike;
voice?: FileLike;
animation?: FileLike;
};
const getHeader = (request: HeaderMapRequest | null, name: string): string | undefined => {
const headers = request?.headers;
if (!headers) return undefined;
const get = 'get' in headers ? headers.get : undefined;
if (typeof get === 'function') return get(name) || undefined;
return (headers as Record<string, string>)[name];
};
export const extractFileName = (msg: MessageLike, request: HeaderMapRequest | null): string => {
const headerFileName = getHeader(request, 'x-file-name');
if (headerFileName) return headerFileName;
return (
msg.document?.fileName ||
msg.photo?.slice(-1)[0]?.fileName ||
msg.audio?.fileName ||
msg.voice?.fileName ||
msg.animation?.fileName ||
'file'
);
};
export const extractMimeType = (msg: MessageLike, request: HeaderMapRequest | null): string => {
const headerMimeType = getHeader(request, 'x-mime-type');
if (headerMimeType) return headerMimeType;
return (
msg.document?.mimeType ||
msg.photo?.slice(-1)[0]?.mimeType ||
msg.audio?.mimeType ||
msg.voice?.mimeType ||
msg.animation?.mimeType ||
'application/octet-stream'
);
};
export const computeHash = (buffer: Buffer): string => {
const hasher = new Bun.CryptoHasher('sha256');
hasher.update(buffer);
return hasher.digest('hex');
};
export interface TelegramMessageFile {
file_id: string;
file_unique_id: string;
file_size?: number;
mime_type?: string;
file_name?: string;
}
export interface TelegramMediaMessage {
message_id: number;
document?: TelegramMessageFile;
photo?: TelegramMessageFile[];
video?: TelegramMessageFile;
audio?: TelegramMessageFile;
voice?: TelegramMessageFile;
animation?: TelegramMessageFile;
sticker?: TelegramMessageFile;
video_note?: TelegramMessageFile;
}
export const extractFileFromMessage = (
msg: TelegramMediaMessage,
fileType: string,
): TelegramMessageFile => {
if (fileType === 'photo') return msg.photo?.slice(-1)[0] as TelegramMessageFile;
if (fileType === 'sticker') return msg.sticker as TelegramMessageFile;
return msg[fileType as keyof TelegramMediaMessage] as TelegramMessageFile;
};
export const detectFileType = (msg: TelegramMediaMessage): string => {
if (msg.document) return 'document';
if (msg.photo) return 'photo';
if (msg.video) return 'video';
if (msg.audio) return 'audio';
if (msg.voice) return 'voice';
if (msg.animation) return 'animation';
if (msg.sticker) return 'sticker';
if (msg.video_note) return 'video_note';
return 'document';
};
export const getFileSizeLimit = (fileType: string): number =>
FILE_TYPES[fileType] || FILE_TYPES.document;
export const formatCreatedAt = (createdAt: Date | string | number): string => {
return createdAt instanceof Date ? createdAt.toISOString() : new Date(createdAt).toISOString();
};
export interface UploadResponse {
public_id: string;
file_name: string;
mime_type: string;
size_bytes: number;
file_type: string;
created_at: string;
download_url: string;
}
export const buildUploadResponse = (file: FileMetadata, baseUrl: string): UploadResponse => {
return {
public_id: file.publicId,
file_name: file.fileName,
mime_type: file.mimeType,
size_bytes: file.sizeBytes,
file_type: file.fileType,
created_at: formatCreatedAt(file.createdAt),
download_url: `${baseUrl}/f/${file.publicId}`,
};
};
-16
View File
@@ -1,16 +0,0 @@
import { config } from '../env';
export const extractClientIp = (req: Request): string => {
if (!config.trustProxy) return '127.0.0.1';
const forwardedFor = req.headers.get('x-forwarded-for');
if (forwardedFor) {
const firstIp = forwardedFor.split(',')[0]?.trim();
if (firstIp) return firstIp;
}
const realIp = req.headers.get('x-real-ip')?.trim();
if (realIp) return realIp;
return '127.0.0.1';
};
-25
View File
@@ -1,25 +0,0 @@
import winston from 'winston';
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json(),
),
defaultMeta: { service: 'filedrop' },
transports: [
// Write all logs including error logs to file
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'logs/combined.log' }),
// Console transport for docker logs / CLI visibility
new winston.transports.Console({
format:
process.env.NODE_ENV !== 'production'
? winston.format.combine(winston.format.colorize(), winston.format.simple())
: winston.format.json(),
}),
],
});
export default logger;
-111
View File
@@ -1,111 +0,0 @@
// No imports needed — logger used only by setInterval which moved to index.ts
interface Metric {
name: string;
value: number;
timestamp: number;
tags?: Record<string, string>;
}
interface MetricsSnapshot {
uploadLatency: { p50: number; p95: number; p99: number };
uploadThroughput: number;
queueSize: number;
errorRate: number;
cacheHitRate: number;
botUtilization: number;
timestamp: number;
}
class MetricsCollector {
private metrics: Metric[] = [];
private uploadTimes: number[] = [];
private errorCount = 0;
private totalRequests = 0;
private cacheHits = 0;
private cacheMisses = 0;
private maxMetricsSize = 10000;
recordUploadTime(durationMs: number): void {
this.uploadTimes.push(durationMs);
this.totalRequests++;
// Keep only last 1000 measurements
if (this.uploadTimes.length > 1000) {
this.uploadTimes.shift();
}
}
recordError(): void {
this.errorCount++;
}
recordCacheHit(): void {
this.cacheHits++;
}
recordCacheMiss(): void {
this.cacheMisses++;
}
recordMetric(name: string, value: number, tags?: Record<string, string>): void {
this.metrics.push({
name,
value,
timestamp: Date.now(),
tags,
});
// Keep metrics bounded
if (this.metrics.length > this.maxMetricsSize) {
this.metrics = this.metrics.slice(-this.maxMetricsSize);
}
}
private calculatePercentile(arr: number[], percentile: number): number {
if (arr.length === 0) return 0;
const sorted = [...arr].sort((a, b) => a - b);
const index = Math.ceil((percentile / 100) * sorted.length) - 1;
return sorted[Math.max(0, index)];
}
getSnapshot(): MetricsSnapshot {
const errorRate = this.totalRequests > 0 ? (this.errorCount / this.totalRequests) * 100 : 0;
const cacheHitRate =
this.cacheHits + this.cacheMisses > 0
? (this.cacheHits / (this.cacheHits + this.cacheMisses)) * 100
: 0;
return {
uploadLatency: {
p50: this.calculatePercentile(this.uploadTimes, 50),
p95: this.calculatePercentile(this.uploadTimes, 95),
p99: this.calculatePercentile(this.uploadTimes, 99),
},
uploadThroughput: this.totalRequests > 0 ? this.totalRequests / 60 : 0,
queueSize: 0, // Will be updated by queue
errorRate,
cacheHitRate,
botUtilization: 0, // Will be updated by bot tracker
timestamp: Date.now(),
};
}
reset(): void {
this.uploadTimes = [];
this.errorCount = 0;
this.totalRequests = 0;
this.cacheHits = 0;
this.cacheMisses = 0;
this.metrics = [];
}
getMetrics(name?: string): Metric[] {
if (!name) return this.metrics;
return this.metrics.filter((m) => m.name === name);
}
}
export const metricsCollector = new MetricsCollector();
export { MetricsCollector };
-89
View File
@@ -1,89 +0,0 @@
import { config } from '../env';
import { extractClientIp } from './ip';
import logger from './logger';
interface RateLimitEntry {
count: number;
resetTime: number;
}
const rateLimitStore = new Map<string, RateLimitEntry>();
const MAX_STORE_ENTRIES = 50000;
const evictExpiredEntries = (now = Date.now()): number => {
let cleaned = 0;
for (const [key, entry] of rateLimitStore.entries()) {
if (now > entry.resetTime) {
rateLimitStore.delete(key);
cleaned++;
}
}
return cleaned;
};
const ensureStoreCapacity = (now: number): void => {
if (rateLimitStore.size < MAX_STORE_ENTRIES) return;
evictExpiredEntries(now);
while (rateLimitStore.size >= MAX_STORE_ENTRIES) {
const oldestKey = rateLimitStore.keys().next().value;
if (!oldestKey) break;
rateLimitStore.delete(oldestKey);
}
};
export const checkRateLimit = (key: string): boolean => {
const now = Date.now();
const entry = rateLimitStore.get(key);
if (!entry || now > entry.resetTime) {
ensureStoreCapacity(now);
rateLimitStore.set(key, {
count: 1,
resetTime: now + config.rateLimitWindowMs,
});
return true;
}
if (entry.count >= config.rateLimitMaxRequests) {
logger.warn('Rate limit exceeded', { key, count: entry.count });
return false;
}
entry.count++;
return true;
};
export const withRateLimit = <T extends Request>(
handler: (req: T) => Promise<Response>,
): ((req: T) => Promise<Response>) => {
return async (req: T): Promise<Response> => {
const ip = extractClientIp(req);
if (!checkRateLimit(ip)) {
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 });
}
return handler(req);
};
};
export const cleanupRateLimitCache = (): void => {
const cleaned = evictExpiredEntries();
if (cleaned > 0) {
logger.debug('Rate limit cache cleanup', { cleaned, remaining: rateLimitStore.size });
}
};
export const getRateLimitStats = () => ({
trackedIPs: rateLimitStore.size,
windowSize: config.rateLimitWindowMs,
maxRequests: config.rateLimitMaxRequests,
maxTrackedIPs: MAX_STORE_ENTRIES,
});
export const clearRateLimitCache = (): void => {
rateLimitStore.clear();
};
-91
View File
@@ -1,91 +0,0 @@
import logger from './logger';
interface RetryOptions {
maxRetries?: number;
initialDelayMs?: number;
maxDelayMs?: number;
backoffMultiplier?: number;
shouldRetry?: (error: unknown) => boolean;
}
const DEFAULT_OPTIONS: Required<RetryOptions> = {
maxRetries: 3,
initialDelayMs: 100,
maxDelayMs: 5000,
backoffMultiplier: 2,
shouldRetry: (error: unknown) => {
const errorStr = error instanceof Error ? error.message : String(error);
// Retry on transient errors
return (
errorStr.includes('ECONNREFUSED') ||
errorStr.includes('ETIMEDOUT') ||
errorStr.includes('ENOTFOUND') ||
errorStr.includes('429') ||
errorStr.includes('timeout')
);
},
};
export const withRetry = async <T>(
fn: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> => {
const opts = { ...DEFAULT_OPTIONS, ...options };
let lastError: unknown;
let delay = opts.initialDelayMs;
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
try {
return await fn();
} catch (error: unknown) {
lastError = error;
const errorStr = error instanceof Error ? error.message : String(error);
if (attempt === opts.maxRetries || !opts.shouldRetry(error)) {
logger.error('Retry exhausted', {
attempt,
maxRetries: opts.maxRetries,
error: errorStr,
});
throw error;
}
logger.warn('Retrying after error', {
attempt,
delay,
error: errorStr,
});
await new Promise((resolve) => setTimeout(resolve, delay));
delay = Math.min(delay * opts.backoffMultiplier, opts.maxDelayMs);
}
}
throw lastError;
};
export const withTimeout = async <T>(
fn: () => Promise<T>,
timeoutMs: number = 30000,
): Promise<T> => {
return Promise.race([
fn(),
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error(`Operation timeout after ${timeoutMs}ms`)), timeoutMs),
),
]);
};
export const withFallback = async <T>(
primary: () => Promise<T>,
fallback: () => Promise<T>,
): Promise<T> => {
try {
return await primary();
} catch (error: unknown) {
logger.warn('Primary operation failed, using fallback', {
error: error instanceof Error ? error.message : String(error),
});
return fallback();
}
};
-342
View File
@@ -1,342 +0,0 @@
import { timingSafeEqual } from 'node: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 {
isValid: boolean;
credential: {
accessKey: string;
date: string;
region: string;
service: string;
} | null;
errorCode?: string;
}
export interface VerifyPresignedUrlInput {
url: string;
method: string;
headers: Record<string, string>;
s3AccessKey: string;
s3SecretKey: string;
region: string;
now?: Date;
}
const SERVICE = 's3';
const TERMINATION = 'aws4_request';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const buf = (data: string | ArrayBuffer | Uint8Array): Uint8Array => {
if (data instanceof Uint8Array) return data;
if (data instanceof ArrayBuffer) return new Uint8Array(data);
return new TextEncoder().encode(data);
};
const sha256Hex = async (data: string | Uint8Array | ArrayBuffer): Promise<string> => {
const hashBuffer = await crypto.subtle.digest('SHA-256', buf(data) as never);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
};
const hmacSha256 = async (key: Uint8Array, message: string): Promise<Uint8Array> => {
const cryptoKey = await crypto.subtle.importKey(
'raw',
key as never,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const result = await crypto.subtle.sign('HMAC', cryptoKey, buf(message) as never);
return new Uint8Array(result);
};
const getSigningKey = async (
secretKey: string,
dateStamp: string,
region: string,
): Promise<Uint8Array> => {
let key = await hmacSha256(buf(`AWS4${secretKey}`), dateStamp);
key = await hmacSha256(key, region);
key = await hmacSha256(key, SERVICE);
return await hmacSha256(key, TERMINATION);
};
const hmacHex = async (key: Uint8Array, message: string): Promise<string> => {
const result = await hmacSha256(key, message);
return Array.from(result)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
};
const parseAuthorizationHeader = (authHeader: string) => {
const credentialMatch = authHeader.match(/Credential=([^,]+)/);
const signedHeadersMatch = authHeader.match(/SignedHeaders=([^,]+)/);
const signatureMatch = authHeader.match(/Signature=([^,]+)/);
if (!credentialMatch || !signedHeadersMatch || !signatureMatch) return null;
const credentialParts = credentialMatch[1].split('/');
if (credentialParts.length !== 5) return null;
return {
accessKey: credentialParts[0],
date: credentialParts[1],
region: credentialParts[2],
service: credentialParts[3],
termination: credentialParts[4],
signedHeaders: signedHeadersMatch[1],
signature: signatureMatch[1],
};
};
const buildCanonicalRequest = (
method: string,
canonicalUri: string,
canonicalQueryString: string,
signedHeaders: string,
headers: Record<string, string>,
hashedPayload: string,
): string => {
const canonicalHeaders = signedHeaders
.split(';')
.map((h) => {
const value = headers[h.toLowerCase()] || '';
return `${h.toLowerCase()}:${value.trim()}\n`;
})
.join('');
return `${method}\n${canonicalUri}\n${canonicalQueryString}\n${canonicalHeaders}\n${signedHeaders}\n${hashedPayload}`;
};
const normalizeUri = (uri: string): string => {
if (!uri || uri === '') return '/';
// AWS SigV4 requires URI-decoded paths in the canonical request
return decodeURIComponent(uri);
};
const awsEncode = (value: string): string =>
encodeURIComponent(value).replace(
/[!'()*]/g,
(ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`,
);
export const buildCanonicalQueryString = (
searchParams: URLSearchParams,
excludeKeys: Set<string> = new Set(),
): string => {
const pairs: Array<[string, string]> = [];
for (const [key, value] of searchParams.entries()) {
if (!excludeKeys.has(key)) pairs.push([key, value]);
}
pairs.sort(([ak, av], [bk, bv]) => {
const a = `${awsEncode(ak)}=${awsEncode(av)}`;
const b = `${awsEncode(bk)}=${awsEncode(bv)}`;
return a.localeCompare(b);
});
return pairs.map(([key, value]) => `${awsEncode(key)}=${awsEncode(value)}`).join('&');
};
const getHashedPayload = async (
body: string | null,
contentSha256: string | null,
): Promise<string> => {
if (contentSha256) return contentSha256;
if (!body || body.length === 0) return await sha256Hex('');
return await sha256Hex(body);
};
export const verifySignature = async (
method: string,
url: string,
headers: Record<string, string>,
body: string | null,
s3AccessKey: string,
s3SecretKey: string,
region: string,
): Promise<SigV4Result> => {
const authHeader = headers.authorization;
if (!authHeader?.startsWith('AWS4-HMAC-SHA256')) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
const parsed = parseAuthorizationHeader(authHeader);
if (!parsed) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
if (!timingSafeCompare(parsed.accessKey, s3AccessKey)) {
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
}
if (!timingSafeCompare(parsed.region, region)) {
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
}
const parsedUrl = new URL(url, 'http://localhost');
const canonicalUri = normalizeUri(parsedUrl.pathname);
const canonicalQueryString = buildCanonicalQueryString(parsedUrl.searchParams);
const contentSha256 = headers['x-amz-content-sha256'] || null;
if (contentSha256?.startsWith('STREAMING-')) {
return { isValid: false, credential: null, errorCode: 'NotImplemented' };
}
const hashedPayload = await getHashedPayload(body, contentSha256);
const canonicalRequest = buildCanonicalRequest(
method,
canonicalUri,
canonicalQueryString,
parsed.signedHeaders,
headers,
hashedPayload,
);
const hashedCanonicalRequest = await sha256Hex(canonicalRequest);
const amzDate = headers['x-amz-date'] || '';
const dateStamp = parsed.date;
const credentialScope = `${dateStamp}/${region}/${parsed.service}/${parsed.termination}`;
const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${hashedCanonicalRequest}`;
const signingKey = await getSigningKey(s3SecretKey, dateStamp, region);
const expectedSignature = await hmacHex(signingKey, stringToSign);
if (!timingSafeCompare(expectedSignature, parsed.signature)) {
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
}
return {
isValid: true,
credential: {
accessKey: parsed.accessKey,
date: parsed.date,
region: parsed.region,
service: parsed.service,
},
};
};
const parseAmzDateUtc = (amzDate: string): Date | null => {
const match = amzDate.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/);
if (!match) return null;
const [, year, month, day, hour, minute, second] = match;
return new Date(
Date.UTC(
Number.parseInt(year, 10),
Number.parseInt(month, 10) - 1,
Number.parseInt(day, 10),
Number.parseInt(hour, 10),
Number.parseInt(minute, 10),
Number.parseInt(second, 10),
),
);
};
export const verifyPresignedUrl = async ({
url,
method,
headers,
s3AccessKey,
s3SecretKey,
region,
now = new Date(),
}: VerifyPresignedUrlInput): Promise<SigV4Result> => {
const parsedUrl = new URL(url);
const searchParams = parsedUrl.searchParams;
const algorithm = searchParams.get('X-Amz-Algorithm');
const credential = searchParams.get('X-Amz-Credential');
const signedHeaders = searchParams.get('X-Amz-SignedHeaders');
const signature = searchParams.get('X-Amz-Signature');
const expiresText = searchParams.get('X-Amz-Expires');
const amzDate = searchParams.get('X-Amz-Date');
if (
algorithm !== 'AWS4-HMAC-SHA256' ||
!credential ||
!signedHeaders ||
!signature ||
!expiresText ||
!amzDate
) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
const expires = Number.parseInt(expiresText, 10);
const signedAt = parseAmzDateUtc(amzDate);
if (!Number.isFinite(expires) || expires <= 0 || !signedAt) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
// AWS S3 spec limits presigned URLs to 7 days (604800 seconds)
const MAX_PRESIGNED_EXPIRY_SECONDS = 604800;
if (
now.getTime() > signedAt.getTime() + expires * 1000 ||
expires > MAX_PRESIGNED_EXPIRY_SECONDS
) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
const credParts = credential.split('/');
if (credParts.length !== 5) {
return { isValid: false, credential: null, errorCode: 'AccessDenied' };
}
const [accessKey, dateStamp, credentialRegion, service, termination] = credParts;
if (
!timingSafeCompare(accessKey, s3AccessKey) ||
!timingSafeCompare(credentialRegion, region) ||
service !== SERVICE ||
termination !== TERMINATION
) {
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
}
const signedHeaderList = signedHeaders.split(';').filter(Boolean);
const canonicalHeaders = signedHeaderList
.map((headerName) => {
const lower = headerName.toLowerCase();
const value = lower === 'host' ? headers.host || parsedUrl.host : headers[lower] || '';
return `${lower}:${value.trim()}\n`;
})
.join('');
const canonicalRequest = `${method}\n${normalizeUri(parsedUrl.pathname)}\n${buildCanonicalQueryString(searchParams, new Set(['X-Amz-Signature']))}\n${canonicalHeaders}\n${signedHeaders}\nUNSIGNED-PAYLOAD`;
const hashedCanonicalRequest = await sha256Hex(canonicalRequest);
const credentialScope = `${dateStamp}/${region}/${SERVICE}/${TERMINATION}`;
const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${hashedCanonicalRequest}`;
const expectedSignature = await hmacHex(
await getSigningKey(s3SecretKey, dateStamp, region),
stringToSign,
);
if (!timingSafeCompare(expectedSignature, signature)) {
return { isValid: false, credential: null, errorCode: 'SignatureDoesNotMatch' };
}
return { isValid: true, credential: { accessKey, date: dateStamp, region, service } };
};
export const isS3Request = (headers: Record<string, string>): boolean => {
const auth = headers.authorization || '';
return auth.startsWith('AWS4-HMAC-SHA256');
};
-44
View File
@@ -1,44 +0,0 @@
export const S3_CORS_HEADERS: Record<string, string> = {
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET, PUT, HEAD, DELETE, POST, OPTIONS',
'access-control-allow-headers': [
'Authorization',
'Content-Type',
'Content-MD5',
'Range',
'If-Match',
'If-None-Match',
'If-Modified-Since',
'If-Unmodified-Since',
'X-Amz-*',
'x-amz-*',
].join(', '),
'access-control-expose-headers': [
'Accept-Ranges',
'Content-Length',
'Content-Range',
'Content-Type',
'ETag',
'Last-Modified',
'x-amz-id-2',
'x-amz-request-id',
].join(', '),
'access-control-max-age': '86400',
};
export const s3Headers = (
requestId: string,
extraHeaders: Record<string, string> = {},
): Record<string, string> => ({
...S3_CORS_HEADERS,
...(requestId ? { 'x-amz-request-id': requestId, 'x-amz-id-2': requestId } : {}),
...extraHeaders,
});
export const applyS3Headers = (headers: Headers, requestId: string): Headers => {
const result = new Headers(headers);
for (const [key, value] of Object.entries(s3Headers(requestId))) {
result.set(key, value);
}
return result;
};
-130
View File
@@ -1,130 +0,0 @@
import { gunzipSync } from 'node:zlib';
import { applyS3Headers } from './headers';
import { contentRange, type RangeParseResult } from './range';
export interface ObjectPartSource {
telegramFileId: string;
telegramUrl: string;
sizeBytes: number;
partNumber: number;
storedSizeBytes?: number;
compressionAlgorithm?: 'gzip' | null;
}
export interface ObjectResponseInput {
reqId: string;
contentType: string;
etag: string;
lastModified: Date;
totalSize: number;
parts: ObjectPartSource[];
range: RangeParseResult;
}
interface PlannedPart {
part: ObjectPartSource;
relativeStart: number;
relativeEnd: number;
}
const baseHeaders = (input: ObjectResponseInput, contentLength: number): Headers => {
const headers = new Headers({
'content-type': input.contentType,
'content-length': String(contentLength),
etag: `"${input.etag}"`,
'last-modified': input.lastModified.toUTCString(),
'x-amz-request-id': input.reqId,
'accept-ranges': 'bytes',
'cache-control': 'public, max-age=31536000',
});
return headers;
};
const planParts = (parts: ObjectPartSource[], start: number, end: number): PlannedPart[] => {
const planned: PlannedPart[] = [];
let offset = 0;
for (const part of parts) {
const partStart = offset;
const partEnd = offset + part.sizeBytes - 1;
offset += part.sizeBytes;
if (end < partStart || start > partEnd) continue;
planned.push({
part,
relativeStart: Math.max(start, partStart) - partStart,
relativeEnd: Math.min(end, partEnd) - partStart,
});
}
return planned;
};
const streamFromBytes = (bytes: Uint8Array): ReadableStream<Uint8Array> =>
new Response(bytes).body!;
const fetchWholePartBytes = async (telegramUrl: string): Promise<Uint8Array> => {
const res = await fetch(telegramUrl);
if (!res.ok) throw new Error(`Telegram fetch failed: ${res.status}`);
return new Uint8Array(await res.arrayBuffer());
};
const fetchPartBody = async (planned: PlannedPart): Promise<ReadableStream<Uint8Array>> => {
const wantsWholePart =
planned.relativeStart === 0 && planned.relativeEnd === planned.part.sizeBytes - 1;
if (planned.part.compressionAlgorithm === 'gzip') {
const storedBytes = await fetchWholePartBytes(planned.part.telegramUrl);
const bytes = gunzipSync(storedBytes);
return streamFromBytes(bytes.subarray(planned.relativeStart, planned.relativeEnd + 1));
}
const rangeHeader = `bytes=${planned.relativeStart}-${planned.relativeEnd}`;
const res = await fetch(
planned.part.telegramUrl,
wantsWholePart ? undefined : { headers: { range: rangeHeader } },
);
if (!res.ok) throw new Error(`Telegram fetch failed: ${res.status}`);
if (wantsWholePart || res.status === 206) return res.body!;
const bytes = new Uint8Array(await res.arrayBuffer());
return streamFromBytes(bytes.slice(planned.relativeStart, planned.relativeEnd + 1));
};
const concatPartStreams = (plannedParts: PlannedPart[]): ReadableStream<Uint8Array> =>
new ReadableStream<Uint8Array>({
async start(controller) {
try {
for (const planned of plannedParts) {
const stream = await fetchPartBody(planned);
const reader = stream.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value) controller.enqueue(value);
}
}
controller.close();
} catch (error) {
controller.error(error);
}
},
});
export const createGetObjectResponse = async (input: ObjectResponseInput): Promise<Response> => {
if (input.range.type === 'invalid') {
throw new Error('createGetObjectResponse received invalid range');
}
const start = input.range.type === 'valid' ? input.range.start : 0;
const end = input.range.type === 'valid' ? input.range.end : input.totalSize - 1;
const plannedParts = planParts(input.parts, start, end);
const contentLength = end >= start ? end - start + 1 : 0;
const headers = applyS3Headers(baseHeaders(input, contentLength), input.reqId);
if (input.range.type === 'valid') {
headers.set('content-range', contentRange(start, end, input.totalSize));
}
return new Response(concatPartStreams(plannedParts), {
status: input.range.type === 'valid' ? 206 : 200,
headers,
});
};
-46
View File
@@ -1,46 +0,0 @@
export type RangeParseResult =
| { type: 'none' }
| { type: 'valid'; start: number; end: number }
| { type: 'invalid' };
const DECIMAL = /^\d+$/;
export const parseRangeHeader = (rangeHeader: string | null, size: number): RangeParseResult => {
if (!rangeHeader) return { type: 'none' };
if (!Number.isSafeInteger(size) || size < 0) return { type: 'invalid' };
if (!rangeHeader.startsWith('bytes=')) return { type: 'invalid' };
const spec = rangeHeader.slice('bytes='.length).trim();
if (spec.includes(',')) return { type: 'invalid' };
const dash = spec.indexOf('-');
if (dash === -1) return { type: 'invalid' };
const startText = spec.slice(0, dash).trim();
const endText = spec.slice(dash + 1).trim();
if (!startText && !endText) return { type: 'invalid' };
if (size === 0) return { type: 'invalid' };
if (!startText) {
if (!DECIMAL.test(endText)) return { type: 'invalid' };
const suffixLength = Number.parseInt(endText, 10);
if (suffixLength <= 0) return { type: 'invalid' };
return { type: 'valid', start: Math.max(size - suffixLength, 0), end: size - 1 };
}
if (!DECIMAL.test(startText)) return { type: 'invalid' };
const start = Number.parseInt(startText, 10);
if (start >= size) return { type: 'invalid' };
if (!endText) return { type: 'valid', start, end: size - 1 };
if (!DECIMAL.test(endText)) return { type: 'invalid' };
const requestedEnd = Number.parseInt(endText, 10);
if (requestedEnd < start) return { type: 'invalid' };
return { type: 'valid', start, end: Math.min(requestedEnd, size - 1) };
};
export const contentRange = (start: number, end: number, size: number): string =>
`bytes ${start}-${end}/${size}`;
export const unsatisfiedContentRange = (size: number): string => `bytes */${size}`;
-23
View File
@@ -1,23 +0,0 @@
const stripPort = (host: string): string => {
if (host.startsWith('[')) return host;
return host.split(':')[0].toLowerCase().replace(/\.$/, '');
};
const isValidBucketLabel = (bucket: string): boolean =>
/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket) &&
!bucket.includes('..') &&
!bucket.includes('.-') &&
!bucket.includes('-.');
export const extractS3BucketFromHost = (host: string, domains: string[]): string | null => {
const normalizedHost = stripPort(host);
for (const domain of domains) {
const normalizedDomain = stripPort(domain);
if (!normalizedDomain || normalizedHost === normalizedDomain) continue;
if (!normalizedHost.endsWith(`.${normalizedDomain}`)) continue;
const bucket = normalizedHost.slice(0, -(normalizedDomain.length + 1));
return isValidBucketLabel(bucket) ? bucket : null;
}
return null;
};
-309
View File
@@ -1,309 +0,0 @@
import { s3Headers } from './headers';
const escapeXml = (str: string): string =>
str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
const isoDate = (d: Date): string => d.toISOString().replace(/\.\d{3}Z$/, 'Z');
const encodeKey = (value: string, encodingType: string | null = null): string =>
encodingType === 'url' ? encodeURIComponent(value) : escapeXml(value);
// ─────── Bucket operations ───────
export const listBucketsXml = (
buckets: { name: string; createdAt: Date }[],
_requestId: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Buckets>
${buckets
.map(
(b) => `<Bucket>
<Name>${escapeXml(b.name)}</Name>
<CreationDate>${isoDate(b.createdAt)}</CreationDate>
</Bucket>`,
)
.join('')}
</Buckets>
</ListAllMyBucketsResult>`;
export const bucketVersioningConfigurationXml =
(): string => `<?xml version="1.0" encoding="UTF-8"?>
<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"/>`;
// ─────── Object listing ───────
export const listBucketResultXml = (
bucketName: string,
objects: { key: string; sizeBytes: number; etag: string; lastModified: Date; mimeType: string }[],
prefixes: string[],
isTruncated: boolean,
marker: string | null,
maxKeys: number,
prefix: string,
delimiter: string | null,
nextMarker: string | null,
_requestId: string,
encodingType: string | null = null,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>${escapeXml(bucketName)}</Name>
<Prefix>${encodeKey(prefix, encodingType)}</Prefix>
<Marker>${encodeKey(marker || '', encodingType)}</Marker>
<MaxKeys>${maxKeys}</MaxKeys>
<Delimiter>${encodeKey(delimiter || '', encodingType)}</Delimiter>
${encodingType ? `<EncodingType>${escapeXml(encodingType)}</EncodingType>` : ''}
<IsTruncated>${isTruncated}</IsTruncated>
${objects
.map(
(o) => `<Contents>
<Key>${encodeKey(o.key, encodingType)}</Key>
<LastModified>${isoDate(o.lastModified)}</LastModified>
<ETag>"${o.etag}"</ETag>
<Size>${o.sizeBytes}</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>`,
)
.join('')}
${prefixes
.map(
(p) => `<CommonPrefixes>
<Prefix>${encodeKey(p, encodingType)}</Prefix>
</CommonPrefixes>`,
)
.join('')}
${nextMarker ? `<NextMarker>${encodeKey(nextMarker, encodingType)}</NextMarker>` : ''}
</ListBucketResult>`;
export const listBucketV2ResultXml = (
bucketName: string,
objects: { key: string; sizeBytes: number; etag: string; lastModified: Date; mimeType: string }[],
prefixes: string[],
isTruncated: boolean,
maxKeys: number,
prefix: string,
delimiter: string | null,
continuationToken: string | null,
nextContinuationToken: string | null,
keyCount: number,
_requestId: string,
encodingType: string | null = null,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResultV2 xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>${escapeXml(bucketName)}</Name>
<Prefix>${encodeKey(prefix, encodingType)}</Prefix>
<MaxKeys>${maxKeys}</MaxKeys>
<KeyCount>${keyCount}</KeyCount>
${delimiter ? `<Delimiter>${encodeKey(delimiter, encodingType)}</Delimiter>` : ''}
${encodingType ? `<EncodingType>${escapeXml(encodingType)}</EncodingType>` : ''}
${continuationToken ? `<ContinuationToken>${encodeKey(continuationToken, encodingType)}</ContinuationToken>` : ''}
<IsTruncated>${isTruncated}</IsTruncated>
${objects
.map(
(o) => `<Contents>
<Key>${encodeKey(o.key, encodingType)}</Key>
<LastModified>${isoDate(o.lastModified)}</LastModified>
<ETag>"${o.etag}"</ETag>
<Size>${o.sizeBytes}</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>`,
)
.join('')}
${prefixes
.map(
(p) => `<CommonPrefixes>
<Prefix>${encodeKey(p, encodingType)}</Prefix>
</CommonPrefixes>`,
)
.join('')}
${nextContinuationToken ? `<NextContinuationToken>${encodeKey(nextContinuationToken, encodingType)}</NextContinuationToken>` : ''}
</ListBucketResultV2>`;
// ─────── Multipart ───────
export const initiateMultipartUploadXml = (
bucketName: string,
key: string,
uploadId: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<InitiateMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Bucket>${escapeXml(bucketName)}</Bucket>
<Key>${escapeXml(key)}</Key>
<UploadId>${uploadId}</UploadId>
</InitiateMultipartUploadResult>`;
export const listPartsXml = (
bucketName: string,
key: string,
uploadId: string,
parts: { partNumber: number; etag: string; sizeBytes: number; createdAt: Date }[],
maxParts: number,
isTruncated: boolean,
_requestId: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<ListPartsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Bucket>${escapeXml(bucketName)}</Bucket>
<Key>${escapeXml(key)}</Key>
<UploadId>${uploadId}</UploadId>
<MaxParts>${maxParts}</MaxParts>
<IsTruncated>${isTruncated}</IsTruncated>
${parts
.map(
(p) => `<Part>
<PartNumber>${p.partNumber}</PartNumber>
<LastModified>${isoDate(p.createdAt)}</LastModified>
<ETag>"${p.etag}"</ETag>
<Size>${p.sizeBytes}</Size>
</Part>`,
)
.join('')}
</ListPartsResult>`;
export const listMultipartUploadsXml = (
bucketName: string,
uploads: { key: string; uploadId: string; initiatedAt: Date; initiatedBy: string }[],
maxUploads: number,
isTruncated: boolean,
nextKeyMarker: string | null,
_requestId: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<ListMultipartUploadsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Bucket>${escapeXml(bucketName)}</Bucket>
<KeyMarker></KeyMarker>
<UploadIdMarker></UploadIdMarker>
${nextKeyMarker ? `<NextKeyMarker>${escapeXml(nextKeyMarker)}</NextKeyMarker>` : ''}
<MaxUploads>${maxUploads}</MaxUploads>
<IsTruncated>${isTruncated}</IsTruncated>
${uploads
.map(
(u) => `<Upload>
<Key>${escapeXml(u.key)}</Key>
<UploadId>${u.uploadId}</UploadId>
<Initiator><ID>${escapeXml(u.initiatedBy || 's3')}</ID><DisplayName>${escapeXml(u.initiatedBy || 's3')}</DisplayName></Initiator>
<Owner><ID>${escapeXml(u.initiatedBy || 's3')}</ID><DisplayName>${escapeXml(u.initiatedBy || 's3')}</DisplayName></Owner>
<StorageClass>STANDARD</StorageClass>
<Initiated>${isoDate(u.initiatedAt)}</Initiated>
</Upload>`,
)
.join('')}
</ListMultipartUploadsResult>`;
export const completeMultipartUploadXml = (
bucketName: string,
key: string,
etag: string,
location: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<CompleteMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Location>${escapeXml(location)}</Location>
<Bucket>${escapeXml(bucketName)}</Bucket>
<Key>${escapeXml(key)}</Key>
<ETag>"${etag}"</ETag>
</CompleteMultipartUploadResult>`;
// ─────── Delete result ───────
export const deleteResultXml = (
deleted: string[],
errors: { key: string; code: string; message: string }[],
): string => `<?xml version="1.0" encoding="UTF-8"?>
<DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
${deleted
.map(
(key) => `<Deleted>
<Key>${escapeXml(key)}</Key>
</Deleted>`,
)
.join('')}
${errors
.map(
(e) => `<Error>
<Key>${escapeXml(e.key)}</Key>
<Code>${e.code}</Code>
<Message>${escapeXml(e.message)}</Message>
</Error>`,
)
.join('')}
</DeleteResult>`;
// ─────── Copy ───────
export const copyObjectResultXml = (
etag: string,
lastModified: Date,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<CopyObjectResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<ETag>"${etag}"</ETag>
<LastModified>${isoDate(lastModified)}</LastModified>
</CopyObjectResult>`;
// ─────── Error ───────
export const s3ErrorXml = (
code: string,
message: string,
resource: string,
requestId: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>${code}</Code>
<Message>${escapeXml(message)}</Message>
<Resource>${escapeXml(resource)}</Resource>
<RequestId>${requestId}</RequestId>
<HostId>${requestId}</HostId>
</Error>`;
export const s3ErrorResponse = (
code: string,
message: string,
resource: string,
status: number,
requestId: string = '',
extraHeaders: Record<string, string> = {},
): Response =>
new Response(s3ErrorXml(code, message, resource, requestId), {
status,
headers: s3Headers(requestId, {
'content-type': 'application/xml',
...extraHeaders,
}),
});
// ─────── DeleteObjects XML parser ───────
export const parseDeleteObjectsBody = (body: string): { keys: string[]; quiet: boolean } => {
const keys = Array.from(body.matchAll(/<Key>([^<]+)<\/Key>/g), (match) => match[1]);
const quiet = body.includes('<Quiet>true</Quiet>') || body.includes('<Quiet>true ');
return { keys, quiet };
};
// ─────── CompleteMultipartUpload XML parser ───────
export interface CompletePart {
partNumber: number;
etag: string;
}
export const parseCompleteMultipartBody = (body: string): CompletePart[] => {
const parts: CompletePart[] = [];
const partRegex = /<Part>[\s\S]*?<\/Part>/g;
const partMatch = body.match(partRegex) || [];
for (const partXml of partMatch) {
const numMatch = partXml.match(/<PartNumber>(\d+)<\/PartNumber>/);
const etagMatch = partXml.match(/<ETag>"?([^"<\s]+)"?<\/ETag>/);
if (numMatch && etagMatch) {
parts.push({
partNumber: parseInt(numMatch[1], 10),
etag: etagMatch[1].replace(/^"/, '').replace(/"$/, ''),
});
}
}
return parts;
};
-200
View File
@@ -1,200 +0,0 @@
import { Telegraf } from 'telegraf';
import { config } from '../env';
import logger from './logger';
import { enqueueUpload } from './telegramQueue';
const botTokens = Array.from(new Set([config.botToken, ...config.additionalBotTokens]));
const bots = botTokens.map((token) => new Telegraf(token));
let nextBotIndex = 0;
const claimBotIndex = (): number => {
const botIndex = nextBotIndex;
nextBotIndex = (nextBotIndex + 1) % bots.length;
return botIndex;
};
const sleep = (seconds: number): Promise<void> => {
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
};
const executeWithBotRetry = async <T>(
action: (botInstance: Telegraf, botToken: string) => Promise<T>,
retries = 5,
attemptedBots = 0,
): Promise<T> => {
const botIndex = claimBotIndex();
const currentBot = bots[botIndex];
const currentToken = botTokens[botIndex];
try {
return await action(currentBot, currentToken);
} catch (error: unknown) {
const errorStr = error instanceof Error ? error.message : String(error);
const match = errorStr.match(/retry after (\d+)/i);
if (match) {
const nextIndex = nextBotIndex;
const nextAttemptedBots = attemptedBots + 1;
if (nextAttemptedBots < bots.length) {
logger.info(
`Bot Index ${botIndex} hit 429. Instantly rotating to Bot Index ${nextIndex}...`,
);
return executeWithBotRetry(action, retries, nextAttemptedBots);
}
if (retries > 0) {
const seconds = parseInt(match[1], 10);
logger.warn(`All bots in the pool are rate-limited. Sleeping for ${seconds} seconds...`, {
error: errorStr,
});
await sleep(seconds);
return executeWithBotRetry(action, retries - 1, 0);
}
}
throw error;
}
};
interface ForwardResult {
telegramFileId: string;
telegramFileUniqueId: string;
storageMessageId: number;
}
export interface TelegramFileInfo {
file_size: number;
mime_type: string;
file_path: string;
bot_token: string;
}
interface UploadedTelegramFile {
file_id?: string;
file_unique_id?: string;
}
interface TelegramMessageResult {
message_id: number;
document?: UploadedTelegramFile;
photo?: UploadedTelegramFile[];
video?: UploadedTelegramFile;
audio?: UploadedTelegramFile;
voice?: UploadedTelegramFile;
animation?: UploadedTelegramFile;
sticker?: UploadedTelegramFile;
video_note?: UploadedTelegramFile;
[key: string]: unknown;
}
type FilePayload = { source: unknown; filename: string };
type SendPayload = { caption?: string };
type SendMethod = (
chatId: number,
filePayload: FilePayload,
payload?: SendPayload,
) => Promise<TelegramMessageResult>;
const sendMethodMap: Record<string, string> = {
photo: 'sendPhoto',
audio: 'sendAudio',
video: 'sendVideo',
voice: 'sendVoice',
animation: 'sendAnimation',
sticker: 'sendSticker',
document: 'sendDocument',
video_note: 'sendDocument',
};
const extractUploadedFile = (
result: TelegramMessageResult,
fileType: string,
): UploadedTelegramFile | undefined => {
if (result.document) return result.document;
if (result.photo) return result.photo?.slice(-1)[0];
if (result.video) return result.video;
if (result.audio) return result.audio;
if (result.voice) return result.voice;
if (result.animation) return result.animation;
if (result.sticker) return result.sticker;
if (result.video_note) return result.video_note;
return result[fileType] as UploadedTelegramFile | undefined;
};
const buildSendPayload = (fileType: string, fileName: string): SendPayload => {
const basePayload = { caption: fileName };
if (fileType === 'sticker') return {};
if (fileType === 'document') return { caption: `📁 ${fileName}` };
return basePayload;
};
export const forwardToStorage = async (
fileChunk: unknown,
fileName: string,
fileType: string,
): Promise<ForwardResult> => {
try {
const result = await enqueueUpload(async (): Promise<TelegramMessageResult> => {
const filePayload = { source: fileChunk, filename: fileName };
const sendMethod = sendMethodMap[fileType] || 'sendDocument';
const payload = buildSendPayload(fileType, fileName);
return executeWithBotRetry((activeBot) => {
const telegram = activeBot.telegram as unknown as Record<string, SendMethod>;
return telegram[sendMethod](config.storageChatId, filePayload, payload);
});
});
const uploadedFile = extractUploadedFile(result, fileType);
logger.info('File forwarded to storage', { fileName, message: result.message_id });
return {
telegramFileId: uploadedFile?.file_id || '',
telegramFileUniqueId: uploadedFile?.file_unique_id || '',
storageMessageId: result.message_id,
};
} catch (error: unknown) {
logger.error('Failed to forward file to storage', {
fileName,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
};
export const getFileInfo = async (telegramFileId: string): Promise<TelegramFileInfo> => {
let lastError: unknown;
for (const activeBot of bots) {
try {
const result = await activeBot.telegram.getFile(telegramFileId);
const fileData = result as unknown as Omit<TelegramFileInfo, 'bot_token'>;
return {
file_size: fileData.file_size || 0,
mime_type: fileData.mime_type || 'application/octet-stream',
file_path: fileData.file_path || '',
bot_token: activeBot.telegram.token,
};
} catch (error: unknown) {
lastError = error;
const errorStr = error instanceof Error ? error.message : String(error);
if (
errorStr.includes('wrong file_id') ||
errorStr.includes('file is temporarily unavailable') ||
errorStr.includes('retry after')
) {
continue;
}
throw error;
}
}
logger.error('Failed to get file info from any bot', {
error: lastError instanceof Error ? lastError.message : String(lastError),
});
throw lastError;
};
export const getBot = (): Telegraf => bots[nextBotIndex];
export const getCurrentBotIndex = (): number => nextBotIndex;

Some files were not shown because too many files have changed in this diff Show More