# v2 git bundle
af8949de02fbc5e5ba17d0557a6d653298f88488 refs/heads/bundle
af8949de02fbc5e5ba17d0557a6d653298f88488 HEAD

PACK     xj0ໞb-%lYlIkh޾zJeFꈂ^ڨǨ;7XoZK؉CFeɌgg=`[M0R	y%Je]0Tssml<I'L_v?00LJPiy-GsNPLB\Mv;>ʾABNLK|Emvbx0@QQnpaLpb Cb;KURR\8/=oe2sy(Gl䆢((UI9x^Jtd-%-QJRqmtVͣr &xKJD1 9EId]
	O$wn
Fg2G0N4qYZjѧDfܺؒBst"XQ9gqRryةnܟXC4N:VRAifN*LjR&x tree d30b39423b88932fdd600a95d4638fd1c4a44146
parent 340c12d671b01882edbb484794470468dca76b09
author asepharyana <superaseph@gmail.com> 1783431581 +0700
committer asepharyana <superaseph@gmail.com> 1783431581 +0700

ci: migrate deployment to gitea actions
?OP.xtree b5ae1180008e9e6584ade076724b3da53037013f
parent 144ebe6dd333efb8f43ef034e90349e831d97a90
author asepharyana <superaseph@gmail.com> 1783429095 +0700
committer asepharyana <superaseph@gmail.com> 1783429095 +0700

feat: implement authentication routes with login, logout, and user info retrieval

feat: add S3 bucket versioning support and related XML response handling

refactor: rename temporary file paths from 'teleuploader' to 'filedrop' for consistency

fix: update Swagger documentation to reflect new API name and descriptions

test: add unit tests for authentication routes and utilities

test: implement end-to-end tests for S3 bucket configuration and versioning

chore: update environment variable defaults for new service name
x tree 90a8a449433282e779324f5219106e6d1f15e9dc
parent fbfff3d9ec64620737d7bf17d538a4fa5cc5fcb0
author asepharyana <superaseph@gmail.com> 1783386502 +0700
committer asepharyana <superaseph@gmail.com> 1783386502 +0700

feat: implement chunked storage for Telegram file uploads and retrieval
f\x tree 7b5e5d255c4fb56134aecd43a5557d936bb4e171
parent 1ef73fefa93cbc60927cb03612898f86113f3d35
author asepharyana <superaseph@gmail.com> 1783380438 +0700
committer asepharyana <superaseph@gmail.com> 1783380438 +0700

fix: normalize web object sizes
Mx!tree 35479cd691cab732c2ae1c63f186bc903820c6c0
parent bc806313b115dc7f500f881b54f7ba6933cfbf98
author asepharyana <superaseph@gmail.com> 1783379509 +0700
committer asepharyana <superaseph@gmail.com> 1783379509 +0700

fix: restore multipart part order validation (without strict ETag check)
p\xtree 16e66173099d1f84a9b4285e8d3dc8bebe2493b0
parent d1c8cc9d23339793d48292bc3c764d79f67e4a6b
author asepharyana <superaseph@gmail.com> 1783379057 +0700
committer asepharyana <superaseph@gmail.com> 1783379057 +0700

fix: use safe LIKE prefix matching instead of non-character range
YHx7tree 47cd6b8eb27629dbaa3c909f8725a9046264a374
parent 34962eb08a476d0c5b5a68fe5045afb1977e49f6
author asepharyana <superaseph@gmail.com> 1783378936 +0700
committer asepharyana <superaseph@gmail.com> 1783378936 +0700

fix: ensure S3_DEFAULT_REGION and S3_VHOST_DOMAINS env vars are properly set in docker-compose
"ax"tree 4c9323551dc76df9c07a24b80b7ef190413f2685
parent 8e6ccf211081ef674b692e6d792c2a7e200f04cc
author asepharyana <superaseph@gmail.com> 1783378064 +0700
committer asepharyana <superaseph@gmail.com> 1783378064 +0700

chore: update S3 vhost domains to include asepharana.web.id root/wildcard
I\dCx=tree 4008191d82a36efb4eab6b93c73fb72fc1711986
parent 6aee3723fb0f46b1ed4465fc92a95abc0d2a9c7e
author asepharyana <superaseph@gmail.com> 1783377563 +0700
committer asepharyana <superaseph@gmail.com> 1783377563 +0700

feat: S3 client compatibility — virtual-hosted style, CORS, presigned multi-method, ListMultipartUploads, edge case fixes

- Virtual-hosted bucket detection from Host header (extractS3BucketFromHost)
- S3 CORS headers + OPTIONS preflight + x-amz-id-2/HostId everywhere
- Presigned GET/PUT/HEAD/DELETE via centralized auth (no GET-only restriction)
- ListMultipartUploads with DB/xml helpers
- UploadPart partNumber range validation (1-10000)
- CompleteMultipartUpload ETag matching and ascending order validation
- DeleteObjects quiet mode support
- CopyObject URL-decode and conditional if-match/if-none-match
- encoding-type=url support in ListObjects V1/V2 XML
- Safe range-based prefix matching (replaces SQL LIKE)
- STREAMING-AWS4-HMAC-SHA256-PAYLOAD → 501 NotImplemented
- Traefik wildcard HostRegex for virtual-hosted style
- S3_VHOST_DOMAINS config env var
Coٟx  tree 73fd8dbc7828afeb7c827cb5714bb76900e87433
parent ad3f4ce75e4d3ca66ca55a9fea26dfd0845ab388
author asepharyana <superaseph@gmail.com> 1783374524 +0700
committer asepharyana <superaseph@gmail.com> 1783374524 +0700

fix: remove home page CSS lint warning
Pxtree bffdddab98c0a1de10b5ccc0f7264d9f99493743
parent 339f788dc94b76f6f43b34bf096a19b28a4814dd
author asepharyana <superaseph@gmail.com> 1783374427 +0700
committer asepharyana <superaseph@gmail.com> 1783374427 +0700

test: enable strict AWS SDK multipart coverage
@Saxtree b351aef5ba9803a6158e0fb271eb022b186170c3
parent 2e447ee58ad012c493c159fd879b791515ec333e
author asepharyana <superaseph@gmail.com> 1783374127 +0700
committer asepharyana <superaseph@gmail.com> 1783374127 +0700

fix: ensure multipart part sizeBytes are Numbers, not DB strings
4Yxtree 861683a0c327f3aa2ce691e2b296ba93ff5ba95f
parent ad454041321d8c1d67d384067859c1b393d9de8e
author asepharyana <superaseph@gmail.com> 1783373840 +0700
committer asepharyana <superaseph@gmail.com> 1783373840 +0700

fix: stream complete multipart S3 objects
9Q x<tree 8260996a4de0b4bccb38cbadd909edd0cd8afa13
parent 378a084fd3714635a0ee355ee4a464ab4f109806
author asepharyana <superaseph@gmail.com> 1783373364 +0700
committer asepharyana <superaseph@gmail.com> 1783373404 +0700

feat: support ranged S3 GetObject responses

Wire createGetObjectResponse into single-part GetObject handler for
proper Range/Content-Range support (200, 206, 416). Update presigned
E2E test to require 200. Add SDK Range request test.
вIx
tree 824dca7591c3a568cf536b1300b7f12fdb7cf757
parent 5253fb384dd91d59d0a3bdcf0941b4b9fb529204
author asepharyana <superaseph@gmail.com> 1783372992 +0700
committer asepharyana <superaseph@gmail.com> 1783373105 +0700

feat: stream S3 object bodies from Telegram parts
z#Sx
tree 935dc3eda21023563e9998da74eb7ae60642eb7d
parent 85aa54592ae6618a766c13deb7e089d7171e2743
author asepharyana <superaseph@gmail.com> 1783372657 +0700
committer asepharyana <superaseph@gmail.com> 1783372861 +0700

fix: verify presigned S3 URLs against public host
)SNx tree 3f93cf947048150fc6aacf4f132596f96c437218
parent 2bf0fa5af5694fadd04fd964159dac83cf49053d
author asepharyana <superaseph@gmail.com> 1783372270 +0700
committer asepharyana <superaseph@gmail.com> 1783372450 +0700

feat: add S3 byte range parser
yLx  tree 9a30e3aaa192813d74c9db714d50fec0a43585b8
parent dbdc5ed1ee8138d08c4fb094be95633a39db25bf
author asepharyana <superaseph@gmail.com> 1783371791 +0700
committer asepharyana <superaseph@gmail.com> 1783371791 +0700

docs: plan S3 compatibility completion
kQxtree 60e0dddafd0fa557d1da93fb0e9cc4c425df0059
parent 9a48fbf2279edc0855ca3faadd9aaf3e1bad4df6
author asepharyana <superaseph@gmail.com> 1783371400 +0700
committer asepharyana <superaseph@gmail.com> 1783371400 +0700

docs: design remaining S3 compatibility fixes
%U 6xctree c07a79aaddbba44cc386980483275d04162f4681
parent 06f93e30f633d8f244d0be06665ae81217baeadd
author asepharyana <superaseph@gmail.com> 1783368471 +0700
committer asepharyana <superaseph@gmail.com> 1783368471 +0700

fix: satisfy deploy lint gate for S3 compatibility work

- Apply Biome organize-import/formatting fixes across changed S3 files
- Replace remaining string concatenations with template literals for lint
- Make home page inline handlers explicit via window.* and add button types
- Clean S3 auth lint issues with dot-property access and optional chaining
- Keep GetObject proxy and production/S3 SDK tests passing

Verification:
- bun run lint (0 errors, 1 CSS specificity warning)
- S3_SECRET_KEY=<env> bun test test/production-e2e.test.ts (29 pass)
- S3_SECRET_KEY=<env> bun test test/s3-sdk.test.ts (20 pass)
- bun test test/s3-auth.test.ts (5 pass)
hz%7xqtree d2967ae6c1acb9d81da923a28496e930a767c1fa
parent f88ac1a124eaf0cf8ad0dc488672a282bde8ace7
author asepharyana <superaseph@gmail.com> 1783367691 +0700
committer asepharyana <superaseph@gmail.com> 1783367691 +0700

feat: proxy GetObject from Telegram CDN for real S3 client compatibility

- Add proxyS3Get config (PROXY_S3_GET env, default true) to env.ts
- Proxy handleGetObject and handleGetMultipartObject: fetch from Telegram
  CDN and return 200 with streaming body instead of 302 redirect
- Real S3 clients (AWS SDK v3) expect 200+body on GetObject, not redirect
- Legacy 302 redirect path preserved when proxyS3Get=false
- Updated production-e2e: GetObject asserts 200 with body content
- Updated s3-sdk.test.ts: GetObject asserts 200 with body (removed try/catch)
- Cleaned up unused multipart imports in s3-sdk.test.ts

All 49 tests pass (29 production-e2e + 20 s3-sdk).
%ې8xtree 782cd369b90c96353e35f3bca8bb50ee12691c64
parent 57f2740df967ba7e36faa525b3d74aef4c0e0817
author asepharyana <superaseph@gmail.com> 1783364357 +0700
committer asepharyana <superaseph@gmail.com> 1783364357 +0700

test: add real AWS SDK v3 S3 compatibility tests (20 tests)

Tests use @aws-sdk/client-s3 against https://upload.asepharyana.my.id
with forcePathStyle=true. Covers all standard S3 operations:
ListBuckets, CreateBucket, HeadBucket, DeleteBucket, PutObject,
GetObject, HeadObject, ListObjectsV1/V2 (prefix, delimiter),
CopyObject, DeleteObject, DeleteObjects (batch), error handling.

Multipart is excluded from SDK tests — the SDK adds amz-sdk-* /
x-amz-user-agent to signed headers, which can differ between
signing time and the actual request through Cloudflare, causing
403 SignatureDoesNotMatch. Multipart is verified via manual
SigV4 signing in test/production-e2e.test.ts.
t2~;xFtree 84855ac5479687c8c0778706ac075d05e4f788ca
parent b731ddd7e1e5f950ff9b2b96c32ec57a78ee7ca4
author asepharyana <superaseph@gmail.com> 1783360185 +0700
committer asepharyana <superaseph@gmail.com> 1783360185 +0700

fix: production bugs + comprehensive production e2e tests

Fixes:
- RowList bug: postgres.js returns array directly, not {rows}. Fix in
  buckets.ts, files-ext.ts, multipart.ts (3 files, 8 functions)
- S3 ListBuckets routing: GET / was intercepted by handleHome route
- Presigned URL detection: isS3Request() only checked Authorization header
- FK constraint on bucket delete: cascade-delete files & multipart rows first
- docker-compose.yml: pass S3_ACCESS_KEY / S3_SECRET_KEY to container
- Dockerfile: copy home.html to runner stage for handleHome

Tests:
- test/production-e2e.test.ts: 29 tests (11 Web API + 18 S3 SigV4)
  All pass against https://upload.asepharyana.my.id
- Creates and cleans up real buckets/objects on production
YAxtree 9577dbadb4df24f07d1e90c599b854956855eec3
parent 245138e23a67720380ef2d58d8654c7acc18502f
author asepharyana <superaseph@gmail.com> 1783353059 +0700
committer asepharyana <superaseph@gmail.com> 1783353059 +0700

fix: sequential test execution to avoid mock pollution between files
0[L%xQtree b08cc1ac8b0e16e7dcc71abd0e17c56e868da78c
parent 2d3d7891a0f00868e220e941b6b8e335bc78a59c
author asepharyana <superaseph@gmail.com> 1783352175 +0700
committer asepharyana <superaseph@gmail.com> 1783352175 +0700

Refactor code for improved readability and consistency

- Updated formatting in web-api.ts for better alignment and readability.
- Enhanced XML builders in xml.ts for clearer structure and maintainability.
- Improved test cases in s3-auth.test.ts and s3-operations.test.ts for better clarity and consistency.
- Refactored mock data in web-api.test.ts for improved readability.
Qx&tree 41b7a36cfe9477c2c79db5b0baec1dcb5b163c86
parent 0ad783b79480ee80e3170fb28e35b0e400c6e6cc
author asepharyana <superaseph@gmail.com> 1783351926 +0700
committer asepharyana <superaseph@gmail.com> 1783351926 +0700

refactor: auto-run DB migration at startup, robust schema.sql path resolution
_x0tree 5b53efb9170ade30aff42c221836e4eac896dfac
parent e175a275129be6ee26ceec51ab5b43a13b2f59f5
author asepharyana <superaseph@gmail.com> 1783348697 +0700
committer asepharyana <superaseph@gmail.com> 1783348697 +0700

fix: PutObject raw body, presigned URL auth guard, DeleteObjects tracking, home.ts path
bgx	tree 148bef42f2fb9f67b76bb9278e6edcb10cdda798
parent 5e38f80267f838a33ef98fc1f3beb7b9a4e0c242
author asepharyana <superaseph@gmail.com> 1783347326 +0700
committer asepharyana <superaseph@gmail.com> 1783347326 +0700

test: add S3 auth, operations, and web API tests
8Sxtree ae6d20f54ccc818b65e18f908f80f602ca8ad173
parent a4f902a37b77eef4376546a880c66cf1fa0578d0
author asepharyana <superaseph@gmail.com> 1783346215 +0700
committer asepharyana <superaseph@gmail.com> 1783346215 +0700

feat: add web file manager UI and S3 catch-all route
xS˗xtree 6eb4478f8887bd4e2d908dff5c5f9ea86d46cde7
parent dd61e7e0fc3952d2ea610c014d6899c11a829808
author asepharyana <superaseph@gmail.com> 1783329017 +0700
committer asepharyana <superaseph@gmail.com> 1783329017 +0700

feat: add JSON v1 web API for S3 management UI
Px6tree 3a2ab7ef72b1ce9f523324e2c331c74fe1688606
parent 084ff3fb9dfce2b2af1aa2180410897adbb5df9a
author asepharyana <superaseph@gmail.com> 1783327962 +0700
committer asepharyana <superaseph@gmail.com> 1783327962 +0700

feat: implement S3 protocol dispatcher with bucket, object, listing, and multipart operations
"e`xGtree 8176c6ad5841cb67eb75d2e0edc2807a74fc6422
parent ee758aa939ca32954b5d581784b67b33847c10db
author asepharyana <superaseph@gmail.com> 1783326666 +0700
committer asepharyana <superaseph@gmail.com> 1783326666 +0700

fix: add region validation, fix presigned URL canonical request (add method, fix headers), normalizeUri decode
4ixtree aee21a516531b02faefa52d54f6ac9fa8b3f492c
parent a2ca4890395e36f1482b4cb4e783a4274d3177c6
author asepharyana <superaseph@gmail.com> 1783324599 +0700
committer asepharyana <superaseph@gmail.com> 1783324599 +0700

feat: add S3 SigV4 auth verification and XML builders
Tx@tree 6529aed94e761b73682eb20d1db856de2189fe8f
parent caef31c7039d55c431340344ce2efdf44407a475
author asepharyana <superaseph@gmail.com> 1783323207 +0700
committer asepharyana <superaseph@gmail.com> 1783323207 +0700

fix: add explicit snake_case-to-camelCase mapping for S3FileRecord, add missing findOrphanFilesByBucket
@]gxtree d57a456e9fecef18f63a6c104ca24150bd57640b
parent e37fb00295ce61b167ad947e84684403fc53e463
author asepharyana <superaseph@gmail.com> 1783322617 +0700
committer asepharyana <superaseph@gmail.com> 1783322617 +0700

feat: add DB CRUD layer for buckets, multipart, and S3 file extensions
:Yxtree 2d0b1befd0d175379a370d1cfd2574dc228dfa6f
parent 0d0d7a5dfab54cdd8e0f3ebd3ef1e12ba80e940d
author asepharyana <superaseph@gmail.com> 1783321143 +0700
committer asepharyana <superaseph@gmail.com> 1783321143 +0700

feat: add S3 env config, bucket and multipart DB schema
VĞxtree b3b7a0cb1de20c6be7867550e5f03a10d1d3138c
parent 52bd704d21ba4f98c14b2866dcf5a541673c5aa8
author asepharyana <superaseph@gmail.com> 1783275937 +0700
committer asepharyana <superaseph@gmail.com> 1783275937 +0700

chore: remove GitHub Actions workflow for notifying parent repository
I\x/tree 0ba5ec7d813cc04427bf5fd6147f18b40e68fe3b
parent bbf420759b5ab1e3d0542cad582d71061535f7b4
author asepharyana <superaseph@gmail.com> 1783275918 +0700
committer asepharyana <superaseph@gmail.com> 1783275918 +0700

refactor: enhance file handling and metrics tracking; remove unused bot health tracker
	NbLx7tree ea46106775c0d829717e4b7d290c55d750f2dcc7
parent 20b622c4c2f063750fe24025c096d93a5564cf62
author asepharyana <superaseph@gmail.com> 1783264012 +0700
committer asepharyana <superaseph@gmail.com> 1783264012 +0700

chore: update Dockerfile and docker-compose for improved deployment process; add deploy script
d($xGtree 50836317caeff4cbbdf33a22ee664d5b25a5ba0b
parent 50176e57643789119a65e676bff1e0f87380bb0e
author MythEclipse <asepharyanalm001@gmail.com> 1781968910 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1781969063 +0700

fix(core): resolve wrong file_id error and zip stream memory leaks

- Refactored Telegram bot pool iteration to handle 'wrong file_id' properly by verifying against all active bots
- Upgraded TelegramFileInfo interface to track owner bot_token
- Patched MaxListenersExceededWarning memory leak in zip.ts stream writes with events.once and async iteration
KxYtree 5f380722e9e8946983bb3632685b1ca1f85e9941
parent 2a833d3908837f566462adacddbf144de1cfe576
author MythEclipse <asepharyanalm001@gmail.com> 1780724366 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1780724366 +0700

chore: migrate domain asepharyana.tech to asepharyana.my.id

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cnx#tree d75b0711d31f1e74391c8844b1585c795ef43102
parent 3ca5ab798337709088caea811611d3ad045a84de
author MythEclipse <asepharyanalm001@gmail.com> 1780074935 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1780074935 +0700

feat: include bot token in file info response for Telegram API
Z?xKtree 3325ba98b39acdd21bb6681262fedb89d3bb9a6a
parent cc0f1dde7949c12c45c6ccb5999a9be330dec98e
author MythEclipse <asepharyanalm001@gmail.com> 1780048435 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1780048435 +0700

refactor: update Telegram file handling to use new getFileInfo function and include bot token in cache
kxYtree 78d9e919fbfc54e362f63e56be99ba94d54bfd8e
parent e88e5dbc392154c49abcff683c62d96c13f0fbdc
author Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> 1780041600 +0700
committer Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> 1780041600 +0700

ci: remove obsolete deploy.yml, replaced by notify-parent.ymloxctree b439dcdff2e7b191f925d41eaa77b8f55b599cf5
parent 0e2f09012f2bb79021edb301924de8769cd51469
author Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> 1780038530 +0700
committer Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> 1780038530 +0700

ci: slim deploy.yml to test-only; build/push/deploy handled by monorepo/r.xStree 41f17987446a2336b626cd9040fa9df827c99abc
parent 134a7d4bf6623ed4bb2c5cce42224c064eddb269
author Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> 1780038501 +0700
committer Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> 1780038501 +0700

ci: add notify-parent workflow for monorepo integration`0lxNtree 0cd4b387f5537b1635ef431311ec88947c890c1b
parent 8508b360268f649e19763d1fb8b02f95b2fbe593
author MythEclipse <asepharyanalm001@gmail.com> 1780001157 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1780001157 +0700

feat: update Traefik router rules to support additional domains and change TLS certresolver to Cloudflare
'k>x<tree ac3ed7af1266d9e266206b434af38d3adef03aa7
parent 5425f6d33ddc7d85095e40ca3922189a26893ba6
author MythEclipse <asepharyanalm001@gmail.com> 1780001029 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1780001029 +0700

feat: update file handling to redirect to Telegram CDN and adjust Swagger documentation
6XdAxtree 238174bcfd5b14b21326dc41f8a1bc43b4ff05e4
parent be813b1c0ed57ffa0cd2b817f52111a736ed474c
author MythEclipse <asepharyanalm001@gmail.com> 1780000419 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1780000419 +0700

feat: enhance configuration and rate limiting

- Added new configuration options: trustProxy, uploadConcurrency, batchMaxItems, batchMaxSizeBytes, and maxRequestBodyBytes to AppConfig.
- Implemented utility functions for parsing environment variables and masking sensitive data.
- Updated rate limiting logic to use configurable window size and maximum requests per window.
- Introduced a middleware for rate limiting on specific routes.
- Refactored file handling routes to support streaming downloads instead of redirects.
- Improved error handling and response formatting in file routes.
- Added support for oversized request rejection based on Content-Length header.
- Updated Swagger documentation to reflect changes in API behavior and responses.
- Enhanced tests to cover new features and ensure proper functionality.
Wux/tree eb2e4f5ae7b4a602a9144f420c5b994bd90d26a3
parent db5de1367d10b0cfe302307583f978f4e7f26004
author MythEclipse <asepharyanalm001@gmail.com> 1779998270 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779998270 +0700

feat: update Traefik router rule to use new domain for file upload service
'<`Gx_tree 870f0ecb920364bbe6c1511897218c87fffd8d7f
parent 56b929ffb1a7cccce64fa6cde1f8fa808a4a084e
author MythEclipse <asepharyanalm001@gmail.com> 1779385909 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779385909 +0700

feat: extend file schema with archive metadata, implement batch upload processing, and add zip utilities for file handling
usxStree df8f3dc68c75626ac4ca4f8733add8369318fafe
parent fd5eb98586471e609a95402cf692bfd9f60c9235
author MythEclipse <asepharyanalm001@gmail.com> 1779384594 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779384594 +0700

feat: enhance file upload process with temporary file handling, improved error management, and metrics logging
jnśxKtree e80f5aca7db2726df26b0d123f0ef07b757aafcc
parent 10c968cf0105ed08e1a4412c3f142ac455ce3036
author MythEclipse <asepharyanalm001@gmail.com> 1779381109 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779381109 +0700

feat: implement caching for file info, add rate limiting, and enhance upload handling with retry logic
rjĜx\tree 7e46ef0b3eed0d72890844e9974c87f7b077b4c0
parent 52340ee77db8ba46d74ebfb07b2e780845f21562
author MythEclipse <asepharyanalm001@gmail.com> 1779378942 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779378942 +0700

feat: add file_hash column and related index to files table; refactor Telegram API utilities for improved file handling
`	p{xJtree 196111f2329d8bc2a06fd00a354bec88d3b8281e
parent 06844f1fa1a7fe8ba4f54fabc11568eab5812585
author MythEclipse <asepharyanalm001@gmail.com> 1779377515 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779377515 +0700

feat: enhance file upload handling with improved file detection, size limits, and response formatting
jיx9tree 12cc495bebb3300cb0faa4045d81152d5c5e4f7b
parent 884710fd4244224b457994c4a99d1ac8d2bace4f
author MythEclipse <asepharyanalm001@gmail.com> 1779116635 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779116635 +0700

docs: update CLAUDE.md to include details on temporary file handling for API uploads
c8xEtree fd0ef86635ba8f7a6beb75d82254954d3d48a530
parent cc3f85284bdbf2e52f3e9dae6e3ffac3f17737c1
author MythEclipse <asepharyanalm001@gmail.com> 1779116486 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779116486 +0700

feat: implement temporary file handling for uploads and update tests for dynamic ID verification
'j x)tree e8326c34eab734e18713aca125193a30a1ad623a
parent d58b2390efbce1b1b9e2778c0a6a926d6482f84d
author MythEclipse <asepharyanalm001@gmail.com> 1779116097 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779116097 +0700

docs: add dynamic image fetching details for unit tests in CLAUDE.md
\].xCtree 3644f9a34a5457778c3db5200636be15a1fe80c6
parent b91b276214729c7cd46cdf79ecaadd89032d4869
author MythEclipse <asepharyanalm001@gmail.com> 1779116079 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779116079 +0700

refactor: remove concurrency limit from Telegram upload queue and enhance media group handling
hx;tree 8d3360c90815ffd41372b1bf9565554b62586b0c
parent 0bc12651db86c9a33f01caeb3263540ee4295c38
author MythEclipse <asepharyanalm001@gmail.com> 1779114680 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779114680 +0700

feat: add support for batch uploads of media groups to Telegram with debounce handling
ɥcx>tree c6c031f2c1522f0d4c4e1a748aae55cbeea212bf
parent 825e9695698d6c406fda99caea74099cc36851ed
author MythEclipse <asepharyanalm001@gmail.com> 1779113978 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779113978 +0700

fix: clean up logger info message formatting and remove unused import in rate limit tests
gfxOtree fe4b94f9ecfc7b2003fd32faf0a2664807481b9b
parent 721fa3db7d80cf49e75bee8ede46240346eeee95
author MythEclipse <asepharyanalm001@gmail.com> 1779113896 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779113896 +0700

feat: implement multi-bot support for Telegram API with rate limit handling and add task queue for uploads
Km!x?tree b4bc79f9bc5b7c4f2df20459f3f9f500802470c8
parent 91cfee0b375b2ed93cb76db7ab0e17207a288325
author MythEclipse <asepharyanalm001@gmail.com> 1779112958 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779112958 +0700

feat: implement auto-retry for Telegram API requests on error 429 and update documentation
fB#x1tree 545ab9856e7e0f488d8df2309536310cc9dca63a
parent 525348b1f3c24148a79bd85154a65be52df91ce5
author MythEclipse <asepharyanalm001@gmail.com> 1779108903 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779108903 +0700

fix: load BOT_TOKEN from process.env in tests to prevent leaks

- Update test/bot.test.ts to use process.env.BOT_TOKEN || 'fallback'
- Update docs/superpowers/plans/2026-05-18-deploy-plan.md to use placeholders
- Completely eliminates hardcoded active API token from codebase

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Q;v2x,tree 885f872069069d51824b17ebd5c145a300e29121
parent bf6d5f04c4e6243ed679852e2a23d246dbca9fe9
author MythEclipse <asepharyanalm001@gmail.com> 1779108766 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779108766 +0700

fix: make forwardToStorage fully dynamic based on fileType

- Replace forceDocument boolean with fileType string in forwardToStorage
- Dynamically call matching sendPhoto, sendAudio, sendVideo, sendVoice, sendAnimation, sendSticker, or sendDocument Telegraf API method
- Correctly extract uploaded file details based on what Telegram returned
- Fixes IMAGE_PROCESS_FAILED 500 error when uploading ogg audio, video, sticker, or voice notes via HTTP
- Update upload.ts, bot.ts, and test suites to match the new dynamic signature

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
(x{tree d363c40e1da7a98b7f105f2a2fff32fa5f570c9f
parent b2de5f583dc33c5b523188dac54f75e5acd95507
author MythEclipse <asepharyanalm001@gmail.com> 1779108381 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779108381 +0700

fix: bundle migration script and ensure file_hash column exists

- Compile src/db/migrate.ts to dist/migrate.js during build
- Copy dist/migrate.js to Stage 2 (runner) inside Dockerfile
- Run bun dist/migrate.js on db:migrate command
- Modify schema.sql to use CREATE TABLE IF NOT EXISTS and ALTER TABLE to add file_hash to existing database instances on VPS

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
$nx3tree 4314d81b6954b986b9988d59744b6f133720ee8d
parent a0262e2d46e86ea750467229de02c8f8e759b4dc
author MythEclipse <asepharyanalm001@gmail.com> 1779108137 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779108137 +0700

feat: add design document for multi-format upload and anti-duplication feature
"ahxtree 705ddb7935ee847e253bf74a3dbe699246be270a
parent ea4341fdfe443845d9c2ba17b69b272db75ead25
author MythEclipse <asepharyanalm001@gmail.com> 1779108111 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779108111 +0700

style: run formatter on modified files
Sߑ%xQtree f739b5a8cd348325205f4f284df1f9d7ad1d989a
parent 04a2e52250658ffc17499766546c4ff0880f2d6b
author MythEclipse <asepharyanalm001@gmail.com> 1779108076 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779108076 +0700

feat: implement deduplication and all media types in bot listener

- Expand registered bot listener to support sticker and video_note media types
- Check database for existing telegramFileUniqueId to bypass forwarding duplicates
- Update tests to assert deduplication and new formats handling in bot.test.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Y'xtree f4a6c617232e946dba90f4339a5b369b4b309f48
parent 4e805689c14fd3702b0c9f1f38c03d242f5ccc61
author MythEclipse <asepharyanalm001@gmail.com> 1779107995 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779107995 +0700

feat: implement deduplication in HTTP upload router

- Compute SHA-256 hash using computeHash for multipart and JSON uploads
- Query database for existing file records with the same hash
- Return existing metadata and download URL immediately on duplicate match without reforwarding to Telegram
- Include fileHash in newly inserted file database records

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Jׄ&xctree 49b33aab22778d2fd3dc4223b5709affd32ec8f4
parent 22c2133b24d47fc9d4e3fad1925cf8bf67067e37
author MythEclipse <asepharyanalm001@gmail.com> 1779107892 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779107892 +0700

feat: extend file type classification and implement computeHash

- Add sticker (webp) and video_note to classification mapping and file limits
- Handle unknown mime or caption fallback to document
- Implement computeHash utility using Bun.CryptoHasher for sha256 checksums
- Update and add comprehensive tests in file.test.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Gxmtree 732058b5a4cbbb1715e08a053d8aa03d4ab089d0
parent 3d2500ed7f8a83a11325f6ae84eb57338896125c
author MythEclipse <asepharyanalm001@gmail.com> 1779107779 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779107779 +0700

feat: add fileHash column to files schema

Update files database schema and test suite to support fileHash property.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
B+x1tree ab661af0d1b5d30035be5d3dbb6664a594637c9b
parent 5de424bf8e90a23fc8544b9a5f12f6c4a49b9495
author MythEclipse <asepharyanalm001@gmail.com> 1779070803 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779070803 +0700

fix: auto-append file extension based on magic bytes or mime type if missing
8)a>xtree 2116562f406a0c84b702c26ae35a7445e009d07a
parent ed7f607ee4c5409c19292bc2e627732ed99f7386
author MythEclipse <asepharyanalm001@gmail.com> 1779069382 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779069382 +0700

ci: add write permission for packages to GHCR pushing
;W@x tree 786a33112e62f191c3e74cdca56e19e08bb5f4c3
parent ed88069e60f7f2f352b1c66264f084a1e77bcd21
author MythEclipse <asepharyanalm001@gmail.com> 1779069322 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779069322 +0700

style: sort imports in telegram test to satisfy Biome check
s[x7tree 333d6b534362bc8cc56b8d2ab8b3a8956d6a9385
parent 910d29d5b9cae2f1050f1aba99e9740f60dbb80b
author MythEclipse <asepharyanalm001@gmail.com> 1779069264 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779069264 +0700

fix: assert sendDocument with config.storageChatId to match dynamic mock env in CI
cbx/tree f58a341962cd71da99af41a8e0e2a7ff419b0cfb
parent c0827b09c5734f1a38bb3750bf896403379dd7bb
author MythEclipse <asepharyanalm001@gmail.com> 1779069224 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779069224 +0700

fix: upgrade Bun setup version to latest to support modern lockfile format
NaDx=tree f07cdff798467b586c50c5891e1a6bb3fa0afc03
parent 7f1178d72b510522f79f99d630498b5b17426096
author MythEclipse <asepharyanalm001@gmail.com> 1779068569 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779068569 +0700

fix: make DB tests more robust by checking definition rather than strict module identity
e(xDtree 7dee9e60ce11d243d9ceebee9db7d455973836b8
parent 06a1b38c025ae868a2f1ada6858d1d8623d2770a
parent 1fc408046627586e1906b9f2454c7f07e3f97805
author MythEclipse <asepharyanalm001@gmail.com> 1779068497 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779068497 +0700

Merge branch 'worktree-agent-a7a26e7873ab5b94e'
Taוxetree 633f9563e2e028939a3debfdbfca1573425d1e73
parent 2c1c5ad6383264c0ec97910b56e7a80ea3f19b62
author MythEclipse <asepharyanalm001@gmail.com> 1779068489 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779068489 +0700

ci: add GitHub Actions pipeline for tests, Docker build, and deployment

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
;qxDtree 4ef940d99619d1e7e4e3eb84c0bf4df18dd2e1e0
parent e4c074c81f47bea51fc257596c5949db6b9e6905
parent b41fdb4a887e94217e674d40751f2f409a4d6d1f
author MythEclipse <asepharyanalm001@gmail.com> 1779068460 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779068460 +0700

Merge branch 'worktree-agent-aae37f6f3cccac163'
c=xtree ce8e68d24ba8fa90f5ffae02dcd480bf5a71d3b7
parent d7f5180eab08eefaa82377da156ff7e02da24541
author MythEclipse <asepharyanalm001@gmail.com> 1779068456 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779068456 +0700

docs: add deployment design and implementation plan
kYhx x-tree d3731c514bb8193f08d58f93f8fb65823b5fc23a-h397AF397 +0700

chore: add Dockerfile and dockerignore for production build+:s*x z-tree ec818722ada3fc406688496f7b7d24e7ef841ded-h442AD442 +0700

chore: add docker-compose.yml with Traefik configurations:*xtree 325913e92098ead18af34767e5b5836e27dd9f0a
parent 91986d1d51c2411f144696fa016a0ec50507c872
author MythEclipse <asepharyanalm001@gmail.com> 1779067524 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779067524 +0700

fix: add Bun database migration runner
tQxtree 42d98de0973264cb6d213958261038d2cb55eeed
parent 9aedc59b69772102cafb5b496747df09be8547dc
author MythEclipse <asepharyanalm001@gmail.com> 1779067135 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779067135 +0700

fix: upload binary files to Telegram with source payload
Yxtree 1c38e45dc33cce54ef800aca2d3916b77f26d121
parent 6feca3564af2e2a58cdc567e601140c039ab81e8
author MythEclipse <asepharyanalm001@gmail.com> 1779064995 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779064995 +0700

fix: refine Swagger docs headers and dynamic examples
Y'xtree 68eb596a98a9ff83e3dc5f4c90e338f698690152
parent aae8ebfbcf74594ee2282b238f4de43386bc61ae
author MythEclipse <asepharyanalm001@gmail.com> 1779064767 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779064767 +0700

feat: add Swagger OpenAPI documentation endpoints
iWx;tree abe8cb98ec8b13338f7630edc8d452ce7621caa0
parent 825a8f42cb36e64995cc441eb73f533fbf41d52f
author MythEclipse <asepharyanalm001@gmail.com> 1779064247 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779064247 +0700

chore: update bun.lock to include @biomejs/biome, typescript, and related CLI packages
dxtree 6714e5c8786aaca5e43ffd78c28523aca8f0b70e
parent cf79a1f1955f6a6eb4441fd4a9cc841a3528a1b1
author MythEclipse <asepharyanalm001@gmail.com> 1779064233 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779064233 +0700

chore: add build script and target bun bundle output
gXxtree 1d1c4f213a385bfc81f9f19184966923a26c5be4
parent 3f4e697733039b9b4383d098d97da9a79c8141e8
author MythEclipse <asepharyanalm001@gmail.com> 1779064141 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779064141 +0700

style: format and lint codebase using Biome v2
`T͐xtree a4dcfd12d72020fb50f9c15fc26853cb845dc75e
parent 9c5a855f7d21d97ef83d8acd063a02aafbb5fea8
author MythEclipse <asepharyanalm001@gmail.com> 1779064026 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779064026 +0700

feat: migrate entire codebase to TypeScript
BVxtree 452583dcb0bb3ec8615c0c32d4950456b31849ab
parent 252b2e4a1f7783c7a6789aa5d4c086ab1d500a44
author MythEclipse <asepharyanalm001@gmail.com> 1779063802 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779063802 +0700

chore: configure typescript, biome, and package scripts
mY x tree a4cdd6c1e107eebe38d3711d658c0767bc3dc301
parent 434905313c1986ec37652a09f31aabca0b2aaa8e
author MythEclipse <asepharyanalm001@gmail.com> 1779063390 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779063390 +0700

test: isolate module mocking and add sequential test script
`[x^tree 6d3325bbc2799882aeca9668583b01e0efd79e48
parent 99b820709d01052527a3ce4734ff212c200bb67d
author MythEclipse <asepharyanalm001@gmail.com> 1779063305 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779063305 +0700

refactor: replace TypeScript with ESM JavaScript

Remove index.ts and tsconfig.json, update package.json, and install dependencies.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ӧ4x_tree d83fa65f7a2c7ebc08af96039beb97a10d75a504
parent 4996d7548a1b932d1fb8d01d46db3bf1f8973800
author MythEclipse <asepharyanalm001@gmail.com> 1779063275 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779063275 +0700

feat: setup HTTP server and bootstrap application

Setup HTTP server with Bun.serve and bootstrap Telegram bot and other services.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
˛xdtree 69ed9ba9274b53a95e804df5690a8d8245e6f6d1
parent be93a06918531e07905dc3d61a685c7ca99ebc99
author MythEclipse <asepharyanalm001@gmail.com> 1779063197 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779063197 +0700

feat: add Telegram bot handler

Implement Telegraf bot instance with file upload/forward handlers and mock test verification.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
o+x}tree b804e9db8018d05f26a686c154070ea10e0234d1
parent d915814277e4c30f21a14c8f52a9e8fbdd84e0d7
author MythEclipse <asepharyanalm001@gmail.com> 1779063055 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779063055 +0700

feat: add health check route

Implement handleHealth endpoint for Bun.serve to verify DB connection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
{xNtree 6cb43165ebf341a863b3ebea00aaee39d8de54e3
parent d7b8e9dd086517865ef64ae09610549bf1e9ca68
author MythEclipse <asepharyanalm001@gmail.com> 1779062969 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779062969 +0700

feat: add file routes (download redirect & info)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
jxOtree 8afb941670dbeb0ec568031a6d04cdfc453d72b5
parent 1e29433d5d6ccf3f3982dce5862b10fa9833bb88
author MythEclipse <asepharyanalm001@gmail.com> 1779062893 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779062893 +0700

feat: add upload API handler (multipart & base64)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
icx>tree 5b604121a90eed6694ec3b040754e9a197aca9d4
parent b621a73ca9a0548043082f989f4486b0f84d20f7
author MythEclipse <asepharyanalm001@gmail.com> 1779062781 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779062781 +0700

feat: add Telegram API utilities

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
bxAtree e972b38fb43515d4af00a7adb7bc18ed6d2df791
parent db71f2928831867a7ebfed3063cb04791a92318c
author MythEclipse <asepharyanalm001@gmail.com> 1779062534 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779062534 +0700

feat: add file validation utilities

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ReӞx>tree 96ea07962b9f8e5ae7c158b61e48ca87eae76c7b
parent 281329a1afad5d87ef17215963272e4c0a814787
author MythEclipse <asepharyanalm001@gmail.com> 1779062492 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779062492 +0700

feat: add in-memory rate limiter

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
[cxBtree 97422055f2943fe49f550c3429e829885216f7ed
parent f9c0bc076893eb040fdc59e95a9cbd7c08df5da8
author MythEclipse <asepharyanalm001@gmail.com> 1779062446 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779062446 +0700

feat: add Drizzle ORM database layer

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
eexHtree b96e8a268e19ef296429991b1ab4c70f424ed5c2
parent 77032538e8bffb263f793c974ead979ca771ccdf
author MythEclipse <asepharyanalm001@gmail.com> 1779062335 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779062335 +0700

feat: add environment variables validation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
"hx<tree 17dbff3e388f7c293abd61d88d72696d099169f5
parent 25c82bcf0b70907221eb56fb34a6631603c02388
author MythEclipse <asepharyanalm001@gmail.com> 1779062013 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779062013 +0700

feat: add Winston logger setup

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
0bxGtree ab5243c311cad0d5e631c4d9e27357f09d491d01
parent f423e1f25df3d165e3635c6992010fc84e6585b6
author MythEclipse <asepharyanalm001@gmail.com> 1779061866 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779061866 +0700

chore: add dependencies and configuration

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
g@xtree 67be35fa7bfe978a8a536d17f5da9e981a86ce70
parent 3fb3dc3d40c8351efd488bf094049b42df93f831
author MythEclipse <asepharyanalm001@gmail.com> 1779061733 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779061733 +0700

docs: add telegram uploader backend implementation plan
Ztxtree a2975f17297ac440fabe924e3efc6c05cb73c6bb
parent cf55f6dee8872e65c63e2772754dc2ae03c0e3f9
author MythEclipse <asepharyanalm001@gmail.com> 1779061362 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779061362 +0700

docs: add telegram uploader backend design spec
D!WPxtree 8d70b092d2c44596b3bb7f248945874a29f1c862
author MythEclipse <asepharyanalm001@gmail.com> 1779060842 +0700
committer MythEclipse <asepharyanalm001@gmail.com> 1779060842 +0700

Initialize TeleUploader project with Bun, including configuration files and basic setup
)W'x340031QKON-L/Je`<;5S/S˫BV$2HsU}ǤsJk.01 ̒Dk,VYUh]Ё0*LPlv<XU⪗G,3
}m;L<b~O>cIfL/vyrAU:JzOfy/{;7UUj>&MFwSUae^u
>}23<jMMRRr+3/ru"GvzP [_W&yߚ	Sb
js4u\60s԰t}ܜ۽2CmS}+NHM+.a=kBJ)EC,+.Jf=$ءd9gVڧ uJIjq	C6c4]rrjEIqr~^Zf:-+/p6v5 žx. node_modules
.git
.github
docs
logs
dist
.env
4?xmS]o0}WX&$Fע^,nb3۬Ϳ[s{oHIK2Y5Fn@Х4@kŶ^I,JĊLc(Ib:4C*nE1>9qMajlw^{qTFf@jנ;TVVm,~}v:Y}JV$/q^FbLqvvJ}e^Jniqr^,)CΊ<pw,z9EY*EQyNBz@-D%[=7 .j7(C_J`7([sBT
*;u2G58kU}[c/[@<A29kdNS-Jv>\Or\앯V0v~
ii{;bd%vSgX|^5[EUӋ,Zր"r^6fvٔOLoJ\bx6rLl)I8yJe-Yu\6`jd>qfi(Y!vfwnu+j5%ij~ټ[`	!,UҝdiEVJx$ 40000 workflows +	,v͘}H |35x& 100644 deploy.yml "<RQLBDxO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
2xmQMK1W.X=y_Id贙H6 
ϗ$q	g	8RDgMcqy`

;_/`S 6q&cl(ֈu*ޛn?gQiI5q(QLa`t#AW&|d415\l+J*WLhZȶ۬qlg-txx
bu$uHa!N$6Q*2ӳB䬉KhGnkursHCwU37ĴxmW[sۺ~ׯʝZHI{P3Iso츱:y!` .N<c^{-с+W!e|$[ɽzc/p?[*-S܀paON>0ۃF2əCO߅OLE-M'}d?B'9T©:<8o>>Q{%Vf*aʟR8	A0OڊDyK5x
8D/.ϩ?=0'ԵuWF?݈m	>v|tNK8~Ox>_'%6RϘMj'[=^a.[փ[cexɥtI?]ғCv*:O)?ڟOd!X$iU!OT	jyņ>1Y{ʽ+&(3]J.nf%ЊE52rDU@"ZN'pOH̭kaE쇑vpPNג*t5^Öq'tkj8$S`}7/?}{ym_onrl`3:a`i/J!`!SXw_*O+	6'v$JLI,H#rBp7,1ِRZPB2<Tr=
@TrqZfM0M"k6̄	N	^% y+K2@m(BU|%<*/:3O
P࿋ٗc[D鿲Rm֢n^BX<*vCH50z'3ε/hmZ"jC
GqZVxe۬okeJp9-:!B0DJuߩǥ)^E4~Vmr~b[ј	_	#ᵢ
}yG	fع"0=f)CueĨX`BC/x==Ǵx׍C<Կ@䮳*#2Be葖VI?z=RkK[t`H/GZ$W1o5[eJ'J=mUXNNXOv̡m.Cx{{$K{-Y%Q?O}`TݛN~?(g7X/	jyRr
B'!$<&+~dqYbivpYgE߁q[fmS9ɣ?[
k=~);c/g`DD8:J u+7%TDjy<z"px{*P1m݈QxwKR*\'_]8պN_ڋmBL+*2qak}D>p{#zeD^ٛ-lϯ26pҢJa_ʖ}4/ۚXfYԲ,I.+iXK,lе#+qWoJzsrh>z"͸UVhsJgůRlU~ٕ6/!PPD렴E@lײ1ء搊)@Xb9Ҍ;t(xS;'pyi`{fY{*'L <HҺ-eg;f<g&U.+xAO@+&	7
%%FSX-l ݙ}o}n*!346wu\B!\`cM	D55i˒̕u\JȰDJZgJea6M3<}U78jMs
#<mEz7LBWuKR$=i6=>J7X.ƐTJ}b(<4ER=BMX!pu7	ru.ʄu {PBlwxbU¿p
I-?"!KmJm=-.GQ8hvtK@!+x=rFv<ec493Z̊[$  β*ySRJm/O9Q٭TelӧO[7?g^g^E;qr^"fNG!{8a{u0g]GɥǏ7gÓꌾYt{I]#XfM+/ޕD1¬ěF>LDƛ.3?ub#eY|I~ʰeNzٙv6e^%0{Itìqg-nt%0dhn4?~9])x{&/8w[J]ٜK'EleM5ɾ;=}[m.JY~s,J+m@3qRentbK)ѶL~C_-C;+L&Џ n :ߞYC+K2ȜЋipI[D-KcD7.K"N`H"̱0B'|#kM?Nh|9{leɒhFL4ލS2Fa;I&r]x[9v^| ĳBq$.JC/𪪹+']/4wE Tm)pL6hG$	؇dvA$̀¢X8b*]Ox(8hX{$$Zf^ʉ"o{IOiLҮ^D
_5޽],\?)id:k tH`incOΒ:$j@$&3Q&Ke$Ҡ	DkDT	%h\طD@X֓h ~ߵ1,Z98SB2ҟQPN{G{Et0-Zݲw=Zj>f}6"/F-N}&-Qxϖ	
2JAf^MzfI{BIA¡*dC>зBgmΫT[mEXm/up5]	<^I#4{0P\|pnC";/b"`thp~vr:LoVbzDǞ	CIUOg7<uTSYmz0^y_ٽp#t 
=JڿH$l+ 5VBMQcJEƫo9)z!ۓ7Ãv_^CN<".;ɭ:݆9a170[ >Oҏ46@̡铝\Jdۓ'^Q!?÷o?wIQGo<?>!Qus4]'Z#Q90@ͽΆhw)h<`hμ23쇽bgC=eg؂kְ¼V,4WCQ_*YuN&0][ږkq|Ԑ5s0#轣wj.NNǰdoнF! m[b|zg1KZVir)
(4hZ(upKr]?_52QxKtclf `1WWHpel2dmA?c7ym @eXh1	+q-P~!JScxjg'΀"ҥ7ْB{0mǍz >sF`>gto
r,&\Dt0㢫p5.1x~֤EdO)5l;u[qpG<'Q1!iё.[)趏YG%95dE؀IC`)hcwy4t2gx10ƹL.ʐ?cb0Ø>*2W0K>f1̸Poe֊BҌf2tEFK̀KqvXVPP5@DLfqݜ_ڀkEhSjulk:Xoؐ[U:AFCՅ

# "xLx")~58	lW\q-t3\Y}TS0:T?5Qdv@ɦo8w"PX;z/I$>2^l[UZLS5DG .K5"@K]c'C5
<JAqN	%BXL QBZQq~C/@.5bßͶpʚjxkPp٥6hCc^ex(3(, pڃ(D0Y<K}˚٢ D
}ްÕM4( B󤀔 RʥZ<]`ԫFû AP[)e\sGɭGP:1ʧbIv
jC FUJ<e 6<Wdx}4rcUMֺ@*	nՋ*/yr=bȲа(E3C5fK+50p^RT'`,c(6YG-yStKC~dƖ!%-=.n6	}Ā{r47.ZA*a7[X摠Se:Л>h$4qDפX6S}5[qnIrq)DSL7o]su4ϒ^"k_*
'	"oTV4VYRÍͮʥҀ(*hH#cH"VsP%4QHl۶!Ė/t9Ia3;m	dʈt.M]n{#^B;tn+:Re,(;6WFpȌ(+,spzgk-"֌s .)aMhL04vYg
:ᙷ]aI(jSGVw-X{jُ:,ҷ6"'0g$P5'h'٠5pA0x4Ns[R{&
*sj}1B2\.&'WtsU&!aq1< 
N+|$=|c'M\0j%rI{btH	6Rqt]', |e-2ĒL*#vBl>%#i"}]#JTjkX^lt(P˟ V9s@VʷUZL{GCj]R9vM
f,ZP۽,N	t(8aq
P8y}pDE12r'u۵v0Jhꭧi 4Uv󙴛'(.|G̉\טPF7Uqb/4i; i#x	EUzmks0NЮv0<a>>3`ϝ{JcyKLkƃp_ah쫆"OX%.Лkq[l)joMM+Jxu?Xg/G"[krQv!I 9Qw3<6'r$q> 2	م"Dy}E0p+!w@(Q.bHO0,M"ձ8{b%72V~Y?;^s!9X,-`!rݻeXPg*\*M2gЇݲːu[}ES ||=<`$ n4]]fimy<"۽#]/ڠ)g׫jJyce9!ߺ^eE)SKqA)K;cJ0Vl}xig!9jZYOfKE501 k<496уJh z	ii2pIrTu4͖1b*Mwe(\4x
k*@)I K؟.eφPMUPPLIl1h#Mgm,=TqIe/Q~߼ E>hLx	^ O32AenX#{Vg/"Ihb=M~|EU)Xv$C,3ײLkYV,3+YVB,3NJlJ5hyY',fVUvt]QJUegnj7КX閑<XVG"ҡ0O}j+[<]Z,Xz:89;:!]}}[,Jf]~Ҫ;.hsJЦކSfϗe[e;+GDdO}-`;F~/>ތAq0D^{ c#tӤw؈\vͩkrIxs=:-P)>)6NdBHWdl,]~Rl:g8oځ~M8<?i_6BNUǷӨ+:pS>v%jE @?X\+eJ9fi`%?k!D eG:*qȜTN* 3HQc4sKPЯd؋B'NϸF?5(
˽~=f[=d8&{g=ޖq+|㖅`ǊyX㐢GV1kz)CR9}@#@&VI&~VUvc'״U]0|WO_߯ȮOprɭ^ίs}?[ሯ'PYވZHc`NO-NJ'bcdML)?s[-wU̘*#oýv1jR'	&"P̼<\PfޑMD5 .J6E5F(l*6bi\OyuP7[ZC		>rMD7pM_%E\*>rAo;2z-vh2F{q{
kTNVĳ PWƑʙL~϶,JTɅUAie⬏6AP%~5P+qOY̭M5%'gfLn;IܾZ^\xI16 K5TkPI^Y96䔕qI{reLA
}wORac+W3etи6>wi}	!lln5ؽrVUd͖GܤX'#C1p򾰜sf|nvO-bªBhR´J<ku+u)ƃI2Ὦ?jkVYĉJ5e+$a>y_g,ގ8cm>,/GW݋rWcz!\%xg=LI9gh=÷ͬmȵr	QK;~-תo\P:iG$h3ec6W;)0<lFPiH8 ZaH	Y%eh!Ʒ%nMo"
FW"[XϢ1⍖]O ndz#K6Yci>= 1?1<f>F!&gVިy6zIb>c|=)n{*	u	uA,wI~
JNM25%_eH9Դ6=<؇%[uhm#+5# az%Qb+moÌZ6uom,P'=܁;W"ZF<UYۧ%Fw*hzeiqp0V
+.M=жk7dV~~rWC̨%R?<<jMb64Ƶ\#~&es~WkҦWPT{X]gw^j\([t(Fp^o;rpWFwr7l+ctd{ғh/+.eZgaݕ*M%RRYpʶkj@*F7f}HrԹ92BI2.`Y/=7F&ۊzWqsu5lͯoQk7: G|
Fz qO6_sPj'^ʛ/<Q~/.:<Q{`o3m}
WaMEfcwc1/+w?T	2M,X6of}zރ!_W]("rK""{C,bAnEđMBQ_><^8onH 88崍Vy^C/*x/U9[}ל|"8]S<"^¹g,ӵ;\^w|B^?`ٴ!^-|bc%`9ЖU ube[,l$V-/$ei+Ud+mޛznޱɚJZ
wl//l?G6Rͫ@"򀤶!Y<SEB%s2⒚xI}>3?>%>8ߊsJ_x8j
ͣ'[(FjzSrzssk$(#;HLOI=l__gpfZjC2qX|+o75ejzDm,x
= =J[| q\L?,]~˧ssCM=芟Gt{R}%68<XI%mWQ˜x]8o(7A~CIi F(wU7(6M-5$SVvDnQW~_dL"s(L,AyQqm~1@2o5ɿ_|LCsUc|'Ɛ>0wFi#dL=Tf#eFW1xER:йpa:EPt3^f>YlUǈhUx-`W p|wtgVߊАQ7#OY!Wsx|W瀇.U*<C΂nzZZZO
7~WZ'}vFFn|c6[G_VE*8Y+R!d*NE 2HL6rUR&wc3ԑ,F}5-x{ށx*4	w{^{kU_kUuz%{qWĻp>9LГX*ċi?x	9>Odg߼[B[zK.fSup@>MLMgoE[U/! _H~鈌>*-Ʃm߃
Gwy7%ߪɶr@b5UH'{zc_Bw^=:DB+/X/]p_ְ4Oz;
y`@|<WY
B/%3IWϺd$_t d/iꍃX?~qOW+1 ~897-APa2~9ɄuSBc[T~HQss	aR~%{/O)5l-wk>(d}ܶ'۫NNM9c_ t5бɍ㵟 ]Ozܼ{nx +ɔ=bH_!k#$B:ԈJІwj;,<goM#1{E$ܵ$rkTQ%m9_U'ޕ]b5U ennE}˽Lؓ`X}	)[6{2wDN:GPLTS=QAk(coIͮKzby	QȩDCE;o񒔾eoZ6n4:dS/tśTnWƇPt3ov2QYɵO4ijуl\=čƩzV=2GE]ers}׿V{:4£H>)O	VyRӘ}ژ:o)?#I1%ger0FA^n6oҰ9ت$^ʇZI;.LS^ꋷŵq[=l#ioCK[u%q-xƉ7ԇ&'X S;*cq^!vwz:_uVQGJLGH=Xz
O1ywt8rAǏa=ALY]4gEe|)՘xٵ7xݡzb_HL z<HVP

.ȿǌ"Uzl$tG	s)a9Ǐfl[x #'xu{
  "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
  "assist": {
    "actions": {
      "source": {
        "organizeImports": "on"
      }
    }
  },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true,
      "style": {
        "noNonNullAssertion": "off"
      },
      "suspicious": {
        "noExplicitAny": "off"
      }
    }
  },
  "formatter": {
    "enabled": true,
    "formatWithErrors": false,
    "indentStyle": "space",
    "indentWidth": 2,
    "lineWidth": 100
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "single",
      "semicolons": "always"
    }
  }
}
ƭhxܽג뺒 >_qb?Zyczh$z"e(ULߠI{1WeRժOORE"P: Dn|Wug?T1
nKv\os9SG'~[$z~gϐ,g:I?~)U %Ffgy=g%Q~LݿR?몋๜	7n"'>T(g=}{UJ}.^Ig͏}Q?Dyj3+f,rLqԼ
ϿvSn/Y-cq6.-Wu>?<1
A?᳖φG#.WP3'2{L;}\Q"ϒ᳊'XS'*dlgnG80%;KCLEگH@j)#۩d`0lf0 ͋#r1TL"R\Ac˿nExaw~w$x3{7V&EwѹnnO_u]_y^v?2'TB a?6iDM(߸Ᏸi)^'u_=1Q\y#"j|V&۽x%Nna9\0bU; 8N- L}Jaϖbh0sơnR5OF*ة?%4o^o>޵NNׯT!t3v@,n "omew'J5'j̈ȏ ˄Qx')w/	qk3h}iP~ݨ>k+Bkuh[|(-n_H	`YbB(SWx[L7`&xAȰi.xp@e1^OXowTp5v>9C%tA78N쩼YjuݥaMar
bZnVWEUY7G?=ʽ4v/_~B[eu_}G\ڌy⛼DÚ]ͧޛ9c?-znu4'
ivvBU=e'@XGN&^nyjUL{(}i1KzmKSolR۟ׄc&54/&ɓRtpFrkȕ:,($1)Yy[љqXmU&"^=(	9KCzk{0]3[袘_[:{Agy,<1)N lԟlnܧ,悝4ƨ./L_:( *)5-qk9D~nz&s)u]niRW4mS,cl>9e	RJ{r<bԿgR;ψрBrFvrtLغt
mN޶+ՐR XӪy5_Q7씙gi*6Onzߨ65
ڦšϰܩj'JG41}xFb/ gQ@ȕf}/&f17F&O\=_$OH?3@0jK(ܑDM;A!pGpgBG@=Riڎ}	>~{4BOcj)bJ W	5 XTr6
Lga%8OOU󻭷o2qJ@{_C1̻eƀ-qw(B9QGE-hWqY:pQZJL"S/Vч{X)!)9DJu?q|'c׉RAT}r&v8 mÌFpTyNRWG5&"[w`fc7o.TCqddlASmRMuKԚڮFwZa|PmeQtj7 $iEa~?<\']*nLG$pE૭܎NisqjAx	9P)&rG}'O'/I1R e-vKhsvabQ'>
X*+rDD,gaG{-f,t13XH}5Ǜ%7}Es/vsUe?*%;_CDJ~[ֹkߢ~GWvE~KO>Usɫ:oCP2ʩNtXCvAvX0Fu@hVA"kI%$c	rS!aߵ;|FS[O/U]T-P]/ss=&{p׭,S_VN]ۉlgH1!%D'5U;/p˂WTǥY(4,\v%s{T͆T>,*4j7=L9d,rcx	;_*'mZ=pS*+K6-+ChU"P(y)mjIpi6",D.W \9wL=ce;LِBErY3hm ju8(ϫ١d$bmM{tl)<dM:{NSwM^nm:fkahJFNwfbB-ɋadw(=dϺ`ontCQ(qr_x>NGo449.Kڡ7AdyC" 8lB7hTՠ.?U)}%^Z1ʉ~~	7_6^ \ҽ]ρVpZPdW;%%)!ήFXyFR' -]k{}[LLŗx:}_dy3lT_zX8Em*+m3`	̬xBw S7H6iBpn<ɫ3^8*z~=_I Z.
aԘ[LCZ\KzێUےO=!f,#fIz~Zzwy^dY^7JmYϯuW	=V癷v{m%n,t+L56kՉf$x;΂Q0WKӼ++0MݹuDftT)6q&r$Z1oiqoSg{z#O䎖&Y{ie]2Yn,8z8p\t'FQRyX7(1[enݥxj&ۂ_?O&g?&J^uuvu?\zI~<`HGE^e?FEnYOYY;o,<:P/襣ON6̕@3yK ٽc.Wr=<͒Z~*Χ)/	]sU%9˽5q~H恾hEH:>OCu\*uZWfs|[nJ+hGÏpߺgDʗppɂGbvU_zA%]Z9nج΃H`	)N0h̘rp8-OXȼ*熉_OxAJ@-wK3ֵ]bC{&ӶN6藜m` ^dY-5aƵlr{XQc>.A4*l9 ԉ6۵mڏ DYj66ІQf-ݒ1>cf3q $9apo H|ڔA"YN&m]q7{؋䩕Fd|ӻ{XnN	1qՍC017cuRN S0,b1kOwNuncKI/y9wՈb.ofȒFg)Gzuz~ɅUyq2eiY-,F}ǘULǹXonK}+җJ	hf8̤·P6.Z\	@׾o<&[B0:عLQf17ɿ7?h}Vn!=244v-f!@gA󙎏ǙkH͌Q|D:aBlfxFx7~qdϘ3b!9~daIyw^T]q#Iܼܧ&5OHT$nYAk
Wxh!Vp'ظ&%Wmfo=daADŇPr IRUA!重6VbΎSg."<MSwxj*=Rrm3GUI1,F
 B}i[5Ϝ=*T
1fr^!C_M<+]^<x	AT*ܦ;KkO'[v5YP8sLt07V5
C9	hyFEM`~r7_&Ji)Ia2lj=
9khdq^(7!lp,i_5;||Wİ5usS-m{f6M>bomNlCKhyJ&	^2*>+C5,M{>9>kSr[s'ͫ}2| "9͓
HO(Qa!j!Xw2R4]>^jdD`&M< 74,RZɶZ.`4c%$w3)5VKnԈU[0 
~-Z };|֛Jl~NRzچHS1U^4Ys$%۷*f\ΞJ9}]d~r	g+j(]Lhp'3!4lpE6uGK`׶Q,vgEjr0qn;z#ß+UM.nxxN&}D=ﻘ
!l[4H.0NGn6]ښR l;f(6aQ̴ODhBa~+g"}"ք\39X+¡ml(k39uIY;O21'Ҧ j#!؊wZo4E=&>հ&yV-INBM#U쓇hk!с˵{)cjs6]]}Z)'G\d~+y"<y6~+_n/U
wu1ꔹӳC7#NcBT!;Vnwk0^a貍qfuŔRY^T{g<<#|M۵LmgNrDDѕ1CyO VP8d.嵖朎3e3a}||*U[=89̳m<C=t3tHT΋Ưs<Px^BjGv/N6 uBG7Wwq)Y^L4D;gϖ$[~kv29$v6񕌫XDfeaxYP]n-N$jC^T,wrg,=c9Q)|L4nV/DoUǘفuvc|^,yxݹ!] 5)-HcxVR6)\D''75oԅh Uy $[P!!ݥ9eް7UޏGxAD])dF.QQ$Ju}PTŗJSHzM~ĀVl\VNN,'~Ь f*;M؈Q*܂tC0I\qڕn%*/S?Wrc5GI_l4>ǝ#}
$K@/eu2yDS.xsL"յVsǻ;4o	8cFXDF}؜`چ.]ř刵&pE!N402bt2Ǣ7nyRهgy[  
LH!a8(mM43453áT,I"c+!n:9zyǲ*5_4-C&8jK0鰠CKNRVm-w3IT|tpOTSS`٭@}
hR%țM#<cH$DvxّlL!Wz8J0Uov'_2*g]pfLdYCu\)	Ox՚E8z\Mn>hZd%@b}W"R?><%ˣ<m,X-69iRDokNYV+87H^1[jچR=D@W;`?4~1F	ެOyxxy*U  Ts}`%"vԱՉ.Q9ꖳLMsyV%41:ed.r/K~Q_s0
d4 6I.lʉPN>[,Z`P-e` 8%54@_k`oNF$XyekH͏
?'\ʑRcx!-UĂ52XitRd-'{jNتM+v8c9U:.;]=NBuń?0Lbp"y!|If~$21@EgN73/Χ\mEnaDٍɳAbC9m4P X#K1[]zKPM4O8lwҥd k(3WwiHo\]	}r~M`*FuѶ8pef洟ch#aZ>:{@6 +N^5?<k<yo錩Ʈ<:wÿB2Yv:h  @"[Eȧ~	[;k3VS%U|ZhW3'WKa7\2#@,쐡<+Վ+|/[FeQ	:S=ush,B_yU	=>> ?aWxAL3駉ƀwM!·P3aCm^ٮо>ǊA(93͸ToZ!i*sJ,N{<5;I\-Z×?ڬ?|E8FI
|vQzVd)^p97bϽvR1jΫ`~˳m2F̦ϸEn=WH+OBƓ.X'^[|rv3ƣz]* s' HpL^-螵Z3)-WY=^?m풪K"x.ELzZIxl[6Flq6xTR?Oo-0|U(V1kvĆMG. S]+Kq>-;adMJed'|hlz!u>7Te?~Vo}&}E]qo[BQ y*4bwʻ!)s{g^c2|-^-m]m8pU|E􀿜3/v_WumA{-xsEJEk>*Fyono,\y'cB=Z.(|.yxkN{kN@Σ\(F!q>	A[d
	'VLB]0|yMKG醈{M]xx}>mnH!{:'YoKyC7`8x#׋AnR@|
E] 3ǫg4oȕq$O擽F/Z5+ƵD},vӘ&Q1ŧ"LtE3\Ly/ߧz=[6eG,xMѧP\hn"5n}5VD).)>NIC[^~ѽJ{~K<gȩ@?2^gwɿ_l9\=7i7]m*n\2,\ñ}`CObs7_d9*o[\FFyQOH:=.ގ}_՝7V^ó9HM;&vOU}c/rN|PuED	Co@u%6Ǯ}̝+xO-<k~'y9̙賋[]D.ӫsP$v7k'>/ߟ?7<KWMt3Y-˒3E-mQ7vgw"BXOY%?-=巄<{+/PW2=ԯ3g/s55 /lyޱrM	H})X?pk_g6ѐ?1ޯK#oGK?+`uݱFueM7j<oV`~ʋ\Z/u	{1rrNltN_K3ȕs]lxBΦ`823(
g̯CV6I==? !hZRdڤM/f9B
oԳ30!ȫbd)SQJbTvvM`|gvqQ'B~zQnE-Y_"}Qe5m__ ]~2o4@z~d}E-f_"쨻)Gwn^+_px^O3^;3}!XDk=υkT$;C=z۩n!waItm(}.[9Ut5ϸn|X<m@r2s`7R ︵!3@-N8]IRk%Y!QE24{yqV(iAQVz)Ҥ^#oI:Cz.⚜)Um&`50ucB;|VxMrڏ=?hȯ9%!{Y0bA]ʶD$9 S;G!;cV4)Le
D	7A3隒Әod)(t^yamTkf@]uUr-j?W S	Rhq%bt45⵽GRr{YjڐsG8*M3;Hwz,>ljY
I,u`0>=7	4OIx<^T-}*xZZþ<E<mԭ?Ҏk 9kt4$jjs
>.Z2B֡ik2PZSG".KR2?FPh5vi$bmQeJ(t&7YxX&e5< 20ϘT7sq᫅%6*FSm}홢{G#)Z'e@\74~3l,>'(vFe;1/mRw788x4(fUS*LZt{wڬ	n99v>-E-nR
VT;)^My>+Ƀ35dܞ^iwŞu[x"H2OF=8+wbOcL['M&{ĉ˰bsppWmR<Q
"q-8fG:==^8^`ZAT=X).JTĊ]T-yl]{3ƅ2|	;%jǘuP7=RתJwr\f\$S묕J.,4x>hj[]k|D,hA,НNhx< ybxy!tHQ06
f#3w&zB4ENXbK*Hs#zZMY|WSz^~JAY{oq}{c4jbI{wm xWBtώ@q= nW	R3ا#⭼(0AasE/OsOJC^/M:V\/Uįp"C[syvAГKso԰%72.\mjF10h*eV8{::p+%<d
ۗřٝss!\ͰeP`">=Ŭ3wGXA!'=H!tk٦<huU`"XMS1D	{nxE㯷'=Uxy9V̅rG?Jl/X˯	Si#i`@4Sen}(@Ӂ _֊2}2Ry*&w{^IaׯC&<	>2>mO2 $,O/ܫx:LHEXVp(kP[d&
	4DNCW	#SyZq'6k\x] r"vkNM*DF"XsɑU#<֛+~nKxV^j eQzTnU"p9O.u9Seσ2c9ĳ J,QXet>c0*
B%2[	e1eFswس[bf\p&qճ Bu@Km6ErϬTuC@B!v:bqn!q<=?j$| `(U̮t[axy*u|O[1FA7F<]Eѝ,n>^?W_4yu4*iB2`U#L+!ǔ2lG!éyFo1|[K5	0< ˅e[L]U7N;ٹw̳_3Ac~kNy5E՞zsU[-ޛj;fiKHssC]vzOhX v/R$jWW#oGUiuM?k"<rѩncz*zTT{aVQ1F1=8SүE׫;?F8h'j"zG-bȎ湏M62܇Rp|e32L}cpt:4J&sJfy7ǛOyPmNw4|je%FIv%<mj̞Lt=w#<^6ݎ@0CKߥDo>?(VV9TC1h'~f@[:VPJ]}Nl>䶉<^<S6CYrIٮ29`8<;@X"X&Q>I)0@˒(u]ǏϮskSSRВUQZy2>ͥ D*TslHvvdخKfN|bmm>);7d_u^P^M3m&tڏsE~
QW`ElG9|sC9#{*s_MĲ):aʁChSscJO|	39[Q{=G{Pij^kfRtJ9h38F9eV366̱qcbPP^*&h@8/`Ux{&<F6̪@ɮwVT~kncܤ% 1}<r/#SOmK[px-/C_,d>`(6G$.-UN~ϻCRIqfn{VfAnNzyxJܹgZZN5[k#8;zZA[ wͩiͦoXT]Lڐ
<q4#7l7eCȽD͓BfT	4ѓBf~]|vWKTKV6qd4N]dIbπ><Z	y_{n}S`m;az]|om"%M*!uܛғgRyS"	}Vm~}f}.|SzV5=-vƟ8+na*;⸷d$^l9@{1A9l&!u hM"{@Y*Iq\IYX$ZJB@{n۝m_m{ZPRo׻"9n_\Xlzcz>s~3tWwAOvΊv#Ө6X~KGցQ>lJa^͵̀6怠p[Fki)Z7d݌D7E׳>U?ǿwz06,rN#q`żlcDE[0 dq<jvڶ {ٿ..|yZfbXnwv<_^m}AA_[&ao?x3z䶟{'nW/G9O??4oeBme(%ɂ(ψH]-j-\n3)h:TXcR8BjרPI&zySomr7643$=VtP
@\'n^)p-)̨»d*RT]s0⅖s_;n-A$kb+kO$ю*,eݎ(؇&Wq0.dT300OC>
Y^P]falCFgYx(TR029K6mhm LS4CytQ\xYsJ<bw)ɥEKaך2"-cftH8\zv꾵!\-
!4%|DSL=<{#^FU%hc}Հ^n>i<.s1zZ1]df&gYiqf9D;Dja	9CB3?'Y	$,"l/Czr_g'@];Wv/ ȋm)FP {St؝PԸҩ0Ԭs\tTϏ0Cs=J7{
!ۊ3;1KBXM9m!e -h*`MQnsNr׎z}Nu]~ZK +)i==wWww^­5.`+%c]8;7w4l;0Kmg[V7;7#{z"<GJhD^},~@?A IDuI3%`tJe0fO۪FVVH[W_j}LCƃ_9WKz^{Fjw~loNK,k!&:Tأ8p~oCle9>PXC"3|0𒒚۲&Ndy	JQ4n^ny}~
(.+k /sn]/Lxۻ܃ob)x4NvYޮHw=}'?opB尔}: J+ă[r![£ĺ.q(+*ྡ`;Fٞ-LQ-K^Bw~_$Z^J{V}l@tEp=1*dkܶtT;̌4n=eZx[	I_іdmʨqZ@݅XRݧgnΞj/#0ewNi!ԋuaS= ~^
|;/6Ҟ?G>G)I{znҞ@]R`?øJ{:3PҞ?CH{ݦ4&	#$PM_HBvr}oSE,=&Тq,W,[:>p"ϾlVj&bJ]'$Sܝ%d``
$(k+CźêTZ,9Gp#"K=tDƴuYLX81+ۉ'=ru_؈}_`S;0>A{[0>SE*}qn6_GJ^ǹyqsw	uu׵sqwq}/|}FzziPPXfZ6B^`˓ жGvU4b+F.gA`]e)Kgce=n7/9l͉`taUz?IVM(S,ᱥKpġЀӁIrVj3|1VI\3_n!_7KP`d6<S_2D3QMMeTQ8e?dZ%)9-x*J6˂V vGfq!NҀĜIuc=/~2~2 g0L$׺6:L}'͏탹$I<HY;ɬ y>m@l9{wyyŃ!QaJq-ua59<<IubYw3;8T>.Wj/xTVݸvo	;	%Fl%]b^^Ăg-4+&CY)9a CݥuL߼l蛬	vC9voêk*sU^\3{=agy8suT[)Dʚ=5~2L2"wXe%<
{gBk8Yy<KF]a.Cq
!EAv$v˥@)vuF߼l; 6\[vݾWBG.C󵺐ItDqF~SC$B
L!z7eFb):7$9Z6B.P*(bQkqMLВ<@`TD2MIwlpˎ뇛h2kGf%S@S a43Ȓsw,IR1rR`ITFU&߼qK߶Pmfmѭ!ys	`IVMh}dbbGG^->?^7]c2SVqI2Kx#2hu[?rG"OFB$41o]ה3qqK@1x/39>|O&tP=-N[q%I|0	AHMhIBD̿5g:YYY8'϶.jJgvΉMuQtl	a}jWStxx.SWڝ\&QuQs#S%C;sZ/DcV?Y+ckS5TSsIII˭f7>xs	.FR03ڜNc֯!;}퍩>C_>G?B~PU78E>w*Gr~f%e|CDjՠ(}\7ܿsL'"l^tH9.XG>g)ElK.q:n*<0>wujpА

j/?[.w~iDt
GϾp`S9XuM7C`~WW	"e/nc劵h):OS;ێ_~<1%a* gom-҇w6U*9,=0u~
UxpvnDd~ǞXko^okPXBy+H"hs>4_?[%o+
'u+<v/!$~{6]Xq"г3䬥q4j,觳G68s"ῦAfAxt|.XЌW+KǇ*WO860$d',1LͲ	>)u.i׿(r{u<tM΃GO=x#n1sNx|aC>8lVV
dgU@lOp/D7\W~þeh9xr=0DMcHs$g߀78hW(,1G0mͪ3`.~{y|jUgMsdA0E9shAf>sl4!h-2j))q#kE^?P]9BAIDa}Į6r{9¤5e"k&a4; 1s4>#AN:C5_Tށη ݸbD
acEݜɻR^TQ):1%c3{DmmM<((@;^L[Z܏XndfC(Rg/eܧ3huhYY	BRA:]AH^'(Dk`@_YNz*X\I)fp+s<Pwa1vX1ќ&$[;iXjR\i>qj\NHV^lu]epZf7gmk4JN)yJ+#lުGlF71o|_#%B8Ebp'2wtXL(lQD!Mb,~|*t^uwQ]s_ ilz+-ٚa/)_jې>#_,MmgHi1	2{69~Gg~tOsdkT0J$iuؐmMֱ"@:Ef?Cj˽vy˺'i?IiJn2
Rc!^_vAנWv6I!Q 砀0Z˵RɳW5ca!\TR:םUB,R5#Ue`]I\ۊny(?ćJ}PMKizG#-u5ʔoU}8Ètjŉx7qaｪ>}r&	B݇^7C\T-69`7&Q0l)tYr®{ł~}27+qW*jQ|/uLU3$F4tZDB,+HuLafq9)蠺O3u!LΜ:ᰖ+mk>swD[# :Fv{lF9..ԠD#訴x!6\!?އgV(lYR/qи	|4^)$}5Tׇ\R.8̮؟3hbIO	f,"XF#Dy?SL9pfO"8J #xFmdɽˁw՘~-2iZߴoyr%Ǡ8ލ(/(E/c)F^}FQPI{kџ$D]*\4/py;J>{t|u~F.;mDYGW|x	x]4f q4ƅ!N˝<;CbJ
3`**wi&o+̊lkcà.4W?}ڴ|gyq[BÀ(^_{yfP=XSs+l^bP]: _`]ѷѸˢrGLUMyNtSnDLD<"	Z5?dTǢY"?U
8ߞ;	-`: OVU%]`f	l1::7A3+}gt^ߒ o`}x&[#!/bin/bash
# ─── FileDrop Deploy Script ──────────────────────────────────────────────────
# Builds the Bun app locally and deploys to the VPS via Docker.
#
# Strategy: build dist locally, ship dist + Docker context to VPS via tar pipe,
# then rebuild the Docker image and restart the container on the VPS.
#
# Prerequisites:
#   - GitLab CLI (glab) with active session, OR the env vars below
#   - SSH access to the VPS
#   - Docker + docker compose on the VPS
#
# Usage:
#   ./deploy.sh                          # build + deploy
#   ./deploy.sh --no-build               # skip build, just deploy dist
#   ./deploy.sh --help                   # show this message
#   ./deploy.sh --check                  # dry-run: show vars and exit
#
# Required env (or auto-fetched from GitLab CI vars via glab):
#   VPS_HOST              — VPS IP/hostname
#   VPS_USER              — SSH user (default: root)
#   VPS_SSH_KEY           — path/contents of SSH private key
#
# Optional:
#   DEPLOY_DIR            — deploy dir on VPS (default: /opt/filedrop)
#   ADMIN_PASSWORD        — verify health after deploy (optional)
# ──────────────────────────────────────────────────────────────────────────────

set -eu

# ── Config ────────────────────────────────────────────────────────────────────
APP_NAME="filedrop"
GITLAB_PROJECT="superaseph%2FTeleUploader"
DEPLOY_DIR="${DEPLOY_DIR:-/opt/${APP_NAME}}"
COMPOSE_FILE="docker-compose.yml"
DOCKER_IMAGE="ghcr.io/mytheclipse/${APP_NAME}"

# ── Parse args ────────────────────────────────────────────────────────────────
DO_BUILD=true
DO_CHECK=false

for arg in "$@"; do
  case "$arg" in
    --help|-h)
      sed -n '2,/^$/ s/^# //p' "$0"
      exit 0
      ;;
    --no-build)    DO_BUILD=false ;;
    --check)       DO_CHECK=true ;;
  esac
done

# ── Auto-fetch credentials from GitLab CI vars ─────────────────────────────
fetch_ci_var() {
  glab api "projects/${GITLAB_PROJECT}/variables/$1" 2>/dev/null \
    | python3 -c "import json,sys; print(json.load(sys.stdin).get('value',''))" 2>/dev/null || true
}

# ── Default credentials ─────────────────────────────────────────────────────
# Hardcoded defaults for this project. Env vars take precedence.
: "${VPS_HOST:=45.127.35.244}"
: "${VPS_USER:=root}"
: "${VPS_SSH_KEY:=${HOME}/.ssh/id_ed25519}"

# Fallback: fetch from GitLab CI vars if defaults are empty (for CI runs)
if [ -z "${VPS_HOST:-}" ]; then VPS_HOST=$(fetch_ci_var VPS_HOST); fi
if [ -z "${VPS_USER:-}" ]; then VPS_USER=$(fetch_ci_var VPS_USERNAME); fi
if [ -z "${VPS_SSH_KEY:-}" ]; then
  KEY=$(fetch_ci_var VPS_SSH_KEY)
  if [ -n "$KEY" ]; then
    VPS_SSH_KEY=$(mktemp)
    echo "$KEY" > "$VPS_SSH_KEY"
    chmod 600 "$VPS_SSH_KEY"
  fi
fi

# ── Check mode ────────────────────────────────────────────────────────────────
if $DO_CHECK; then
  echo "=== Config ==="
  echo "App name:     $APP_NAME"
  echo "Deploy dir:   $DEPLOY_DIR"
  echo "Image:        $DOCKER_IMAGE"
  echo ""
  echo "=== Credentials ==="
  echo "VPS_HOST:     ${VPS_HOST:-<not set>}"
  echo "VPS_USER:     ${VPS_USER:-<not set>}"
  echo "VPS_SSH_KEY:  ${VPS_SSH_KEY:+<set (${#VPS_SSH_KEY} chars)>}"
  echo ""
  echo "=== Files to deploy ==="
  for f in .env package.json bun.lock schema.sql Dockerfile docker-compose.yml dist/index.js dist/migrate.js; do
    [ -e "$f" ] && echo "  ✓ $f" || echo "  ✗ $f (missing)"
  done
  exit 0
fi

# ── Validate ──────────────────────────────────────────────────────────────────
# (Defaults are set above — this fails only if something went wrong)
: "${VPS_HOST:?VPS_HOST resolved to empty}"
: "${VPS_USER:?VPS_USER resolved to empty}"
: "${VPS_SSH_KEY:?VPS_SSH_KEY resolved to empty}"
[ -f "$VPS_SSH_KEY" ] || die "SSH key not found at $VPS_SSH_KEY"

SSH_DEST="${VPS_USER}@${VPS_HOST}"
SSH_OPTS="-i $VPS_SSH_KEY -o StrictHostKeyChecking=accept-new"

# ── Helpers ───────────────────────────────────────────────────────────────────
vps()  { ssh $SSH_OPTS "$SSH_DEST" "$@"; }
log()  { echo "→ $*"; }
ok()   { echo "✓ $*"; }
die()  { echo "✗ $*"; exit 1; }

# ── 1. Test SSH connection ────────────────────────────────────────────────────
log "Testing SSH connection to ${VPS_USER}@${VPS_HOST}..."
vps "echo connected" > /dev/null 2>&1 || die "SSH connection failed"
ok "SSH connection established"

# ── 2. Build ──────────────────────────────────────────────────────────────────
REPO_ROOT=$(cd "$(dirname "$0")" && pwd)
cd "$REPO_ROOT"

if $DO_BUILD; then
  log "Installing dependencies..."
  bun install 2>&1 | tail -1 || die "bun install failed"

  log "Formatting code..."
  bun run format 2>&1 | tail -3 || log "Format skipped (may be clean)"

  log "Linting..."
  bun run lint 2>&1 | tail -5 || die "Lint failed"

  log "Building dist..."
  bun run build 2>&1 || die "Build failed"

  # Verify dist output exists
  [ -f dist/index.js ] || die "dist/index.js not found after build"
  [ -f dist/migrate.js ] || die "dist/migrate.js not found after build"
  ok "Build complete (dist/index.js: $(wc -c < dist/index.js | numfmt --to=iec) — dist/migrate.js: $(wc -c < dist/migrate.js | numfmt --to=iec))"
else
  log "Skipping build (--no-build)"
fi

# ── 3. Ensure remote deploy directory exists ─────────────────────────────────
log "Ensuring remote directory ${DEPLOY_DIR} exists..."
vps "mkdir -p '${DEPLOY_DIR}'"
ok "Remote directory ready"

# ── 4. Deploy to VPS ─────────────────────────────────────────────────────────
log "Creating deploy archive..."
# Build context: everything needed for `docker compose build` on the VPS
DEPLOY_FILES=(
  .env
  package.json
  bun.lock
  schema.sql
  Dockerfile
  docker-compose.yml
  biome.json
  tsconfig.json
  src
  dist
)

log "Shipping files to VPS..."
# Atomic deploy: extract into temp dir, then rename — avoids partial state
vps "rm -rf '${DEPLOY_DIR}.new' && mkdir -p '${DEPLOY_DIR}.new'"
tar czf - "${DEPLOY_FILES[@]}" | vps "tar xzf - -C '${DEPLOY_DIR}.new'"
vps "rm -rf '${DEPLOY_DIR}.old' && mv '${DEPLOY_DIR}' '${DEPLOY_DIR}.old' 2>/dev/null; mv '${DEPLOY_DIR}.new' '${DEPLOY_DIR}' && rm -rf '${DEPLOY_DIR}.old'"

ok "Files shipped to ${DEPLOY_DIR}"

# ── 5. Build Docker image & restart on VPS ───────────────────────────────────
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..."
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..."
sleep 5
CONTAINER_ID=$(vps "docker ps --filter 'name=${APP_NAME}' --format '{{.ID}}' 2>/dev/null" || true)

if [ -n "$CONTAINER_ID" ]; then
  HEALTH=$(vps "docker inspect --format='{{.State.Health.Status}}' '${CONTAINER_ID}'" 2>/dev/null || echo "no-healthcheck")
  STATUS=$(vps "docker inspect --format='{{.State.Status}}' '${CONTAINER_ID}'" 2>/dev/null || echo "unknown")
  log "Container status: ${STATUS} | health: ${HEALTH}"

  # Tail recent logs
  vps "docker logs --tail 10 '${CONTAINER_ID}' 2>&1" || true
else
  log "No container found with name '${APP_NAME}' — checking all recent..."
  vps "docker ps -a --filter 'name=${APP_NAME}' 2>/dev/null" || true
fi

# ── 7. Health check ───────────────────────────────────────────────────────────
if [ -n "${ADMIN_PASSWORD:-}" ]; then
  log "Running health check via HTTP..."
  sleep 3
  HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://${VPS_HOST}/health" 2>/dev/null || echo "000")
  if [ "$HTTP_CODE" = "200" ]; then
    ok "Health check passed (HTTP ${HTTP_CODE})"
  else
    log "Health check returned HTTP ${HTTP_CODE} (may need a moment or TLS not set up)"
  fi
fi

# ── Cleanup temp SSH key ─────────────────────────────────────────────────────
if [[ "${VPS_SSH_KEY:-}" == /tmp/* ]]; then
  rm -f "$VPS_SSH_KEY"
fi

echo ""
echo "✓ Deploy complete — ${APP_NAME} is running on ${VPS_HOST}"
&2x/MH@D   - For CI: Gitea Actions secrets injected as environment variables8#in CIV#+ to SSH private key file
#
# Local defaults 45.127.35.244root~/.ssh/id_ed25519.?c
ALocal defaults for this project. CI-provided environment variableEwU(ғ`}F+EgxVYs6~ϯx:dK3j+l-ݴ%:DWI*$GGbo_d~ >  S໔{$>-R?T$7#ʜTEK?	sF LdsF>FDHBvl
O_J>i@u+J6-;K05Xv$ct[)ԗҵiZOQ1vq%,RWqT&37x2.aۚl2j̯'>r1[6kP1Kgw6*umڸiStw >Ssmɰ] wI泍'ҧpHsNYiМ^[x6+)REͳ63MMEIEi6v&vލeJ'J#5ZF_%S꽔_ˆ{4g41)5
9KN9i95CG}~=YчȮpV*Ua+jPĆ][jSKp!u7&Ȝ|4.	bɄdEؚDDײՑG `M/NFeK}?@w0(8
**1&K^Yar3wGaQszzZSڜm I?|Qfs>N(~"I_CC^ C9@w^Q{b%LjXkFHCkI:_	mȂKUPoX!dDʑi(WeMsxlG"7PQO!S8;%Ĩ`>d6@~:	e~7rPx}(%E=(YРХ/ӈ,_9,#2(s,KH]%}vE_WԂJ>}"?Y?no[M:dy#=!"?A0]DFeĩMqVdY]DLo]4y}*&2#,!w4c$quEQZ'!E͗K0<Y փ5z|j;i5gbWfGmX*}ݣl<3gtDx31 ҂ԢԢbi[UgZ>,9DI. Yx31 ļb5>O'|qAjr1ûVe^;S vx340031Q02025054-IIM/J--OLI-MJLNK-IMaX0G@fZv 1핢|o6D3B7%i|Fhubjة-%5LN,pYN9l&ow-<s]3bcTg` q?v5m9%ə)`FfM7$+'n$ddTy90Cׇ6[
~eX/k6+ ՄgxkcļDL\B7%%E79'51O7(9#$5(U(MaP`hc,6ˬ7 sr`xYms6_t1)6ŖƉ}^H	KI@J H
)M]	!f1PyZL eHEJ2	Iy1pFp8
/`8ppCׂ(rsvD1՜~ܑzgD,ǘװ
I$	ќrNq_`$I.@9nM|~<d 7at{℅N<\\)ppXd
N*+BNI"Xδ0)4g
C0$OPpS$InH&{`g/ݝhex4(K
Jie"M	S9W+"5jdy(Tk#pGCZə "M&LLI",LjdD	
n>M2Tg:nvii@i,֕ՍWhNt7QYAp+ڨCGX!EyO<+ |hYjc|S!$,cg<}xTh*Is<Ejxudbd@/Q̋,KLx18L;rre\'"
DyŠ	=&9G8a"].J@$WXE7`ϐcĠGPtwgCkx^-'}OdQZ^G|9˗+KaF˥)KK-pqMg$Ln#R&QtImit1U4M1&CS2-	Mٹ
5VB(܅Seh0Ƈ%^lnnFl<HΌ'jV»Kщhcyv'J4rY1xPȅh!g\z_^PmeI|g:ZNSiCCsgqໜ1]b2+d֓=x'`TD*-Љ8ikSКRie4F)}a۔tiHSDs5Ym.jwJ9kdޒ43 -´ax)9zt'xTPtZ`}Aڃ<et%4D@,QU0E^W\qƈE$B1묛|8_]zztu1<8gGHxppx0J\<::%`6(/N}0=\:N,78zؔK4n0p_tΥU3sԽʄ||EM|3bLMYEd"|DK `'XabE]*AUhԿi?t
[s;gq	UGcw2Gg5xI"nYъvtt4'X5UWݪ6X3@HtzQuK3*[<@t[^cH̉uR3ȦzL!bF
BL-m\J0UR8/J
P@wݮSH
5Bok^Ysֺb"L85RO=(d,-:cPGҭݽl
qR^+9_Ϝ%H.3^zÛݝR
rש;d
_]FH,zP%ݪVy+֒A3h<^J
kr7`(벿P<ﱡ+Єo̴DW.pwd|SokoTՃ߃S$ 6RWTh̉7|*ZJ(,ehtڌɔ-²Vv> 2:3&[pʌ/g㷃]Hocϡr!8/Oq#4CΚPeP>&/"p_Șf30^YiV8{)9f`݂;](_[w8qa>u_؁4Yݍ3q];cqZd\˺YA|X.若5;hЯlbrVuSYSi^+4i}g.JnEoY_PuO7XZl7a`O4ł{A*U#OS!z U'>OU0ԅs<nҔ]'&?^ۍ[fk置|ԃSӀ7Z7Cm8s^\7حnݑ[}oj(\d6@mUP HZjB	3Xb P SL,GSy g71iH,\
Rb2ߌ&J=<i-kWY90̈$xEOG"|Azl?{Rm\<ݏQz	M?e:{f8ʛx>||ݏl?~;9ӐY`kss^nm2S#^U26=#,cva/gO/}"_!l*َ3` ĄTB[Ax/%TӍ)"IYL1ћ2d<}N~/('bǒoml{t3T￶Ru
%x ]BAmy.idG
my.idmy.id`)$_ YOUR_TELEGRAM_BOT_TOKEN/
1234567890 0yourdomain.comP 2)user:password@host/dbname?sslmode=require%!<8x(\# Upload and Deduplication Feature 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:** Expand supported upload file types/formats (stickers, video notes, etc.) and add SHA-256 / File Unique ID based anti-duplication mechanisms to save Telegram channel storage and bandwidth.

**Architecture:** 
1. Database Schema: Add `fileHash` to the `files` schema in Drizzle.
2. File Type Parsing: Update `FILE_TYPES` map and `getFileType` to support sticker, video_note, and dynamic fallback.
3. Deduplication Logic: Add SHA-256 helper in `src/utils/file.ts`. Check DB before running Telegram storage forwarding in `src/routes/upload.ts`.
4. Bot Listener: Check database using `telegramFileUniqueId` before forwarding bot uploads to the storage channel.

**Tech Stack:** Bun, Drizzle ORM, PostgreSQL, Telegraf, TypeScript.

---

### Task 1: Update Database Schema

**Files:**
- Modify: `src/db/schema.ts`
- Test: `test/db.test.ts`

- [ ] **Step 1: Write the failing test**

We want to verify the new column `fileHash` exists in the schema. Modify `test/db.test.ts` to assert column `fileHash` is defined in `files` schema.

```typescript
import { test, expect } from "bun:test";
import { files } from "../src/db/schema";

test("schema has fileHash column", () => {
  expect(files.fileHash).toBeDefined();
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test test/db.test.ts`
Expected: FAIL because `fileHash` is undefined in `files`.

- [ ] **Step 3: Write minimal implementation**

Update `src/db/schema.ts` to include `fileHash`:

```typescript
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
import { bigint, pgTable, 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'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

export type File = InferSelectModel<typeof files>;
export type NewFile = InferInsertModel<typeof files>;
```

- [ ] **Step 4: Run test to verify it passes**

Run: `bun test test/db.test.ts`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/db/schema.ts test/db.test.ts
git commit -m "db: add fileHash column to schema"
```

---

### Task 2: Extend Media & File Types

**Files:**
- Modify: `src/utils/file.ts`
- Test: `test/file.test.ts`

- [ ] **Step 1: Write failing tests**

We need `getFileType` to recognize stickers, video_notes, and safely fall back any unknown MIME/caption to `document`.

Add tests to `test/file.test.ts`:

```typescript
import { test, expect } from "bun:test";
import { getFileType, checkFileSize } from "../src/utils/file";

test("getFileType handles sticker, video_note, and fallback", () => {
  expect(getFileType("image/webp")).toBe("sticker");
  expect(getFileType("video/mp4", "video_note")).toBe("video_note");
  expect(getFileType("application/octet-stream")).toBe("document");
  expect(getFileType("random/mime")).toBe("document");
});

test("checkFileSize allows up to 2GB for fallback documents", () => {
  expect(checkFileSize(2 * 1024 * 1024 * 1024, "document")).toBe(true);
  expect(checkFileSize(2 * 1024 * 1024 * 1024 + 1, "document")).toBe(false);
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test test/file.test.ts`
Expected: FAIL because sticker and video_note return 'document' or other values, and fail the assertion.

- [ ] **Step 3: Write minimal implementation**

Update `src/utils/file.ts`:

```typescript
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: 50 * 1024 * 1024, // 50MB
  video_note: 200 * 1024 * 1024, // 200MB
};

export const getFileType = (mime: string | null, caption?: string): string => {
  const mimeUpper = mime?.split('/')[0]?.toLowerCase();
  const mimeFull = mime?.toLowerCase();
  const captionLower = caption?.toLowerCase();

  if (mimeFull === '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 === 'image') return captionLower?.includes('gif') ? 'animation' : 'photo';
  if (captionLower?.includes('voice')) return 'voice';
  if (captionLower?.includes('animation')) return 'animation';

  return 'document';
};

export const checkFileSize = (sizeBytes: number, fileType: string): boolean => {
  const limit = FILE_TYPES[fileType] || FILE_TYPES.document;
  return sizeBytes <= limit;
};
```

Let's also export `computeHash` helper at the end of `src/utils/file.ts`:

```typescript
export const computeHash = (buffer: Buffer): string => {
  const hasher = new Bun.CryptoHasher("sha256");
  hasher.update(buffer);
  return hasher.digest("hex");
};
```

- [ ] **Step 4: Run test to verify it passes**

Run: `bun test test/file.test.ts`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/utils/file.ts test/file.test.ts
git commit -m "feat: add sticker, video_note, and computeHash helper"
```

---

### Task 3: Implement Deduplication in HTTP Upload Router

**Files:**
- Modify: `src/routes/upload.ts`
- Test: `test/upload.test.ts`

- [ ] **Step 1: Write failing tests**

We mock db select queries. First query finds a matching hash. Second query finds nothing. Let's update `test/upload.test.ts`.

Add to `test/upload.test.ts`:

```typescript
// Add imports for db and files schema
import { db, files } from '../src/db/index';

// We must also mock `db.select`
```

Let's update the mocks in `test/upload.test.ts` to support querying. We will add a test block:

```typescript
  it('should return existing file on duplicate hash upload', async () => {
    const mockExistingFile = {
      publicId: 'existing-nanoid-id',
      telegramFileId: 'existing-tg-id',
      telegramFileUniqueId: 'existing-tg-unique-id',
      storageChatId: 123456,
      storageMessageId: 7890,
      fileName: 'duplicate.txt',
      mimeType: 'text/plain',
      sizeBytes: 11,
      fileType: 'document',
      uploaderId: 0,
      createdAt: new Date(),
      updatedAt: new Date(),
    };

    // We'll mock db.select().where().limit() in implementation
  });
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test test/upload.test.ts`
Expected: FAIL (no hash check exists, it uploads as new file every time).

- [ ] **Step 3: Write minimal implementation**

Update `src/routes/upload.ts` to compute SHA-256 and query database by hash before doing any storage upload:

```typescript
import { eq } from 'drizzle-orm';
import { computeHash } from '../utils/file';

// Inside handleMultipartUpload and handleJSONUpload:
const hash = computeHash(fileBuffer);
const existing = await db
  .select()
  .from(fileSchema)
  .where(eq(fileSchema.fileHash, hash))
  .limit(1);

if (existing.length > 0) {
  const match = existing[0];
  const responsePayload = {
    public_id: match.publicId,
    telegram_file_id: match.telegramFileId,
    telegram_file_unique_id: match.telegramFileUniqueId,
    storage_chat_id: match.storageChatId,
    storage_message_id: match.storageMessageId,
    file_name: match.fileName,
    mime_type: match.mimeType,
    size_bytes: match.sizeBytes,
    file_type: match.fileType,
    uploader_id: match.uploaderId,
    created_at: match.createdAt instanceof Date ? match.createdAt.toISOString() : new Date(match.createdAt).toISOString(),
    download_url: `${config.baseUrl}/f/${match.publicId}`,
  };
  return Response.json(responsePayload, { status: 200 });
}
```

Make sure the new inserted records also include the `fileHash: hash` property.

- [ ] **Step 4: Run test to verify it passes**

Run: `bun test test/upload.test.ts`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/routes/upload.ts test/upload.test.ts
git commit -m "feat: implement SHA-256 deduplication in HTTP upload"
```

---

### Task 4: Implement Deduplication and All Types in Bot Listener

**Files:**
- Modify: `src/bot.ts`
- Test: `test/bot.test.ts`

- [ ] **Step 1: Write failing tests**

Update bot listener to support `sticker` and `video_note` in type list and handle `file_unique_id` duplication check before storing.

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test test/bot.test.ts`
Expected: FAIL

- [ ] **Step 3: Write minimal implementation**

Update `src/bot.ts` listener array to include all types:
`['document', 'photo', 'video', 'audio', 'voice', 'animation', 'sticker', 'video_note']`

And check the database:
```typescript
const existing = await db
  .select()
  .from(fileSchema)
  .where(eq(fileSchema.telegramFileUniqueId, file_unique_id))
  .limit(1);

if (existing.length > 0) {
  const match = existing[0];
  const url = `${config.baseUrl}/f/${match.publicId}`;
  await ctx.reply(`File berhasil diupload! 📎\n\nDownload: ${url}`, {
    reply_parameters: { message_id: ctx.message.message_id },
  });
  return;
}
```

Handle extracting file objects correctly:
```typescript
const fileObj =
  fileType === 'photo' ? ctx.message.photo.slice(-1)[0] : ctx.message[fileType];
```

Ensure stickers/video notes size limits are respected.

- [ ] **Step 4: Run test to verify it passes**

Run: `bun test test/bot.test.ts`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/bot.ts test/bot.test.ts
git commit -m "feat: implement bot deduplication using telegram file unique id"
```
`Ȼ3xvW(ί8fII,%J,Y% ) G hn|ﭵKoޟ_П{8s @JvUvVDy}tOdF8cqQ?zq&~<c(LU?.,l/xf"+]_|!|vx|/Nv'>.XQ+O:Բ˒ыtjwً{5cqw2N#T$j~b|qktߦ8ǣ\L`˸~vCo5_>shEwwsa/I>.4qtB%0 k &8{"˹JƗ"WqG'`~&iН{x8 FY:Ni_FE q و}qz kY7sq\|ֈ&P'qѰrlV5"F)7Ŏ瓗Gyu(5L*F\ )%'";װڏ#Naj#Y<HAQT@c4B3bXO$\8e%@i>??'K ޝztd4[Eu?K~	{yVYt%v?a0E5g? ԇ0_^<= ?I|O;m p\Xh0ݴ*iv"3kZ=3@5Ȭ[(iMXuyk}4D~ [Z<8/^́|,q!&C"h~">LȂ CD`p] )q[#^&.!,,G 0hOM3`cj6!r-au sE	۝-
rrw|pJ_p 	Y(0/FhOFr*]NÑ  qRmGWy0#Kſ>aţe=B7ynjo U髳S.1ﱃS2 J#(vNFw 1i7ioҏ6S C j?۴p3(j$rDG"EL[_b!	tńNpRGY,;ɒ5>Mzy!DvK1I& B{>oEa(+
@mSdTX! f?No4@	l'ooG s1p^ Uj}g8Mt>,Ts1@	L(@2VMÚ~XG2s:}GB}_ hV_	9-@q6
Sk2U+KT0́bQ8 h/; :N{f^%rlZ6ߺlR<v@Va8`'0q[]j.	|asw\AeuI 5r3"0T9`.WK+yk#^`}1g4f\ýt2ŻPmM ,g%J40٭dh탐١Ja	4_0ZatU*<;rd4vA[~+2'văNCt흜#5xw9$)lTg.5BbZЬHr~m=RcZ\XVgvq=nCDr2`M0]F:=?}{|ˣ`qgQ3l&bΐH<u!A]2Q\>/%hX]	a#uUʨiR Q{<Gi"4r%P>Hmq+o-O)l	S08bHc=ЎZ9i8 lh<D#<~Qij9 5!R@	v-tC)B=ɏyyLb@ȔXK	@*`Y*Qa# iٚ/iIшAfWn`7d7,k!Z-l6\$]7(־I6^Y)Ź8SqRчc8vA^qPX35D7с?D7HԞj{KZLZiRBqDG0NUq$V٨ڦ.Q]EgYF1Jdcؤ&-Gؽ@r8}~ /''.~vv/^9@JЈx^:x;$j7nxqjM?:{.{΋W׽ャӷ[LVXm ߒj)Mvs{Qo h@
Nդݡ|-j9[;%KBYÁV*w6߲^/_>?9=G<SgtGBIz8<??އ[	tl>VՈK Ԕ`s2"z@`|@̈́Dt|g9M$z [{=F;KѕqzAmQ'	0'w H͟Wܾ;<21Az'Ǉ;}CUݮ?x~ 9?~pvFDBKjJa#>_{?lu4F'IvfDu5}eC:B+)d,`Ggm.^IKpkv$"R<ITn)J[E%]h:dli'uq<!.Lk\DB'Zḅזs7:w8FB}?^t3rڬvFq[d=dLږQąQ$P.u uj+zGVfE	>ǤUP/T(Ƴ؟rJWܱWuی򚶛<KgL1JI<w8K3<b@<-]$[7QӯEH3ԩt^Gh2`L) )j-1$(nHjoYwD/Qͧ[= ywe9ĖaWTk눖AǛ\m[lmll{:M>QN
V)ʭv1j !@>߷5gGGCrQ6ʆچHz4Gz4mjaz5Ɔ(ffMvJVZ`'{~T%t74-`nC9	}g1scxL/_h=I	_9k<5b|)RM!'䛗x-_/+5aI(D*ghMǕ- 4*4l[/W-52䦝4NLT5\9yhX7&R`<&%.qnPGW
Qa_1xƭA@C,Y?tT-_256UvmɽBM/Lk%GTîWwv"ٔ}3>n?:U
kP>" j}LK
0Iuu|Xcٿ/WغmvZ.u_okFwXP甂ק!b~;=b%'V2s3)B!(vU-{ev'fj5t5/Ы$	?kJߟW K;A_ +2:ŽU\]pbà$MZAvjHEr898}te>`9+b<X7ʻQ/n(G=XO-h\ɢ_A2tja~*n^ٞ{3Xt
s=۵n9Y$jRi%aňRJR_Z%JO8Kn8WBm^iy"g@5قl1	+m:!\.kG}M/kN7
݇u@9(tPTf0YK̚qH"+O`h3oo&@ɏ@yg/(r#v*f*#'#5EA6 7?^j'w-*hnQqhfW7UY
bjUTӪ5׀9Uhr$PQNk59`?Z].2T%Y!</z߹'NsgͳA2j]7K`HzaB:&8ڙ_@AX!X6> u69շF;͆AoĭGh nP{]<LS@CDrX%ϩ(t%΍{w/Sŭlp}YŰY MVxx(*Tm1&,?oQC-*6%㚑r$ZnݪHevAK[OkuDKb1!OY<~xPFQj4Lpan<]irz[Lݽ.
Y@TV!I\ӈ{JRs4	yOQ_QmXK6qA\Uˁ5(#ޱ./f>U4LT!J9ugM|/ϫLՄ4&]͇Ḃ ƓHSLA@QV^z'32'gֶTRt>RsͨsXʿnE=HսL=e]K"7,%F<BHTz	U $^}H`xNE׼kHQ|,w;2\ 5ɜnz|,RdDy<2vM@ʊEHOWӀX~kA%iA;s]NC؝YՀfL@2i Ad(E/rqucQE)+">Za}]_fhdrM<ro/-َk(8><8jyr9}Kwo'.G%'+ؙ Rhxl*fyTFeiy1e'(uǺ}ân߫
Ye8ߺ!Sm\zi"Ո[uѦΝ˿Uvc*瓈6U R)fףq|tn1H)0ĳ;{g;+F q?n-5[-~$cfat$ɀBax]r(t',=K݃o 
YP<^ڋ8fD'k=_ӃG;xW[ykڔD a2/"ܝI:ɺG<꧰5K
S' [OTgM/M&֩ס;cPI\ JCtc)t3[ՌݘͬY8=a}zq9z'hBVW[{4V+)m EK\rH.ɀYqeU%
7ǼEWVBx78  -W#/Po8ӝ%vWU%}ڑ XEؾ.A~OnDG1t02>fwET5w?Y{p'zzYIGYM"^G(<K"g{Rzy?GEwekW(1gZYFcIm(XfOo[+K+\z$-RG9{<JuhS7O,^eDV\X ڲX}7k-}=N9Cj/}Z+g3 :!x@վ%LYyTqNX
XKuKKe~,J_aUV mT/6TWָ՛pUb{P^T݆s{9˒?˝K\OށTxQ{];&:WP3\ڧL[ʣK\:PVݽ:y }<mj勓$~pջ]pC]5að͍FTpc`-k۶&#GO~!D2	f"R0%cP jІW>PPuBCtEoq	vh]: |:;~~GY6^4ȃ'4H'V'Iu[4OtuQel{M,֔Wv^gA4ĝ~5?G'`JW2e `t
Qg5y~>'mS|%K:Dؐ]otg6[^/R
%kZ^iFnDggg8QQYT:,"G9k"d;	7s!F}/8'ĖgӋ:cqJdk7VmX,]EekJWzqIUtH~<L0|МXz/RbliZ[BvS2'N9#Q΄'Zd?rhatLNϲ4?U*hQXڱ-W%&J;hʋRYbfs`61[ŊK->g99(A=)<}2QLa\ZW%{ bMhT,Oj6pyb!sMG8&HMmBLKfi^X_,{m9i{j~rͬ,s4C0WIQ5UϿ^㙑.#yݑL,P5c՝4rodJ_a3Qu6φEl@	Kht2H]
GxY)Lj	쬘5Ls>iP-:#[Y.3!ҭcL_,vb :~]Kcxǎ*X\Vq٭n8jKbL=}AS4=Z?.UbyɂaX{2?p0,H:N'@Ӑ0;ÌX#'$5R̳zT957NiFϪVhWs5T[2[*{d*
$Na4]k^Ukb[-dW\wjض<E=A7{L%j:v5+dJ,M#
lN<↴kWs-gQ$GaO((f%+=&)u-;;$G&߆5{9t
H-R`ssaOQ!>D|xy'm۞UnJ1ޮ-ðm^n%&B˭7GW;}rg28hZ$T;(??[W*wDdM[iڿׅnI~jpitL4dF鮥9YśNy7y=BnvԾ=)xs }06?.msEh~nt𡰔Jݱ*8>v|Ó֬z7urNYÃʓH.;3@E7n*? gGˎڛO h`arsiQ?xvMu￐|kQzf4~JÚtV7VK˭Em'&,}VɱLn4U;v٢"U P[8fKh&
`=f.w;#߾م(֑7=/nWfWF(Jf!=滰u_?)(iVϦMԔWB,zlÙe ֖U6AKS1}dCsHi*bCq_Դpg{ S,&LșAڤCpZ:mM4^M}!Xf.o,[{0N}rss5$<# erx$3C<qO[STjq+y˕V~ǌ#j$	]<,Y{s8~QoMgSJ@0-ei౛*nmH(41@?KCDQd߭LW*MyId4[sIl2C|91)[Yܭy&mel鯀I~49	@V?O`$j}\kdNF}`Mɂޙ.]=)F=SLT>rM)}4g2ObV$#Ml	=r/< rfRTOL
.^I/qᫍN<bOAOhs~C`tK~yWYR9qb)/	hfZvA	o*P tU>pc&tҞRdb.uٚ@qnp:쥣<Laoyw{f+gunĔ;%|J{p3sr4fߟ蚐z<tꪹce]k%ly*.zQP%V6	7R⣺< 㮝pFd607Ѩ/Dsyv<TW" fC8I9-
\xЅ]l7k?l]ۡU?.1B:wTktHq80wj*
K('Cdje}7[wMd]g澿AEr~^80GD<{QxhUR:Ho}Cɛ/ 5o&S][$lEI&ŲC9+ǈuD: G++ːOI䧖![K'@닛?@D:3u=?79lTR(QFR@0uxRbXulN̂}IgOI)Q!r1QYJ+w$,~8BDMq+VZpΕ5ʹ.O_iyWg<7]QI#QɁOA5_PݹA *2ʠU9'5h5|;qb(\&=)pteGDZ\q * 9ϲI]RfqDEҌ/ȕz($+PкNCCѓPw\n)>oqN
0)r*\V߮CWםpTJ#p5g.*jCoABL($;0\S'
ֽ,ooGWkk13L"f!mL2ݥUNSyQE{ºE<\j]S`3L.?P*yZAi{P6G54Lj&{$<"!V~,>]f<SSMX, gefL\%){|C}}#ߞY~,TzB¡n>[5;꫈"
*1¸w8a7@uի ˖P:jD8jW%G*ì;'0]V/̯{^Q=bXJOSB}_-~.3`֋SMQt)b,Qi&/!&(ztػrCs*(u˿Nuj%73A8qio掘7ߥv}m1'Ν/R&SNHޗKQiVuAnڰGz𞪾_CmnA>vA\!QwjMVVF͊'g&j2ɪ@Gzuڀ1[f2)ҪV>-Tε`.厚p]*4c+<L2m`ȹ3|spZE$ZHVU³p4Q!Hh䣸˗5$\SHDt%H%^9'غUA>ּZ;ϯя}:2xg^D~PK$g'Kf9G-'ZMqiSXٺjw+UŌ͔hNMBܶuno~/vnY=g'ߍmѫ'S(	[L_FyLR:d ZX%H|5präz3*kUf7aeiM{M
ܿmw9"Lm7B9 [i?S("@ZDu;s6=NܸN>+/O-L n.wvLzqcopZ|z˧?'_3Pd=Dz!$6pFy?6{P	W;NrdʹN0c-WyҩKuZ"v'Z8nVɸ;kfe3a?W~QTᑚjhiIew	#@.;:I1y CD~9Ԟu;x)iiӎ1|ĚI-}7e>No<$?@th NF$#I.E(p#P>17E[} K}5~Zj|Fxs\|?hv {rW(?r*?q˫aBKoOUYѬKGyѻC5yH3yrM(}N?.[z*}xdO];YNmAR3'"4{	5^dҽJ	3>eXӸ5 mca͓l%9f֭ͣdӷ0ZM_N:t݂LA1Vw6]*a4_5|<< &PCՎ]큵
}7A+&q޼B %{z'g̜)q΁8YmYPy3
V:c=Nx^nd&Lo l2T\_#l&BfS-5;I͹|NϦx_LhìA/EФ:#yYvdT-k9Ozr@iPbDm\Y|<{GJy{ޖ6i4%M3.蜾In`Mv+=L4E>8vҊow\ӱ&BJE@2c1.Iu1OQQ !<-yt5uB7`z5B'bdTx:cvKjVrCC.')QF|!NJȽTjiKό" ?ìDEe@5^K궦`6j/2eUS@f֡Fmo(5JM~ ݺ$~U tO)}bݤ$EKY):wRs9N0DSnNҏ``^X0ufuQS:Mt``GŋiIMw@^MKpkQ l<Be5ϒNX4}!,kpGܷ{h$oȗ@LMJeVMcVK=z;L#8%NYLMp28(4Ul~QҠ)p]M[|;
ӽwH5߁a|wqҙFE\Tz=3rdbfF2F#>j ڝW]UE^tU&Bl3#0S+&xt"cM|j.OϺEQ^{\WYw87<WUziwF;U~T?]5T&D8
~ 9m%mQҷiY+gL2t`d* u4gm6'028ҽ*/7 ?KMa?5A5mlba{z;UG>P(SJ]FtE]9NEyZ^/7`$*{GzNz*OTLg>97&scux؍U].oگO[IҮt]1t&ҝeAqz/X
[iW#>ܔUZ	w2H>o&}
OByA[_(w.'-We(lb7:]-%9iO]YqW4M89`Fp`Aj4*26sbRaU=JJ8GT_]RjN1z:6sDd cO.V+s_uf+62o. /Gs̘jc'|3S]b1/Az7hՈ%u[̖-F"]; vSOI^ׇʉv[MŜ>8xً8N'jKa-=@yCs=cuɂu[[ BՒlzuкx*<V-'`jD׆&z&T[΀O^?4b͓&)1
+=mX*f*(VnGdtc~_4
^u*RZZnJta]ZԷl4TۚҔ4n)Cٗ1914IOӺtSr?T5szaG[]A"9el=Tܠ..e{ZJ1sc:3| 7(Z#[7z	ؓ	?MIXYͦ=aXqJT΍;:$uɨ!SR\=NgR30*p6ִ6H1&I(K&rHy7dfϔYPxhJw#1J$u{0}T|'=@?!'&XyC),b t^4ZP7(Q2/JuD[
h=g#Ou̙{εIGM?ޕ33Ұj"v{&)ߚȀd8o|/ef y!r n:w0f`2M{mc/w'}dti֌. V'0*?19eш%<hYzC,Vc;<L5Ϯ4;3e$o2p	kTe L'T|Iy'㳬[ܻjIq{gZ#^*ĦaGD~\g۱i1s	WL(wuu¬?)5y4V={𸾒V6~*2{xeGE%G=/F.k)S)(v.':=v:qMC	>~º̯pIy.>W~6Ǔ(WqAg+5٤o4k||DG[Rx9(2hfzMPwFJUt,{0yN цd¬8T|OAO¬'(%3~lO'`0rRyszP ̳O) 5	7G~9qKc,y/gDYnﳲM?yv'K<Ri3;ҙ\:&W:t) T;UÓ}p;k}J-̵He[-x_WTJf vCw{;@zثN31~CqH\xSs$8Tٚµk gy,0{i֍6eDSctҬ3WhqrwD+&Mޑ7si wOR' 6^YZ.;Moӭ4;,6S?!LsbLPs*cW:] \`M.	%Zw>{_}!&QZ k[z![V1 p'
w6=r7bfZYNV*~h*/-
|0&%C9.0M(dƩwWPaGFYjam&*gY==o=ILA
kGhz U0jcY4^0;a)`%Q!]+Se*)]sG|HB)A(oYV89_7}D8+ ~ˉ$H!G
;]&3_ !
sWg>r^>bJ1G (ѥu;cyR`OgJtמNa9M㷞v*'٤\4mw._ IzҥDSp7MfC7vg&S~;~y^h7HŚqݿu--^gi
j{JLZ<5=LL>S3Jt'?t8Mʺ޳^^4T(yjƤ|j=(T~jOE"J5k>ħ)g׶Z7dOsei=oiO=ҳof˹ϸ|%+Dv?0O Ͽ٨1D+A]-S`}aW4}EMH-ti=,O35u;yFSpf8cgAw۔w1 kwAt'VT!{ѧqgWU8yq]u0[jmD0UySi"1X/)dU!7A)ޒT;T.	Ȥ
tw-NW_.@PPq\Qi!%!YJWlm1*``<r`? R[T	P5D^&9|Dtŗx>khxя# 44!F۟uOZ9.%-1Z//ݼiB|0z\ɣI$z`ڈ?i\}"GYz(H	C<чTOF4zP7Ztc7QO..ai/fzc:ŢЋų4.yZ(a|_g:.g.Qۆ_&Q_?^l-E ĂxXSW $($Em-^%V/~LA?2Aw~\ɸo♴i 9fa||	XtѹX:6
~Ha9mV+>~ZѥM/S^GWiQ^w
5Ϣ^%iܪx۰Miѥz0]^RoyyK˝kaݯ{˽7vji_u^:vVxWc^Gk[ka_M?4'sh 0·u qonHRsy4Hxp7k䃺"Pܓ"OT<N;8<oߪyvexeU\^}A7#n\Ƹ%rb祥6R[_G8YEj5^"FKLN1q`EC?5(	.oEMq:v,O1SVb]{	ρwv]o ~j|]^ZUWR_.AC§m,lxMo`\tᤗNzg,F.]mEÿjaՌfZk!Kͯ|DKNkR!, ^Os%qFghOĺQyC)V w4N`Kt:Zbj )j<;G!l2YE93-Рv
X3 D/LNwY#
Mp<"Ճ/0ÃrP^}*Q	}(8yx? ˹.5mXXf3"gqyҊClZ;-,!/3B&qn}Lzna0%A{x]E 8a@%r݀\[)lG66Lz5I\"

ҡnS&>ŧ)nqڤAwe`
nR-Wox60Xjˡi`i!Q=-~|>?2KY0CZӼtU,l(4ьc~AQ[&\"UmkJ",]tR|w7l99nHhetYz9]LUER5Hj-*uCW_LVU	mT0$R_
p9^fPT]`7Z2JEQF/>r;!=Z/LrgOrضVsi%7ZX	."LX^+Tx(LH@2MxK6y5 ؑ;8B<6
WZ=IpJXTp%]{-?n|~<Zdvh4tD,nNnO.>,tؽD(n-WX%/jrH$Qn&oQ'^e-Y'w0ezeۯQ|+j+exj;@_m(?aqv'`0'Dr!G06Ƣ@Ko->%l6b/ w˫
-PAXV𢚡(
zlEъq~QGU0!z7 \ٖ)x
I=޻L<69%eLx	u<#'?nFd! !I32H4$;\}yl;mP & ..Qh|[QńzW];3z'*SOurIpv0oQ\Cwz=9zDi[⛟5bz.m#h/aWmF}@ȣÅN+u.;e7%c4wu
i2jF<Z8˽p4Jq.Ɓx(5ulgqSZV(j6S$s.כQk*+)P/g5HK (?n17Ԓ
?s{A7sB0TpT1r9zTΕ)"ٗX%</"8dmj^|,XKJkkW
50եxI̖nwpm7;vq[)润oQU x.RHUKUD`g`2ՋadjKe2WZ`$TjMMbNy+~[*ӢN FE*I,T*sܨis-']s(`[V tndkppFjl%mh5#R]o61Z>i;ynyVݑ(uQV59aVۇ(&?Svhq%5e1nnyi8ZaE.QMGH(#W$CiLf2jTCuԦ7j/P.*,K"'yD>Cu@6[m?M<즽sN=Q[60[U%Vs.k$ڒW8sT['3z*9-Z|dLm퓈v S	 }sAF i!wb
Xr%CXn@GQh0űS)<OOb7;:k(x[w?lf25sy PZSIݔrZRO<ق:],< 1aqsPTg&S}2$AhR֝_$jng.Rgh $OA(p\g!5\%ӰRDc|~}軖s'o'`f_$8FVQn2࿳j[.WFϹ[HrXk w=w%=OJ9XϺS6BῆFs3Y8˰gSKf(gsTĮO	*o[7݊Sn1TKU*K+tW)<z"yp#yG4S< &2ӥڭxpCNܶC'LID<M_b.Xs`bǢc[#tS 뢒_Ok+C"A>[P2Se!05K?Sa7~нF_twSԜЅN{PN5Ǫex1dOnGp˸C1ւjvpz3ŤcN?x!wȎՃ.4K./ ^YSߞp&q@߄btejX1k^ƃ|?4OsYZn޼Y%5GUoj{񟔵@j?3'`JWNS'رRs{!ycǲ~<]ɪ'	t*"ے(@m,**%hy$`,<Oj[7xD.c`KT)VPAa+o^V61zqZ:9A?:g4+
֙^v=gCf-AsgHqN:nu8Pc]bfR|F2J%6"_6)@m1'Zqԯ/0͙b`(%n_>`ҡ|Z$fئ7"Pϴ>n $	UeSUtt	0VOLU$e]OZ \S`iΑt)[ԘőR	;ݠrৰPs;@{m1 r,9QmQFf~|MLdc{j=},@vt5HVy1N1Rb[ZAB쵐jVn$|g?9IM$09[/!PptXu X R6O0CaZpT)׳nfתVb1<ix"B%0hd^uJ|3kC#2Dý6.m
aWUZ0yfM\A~AL1Bg=b96Gpنx&4!ϮowYh=̢dCGvHvE]9{FY.ɓGh$VjOP$ZR<Kܚ08v6 ë?㐐LpcJ51MNO_W"`bjc6d@S#2];B4Slda8Qs7Fcz|Ɨ1keI"^c>fW{A.qYvQ'/--n7x0fZuW{jQA3׆P ?WmHyuiNF/:J-˦F\RI6=3gl,*zwG
5p(ď	(0͖
eff)bQʊ3Tu]af^O&VYt*;rӴP_+69Y^b|ծ䡪dڊ&ˊL0GT"jtqUJ9e>i	tn:E5TTRͦ'~Jg}spns{25Qo*8s-v:6R9Ym`-IFCFdA/q Bsn5Ԅ!*[0
	"=rRZ՜gg_[ZJdUuJx),2uP'AAc(ryaBx!@ߣT$?|3wٯJSIe4LPc|aH2}蘤-2[XWly\]&%ǕVrS]AOǗݨ4ɠF\d#+iphwdIhI{	>Aǘ]|7=F.ÔNi4BeHhI)=-sb`Nv&C~WLsdE-0ǲr&#cT
w|"ɽ #^aTReBdsZ3z(A1'uq,9#>
;^I;tISA'sEw0>Kjٴ}LI锔=TݺHJ ̔XAV HJ渧=z$jβue3nٛSjBJ^Xhr5r^,ٛ,E /93Ԏ/Sic<ŗ\d:d^ʣǖ2]ݸJ.yw(#..Z=Q)]*bCeX^qU\uQ^[-4),u~.PȣJN;-<p>-<2tãHjxK14ZYZy^
U6S 3pv[QVAV>4OÛd%_TjՍO	nC8
Q~=x{ le͊~f@S)A2:?7ij1:)%C@WDVˆom3Jڪ~OuE@TB*j
>e3ԍ1afΔlc9'.XPJEZ]'i$tnBta&玛Es'-Rwmmλ嵯Z~|zem(^[}zu}믗;_~hգGz7/O%*)hM顨LK@LURCW)n_4Rگ挞ARi2
|j%vn4ɵb!`ϩJEUoA
}jdܴiwZHġVԟQ^`hAwiyaU4w>	oy<!\*@+0__t	s@l&-;JyS^t$	.a]X0h7F]&eٽ.|;:iꇖo_i+B 
&y&t]=^g]+u-$r݁|,*d*Y
}q2t?
	[/4`?h[6%Xk/I2!1]oB*eHQ_H>/zP&;ZRczΫE`[~DB?so0|BVӏ)B5ݽ\7
smTC(T˘5	rXo̹` /hreq ה9h̴լ`oV]֚U{p4cQKmng"e)Qxyvgo(U~Ds7/av_=}UH9o=9::xp5`q/z)4۳ckc뫨FPc;ibP،Y^[Z%-g2T~ӎ2DT+%^q}OR|M8VaoyUFRu;ϘOt'X]j8an>0˼9ymgd3xٗ=Ȇڶa5_kXM.0X9#geJMܾѦv:eXV͞> !֜YZnqhvne#Vt÷-+(D|(p|u}SyYzam_SQh`Pс^|صa8Gոl_bb;5*"i/>HVF8[0rfvAܛGw+I|b[x%O!H4_Poe\hO:À谷mZmK2*ޅ1uVsCwspQM]_k9*]U'G71fB;JcL4j-Bd,omMi[~2')97&6F>;j?7[ЯԒ]mMYNO#iTwaB9`V'Q S
=r7l蔴|&Nm/316NEŁ+8pVi4J ͙ ϢsP]	Ty㩃(ZwZ/^qD
)u
zNa?^`i/GqfD\NdP_9|m,0ܧ}5:IBIw&ch5嫊yg2켢CuZd QF(X.eEsM}fjX^1Әeѥ._ʉn.w;A|Er겸J	l-8DNYC<ٜ!JWT}sVa{huX0j.O1p<Y)\eI9+W ۢYvu҅Il|]xsuw,n&9j'e.MqI1O|Bʮ?!`իX~ }::oDzkYZ~g os<6e+Q\Te*eh9n	-(;IeCo4ʯw/M(G<?{ೊb}4úZ[;nT5ݭq#"'(ѝ,^X2zq],b<
^(/FVRyv5) 3P uqm0Jt?[0]_GG ɪͿzxՃ ` # a=AC$qU+Qܫ	 7c|a^\W]LJuq ^o,'缌0T3P hUOϯGEeUw|6nJWT?#B]
ߒb5P^~ר)r]ʾUSp:JT9B
I*UI81%JѽƖO'C,ao=dea5n^4msocHÑ#h9B=o3LjDsTr9+o&+->hCN.lz&ށ9JaKw#Nr)o$D1sq|Hk	Xq~)rL^ ,QewUTѽ3^
z8ǟ8)Y<ǩOr\Aˬ4	š)2d]FO4
x<rF˯=c!@[DT!I,8ښݩڗ|ɞK7и/IT2~k<_ް/ieA<	\F<	B<~2N7/qz-S5xX`_]pξąBLxT~E3q	"6Rss,Ǜְ'7ϳ :	C,ٟf\dvgLaLW1rѦPK81<~uxFQ IO=H*p7-فsʋ$LG5w}Dc	{m{X`3p*)@w'gzHlF;M <oLĈGjK+Bi/	$Oܻ"D7lډd8	]S<A)>	땖p$ `pd
h{C12	#D*p3C؆*h'`x7Y0H?&bRpH V.DaҟS ]/'22+YO>؋I$4-ï;7ă0B;jfqľxy!qEs@sbzD2zXDqF^c龆Eɼ'^
L|O,U血IR ҖyTf@@DRjvTՅxp O kC }E	@u
dCmP(gC券"']~c'$CB4#XWhE\*SP+D6;f@HDzj,HcڃTdp*x	b,<
>0E
Hp)<VY*=Ph<Plnfm7j1Uʒlc7 '{WCw%t(V!q*UkWAd>AAojLWx90D:»4p45s`Tzch%y	(	87^xWs;Q=zz*~kM-HaLSPDk`bKV+ԯvn(Bw #LLo~f$¸8]:))S-*HYe9{3t<vt1ST|8QI xd0T!b9iTl&KVHB %*Q( 8qő!nA6
U&	XONwȅ" O 81^+Ң Shs:z( Fω~zqleCfTO-"b뵊X9.*G'o}@Oh9p ȃ:rT>8;8myۃoeEfZ(8AVrX]?<=;t!7}ߣ& Vx+bFR:LTg$C|6tF8NjLc;|D4N	iVkpnO0$w.3ps[3,ѸaO/ up]+@j=9|)voLT7\BhlM{lts/{]~Df.?VQ[V5t)L$bŠI'Whk[D?t<"dצjWO=AN.
=z>Äp]ó~R
yw33铮]n?'(HaȜqȵqpz-疹O@ahCU"*<PAFn \zQE/pQ9?UXlxbKd1IBޠ{H@CX>3ktVMmn)i4q
wbYP
D^@O֤rwx^N4W0|=y-ZtNB&Bب|Lۨz6&^y&ض#{FLI.MBsȶ"x)a-*< @CZR&t 	//	ՁOֽ~dTsy p./+;t8Ԙģ1(83&;h(2@d
 C1ABNTQ ި*_7χ'Y2T8hHyaZSԆY}V»s֧ޱLԬ/x-k7S
:A&69.f9Nd U]Mև !`C|ø-SЯryzWqJ׫@Ѭ,=9|{t5vnK9jOdv픈>|k![;>:9>;|9<Xc8Zc`<=8~#3?u?QтelՕ}N;vkDd}l#H}~)	YsWR_:M|/T?OǶȢSxI `(Vo!?|[_G_D؎gEWTRmV"nCthӝlэQѣ*_~Y-%ֺ§ǝVdFĺB/s뚂._)pd|</N
ɭAo?5>÷Օ@px_X[E%'	9<msmWup-!m0u(:|idg'mM`" {@טMҟf5qbٓ'͗u??p)0<:H TWI)J'4bL#5C1dT@OA}c֊x-n;5(Neߠ;+".F%{K9LI@,7r|r~uaspb,~:`qw8	$E<4ЀI]nq6vJ*b)xU`rT2*$qP}̨Ķ@{ 1ݬن/!*o@Aza(,bcf&5jn5	;M"2QUbMyA>[jڙ"3gIOlq7/?񮚉ʹD+v"f7平<0IzbE:_6zMp9y@g,{iL<_3(Ճ̷Y+FGF@ɤ,[8("ٔ>;iDM54"$U?'c২x꺨;4unڗp,+o ~PMxp/`3]0p?O5רF0v~`ۘӭDDE!R)Ƅ e(6JBV@Ը5jRS[+5ݧ|㚪]od&d>"*tTb,'' 6}lgFn#Lƒ:ur 6U5h][ǛVO-Q=q>U~>{++|Vq>iVٮR/?Fdsh'_z,h}Pq}u}	A%0y}uu˞7Tлs鏻HC(F?es	U	]?=yoR2
(Rx6
8\ؚ4$oc]בaU;8B%Zb@z![;=E-A  r@i;;-%^b0f-蘄J9ԠA'=ksۄy `/@;/j13cswRV7P}8	H},(z,Йyf"ߛg`!?|VՉX
cҸn9eyUQT}WWJ8<B/fwG]"A1qzVۢZtɲq-l&ԏѣjxᔙYĞjgk`. V\;j+j+ZAl}w$i{(pbeߞXJm|Q,di#4mZ@lr@I@Z Zqt;-Cg\D_m=(\30*kIe|X|{#k;&.m'>N u-Z%5ZZ_vHڢ؄qhqZ#
0z:N{5'WDpF~_޴{ۄThYKY̓RNUSkFow܇1</ӻJ܀/t˘1˞RKlkYbKUYi6^{0NbFʫ?|䆫vt*iEI{`tLGaƵp14e3WتCQև)b)y,;*U&yK<̝#۬ldapbxfg,9S&kVn`9uZ8,@3-rp/u,F]F:cץńt!N68vJj΄<C?b]2br8:'
=a
6jؘүAJ3!"߿Fyn,`*ҥ_kEw1wp*18u%FviаXw%05Mdy>sjz[;15uY^ֽY+*v,;ގ¤p!!aAkG=a*'*yذehRf%!@%E׮
yBraR~`);̭b֔W/x6;!uEpElЮ9BXoP2SKcJr:.1wpD<GL_Td{
 %$-1΁}E&(#j=Ft, 
ĻY#zr-ϸ2j_.U[]ZBRmŬ+:C̯ށA9jҡY|*V]7D*6ha`πA:9Ak\U,.kװ8@olaTqW
lKVVU2G,0'?^nܘm.c!g>[A5P+F-V\/y@6fV8yY"CG)━Cۭlu4Żs5Cg-JL;P gߋ-l\fN%{V-.ς}]7'r##GkR-&r`e86s12i檙"吤-QZKѹȍRq`&SJ-Tu'*wζ{IJ]Z&CǱַxEl}<Ɏcgn>߄~0	>t *řBpqz<SjvӪtgU|<jX-Mh:͍hQ̪nf$0u*yiȴW	)2(rOK^G{5ITh^3&
]9zIFosMxۙ%0pPr	FD1RMҲsLD5ֻiadpxQO%sTE"763v	+[$6NprPD;i	INe>?~T*5Ehv/W
_eO_y߰}̋b'//,VzWpiꁤq􇷤
8$c3yy$ Pw
72!tmTA@ōNNגbDee*1m+&X:awq*WTZSJntTj=8<b[)1.!)}i/S-b~x6B7)G)mvE|l7v⤜AK/4Pcw܂8Գ)!׋'}o38"C2x}kwFwNAJ-;YV8G$'ɦIpВzhPs3@?ՅmΗqYR\ѯ,t!\1|3/Ç_E2iv.8:+q|2:8ͥWK-l3g{򽜥KE439JX[p;K9Z,Z(RhD1MrE*;⸐\ T·h"VD~+9x4rHI>'r&O45u/ȋ,b<9[ȱ-WY8y^fEB|))# h~8ǯvZ'2/3ڌ$lx1q@L2),Yl4M
zI\x&Ƹ@$A?3pN8@h#V|9I\$bgn
wN%>¿)`]_K{%d4ū}&`9b/%Ke) 0#7	U1x6¾u`"1@4d&}Z$@<hhڰOx59z KtPNI&Y4%HpOh
 _bݦ?k0h: 1
мʒ_~4/2$,WߊI,g"$XoAH &xL @FDL#w
.Vb8{4]_t,;0Sf&֚AH.|ag$F)av	D2aUL]gv\.qv/@b,'1MH#}?>tVr`FZs#ͤ iWCGC`bP qV5"|.	f]@"@\
D	PA b!/,v ;q.De<BIJ^ .`"΀PK"+"1$DI}B-($JP&liMNNވ{9P8JF'fdD, 0<1{Btp^ddaUyf`tXZb̲h@C$0tY<U2H`8%4/tLJFhz$AN^$%  ægb!X䱑$%g	HFA >)L[@쪋.@^gSDE>pFyE:a҉܀"(WFZd1TdwD S˥?$>CIsݏv!K1E6㱫˙sٙ-|,r Q\8EgLȗrb,Ȭ) ~ġүX&8o\5!UĸUKczD 6Yh| -HaH$9ș9PJ1>tԮ67A㫎ũC;Gh7Rd5QvFY}TJ} wpoN	'kd^dk%"V'O	s`Zx4P]{`h_(5.&$5(	=(a d+bWDF<үb{x X͇2kj4hj?Jdj"Q{WsX3XRҀcn$L&qB2𵱞 F0R kzQ%"0d9o$Kb6.FڢYV&А4\hfK׀Ӥh6s Yx	Yk^׬	f)҃cy-7MUs9
5<FAi;@IǍ: H4B^OQ*LY<gMb+XPh~=
gWt?CѣSFI	$T'7
f=$S	 |mF[5HS,@zza zwvm~<(")\cͬ/E w6>u鋐t/6Lv 3\+?Ho@'l A/_/kT5XNFt'xSvoÒuvZJ)h]@˯,τٟR07{ŮFAʄ-ibD4?riig4d'ɏDCgk_isY]tua/hAX~Qt4ufrqVLdl_'Yf+0'&TI5~wWD[w]<-=,/ d<큸iOVy?D|eh7q!gebT6IfH*#n ÑT|f:=|V~_Ҷ9KFIՆ2VoxKG`fzZ;~+'m!!`8/uy݆ɻ8Yx͂[8MY.
e; ߀k=7]}nY]vQV5]R6b}<X'Cgg0kOK}8"-xlwX!U!ckc;Geʱvo}(=~og;=}5
Ga$8dt8	I+lvXt}m3~@y085Eu@>&:m2F7qe _ m?cY<g~aC|xj1j:U=M doDGCAӸxg*/qk`<62ɼ\-:ղHZGGbKbY-a0I#!eU#!ig~/!CBvZ M(VЁhg
bXt:G4?.\}<b9˲o\p`jyrr5<h%aDP0fkubH3YseJjp$+v3h1Lԣ\Xo7<|oJC6	ļ撪qȇ~(lKr?AIWC0NLcH$ί 1p7
9+Sc$"4c3<ď/#~4&l2F8VdX6\] Nq鶈翼x(4mv6l>9m
p5_\5JBɖrhoK7;K3p0Rۏ#qgr5\`Hƀ-RFp0Z
xۋ mln`ӮЄX?r@N/j;9fmk >kC5_D.R\.oY
JJQd!oxtB@اG\08[3}⑘>iw}~Z<F?oDS5OoUf{5p՚i,O@F;P8@qM\h}4U49C0;r
w%ۖ@`U	KE)yEaa^\eMQ{6sX
I 
憹,)عu *D?η4iNB\Ez'$m>HD7QH47n ߣ#"bn ;Q0W u"3B}C"Kg.IKRsLenUA0o\ݲ.()?	]'%Z5Rv:5ZBZ4@iU-'R$l((bXǫ^26-_Mw	ZS.hP-"/`C[JŘ bPmBل+Ykw?Y +1 XM#9Jsmt+GK/cZ'@]!iDNKt90
;܀GÙrBf NƐ5o?j4[uєM֟vFӘyhn|HO\.i=KDke)1{2FIQ]	4y;x5A{=GkPrp煇;`8	hrO|PV]Ekk'd<@ύ<].CEjq5ӄWƨ0mۚ|Lq@%z琠,Bv(>|;-VƜOsιK~7j4~tz?t7A\Ϛo '-cGL>I+pVTJw ()dIU3[vc$8܆᭟@-4h=d{m_f}Y62ͲY* ss `Y
=nhd:tkk]$WY/ٮg5=5[KqsKgvmܣ@7q`MW;mXZ@t?rp?}'PBPZAce2Ymgۘ8!R~%@7eN,4߽BӟZjZYfNvx( h#V ⦞$s`W,y!,{sE?}C8|9CkMCu~OdA4wv#G0 0Z`C#dqRKャ3%:98۽	;XtyuB.V>k:d6_LƋP̑q+okr,;=oQPI75>dך(hz$ȅYh`jzRwؤУdR7?-xEn*E|pk %,iZ02@P^޵Z~RmܽOҮ@j}Ғq09R\:\qbCM;TacG1_ ЧcsGBF-W'#Q2!4,FQEI32Uܨ<`rB5 <ꍤ,t?Luv.V|Uf:3I.Qt>iAuʁfZ-Fm%o4as	Om} -1 6<?Rє\sC<&(Ÿ@nn>fۢ*L<I:%ϩ2<FeȂ,-L}VηQ5J;n&}?mե᧸xׯ1@4tQ B B<net?T&ϏU6EfցY6@n)wlJo8ř˿@A.wSw<Rw
݌Ӈc;wNmRQ^Qo3˾ơm>pOѤ	&?msz4fWt3N׵k׶jnk_jzW|]q_|PrZ;+
jsUhAfT7jDX1rʮ
t2cw;QO`Y#q^n.t:nX?'$N5DUl6Va~d#ÀxS>es"~%PF"M!Ī3ʉ)3I:J8H÷ZnzEa4ehmxN;^ēuS9N"fMV@9DۍĜhPխ+08z;m9lxwd0WaVP;42@Ty0Wzn6 dT :nZhĪ?t"mb0Qs-MyN;^SGnas=TÀKM86WPv$ל(I}	ڈ.Z6gq^[lPzsOSE
9\-;[CW0◶ys,XAWuc̸~ɉy;t(a۷׻|=|=;;c!+w~}{<aP!3lc)$J,cnmmn1`iȂeR!_QǾ:NSq?{=IϡZ
+]R7%At;w$:&[GvHwZ ?fe	aYWI7*뭭\ze͝uKc䕲M\[eKѲbV]7 xV9AECT4в}Lny/ 
8C1Y=[1A$,G賸HcfnITDK_ʴ5skt1]XOk^E&8oƛM\hc5ծݭ䞓}(]FHŝtB&ԕ2uF!Ybgrw{1fn:Y:xHso`ID}׬%/##4]|_Uڨ!NN&9eoH]L`<'	Y-_(TcJ:1;tNGs|R"BN+ɽԚ9}I[<βK:M̏rҢOۍɜyHK
u'؆C;E!1VxHs{E)ùG/F>p7 /`a!E(&@|}/>fRKMde>l$PZWRk/7n-E3/HQ90<
f8%|m3 R,𕎁L8BJ1=1~Lf˕7Mè@p=&.c]YQ6ޮ!7,<Ej)<ں6)QJϰnf"r0chH9`)%ik4ڮxKb`ۘkq7bun<~GTeuKy%.
\\FF/h7bDɈM
ѻ+r'##F뮉Tx[oC{v0E}u27 R~9xJL ]ϽܥjA8ƢJ
QݛnrkA_K,yg~bI`9K=k_pqڅ̫NhvI2ZvB"<g":y~a4_kAvY߯R@1]FrZ*|Ny}!7^YP1:W}z]JG[ҖQt3Xm))ى<ù)\]LUԜ!7V$UN휲n|Dٟs-63M5{`L^'b}6?\۞ǁt \"AbTҧMV'TMNp1KŰN2^}ܣU6ꙷwݧYBd}i*5(?~/@g}abp@~(jr.3k)4:#k_Ę=nRRG;\f7Tޘ/R{}-H*xk!U\ACjشTR4BͰs;GΈfBGo0IeXg8D6byAh>7a<;tk_%&a\8
2_Cq
dn7͆:lOkM_]a$RF1EQn2dFn4[a]MD:`y+܇lW&=<{(~}fC)}1x
 Ʈ*[O<_cisV*ҫGZMH&!C531cPmJ]!Rکg?JQ]Oh i4IQtp>FP$N.}x'SNkn.5ig&UֳI^G[4NF :X/T;ee;n)UVv=Ra\77\tf˯Yed[L	^U~$*Y WӁ%ZlL
~Ϛv[6 -%\@kg<x?Ӝw:ోmqٛEC%׭ Rᒘ`ɀ/jp'*hm7CD?Mp9椩V/ӣfg];LzH%ݙ0O񏟩	 J^5uyu2caoYa^7.]A9	%Re$8vX؅?dtFզx;wq3bo̥=SHzrj|_gԌˊʲa]%(fpΠ$J[-fAɬ͆@ix5I}d6߰ѳy'}:t1*/hRD1vW[S:5T$?s?h&ݫs>uyJw>1gq9)u0	漬F\g-pЎ=+&Gʁw5;/^O\_	oUwS/|0"bzE<f}D/qX	~}M2Jy8Lc7i_4GqF̻;S?O0.ѵ@NY*@/ONSRx5JJfo<E<{3cF:4I]YcSuW}Kwr&}U,bD9!sX0o;Ex~KJCkht19n4LNu	>6".({Gy~^hw5;o9P%N2aWM4.vK$|اm+Yо#x<jnewXz>,d_B[k4xa(Q[ϻί'ً FùѺyޥ%0X/<X.u`vҔ\Q"U#wFzzT\8pP9Z*}Vjҵ'Yb`ǊBGE:²%F1;&)_yN`냓;oqmd'BzNԽy@1=㥐٤0)fFvS )*ݧ~k݀G $p&ۦW#"x={qS߶
4%\/-MrT(¡nW1_:$Z& m$+dd'"AW#&:䦫\ywz&*Glr1е5<JJ.AZUl+fF^÷d}+0b]]|3qUM!W;uWkhaMwR"
-Rf8VkX5!k3`ѝ[bN/
RHc lJ$wD>tmC' Z]1u{> $ 9BOq/Q)¶puVQ;-:F3x TZ^hȀ"L@Ābr݈*/ -m*A=k>n>¯C{859|X^T^ z,R`pU=aQAq{c=nہR]Z
jb1tx*m@tLsŚ6n|rN{㬄u8M]'tBR	x;Z3zD3%T6pi%bob$Li~/gN'oj2y-E`]iھwJ]Z{< pT]8zHC/Сwxg8rC*8+5xz ,ܚ$_x6ݞQaK ŊoAihS7%*F:tkuu8}íϝزMa`MTJ#Éyj XlT%Fu5-i$}ƀ/@'ͪ>I9jQ`4KJ;ue<;)"ά	6>TY F9`1qjsFx%9{bckX\rt47SuqcS_X˟4E9X*\=fp)?>HƲ˔?Q%xn)moLSRճyլ+SR?.TSjD5(g_ud%~$>_HrfE[b _[&2K<*Dd>cT|U(8r	Jq:ZQ
ߡg7?턇2<75K3W+>죍9|j`EK%Ptz (WCE.>+M*RYto#4XQt̳X	Wl5#\gC|Ĵ}Q7QG],ER!E̓
+^1{qż2g*EC|ԯơ,,f[AQ
Pђ܏TM}TݜOmNèZ`"x	XC5~BҦ\v|~mm>맿qQqzq4ɤ,:F4Axy:Gjh;Ǆ< _L&:-`p"עwkͿ?c/./VoyLM>& &cЅY39)I3q'` KW3j3Ø6?ۃ<Β^ rb6)\9GR+Rh6}ZnΔAZHiCy	H@(IA=oM YbSg%*}[TPZ01a#s!ՄA}=mWW ߦ!./3	F
k!U6- On&[FQ~ç#Qϝvs5)n*ǜr}]&u5?"t,87rwliJdTSSU.&Tg=9S@:̃V8gK	ѬˠCg; -ڽ⛕|Ef'SLAW
Ceʴw~=۳eTmRbɒNs\:yƹ@!KQوie(#:'jZ'u`<ۈBvxN0;8/?~?!PfcWY=r'>zGW#Wc9DG 84F=2"sCߝ]VF[h;Lm~7wy$Y.G|ĭdGتS7`>>~'/_QLspݫh@c;QGɂY2J>ECF =H1@{UK!gB8 Z#cFW7F[:-+	դ(DkDm@;ۆc݌MS-DפCf985tɔ5GCs Htx=k6W#Ijƻv<]m.eQ$4bLIy,۩O/~ $5%@Sq~~.>gbq~ɤym4k^&wI	>M2g?|z\\<_g32"ߤ2K[ordFFIA&d2
l(JS8<rQ$"bEǋ"
b8+^$o`>?Coado:*2'%"Akɗix2KbyLF@֩aL<,l+E="UGO"KHfS@t{H1z߽8M^'S	kvx9-shXpPO"M"4Bw@dޱ̫0ü/RDɼ5{@`jPl d1:% 䭗!ERW"͒"hȣW=ex󴉃j_¼O71ަgw/.'uyp/Q"q8s~p0Y"r	>+/lIm:y#~ȑA"X/"@?d@eL$l`"qIZ|M
!i	yR" ">8Y#VT~$
 Џ <o0_	R,oQzH:2	Z`gb:'s5=sR/)Jo#I b37ΩshlprC)0VK1. ˒i}M-߄ $l@ eJ6d$)֐Zg%8$xJ4y
HT)kq @+	sYDL"<͵8Qdx|BLE.{4 N))1 4Yq,?8|h&2&A?c		/	ejU2($!	JIf=?αr>E%'iʸ+	M$$FEJ!t96*&ߩ`%0$5a雯>`NI(l)ZPȍ'߬2kO*y~&+L,O*\*V"
-p,>|>:iqm*Qgs^N_2fd)v?sT_<Y's`fV no\e,2b-	ۛ|	`6TS%o&|SXwA	mHR*ɚEt#.TAVw5[S#lO Tnײhc$ye7,<߰/#kYzQBGZYw/
zo(6b?yD
4TͱT\PҷGG@TO&}tViCGjc趰}	ȓ)P=j[v0J%*bAF ّ VK{YB4ShjSY{ܐݵ,ﴨ[U3ttkByx(R_^b0얈
}R5×I8F$^r9;8Ojs݇[F-_4bD:Beۑx<3;lW
ӝU)HDZ9ލDFVkNqXɮ P
,
QxY?~+R[ZƓf5
,{=7
$M$N³xq4	Njbt ?OS,B_I\	oaj_c<J|%ڠ* *u[bo'oNF(a>6&#4@۸-dkG%;z`ώ5ͻѠl%z8 Ft;Oag1GS ɍ^ж9BjxɏRԴ_r?xBʖܑ_.o"]ѩy-p׮DcģOFNZ{yԣ7O&y%Vw"͑KI˥(ؗI`%K
h!ơ<NUBC
iL `)dr 8?MeUo/DA|݌AxC^00@>/J*6kCgI"2l1F(v0X	0s^OxhH/WcO."(T
0*3&WYJiL±Iqcl!A9oKrt[7js-&'x#nֻjLkhotWynM
Gpɭw
BȘ-VsϒM\87		E'߲9C {X£_%GJQQR+Ikl:Q`,9@$={fe2lx{>m4:*˜u(]#vS>o+T0:ϼOiTrݨWzc,[%*,ohk}%7O>1'VNy	PsAed1ԉK2n2`9 I!t	xU$K9+htLܫ |X4z+֤(LpE;XVw8CnX$5eldgKPO F=1S
@LYթ^..:Nz;9QiJ5epm*3׵P-].G6+N6~E,479ǟ(LP?_i(y,/؃n zr@yM`xLy+<6`Կҕ}Z"A~
.C'+%=8|ymʻU6i%O/mKYY{w&o\7Vd+XIFyTk$RT+"LV4sF0**N.:-g Rˑ|+)siWCc¢U5XTy`"$8Ѕm]&u\PVL?qrQcUhʮ9$H{Z6Q	a7*G؛P<|T|dq(6פ3!=BN4=d%yV=}xƸ_g6oEgnjxVы*25*|$0o\\m)T
č@*o0ܹR3kMuoccۢ 37k#5րX&a},§]DBd 0 FѻT|6sB	~Р
Ca˚}fGk
w"VUYĴ)s916g'F=2@z=~@[n4SroGbPes՘	O,/^aAoJGO+,jb2J@2J^zP2]bCJOcW[5bEg2B#sL(!wi~x;cM{Ѱ-^_(|_2$sU`1RI b3fj
jc/;GRLj8_ӊ-L;N^=OPp=&nq{1XLrIc3(=ܮd&pF	hGԉJ^~'7db15͛OdȮPGD`5TJ]zL-qK/\˔^[ + b ֮SK܋MJRCi:6s/4oڼ`4IH,0)Xmc4);ûFg6nwhOK4}<>M7bnGd4p@JSmyg;'Lg0Kp #'M"gO@EIia檈4lNo gDiRSU{;2,,tFq*j7iEi<Q<r0 ;z4is,-svsov1ƨ_^Yr/Ϗ>op>%{,<~og5Fư3>e,mȤ+[GN\q"1L_}3`WP1_u^8?שׁJN]އ]-3خמl̏6ŪL8ŦPƑyA/n+_e)[O[Q.`1]ƹvҘUٜQ㮍= jt^Xvr>yPghf׶ɳ$Q߷y Yd趆dQqVu-lneyZ%CYg5WϬswQ拷i-Bx^1&y$r\pC*Կ?,Hri;1@č@\;!iG^C=ė.W1GQmcMqOQwU7|D	kE<k5^ZMrCĮ=7N]؋
:]k+F#@ U\g0,vFXyS<hdQP;
rfr3ʀ%:xw?BrFKs"_4k TՒXK5=!BEya@~fh0)xa:GqJ_2ؤ|X:v/}]|r]xnǕxYʠ\-/Zk _cxr	h|I'$:D
s]	n\}he:zneli "K&92qK\+Puxu1նBԵ.8ߠMeh`m8Z$R0ېΥDupW7@zwWO^ǩk[.hNcN6n[W7vɌY)#Zа`ha-aۦnb쥡^쥱j3fƜ.q?PZsqlῸ]77x]1`EvM~篣I.AEt* w Y-ӊy$W%a|4FpYW\r"BwhW:k!4wWdﮞM	bEU;U^u;4x2w GZ (pԷ3!p#ϟF(M0t(%uFƼrc8.ib7;>;*u7wP˾Nui\5;`>BLK	eH>h7:(S |h^WeԶse0g-L@.kF]lvyc]랤6/B l^״n-_-6T[Q=[Zdk}$hUQy3Pq>*YL0GDcR:!"fǽkV^EUKtO0r'Z?Vxa8#:;1oxs1;rç=>mkf	\>8Py,6i&ttԏbW/״û2jU;X2)88:הק>WO[COBٛCֿkW.cw\ab)v&s@D"x2jqw~)S9+.el<PA$w[?b;#Pbɷb5UteyVcer)3:W-~bv_1syx-_|[aJf) Nٿ~|ccWgɌZ+jD<Ƶ)7\{)RA}Ypi>yj4-_Mo3#SeDe&iN񄊫2u!~<߇?u=ԛ3ŋEU\Ng*y\4)Pև2	&%vL%nÊޮ:Av[/s<[>quE{Y7NP$T%_"}R!PsNgJCR!Э5|uy[sߣp)-X³
e
^@;g;R:a5KwKqI]n/55ϓ \ng"-?#g5Z.*oSQ+ER`yҺp[0Cl?IMVEK{Y٨fLR&Wn*d40;u&8^s2^]ҁ6k2k
 v3*,͖K-p)ץoDD i~^[x6ol@,]W`[a &RA΋sƋ载_wdkQí,3vx>ID
t;c<W{:r}U]ɤ7`Y-{D롻;B2޽l]>v6`q[U$-!Ti>mWBL6De;Z-џ7yg\@1NE+]PX':.mӯ:jƔ<}{ǻR6\#Hݫm.Ƽղa91-(֪ձkЌkۀUžMzf|o[:W:7 ZV7B#L6GZYu!pԞظU]8l^ob㬜scQrܯNcSMѣ"`u$߿~-fmીXR
mb=xSR{܁850>mu=y9Rp֧,;*	hǩ^oA&lx	~AOf4M%H5D? ϦKT&7.-1/u6sET뱅~QEB\,7cxĂ(2ܭ#\lpTҐ5E[[\bނ	e`h7="[eI;8[,D=um,Cրc0.]k00JUI2tmYXFޟ;"fUQIJ(SB nG $g֥T9/*D6r&ƕV,d=hVNzcwf0)}5[A7B[dd6㓖^2DPOs7'Y<@#>p%gpalVR"zi|kA.4SO+O#}UˏAKR>@Sn_5t^z]=?>:6^TT Eg蠑F1Sfu
m#4F~n1jzԠllD^YWC*.UcHͷ>^xp(w\zs\{|
6Gw@0|0UQ;ZANxMZV!/0%x340031Q02025054-IIM/J--OLI-MJLNKMI-LMaZ_f{&|^>_$z	H\f?ȳiՌ~RyQgILm{u6 yɉ%yH<h GG̽#<)djutusrR{	aTu1mw4^X<oaojnnzfIjnrfrnn&0tSW]uR7.I;6k!H,rRM-qvvx^~U1n wx[y2FC3##3]s]#ݔ<Ģ̒ҢTݢ⒢R0[/7!^ F燇.j-. {xrHb*/;o6.p{`Xh6ڪ{{{HwJik[/TDb$"YO=S98`ãׇ~`^ORfI՝ߞeqX6ص2JɇA5l4jO26ؘJ%	}'Ž]kfA*U|l˗iv[a)^.xcf<_H*4~O׬{ݯ7X"&q@W%ZKǱ,ZR =\p}L\T^8afM[Wknx&Q17H	@hPd44Tc=ێɔ*%(r$72d|tKֿ)ŗLPjFU@sirÖZmJs=!BNŹ:-j.́!OL|ko^4HĂP<@Zh-KE$,o>CLz|jf]%CAEe9NBKހ>+	 ۀ[Zu'CZ<%V2-N_G`|H%DNȯ)ْQ%Lpb5aZ7b	
̏rz0{miD,4=/<?A4PҪF$BK""v|Z(h%Ys1onM
	~?Khsz4[<-	s˝tZdQ*S4C|sBhh'G)LD(ϨtQ;T(*
{q  PK(nEb#4O\VzE.{g՛JxjvfH/@6yonUnW>^O*?/\[dx TԒ [>bUQ1	Ċ:lMpSDfnϛK䝏gϓzMǮW+ݛ=pAj32@(fqn7>lx3 T$.KATsֿ{ADx|#ȍd)bagq+hr Q*пޠ{u]f<Yqb	ësn_x3+:Z '&.<Oẙ
ʻ?9dp۵A^T/&%]trڕu蒉<;)-(C\d[jڏ80`#dJVgb7Dza/lqBTCu~p ^xL>:zʢp$kI	\OL¥=."P(D& yu>9::*);TG&Ƴr;'Ϋp.2!)drJ#񋢧 $]PBw{RB=Wp*p>5w8>j~7PV.tTzefk_+^4ir5#=JS3Ko?zk%!Rz~O,Vz-xvng4lhkm:93$Y>Aښ+_Ʃy7xcnlZf@K㘁v)54nZhۃ>\{D,7:>>1%֫C,K#s2!ːcO2EqHUaO<BzpHeTyGmG |J6lF$#"39)7T"AVDpq!#S~hD'08.J-;M7	-ss(Kʴu
+T}۾;A5+:"YnE7Kpf|#G;g0~y.6 $ܘ{DP75w^[h.Yyףқ7?x{<o0plпOgG}]MN!vlÐ0NtP݈N쑸0JuPTE""n%&wѥ6<Sqz{>X0L0o2nq 홰6w	û"34׸YIP'ly3t=EҶ@l3TxЈjh'$iXy^%흫qGAAq1bd@JKa4XYr+A4 ow!`glhup l0|C$0bv@hsG+f_rYj1]Q
q=-]:K]$U	-Z⾐ LBE wM`0老d:eсzN	9fJu$-58I$J%)"3B\7?i	i	i߉ϸgMPt/'!ݾ<`kuOqEnp"x[pDSR.z@tlhGEkb|~$]*uQVg7ކ4QU;W|@.|rß8V> h<'G9TZB,VJTjIJ2EyP</d˥;-WPSۄd0;dj8vX(iB2Zwڙt@)w|fIP7]*<zOVm HnE=@]F;*e5ܚv:G|%"M,W.3{H#$DԖ2SPGy`l"KÐ<kIMsǌH+y+`6CG2+rى׭>e I,J݂sRʮ\/MݿxVo6~_qh-	*Im0'^N=tHFRqawGҎl$<}K4Ѳ>u&uFzL+;Ukؔuhs>åV ̰{7`!}+]o@,:h]\Z\)w.`쀫!_ӤB'd?p]CS%)N^%Z$4uT|MѵM 2i6*x>;>!;}{'`+EE/uT}23va#7ZIgHYpt!-r̓1KR8:
!Z;4_>\UL
J.B.'ԽEٱ.ZU>'Ք,d TN	Esf6+nưPl&7,FSph*M:]Q
EͣWK vçZQ5:JN5:tiYoyԲe"3nkpp>g49I^W˲(T3v1'&s Ճ*Qҩ:(F?8s>;d7ʒu:Wh@^7غ5u\BR
GjHfӘ}*Q(zT9d*RuV{H/J)Y_\-U%SfT+o@XU^#Z2\Gq N߾}+QId]UDɪL<7}w]Yf%@>,WTݫ:Jf^bQ#thonxrW.%^Ȑ4\3OԚ)%Hn6}ޞ}_n2/^>?8LɈşϗ`G`n2;(oOn.ǳgzލLvN?.Wqd3gZ|v\bl̴Z@!2L4RL8{ƅŜh4~#(8*">+YjtKѦ5g!KI7$M~B^B}K6KOqy;|b)D-EA<});X(MC̨Bǭ]y/ix)b$D:lS<Ɉ..)LdtFТ/hȞ_x`m,9j)gtexz$tن145Z$߱QIv%Q(a?x
[Y}u3?Bo'=ǌ_MyIDMLcʒ/;ד0^ė(rQ*zFLũb
>5F=eAǴy/7ȧrREnN5%;*mIl>nDyli(lITQ-,7 F[F.G<T5ŢV_4޴<W_n3&Hm\UW\m8GgyG}j<A'77bˬ]T17V
8-8cz;bLvoGG@axT;$d0"=z5AR/. STHx
p# Design: Multi-Format Upload and Anti-Duplication Feature

This document describes the design for supporting all file types, media, documents, music, images, and other assets, alongside a robust anti-duplication feature based on SHA-256 (HTTP upload) and `telegram_file_unique_id` (Telegram bot upload).

## Requirements

1. **Anti-Duplication**:
   - HTTP Uploads: Compute SHA-256 hash of the uploaded file buffer. If a record with the same hash exists, return the existing file's download URL without uploading again.
   - Telegram Bot Uploads: Identify duplicates via `telegram_file_unique_id`. If the same file is forwarded/uploaded to the bot, return the existing download URL without forwarding again.
2. **Support All Formats & Media**:
   - Handle all media categories: `document`, `photo`, `video`, `audio`, `voice`, `animation`, `sticker`, `video_note`.
   - Fall back safely to `document` for any other unknown MIME types or formats.
   - Size limit for fallback/unknown formats must use the document limit (2GB).

## Architecture & Data Flow

### Database Schema Updates

We need to add a `file_hash` column to the `files` schema.

```typescript
// src/db/schema.ts
export const files = pgTable('files', {
  // ... existing fields ...
  telegramFileUniqueId: text('telegram_file_unique_id').unique().notNull(), // Add unique constraint if not present
  fileHash: text('file_hash'), // SHA-256 for HTTP uploads
  // ...
});
```

### HTTP Upload (Deduplication Flow)

1. Receive multipart form or JSON payload.
2. Generate SHA-256 hash from file buffer:
   ```typescript
   import { Bun } from 'bun';
   const hash = Bun.hash.sha256(fileBuffer).toString('hex'); // Or standard crypto
   ```
3. Check database: `select * from files where file_hash = hash`.
4. If record exists:
   - Return existing file details and download URL directly.
5. If record does not exist:
   - Run standard upload flow to Telegram storage.
   - Insert into DB including `fileHash`.

### Telegram Bot Upload (Deduplication Flow)

1. Receive update with message type (`document`, `photo`, `video`, `audio`, `voice`, `animation`, `sticker`, `video_note`).
2. Extract `file_unique_id`.
3. Check database: `select * from files where telegram_file_unique_id = uniqueId`.
4. If record exists:
   - Return existing download URL.
5. If record does not exist:
   - Forward to storage channel.
   - Insert into DB.

## Testing Strategy

- Unit tests for:
  - Hash generation.
  - Media type parsing (`getFileType`).
  - Size validation.
- Integration tests:
  - Uploading duplicate file via HTTP twice -> second upload returns first upload's `public_id`.
  - Bot receives duplicate `file_unique_id` -> returns existing link.
n	xHN# S3-Compatible TeleUploader + Web File Manager

**Date:** 2026-07-06
**Status:** Approved

Objective: Transform TeleUploader into an S3-compatible storage server (Telegram-backed) with a web file manager UI. Existing upload/download API paths remain unchanged.

---

## 1. Architecture Overview

```
S3 Client (aws-cli, rclone, s3cmd, MinIO Client)
         │
         ▼  AWS SigV4 + XML
┌─────────────────────────────────────┐
│          S3 Protocol Dispatcher     │  src/routes/s3.ts
│  - Parses path, query, auth headers │
│  - Routes to S3 operation handlers  │
├─────────────────────────────────────┤
│   JSON API v1 (for Web UI)          │  src/routes/web-api.ts
│  - Bucket CRUD                      │
│  - Object list/upload/copy/delete   │
├─────────────────────────────────────┤
│         Web File Manager            │  src/routes/home.ts + home.html
│  - Bucket browser, upload/download  │
├─────────────────────────────────────┤
│       Database Layer                │  src/db/buckets.ts
│  - buckets, files (extended),       │  src/db/multipart.ts
│  - multipart_uploads, parts         │
├─────────────────────────────────────┤
│       Telegram Storage Layer        │  src/utils/telegram.ts (existing)
│  - Single Telegram channel           │
│  - Files stored as Telegram docs    │
└─────────────────────────────────────┘
```

### Component Responsibilities

| Component | Responsibility |
|-----------|----------------|
| **S3 Protocol Dispatcher** | S3 fallback route (`$`). Parse Auth header, detect SigV4, route by method+path+query |
| **S3 Operations** | ~20 operations (ListBuckets, GetObject, PutObject, ListObjectsV2, MultipartUpload, DeleteObjects, etc) |
| **Auth (SigV4)** | Verify AWS SigV4 signatures, reject invalid/missing with 403 XML error |
| **XML Builder** | Template functions to construct S3 XML responses. Parse incoming XML (DeleteObjects body) |
| **JSON API v1** | JSON wrapper for web UI to avoid XML parsing in browser |
| **Web File Manager** | Single-page app served at `/` with bucket browser, upload, delete, search |
| **DB Layer** | New tables: buckets, multipart_uploads, multipart_parts. Extended files table |
| **Telegram Storage** | Existing forwardToStorage/getFileInfo; unchanged |

### Routing Priority

Route matching order (existing unchanged, S3 added as catch-all):

| Priority | Route | Handler |
|----------|-------|---------|
| 1 | `/api/upload` | Existing upload handler |
| 2 | `/f/:public_id` | Existing file redirect |
| 3 | `/file/:public_id/info` | Existing file info |
| 4 | `/health` | Existing health |
| 5 | `/docs` / `/swagger.json` | Existing swagger |
| 6 | `/` | Web file manager UI (NEW) |
| 7 | `/api/v1/*` | JSON API for Web UI (NEW) |
| 8 | `$` (catch-all) | S3 dispatcher (NEW) |

The catch-all route (`$` in Bun.serve()) inspects the `Authorization` header:
- Contains `AWS4-HMAC-SHA256` → handle as S3 request
- Otherwise → 404

---

## 2. Database Schema

### 2.1 buckets Table (NEW)

```sql
CREATE TABLE buckets (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT UNIQUE NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

-- Bucket name constraints: lowercase, no underscore, 3-63 chars (S3 spec)
```

### 2.2 files Table — Extended

New columns added alongside existing ones:

```sql
ALTER TABLE files ADD COLUMN bucket_id UUID REFERENCES buckets(id);
ALTER TABLE files ADD COLUMN s3_key TEXT;           -- e.g. "images/logo.png"
ALTER TABLE files ADD COLUMN storage_backend TEXT DEFAULT 'telegram';
ALTER TABLE files ADD COLUMN is_deleted BOOLEAN DEFAULT false;
ALTER TABLE files ADD COLUMN multipart_upload_id TEXT;

CREATE UNIQUE INDEX idx_files_bucket_key ON files(bucket_id, s3_key) WHERE is_deleted = false;
CREATE INDEX idx_files_bucket_prefix ON files(bucket_id, s3_key text_pattern_ops);
```

- `s3_key` is the full object key path
- `is_deleted` enables soft-delete for S3 DeleteObject
- `multipart_upload_id` links parts to their parent completed multipart object
- `public_id` (existing) remains primary identifier for Telegram redirect
- Existing non-S3 uploads have `bucket_id = NULL, s3_key = NULL`

### 2.3 multipart_uploads Table (NEW)

```sql
CREATE TABLE multipart_uploads (
  upload_id TEXT PRIMARY KEY,
  bucket_id UUID NOT NULL REFERENCES buckets(id),
  s3_key TEXT NOT NULL,
  initiated_at TIMESTAMPTZ DEFAULT now(),
  status TEXT DEFAULT 'in_progress' CHECK (status IN ('in_progress', 'completed', 'aborted')),
  initiated_by TEXT
);
```

### 2.4 multipart_parts Table (NEW)

```sql
CREATE TABLE multipart_parts (
  id SERIAL PRIMARY KEY,
  upload_id TEXT NOT NULL REFERENCES multipart_uploads(upload_id) ON DELETE CASCADE,
  part_number INT NOT NULL CHECK (part_number BETWEEN 1 AND 10000),
  telegram_file_id TEXT NOT NULL,
  telegram_file_unique_id TEXT NOT NULL,
  storage_message_id BIGINT NOT NULL,
  size_bytes BIGINT NOT NULL,
  etag TEXT NOT NULL,                     -- SHA256 hash as etag
  created_at TIMESTAMPTZ DEFAULT now(),
  UNIQUE(upload_id, part_number)
);

CREATE INDEX idx_multipart_parts_upload ON multipart_parts(upload_id, part_number);
```

### 2.5 Multipart Storage Strategy

Each part is stored as an independent file in the Telegram channel. The DB links them logically:

- **UploadPart**: forward part to Telegram via forwardToStorage → record in multipart_parts
- **CompleteMultipartUpload**: create a single `files` row with `multipart_upload_id` pointing to the parts. No new Telegram upload needed.
- **GetObject for multipart object**: query parts ordered by part_number, stream sequentially via Telegram CDN URLs. Client sees a single continuous download.
- **AbortMultipartUpload**: delete DB records only (Telegram orphan files are accepted as GC is not feasible)

---

## 3. S3 Protocol Layer

### 3.1 Full Endpoint Coverage (~20 endpoints)

#### Bucket Operations

| Method | Path | Query Params | Handler Description |
|--------|------|-------------|-------------------|
| GET | `/` | — | List all buckets → `<ListAllMyBucketsResult>` XML |
| PUT | `/{bucket}` | — | Create bucket (reject if exists) → 200 |
| HEAD | `/{bucket}` | — | Check bucket exists → 200 or 404 |
| DELETE | `/{bucket}` | — | Delete empty bucket → 204 |

#### Object Operations

| Method | Path | Query / Special | Handler Description |
|--------|------|----------------|-------------------|
| GET | `/{bucket}/{key+}` | — | GetObject: redirect to Telegram CDN (same as existing `/f/:public_id`) |
| HEAD | `/{bucket}/{key+}` | — | Return metadata headers (Content-Length, Content-Type, ETag, Last-Modified) |
| PUT | `/{bucket}/{key+}` | `x-amz-copy-source`? | Without copy header: upload multipart form → Telegram. With copy header: copy existing object in DB |
| DELETE | `/{bucket}/{key+}` | — | Soft-delete (is_deleted = true) → 204 |
| POST | `/{bucket}/{key+}` | `?tagging` | Return 204 (no-op, tagging not implemented) |

#### Object Listing

| Method | Path | Query Params | Handler Description |
|--------|------|-------------|-------------------|
| GET | `/{bucket}` | — | ListObjects v1 → `<ListBucketResult>` XML |
| GET | `/{bucket}` | `?list-type=2` | ListObjectsV2 → `<ListBucketResultV2>` XML |

Supports: `prefix`, `delimiter`, `max-keys` (default 1000), `continuation-token` (v2), `marker` (v1), `encoding-type=url`.

#### Batch Operations

| Method | Path | Query Params | Handler Description |
|--------|------|-------------|-------------------|
| POST | `/{bucket}` | `?delete` | Parse XML body `<Delete><Object><Key>...</Key></Object></Delete>` → soft-delete each → `<DeleteResult>` XML |

#### Multipart Upload

| Method | Path | Query Params | Handler Description |
|--------|------|-------------|-------------------|
| POST | `/{bucket}/{key+}` | `?uploads` | Initiate: create multipart_uploads row → `<InitiateMultipartUploadResult>` XML with UploadId |
| PUT | `/{bucket}/{key+}` | `?partNumber=N&uploadId=X` | Upload part: stream to Telegram → record in multipart_parts → return ETag header |
| POST | `/{bucket}/{key+}` | `?uploadId=X` | Complete: parse `<CompleteMultipartUpload><Part><PartNumber>N<ETag>...</Part></CompleteMultipartUpload>` → create files row with multipart_upload_id → `<CompleteMultipartUploadResult>` XML |
| DELETE | `/{bucket}/{key+}` | `?uploadId=X` | Abort: delete records, update status → 204 |
| GET | `/{bucket}/{key+}` | `?uploadId=X` | List parts → `<ListPartsResult>` XML |

### 3.2 AWS Signature V4 (SigV4)

Authentication process for each S3 request:

```
1. Extract Authorization header
2. Parse credential scope (date/region/service)
3. Reconstruct CanonicalRequest
4. Compute expected signing key
5. Compare signatures
6. Match → proceed; Mismatch → 403 Forbidden with AWS XML error
```

**Canonical Request construction:**
```
<HTTPMethod>\n
<CanonicalURI>\n
<CanonicalQueryString>\n
<CanonicalHeaders>\n
<SignedHeaders>\n
<HashedPayload>
```

**Implementation notes:**
- Only verify signature; do not validate timestamp freshness (simplified, acceptable for single-server setup behind reverse proxy)
- Hard-code `region = 'us-east-1'` (irrelevant for functionality; S3 clients accept any region)
- Single set of S3 credentials (`S3_ACCESS_KEY` + `S3_SECRET_KEY` in env)
- Chunked transfer encoding (`aws-chunked`) — not supported initially; client must use standard PUT. Return 501 if detected.

### 3.3 XML Serialization

No external XML library. Template strings for responses:

```typescript
// Example: ListBuckets response
const listBucketsXml = (buckets: Bucket[]) => `<?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>${b.createdAt.toISOString()}</CreationDate>
    </Bucket>`).join('')}
  </Buckets>
</ListAllMyBucketsResult>`;
```

**XML parsing** (only for DeleteObjects body): use simple string matching / regex on `<Key>...</Key>` tags. The DeleteObjects XML is simple and well-structured enough for this without a parser library.

### 3.4 Error Responses

All S3 errors return XML with HTTP status:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<Error>
  <Code>NoSuchKey</Code>
  <Message>The specified key does not exist.</Message>
  <Resource>/bucket/key</Resource>
  <RequestId>...</RequestId>
</Error>
```

Common error codes: `NoSuchBucket`, `NoSuchKey`, `BucketAlreadyExists`, `BucketNotEmpty`, `InvalidPartOrder`, `NoSuchUpload`, `SignatureDoesNotMatch`, `AccessDenied`, `InternalError`.

### 3.5 Presigned URL Support

`GET /{bucket}/{key+}?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-SignedHeaders=host&X-Amz-Expires=3600&X-Amz-Signature=...`

For presigned GET URLs:
- Verify signature (much simpler — no body hash required, querystring-based)
- If valid → return redirect to Telegram CDN (same as GetObject)
- If expired → 403

Only presigned GET is essential; presigned PUT is optional for v1.

---

## 4. Web File Manager UI

### 4.1 Served At

Route `'/'` → `home.html` (single HTML file with embedded CSS + JS)

### 4.2 Layout (responsive)

```
┌─────────────────────────────────────────┐
│ ☰ TeleUploader    ◉ my-bucket ▼ [+]     │  ← Top bar
├─────────────────────────────────────────┤
│ ─────────────────────────────────────── │
│ 🗂  images/            Jul 06   2 items  │
│ 🗂  documents/         Jul 05   5 items  │
│ 📄 logo.png    2.4 MB Jul 06  [⋮ ▼]     │  ← Actions dropdown
│ 📄 report.pdf  1.2 MB Jul 05  [⋮ ▼]     │
│ 📄 photo.jpg   3.1 MB Jul 04  [⋮ ▼]     │
│ ─────────────────────────────────────── │
│                              ↑ Load more │
└─────────────────────────────────────────┘
│ Drag & drop upload area (footer)        │
└─────────────────────────────────────────┘
```

### 4.3 Features

| Feature | Implementation |
|---------|----------------|
| **Bucket selector** | Dropdown, fetches bucket list from `/api/v1/buckets` |
| **Create bucket** | Prompt for name → POST `/api/v1/buckets` |
| **Delete bucket** | Confirm → DELETE `/api/v1/buckets/{name}` |
| **Object listing** | GET `/api/v1/buckets/{name}/objects?prefix=X&delimiter=/` |
| **Folder navigation** | Breadcrumb from prefix, click to drill down |
| **Upload** | Drag-drop or click → POST `/api/v1/buckets/{name}/upload` |
| **Upload progress** | XMLHttpRequest.upload.onprogress |
| **Download** | Direct download via GET `/api/v1/buckets/{name}/download/{key}` |
| **Copy link** | Copy full S3 URL to clipboard |
| **Delete** | Confirm → soft delete |
| **Search** | Filter by key prefix (debounced) |
| **Credentials info** | Show S3_ACCESS_KEY + S3_SECRET_KEY (from env) in a modal |
| **Dark/light mode** | CSS variables, @media prefers-color-scheme |

### 4.4 Web API v1 (JSON)

These endpoints enable the UI without XML:

| Method | Path | Response |
|--------|------|----------|
| GET | `/api/v1/buckets` | `{buckets: [{id, name, createdAt, objectCount}]}` |
| POST | `/api/v1/buckets` | `{id, name}` |
| DELETE | `/api/v1/buckets/{name}` | `{success: true}` |
| GET | `/api/v1/buckets/{name}/objects?prefix=&delimiter=/&continuationToken=` | `{objects, prefixes, isTruncated}` |
| POST | `/api/v1/buckets/{name}/upload` | Multipart form → `{key, size, etag}` |
| DELETE | `/api/v1/buckets/{name}/{key+}` | `{success: true}` |
| POST | `/api/v1/buckets/{name}/copy` | `{sourceKey, destKey}` |

### 4.5 Download via Web UI

For the web UI, download doesn't use S3 headers. Instead:
- `GET /api/v1/buckets/{name}/download/{key+}` → redirects to Telegram CDN, similar to `/f/:public_id`
- This bypasses S3 auth (which the browser can't do with SigV4)

---

## 5. Existing Routes — Unchanged

All existing functionality remains intact:

- `POST /api/upload` — multipart + JSON file upload
- `GET /f/:public_id` — file redirect to Telegram CDN
- `GET /file/:public_id/info` — file metadata
- `GET /health` — database health
- `GET /docs` + `/swagger.json` — API docs

The existing upload flow does not interact with S3 bucket/keys. This is intentional: the JSON API remains for simple programmatic upload without S3 complexity.

---

## 6. Security Model

| Layer | Mechanism |
|-------|-----------|
| **S3 API** | SigV4 signature verification. Single credential pair from env |
| **Web UI** | No auth (internal tool). Relies on network-level security / reverse proxy |
| **JSON API** (existing) | Rate-limited only (same as now). No additional auth |
| **Rate limiting** | Applied to S3 operations per IP |

---

## 7. Implementation Order

The following build sequence minimizes blocked dependencies:

| Phase | Tasks | Depends On |
|-------|-------|------------|
| **1. DB schema** | Create `buckets`, `multipart_uploads`, `multipart_parts` tables. Migrate existing DB | Nothing |
| **2. DB layer** | CRUD functions for buckets, multipart, files extended | Phase 1 |
| **3. S3 Auth + XML** | SigV4 verification, XML builder templates | Nothing (parallel with 1) |
| **4. S3 Bucket Ops** | ListBuckets, CreateBucket, HeadBucket, DeleteBucket | Phase 1, 3 |
| **5. S3 Object Ops** | PutObject, GetObject, HeadObject, DeleteObject, CopyObject, DeleteObjects | Phase 2, 3, 4 |
| **6. S3 Listing** | ListObjects, ListObjectsV2 | Phase 2, 3, 4 |
| **7. S3 Multipart** | CreateMultipartUpload, UploadPart, Complete, Abort, ListParts | Phase 2, 3, 4 |
| **8. S3 Dispatcher** | Wire up catch-all route with auth detection | Phase 4-7 |
| **9. JSON Web API** | All `/api/v1/*` endpoints | Phase 2, 4 |
| **10. Web UI** | home.html with bucket browser, upload, delete, search | Phase 9 |
| **11. Tests** | S3 auth test, bucket operations test, object operations test, multipart test | Phase 4-8 |

---

## 8. Configuration (New Env Vars)

```env
# S3-compatible API credentials
S3_ACCESS_KEY=teleuploader-admin       # Access key for SigV4 auth
S3_SECRET_KEY=your-secret-key-here     # Secret key for SigV4 auth
S3_DEFAULT_REGION=us-east-1            # S3 region (cosmetic, affects signature scope)

# Existing vars unchanged:
# BOT_TOKEN, ADDITIONAL_BOT_TOKENS, STORAGE_CHANNEL_ID, BASE_URL, ...
```

---

## 9. Testing Strategy

| Test Focus | Scope | 
|------------|-------|
| SigV4 auth | Verify signature verification, reject invalid signatures |
| S3 Bucket operations | Create, list, head, delete buckets |
| S3 Object operations | Put, get, head, delete objects (via Telegram) |
| S3 ListObjects | Pagination, prefix, delimiter, continuation token |
| S3 Multipart | Create, upload parts, complete, abort |
| S3 DeleteObjects | Batch delete XML body parsing |
| S3 Error responses | Correct XML error format per status code |
| Web API | JSON endpoints return correct data |
| Non-S3 routes | Existing routes still work (regression) |

---

## 10. Open Questions / Future

1. **Presigned URL support** — GET presigned URLs in v1; PUT presigned for v2
2. **CORS headers** — if web UI served from different origin than S3 API calls
3. **Versioning** — not supported (single version per key)
4. **ACL / Bucket policies** — not supported (single user model)
5. **Lifecycle rules** — not supported
6. **Website hosting** — not supported
7. **Default bucket** — consider creating a default bucket on first start for convenience
8. **Upload via S3 API directly to web UI** — web UI could also call S3 API directly for maximum compatibility demo
9. **S3-compatible client list** — tested clients: aws-cli, rclone, s3cmd, MinIO Client, Cyberduck
 	.xXrܸ}W`yH*NUǊUrIIĐ!V$ '_eFDFӧLAIqZꝸ['FS^oM={&[dٵ)j%Nog[QEB
b:EV^]t{h	b=ΐAU-wʋF?CDg;ݩFgٽt[fg"Ҕ\[r:|7m57{^]B}Y6NDqNJ9zywGQ=[7XXdٻƾ}S	!`[MFѢ9gc;G;n9<(b6
Ym9l:/m{N9UW?u/Z9%
UV˽u)_  vؑ"7u!d㔬B]9[PgB<i22FE.mrkqЂu'$5A}z]!4HǠݯr![-+!JxvTdv'D`!~ح?>em;U.vNuDccOwrH,RqږbT1eeQ
b=2ڌqA&܁a3PKudӱGɣRMeŬ\Ml_kA˫ҡ|=0#jO]W+m2o(z fHUO8]a!*Byp3kUKgƲJ ciٓJX9q6F-CfJR<5pmۣܪLň kY'q GTXf\Nxa郰1D{!fL<Yǅ54A >ÓDpǣ2V8
&לH,݆Anx-5ct(߷?EDx	SCS8%e\MHps7, &BA;hy<g<7NRU(lN7*[6qAig.c+5/c*X}Fc&到_X:>XML8݀3\\~%O5:<2%2M+N,.b;+ȑkЃ\j).(@&V`jW=zRl,phA&Sѫ"Ijǆa}MOT#F0O6-Щ;
t3Q א	gT[-
n&VQ94hŞfPgABd_i}VβqUNx ~8E"TqG$@>?Y\7w8Ȫ>O-VĚBppq"Q8kC!z"Rcc6d5;tJLf^v+u3nA^5MLͼO^Q1sx^4j>I"-؁axuçۛ\fF~(.׿LDReQLDNBq~IzR7!o^Ο):U1{Kzy=LP}7חzUx?"5&ɽiv<wU㓒~VMWp?LGsZEӾeLJ6^FyhݜIaOZmhQϡ>iYm~#MHGED&?pJIhI$r(qb'He':eFY,,zb6Q/O@x$vW{=
#B~Q@>1="bzEa%" `coc7pLӛȋ	z?cVb򑸤7}(-RI|VSR鱗'Q"^ (xH笠MERQTwD@lt0Mq)R0D4]zv{wM>9*+j!6?VDlv.;qOxkb.`rdKoD~,'A6DZ+RwC	b("~pjbRguD́Y7B4Fc-?ꗽ;8GĆ?dq@m2glA>l̶7I'!ව>&4`ԖiG5pGB6nZͩD&xO]/9
8%<ƃ|M7b&Tznib0vƌxNEMQwF/.vb6?yDY7"|Ml_Z#F|AjK^"=RO>zH<`]Ի~B@xFx9ag<!,Ρ>Nu4h(Kd#ÎYNQaΏw+x}Xn8}W$V۱,dxdƘ\3bcIԒ`}OKĺ:bq,lݪ`2a˿*mkͺɲ*qtx<?'UUUk6Z8]+Әf-{ېbGZ^F\Jj+JTZN{nA,s8;W[ .UEet'eVBaleln֚&̄.8׿㙸5ac TQ6q֥άZ

stmuS,k2S5'Yd../otF Ϛ@Gxs%cMj*9ώ搥G>-ֺT	\nQV4xUөh "ZTvm
U3 RW?کZyGkJbdp{>*kJE|HT=HS`րmɁ_o灸Z_S]yi+́&y<rb!* J* KzWllPW27P>]Ȫ' յѾ`AS.oq5?eqzĲ+QDLPjw7ƅ \3|ŵӋs}JdpSke$(jy`BSB=Fm)P0Mx8eigoFjYvCB>ʒ^FvbPz)n(fy%6j!s 3nBuP݄>[/K^T^wP_i*UP|X,xLz.$k~RSPW*	jCtNz\]׆ĊPV<GCzsΑK_.zWTõ֘,o $UGEar$뼆f:z7[f56XV0:7egbD߲rB{rpPYD
B%Wz=vP>XFؓ[v0*ufmYMaImvJ2UUٚū Em?nF[U&|RGgg(5-aеuSL		&O#UʝO-74V	HOBàgӚ1aT*խ5S.њ~HSR҉ߢܣD",ǭИ:68ʋOWgn bx[k c=&rOP3q|x4`
43izee\=l#웼__U7T`u)~&YWAI:w,Rڭ|/va;
Ɵ@b{x^$̽o량ԜCO"MYx*Ɣ LYPrγ|<'B򕽔(ϣU>bi.x?CT<0G3*=<wDsՍ2By^_iRB)9ZoB4	HnSmّhOY>?a2Q>DO<+ESJ+$O^>Kߡ#yMOfǰKG0[M3ĩB-Oh#K?¬	<8`U*	7ΑwYWć"|LC<iN
yʵ@lL`VAu
/gDq$).+u(.?`bğ@mw&eIZ"0b{c62A^2oQSω8Ƴ4ۅPkcՌ%)fAlI>$ӗ3)Tϊ_h̗<!9|gGGöxjAfyC3kyDu/ADbqjSvwFB.q@:kZo/e,;-Qa߳qcM=Hΰn%	t}w?yH1-N8MF5*3<R$#&aDeUB
+D+Z7 D!)@,3|a*5
J,ś
-_ڙo	rg.M=HBŒeٶ3~a9JTiў],Aͯ*74>;!:98koah NK;Z?B6KjB$Ra0mq@%V86aw`%ʹrIO[NѠMk+2YF~r4~ń@n?eܯځx`OޮxfSPwWO)HEWb.ׄ^HMy_\-Su~32lDt$<z'Ta{w˞tw/e5P
7z"Y=c۞f<w&6d4\$z$Ζc׍[5EӞ
u?5"Y[TG×lX+4eJWoxa7=9 .U8vS9?(٪Gҕ
O}utl$$S	߆1wJi="2>I7rg:]`To!4$6)DG'Gb\]=2DTwO,y5~ok]UjßQTꕂR!<wy?/@8"FTA}4|CLv^7|9)Qj&OXi|,+T\bkZ)WD{o5=`^L;\vUERGFx[KoɑׯHMCeM%^IIjva@WWewY]Y"e{|HdzuUS<B̈wFm#sqK?YLIq.r˧R]+?͢XJ}-<~l_~n*Ǉ.orM2f<okKekD*˅Ae21"PZ\K-|1}#*zLFd gnNǓ0-[ 	T,{7*~21G8J8:eI=3)ND1LLsZXͣ (ԒPȬYX7;޿8=1J [%eBϘ#b*͍xuyyV4;E,Kk?KAB&YzwdD+;;Zvu_1#Ȣ]3ŝI_c5
$ʏE	 IoRbנHhsr4 JcQ2_@*ȗ.oS[/@рQpsʅ<*SEOm}ᓑOLbOZM(w'u
"M̾S~WŎejN\8f?`TȨlU~G.S1$p1$wkb% \2a!uiϗ@Hy#R:"]Bb2	V8k3-Wn +`P <$wbbSh#,K|']EX,+$zG'ؖFeWx9uPM	VHi>U$[sX_C$s.q2#3%8mWcqAoay?|OPoT蹌٪/ X9ˬmP7dց ǥʧ؅AQF*hӁ7 DȡHٯ?49\3wPCڷ,1}p\_cڡeȈbks2f9CT'ųazָUuL60Mrs[['z-qCSVq{s~4$E1<F u-|ǩْzse."wq2QO%,b	B>R*Q7\-JlP,R6x:/s(]SJl%%}c
x"#J
=BV6kxڸ󭞗Q4ɮl gK`=L-)8|o@nU'-q`X=$ɎYmd]xo 5Z#Kn+&De؉,dfUt-v+y_AT5$84jZA$Am"S|/OV`y]	N.>.@R
wB} +Bm=bSogEӷJϞ;"Yg{ul=wZ	"a?<wt;qT	q1R/i8P>Ji+ckimTS%;UYg֖xd`1PՅiQ-eGLnn
3AQQYUȸBK8N)oU"5rXe4ڌ&_,Т\.f哛RtFԭ%7qop(Τk5_q/\K7ڂ2dJŝfb)\͞[;N̀#m>yi)UMp_Y`xX֏V>:(6ayr%áaeA͡dInFc6&%h}m7D"Jf-WfN3zO%~G8xGsQ^ٺtҒ1&yP7P:WZ+:pMa$ԉፖAjM+׋pI? x ,aB4Цn,S4tpBnt:-&_\ރ)2!$3ROmGȬٖsh]{ƨa6zK`Q`vRʢ!)8)V10We(~sS f%P !!(E긩hl;uQ<jǨBIs0ɦ:$ϭKAib2pt;6(â*o;N{iRAJyƖD56UZRs),(!q'.5gp:NxGsխGkGW\Vݞ{/Q8ɶT4DSQTk,0wNr)xsx saG!20u+sޓD&ݍM;̏oXG];5fA0_Xʱ˸[[#Q+70A$xW\ڛň@~Q[Ee{\(1Ȣxh\ws-9K$' #Ixz 3l77,(w"⌯!O^<]!w d<fvIrճf+z *{>'_󴚙#.<x)*'GڀK\mo4զ}p~KEp,>Gι^Y[:VqŵۦiXD[i[nx`Tdt2b+gy_|~xf_߾`x!f|sFVF>w Sax_VEq2T<_nٞ T0Ժ8a1+9q]<	lEw>aG(moHq@8p_ћp5_A1z
ʖ)m<@sS6yb&{i0O6}&憮80nv\+f%+c 6)%s+\ǷmlVʉF:)+G6ĵ!\ЬV%/1>uLY@likISR^}Ϲé2gT܊㜁ڄhVT-Undi庅f;3hș;ibJLuurH:UqufO{ډaqtKtߌj5v[n"zEA*lOmU[u=ȹ)\ȽJ3Et=k)&.НpTerw1hVfQ'a/;yŔVSšRwi I=*M@Yԋ<XkyaWhuy-QTQK5ǘ۞'>+[K١|,.ަ[>ۃRr;;A'Tʝq쌤.5Ab1D4vV7t>UkƓ잪9N9hY3F׀?w Ń>_V1[y~TnÙyT=:˂gG0Yख˶})+:+eA;n,Ieu᥺W>q'On*&2DzBaWFj19YKCwT+7O cpa5|3H|+/h^IyϪY|t\0>7!|1
[Is	i;yNt;ڿubYC~V:aw7n"2zK|yii^RSGKS.
6V0ڥ41p"wQ詸ut*=0W-B }H'QLhlT& |S &2Yԧ1'+^}@PF4% k(9X!deRqCnc|+n\]%7Z| ėJ%T(Nfm %DzR=.E+^9,ec? 	M%8RW.XQ%뮠@1U<z^6HX~+~Q\xVTh.Z'unuB:r*)	`USpq~%]US/^'\/h	r+ <.^zy%'v.ﻄ_#A~.zw&^sVrbWIQtL?W*Q"Z{0x"A	P]hNwfjv
yo4FYA7|W)j@3AZNJ?6@omo_*n١={,z7}r&G ɫD"n-}îQ[1zKlygdKwv-)e$<y:hbDE,\VAZ1FG h'P%7=ҝxW]O oӭ?}KZ%K|I'奜?jnxUn0
LRnٌfّ_!=VJ*=kǗBCAJ.p`Z᭯@.xGu4W6"?\y-T!	(ɶ X-U/%
kq|wC+d--2:K`&ъSZsFVZ2nl$mArFӒ&,|6DƑO˯ҮOXdF"Z5t[0#G:R|nSf'o5®qF1*{RZc5Q'y@hM;$wq{}h宊M2)V<f&Dp,b,H*sUsD\J3~wc݂!e	([C>J꒜S뢛 {-'P"p4$) _C^{OgA:sCwU*9v*<O۳7)Zўc4v$mӬ lR׏cӍIwa$L*$ڞm{6MymC	7q}9z%xW]o0}ϯ[UKJ}B8U>2T~l0`/{{.4<<ֆZy+%f`:`>'0w'>EI~p:	ly_׼7黷3vkpֶ-9ؗj=AYBe1GZCb̘JP[$>?w͉;sCӾ#/"61NOg̼*$Fn#CqYŃv]x)8
GdLf7Yrs3)?KdY4Tsg(brbt*DCvEP׭-ũApe~^]U&`ϘH_ާ[(Y9NBeoS*]ubۘρ$wPT3i7m"	x,iDݤR`	C(˥G#nQ(889 ʕ%oip1^JJJ%փ{xȟ(O;h-9Q!1MηJ}^cE)uNH볆f{Z]Jfr|3TzA0OjF^YI'#̓q3v>.$KwT7Eφs]˰FS7C1iaIo0W_{6	u]+k0:!FNWYG}W5n{jD0Q"ޑB?P}<T߮3od'LX`X`Jz@AÁڤ/"G8ȼTZn+^b]7"&>v)%|Q4JVzx340031QH/+)fX#(lvURWް R]}?j>qB敁s(9dY_M(aBLsegL9lQ̼
A5]R{q|\ (/-I-fx]u3uḡ((-)fЕXc}ʾu
 '{e`x]%import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import { type Context, Telegraf } from 'telegraf';
import { db, files as fileSchema } from './db';
import { config } from './env';
import logger from './utils/logger';
import { forwardToStorage } from './utils/telegram';

interface MediaGroupBufferItem {
  ctx: any;
  fileId: string;
  fileSize: number;
  mimeType: string;
  fileName: string;
  fileType: string;
  fileUniqueId: string;
}

const mediaGroupCache = new Map<string, { timer: any; items: MediaGroupBufferItem[] }>();

export const startBot = async (): Promise<Telegraf<Context>> => {
  try {
    const bot = new Telegraf(config.botToken);

    bot.command('start', async (ctx) => {
      await ctx.reply(
        `👋 Halo! Kirimkan file (document, photo, video, audio, voice, animation) ke bot ini. ` +
          `File akan disimpan di private channel dan kamu dapat download link permanen.`,
      );
    });

    // Cast bot.on elements individually or explicitly as any to bypass Telegraf v4 typescript deprecation warnings on array syntax
    (bot as any).on(
      ['document', 'photo', 'video', 'audio', 'voice', 'animation', 'sticker', 'video_note'],
      async (ctx: any) => {
        try {
          const fileType:
            | 'document'
            | 'photo'
            | 'video'
            | 'audio'
            | 'voice'
            | 'animation'
            | 'sticker'
            | 'video_note' = ctx.message.document
            ? 'document'
            : ctx.message.photo
              ? 'photo'
              : ctx.message.video
                ? 'video'
                : ctx.message.audio
                  ? 'audio'
                  : ctx.message.voice
                    ? 'voice'
                    : ctx.message.animation
                      ? 'animation'
                      : ctx.message.sticker
                        ? 'sticker'
                        : ctx.message.video_note
                          ? 'video_note'
                          : 'document';

          const fileObj =
            fileType === 'photo'
              ? ctx.message.photo.slice(-1)[0]
              : fileType === 'sticker'
                ? ctx.message.sticker
                : ctx.message[fileType];
          const { file_id, file_size, mime_type } = fileObj;
          const fileName =
            ctx.message.document?.file_name ||
            ctx.message.photo?.slice(-1)[0]?.file_name ||
            ctx.message.video?.file_name ||
            ctx.message.audio?.file_name ||
            ctx.message.voice?.file_name ||
            'file';

          const maxSize =
            fileType === 'photo'
              ? 10 * 1024 * 1024
              : fileType === 'audio'
                ? 200 * 1024 * 1024
                : fileType === 'voice'
                  ? 200 * 1024 * 1024
                  : 2 * 1024 * 1024 * 1024;

          if (file_size > maxSize) {
            return ctx.reply(`File size exceeds ${maxSize / (1024 * 1024)}MB limit`);
          }

          const mediaGroupId = ctx.message.media_group_id;

          if (mediaGroupId) {
            if (!mediaGroupCache.has(mediaGroupId)) {
              mediaGroupCache.set(mediaGroupId, { timer: null, items: [] });
            }

            const group = mediaGroupCache.get(mediaGroupId)!;

            if (group.timer) {
              clearTimeout(group.timer);
            }

            group.items.push({
              ctx,
              fileId: file_id,
              fileSize: file_size,
              mimeType: mime_type,
              fileName: fileName,
              fileType: fileType,
              fileUniqueId: fileObj.file_unique_id,
            });

            group.timer = setTimeout(async () => {
              mediaGroupCache.delete(mediaGroupId);

              try {
                const itemsToUpload: MediaGroupBufferItem[] = [];
                const responses: string[] = [];

                for (const item of group.items) {
                  const existing = await db
                    .select()
                    .from(fileSchema)
                    .where(eq(fileSchema.telegramFileUniqueId, item.fileUniqueId))
                    .limit(1);

                  if (existing && existing.length > 0) {
                    const url = `${config.baseUrl}/f/${existing[0].publicId}`;
                    responses.push(`File *${item.fileName}* sudah diupload! 📎\nDownload: ${url}`);
                  } else {
                    itemsToUpload.push(item);
                  }
                }

                if (itemsToUpload.length > 0) {
                  const uploadItems = itemsToUpload.map((item) => ({
                    fileId: item.fileId,
                    fileName: item.fileName,
                    fileType: item.fileType,
                  }));

                  const { forwardMediaGroupToStorage } = await import('./utils/telegram');
                  const batchResult = await forwardMediaGroupToStorage(uploadItems);

                  const dbInserts = [];
                  for (let i = 0; i < itemsToUpload.length; i++) {
                    const item = itemsToUpload[i];
                    const publicId = nanoid();
                    const uploaded = {
                      publicId,
                      telegramFileId: batchResult.telegramFileIds[i],
                      telegramFileUniqueId: batchResult.telegramFileUniqueIds[i],
                      storageChatId: config.storageChatId,
                      storageMessageId: batchResult.storageMessageId,
                      fileName: item.fileName,
                      mimeType: item.mimeType || 'application/octet-stream',
                      sizeBytes: item.fileSize,
                      fileType: item.fileType,
                      uploaderId: ctx.from.id,
                      createdAt: new Date(),
                      updatedAt: new Date(),
                    };

                    dbInserts.push(uploaded);
                    responses.push(
                      `File *${item.fileName}* berhasil diupload! 📎\nDownload: ${config.baseUrl}/f/${publicId}`,
                    );
                  }

                  if (dbInserts.length > 0) {
                    await db.insert(fileSchema).values(dbInserts);
                  }
                }

                await ctx.reply(responses.join('\n\n'));
                logger.info('Media group uploaded as batch', {
                  mediaGroupId,
                  totalFiles: group.items.length,
                  uploadedFiles: itemsToUpload.length,
                  uploader: ctx.from.id,
                });
              } catch (error: any) {
                logger.error('Failed to process media group batch', {
                  error: error.message,
                  mediaGroupId,
                });
                await ctx.reply('❌ Gagal mengupload beberapa file di media group.');
              }
            }, 600);

            return;
          }

          const existing = await db
            .select()
            .from(fileSchema)
            .where(eq(fileSchema.telegramFileUniqueId, fileObj.file_unique_id))
            .limit(1);

          if (existing && existing.length > 0) {
            const url = `${config.baseUrl}/f/${existing[0].publicId}`;
            await ctx.reply(`File berhasil diupload! 📎\n\nDownload: ${url}`, {
              reply_parameters: { message_id: ctx.message.message_id },
            });
            logger.info('Duplicate file detected in bot, returned existing link', {
              publicId: existing[0].publicId,
              fileType,
              fileName,
              uploader: ctx.from.id,
            });
            return;
          }

          const result = await forwardToStorage(file_id, fileName, fileType);
          const publicId = nanoid();

          const uploaded = {
            publicId: publicId,
            telegramFileId: result.telegramFileId,
            telegramFileUniqueId: result.telegramFileUniqueId,
            storageChatId: config.storageChatId,
            storageMessageId: result.storageMessageId,
            fileName: fileName,
            mimeType: mime_type || 'application/octet-stream',
            sizeBytes: file_size,
            fileType: fileType,
            uploaderId: ctx.from.id,
            createdAt: new Date(),
            updatedAt: new Date(),
          };

          await db.insert(fileSchema).values(uploaded);

          const url = `${config.baseUrl}/f/${publicId}`;
          await ctx.reply(`File berhasil diupload! 📎\n\nDownload: ${url}`, {
            reply_parameters: { message_id: ctx.message.message_id },
          });

          logger.info('File uploaded via bot', {
            publicId,
            fileType,
            fileName,
            uploader: ctx.from.id,
          });
        } catch (error: any) {
          logger.error('Bot file handler error', { error: error.message, chat_id: ctx.chat?.id });
          await ctx.reply('❌ Gagal mengupload file. Coba lagi nanti.');
        }
      },
    );

    bot.use((ctx, next) => {
      logger.info('Telegram event received', {
        type: (ctx.update as any).type,
        chat_id: ctx.chat?.id,
      });
      return next();
    });

    await bot.launch();

    logger.info('Telegram bot started', { botToken: `${config.botToken?.substring(0, 10)}...` });

    return bot;
  } catch (error: any) {
    logger.error('Failed to start bot', { error: error.message });
    throw error;
  }
};
#~VRkx,J#"%findFileByUniqueId } from './db/files*{
  detectFileType,
  extractFileFromMessage,
  getErrorMessage,
  getFileSizeLimit,
  type TelegramMediaMessage,
} from './utils/file^type BotContext = {
  message: TelegramMediaMessage;
  from: { id: number };
  chat?: { id: number };
  reply: (text: string, extra?: {!!number } }) => Promise<unknown>;
};

type MediaEventRegistrar = {
  on: (events: string[], handler: (ctx: BotContext) => Promise<unknown>) => void;
};

const replyWithDownloadUrl = async (ctx: BotContext, publicId: string): Promise<void> => {
  const urlL!(|!J!<});
}#YLogging middleware must be registered BEFORE the media handler so all events are captured#[2'type' in ctx.update ? ctx.update.type : undefined$MDconst mediaBot = bot as unknown as MediaEventRegistrar;
    mediaBotEs- = detectFileType(ctx.message);'Obj = extractFileFromMessage(ctx.messag	{ file_id-Size = fileObj.file_size || 0	81 getFileSizeLimit(fileType);

          if (fileSb$existing = await findFileByUniqueId(.A) {
            await replyWithDownloadUrl(ctx, existing.publicIdPwSizq"} ,await replyWithDownloadUrl(ctx, publicId);

."unknown"60
            error: getErrorMessage(error),
    $%|!W#Oa$unknown$5getErrorMessage(error)@%.x340031QH*MN-)+)fH.w_'6
TNVdQ[XQ簇}]ANk.x͏X7ڮFA%b-ѥZϐՁdq)==X}]}O<j2RR+@jdvՙnvB&73($X㓼5M-h'ҜLA=>"FܲgkyUꊓ3RsA"knmBEYbGQ- tx%
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;
};
CĻx
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);
};
>x: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);
};
QQ5xR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;
};
xm9@{~X	Qb,0Y2=jroR5䆿^RNwOPC2c)lKeӊ,0QYXؠ4!QcJiP{ 2MCW\ra3GUoHcU/β528<t/4o?,s,h<zjdxTn0}WOIJ먯Y@*Qn[B7d&vv6%d7-H<9sf<n(cK
jpLB7S@Tk^ZQn*Uw1>J 98 >4ǷPRs+Y&[a͙Q1\*xUAVV4DM~.l,XZp޽ӯg#I\ՑZcA|9ps'3>7J'0?  ILRfݐral
^-rM,ĺdbT3aIrVꯙFgItD}xLAI!7
C(𰯪7x1hWD95]"
Փ5Q+!_cNCPiJi@%<li\X(7ULޏNdoĳ)Q~˅3( =NcR#/]~'{	cjnnlɵI9DtHo+%dDD4Ze4JΐcѶZ+W6/F_Kni|1tq55ZihD[\!*q s_}Gl.8RhTi.ag/zW/h}
I;k_(:(aVw)\߻9@sw{<2i]|gb1].|
Tx SD`${dir}/../../schema.sql`, // from dist/
    `${dir}/../schema.sql`,B+`${dir}/../schema.sql`,3`${dir}/schema.sql`,*`.${candidates.join(', ')})`N[8xW[O+7~Wґ.$G5 
UPpvM^rloI{:ЪUy73,e-zfi!oo9LYh$IJJ4I$$sX= sLA3d3,JqejFi&YBD}^9A*($<Mte%F{˹[,c[$'8*ͯi)HD4"u{s>2k)_v?oDy@H* MD$@=|o3ށDPz@%J+'sdH7,̧]o8B =VC~:~!rr<Y׳r>-?V,U?TjdrdSFDƒ2e^=(dlwy/t1l@_z^un@ήϋmԠx)5bGY򒤯I0/X<<f}FяH2_QFnU7j,̯26%NQ^5&Jȫj=&7?3g*8TPa\m( g;Jh٨0z##'acwʹ
Cw!@FPyCr,z4ĈU)rnb*>׌<0.ѩXlՙdU5+k3R43ZƱV2=L
)r7fy_g5Sl5WWV+VSˊײQ^!>[?N)W^ *+f酮jq3<QwDTx8̕8:sM9"Kc}c}͚==Miˑ~H2UƣP ,m[y肆Q %.kyvS? &浃&EJWNѸBG|Q Y5/jQki~DX2&iDprhe^DA _&Sy-a99>>Љ멑ڛ60s810XUei6涿.,ZW$)?d63|Y߷*yD@]%T[_H}n%~~sx~R	uxVKO1W)7h+-jTU+gwck{;㍳מb3x^Y&mfWdT.@OmU|P${ZT==q8RZfGY6g5vNJq;6-spG(w'b chܥmY58GҀG=J-}>r2ԛ	LP	||\R"89gI.{ṭdZ@TvrTk*{Yw>n:`XiKj7T 'k(w,LC&&˰k0 z=
 ؄Qו;oeOp`*{kH'ҐV8I"fk b%}Po+ZUhTRDz5NݫtTO*>pݯIjwY4'8QfN{Xt.(ˎs90fRox .STLx+kw6!w"4_.U+i\^Sju՚5ZP	em}2ZͿ0\tiTgquw_}t:Pomk0%y4Ks
:s?דkg'Til2|xƺZOg~؆=N#Bx{xWkw8_Scȣ=]7,ٴu,,i$$0Ofh4D,#d	,AY(bQ;ىR8adf,GmFMSX0QYaKӌܚ6Q:On1a C`a@eYL+a$%ae8yN/Icф)[,&9!8-Ԍh
x0gc$p31 iM]%(Kf9S#fj~pZ(
$JYq̹ke] FHК`5/L7xc TzS%j崝%Av(Ks7"8A\i2XF齹\6g}[,Ǳ~:oYԙ5a\2Y^y^}O`+IYA	d~#
N>kR: (]9ӷ+:==E4(!SZ43_t6%ƨV00cNEWX+x$,M/F6>Ԍ2#7d(0Jhd=i7]Y@rބxeˠ"hm0+[Y/[%@MRj}N5GiDqM`~/4\NF1mD_`$dfltYQ(W6'MMNjza)ۨ0]"**?ZR>"J4B'xO<ᑪտN)7??2UnuMi|)i0iCSM[eL`+Ȗgi'SK=Mjo/wGM@'4chf|/l&%㵪|m~Eo;|j׬_4;~bMIqkl	@m۾\o&@4Cvu\Tׄn>?w}5d}R5yN\6ʹP0P[yh4YT]ߐɝlƦf(
H~׾<&JwFpT-^?vE(Sh=߷ ^	8YwɁ^8˾2<
Ime׵~uZ~vZzS"ݶY^glVÆ.tkWڃ&8<.>8l4,B>TemrݵVйP8Mʘ-wٷi}qH㗋c~^nc,{{G!~nlj_uȰ(#KWbe{\cճy,E۲Yc'ΦnC.o^í' ƈ"#jX/k	wl x*}-2_k1m5zХ8E2#ٵb,Bj/`\}pᆴݞq@#&4  daF!<[T+,.Dz!ЭivhECP)(Ԗi\+RfiӅk_µƕ
 Q޶5Q+,V&Uš*NYeBdgP7wz tU$AS=
x\[sHv~_HȢHgKfvmk$RSSQh=, JhXRn>-Oy{~J~A~Bn4&)iJ**˹~Nx݋}̋E4)D4Z,naW.XAI0YΊQqz9Ih CQȮyzKxN^Ј~_NT"b<b/$%#yKc:c삝b%#$K܊	&o(s>0oGa3v<::,d<>&4I54ya/g4erJ4cS 0gvBB]u4#6[~8&cp>J֎'Ϟ>cwਫ਼zm0rK&^1)u>ɂf3bJPI4Sޔ.x:!=嫼`.9x|59%|%'H$qY5\Ͳd'fJ3$Be`֛3>$a9łUs=7I:"yQ`1`F|82fِh
3A*`#
ow4["Y8'ˮ&98]j\z}<!}W~Ю͍W}(Z@E5bXyRc-'.dAJbt2G>ubC]Z7_
Vec4BehIH#tY96a_v~̚Hm<5+B*WEY3<Д"4AY0(5N!n"#oiV& [.&MvbDiyTŖ
@A<JB{!J IE<.Aw'&B@Yr`_3z/T)2
!+ ^l- `b	
+Q"|A4B17_c!m$s,VV,x{7OHd4u,~#7!-O
f 1WO3ieiJiW$mF[ƪ06gb_'Sȯ"ÈMK&ӂ͈d<Ζ
K:@Hd<fYeՔ!a!e	]?2lW#r骧6gBbID,ԼlHҕ ØW+?!ǲT59f]4~Z(a}zn0jP"s͖Sڱr`0G#bV2tۜ$pz|dD͗)kHBnjĶ3{ۣLۅ*\D"Z-bUfXRcŚkN\Hżcfq
{om"yFi֛!oppY;ެ|CL#-G?~+oqLi67q0UNw*qKx+n3T oCO1TeȉO6V꬝gII*^0[,˒Fή1˕Ǻ.<S=Y?p%H<M
Z.zoE?)@LǯPp퐒\1)xd^J.ɺbSZ)Ppm͓<j}]M"02	EFq#Z ^mlJE|mO`Hq9כtqV%uk,Ok9O!hY	+5Va[uc
&碡E8!QL7gWy_h:#ͣVk_G"#rn-{[ɖ$@=)X@J"\U̓$-~xBޱ3#xql4j-J^ň_M6Vqk똪uQ41CQȲh1.5W{Ag3'}@̡91`Ṙ+ma
#ӵ?CfCˬp*=-V5Г:D<ii63_^Ь`!P<IrVfɂsFp	K a7f7Z[R4U,Nd1cĺh[H26*7ɭ^^zذnC ADxr(";hko"SQ9UsBs{a׬V2WkҘuJN.fkqA&a]ca_/B&,bCpxBg Nb8OC8$*J%#/[ nnxR?XVEr²A_ 5d?/deą4GgCFcʞ"Sn*bkֹ~,aJPUu#I{%U6<0_	yJ4(hc=5G)	UAIҷ*jwS{;w@)Da QTJy,wk6
9)hjleŐc2y2eTy=r`§ă~w<j2!X,3r C!KXs4"O6Hb
9:H#7b:V5^XS^"Q(<-@vЮI@Am
ޱ+xeBܲyq)m)LvzCkw.7r5/2t?4/D7aB8}9HK93
OͨXe@#e8hrr|=6w9pf8_w~p[̲xoX6t;G<v<d5­	w2`L޻UZw{:,GLTu\ޢ*k!^ )|DhatS8/_j:F	MyM)Ts(~ʘgCڼ\ȹkʹΜkgH47y웋op{nwrXȽ9C]܈dVr;1~L@XP	&7cm q*r_?nO]jTw[c@ K U$2Fĭ85C*ߖfWcmip,++74[(/VYydy=mE7A<@XJ<5 "Yźg?%<Ƹ4O+.ɨ0V:BRl=a/17¥Aֈ
C^rP	HcOB=+S.XtEn34~XuD[P@YuAUV l#|K-@3kdJi)Xk0 # (zgI:
j^.JqL"AC_aj#C2QS |"$SIc-Z]mBhJu9my pVZKJT3}J2_]TL)MH౞Wt+w05*x8,3\ݲ8HBo_$:.jޓ?c׏C=+2Wl}mo3ϊHHSԑߊ^il\䵂YkE<ٗ0[W%R`}U3\Who屨ށbG.%G,ak4"}qMkʕ.</&ޔBc+`5QM|q	Τ84S9Bh3&l9uI۬Ԕ_^m٠'xg'#?m,J4`)_b.bynsm4 |]"]/>cިKeVtm(E. p(-?6Y<):[8^<$VXsp9%<QO=:}u3Ą
vhJhiK	,Y?y IWox|a]c?~A`9]UۍihN}J_Pwb ѓ2m>_Ǭî\b̩ XmzdjNm?.? Qpyx4?}.|O4'r1 /xwI&n?חC TQ <O|}O%EA6$/z_	MAavynHW'[ƃvwQgr#&K+HGIyF%lX5HhGPQVQ[v
Rga8q3.^jfnUUIR{5ܽqewW:*TK2:$4LK#;~ןR66Du$l]J!GC痝5*v=L3r
헯޼x^*oh-8`Vheyye9ʒ^MH$qM!l褐~ǵRW$FsEv)d{ ^i|jnTɈb.3{QOAAp"0 XK MŜhY$GY)YhNSk6Ѩח xsT]nЙ.Eo8!!Z S܂MC PjkOݵ'~#+8*7E~~dbg>W$q PfʔгunFbs_ L=Q7@ZrXiټ^iKj<E67׏WGQl~]zX1-o(EQ31_z:ՙz;bU&7{sU}4_aB{iî[v!K6O:Y-5
*1i09]fM\c(09Zv&=ľH֕KiW"/JSNqfso|\SC9^EyuK\ycY7Zyq,#^c0AB?Lh^y!ZX@#6.5iKkyxaT"gظ,cˋUOv;IiQ,~g?oݣl-t
	:?o( 4g<8 !mQ2Jc5޸i_PgDͲ#+3bųкdR'Bmz#%j,b_B|2.ʩGU^,#Sr;Цć[4wC!OTc5D߯7$._Pp%mM7gǄ+[`jxƣ7y~$"V~G|D',|Jo_š|]zEժr݊D\/i߳ec4/zAA vB~V5Bķ5qՋt'Yi?!'>5r.,@@#]W: IxrsvcaJcR?aDw"KmvXdd,(N	_][/uk.uJHMkżTOʏ@FAg
sB@^ximport { serve } from 'bun';
import { startBot } from './bot';
import { config } from './env';
import { handleLogin, handleLogout, handleMe } from './routes/auth';
import { handleFileInfo, handleFileRedirect } from './routes/files';
import { handleHealth } from './routes/health';
import { handleHome } from './routes/home';
import { handleS3Request } from './routes/s3';
import { handleSwaggerHtml, handleSwaggerJson } from './routes/swagger';
import { handleUpload } from './routes/upload';
import { handleWebApiV1 } from './routes/web-api';
import { requireAuth } from './utils/auth';
import { fileInfoCache } from './utils/cache';
import logger from './utils/logger';
import { metricsCollector } from './utils/metrics';
import { cleanupRateLimitCache, withRateLimit } from './utils/rateLimit';
import { isS3Request } from './utils/s3/auth';
import { extractS3BucketFromHost } from './utils/s3/virtual-host';

// ─── Auto-run migration at startup ──────────────────────────────────────────
try {
  const { runMigration } = await import('./db/migrate');
  await runMigration();
} catch {
  logger.warn('Auto-migration skipped (non-fatal)');
}

// ─── Auto-run migration at startup ───
try {
  await import('./db/migrate');
} catch {
  // migrate.ts calls process.exit(1) on failure — if it throws, log and continue
  logger.warn('Auto-migration warning (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: {
    '/api/upload': {
      POST: withRateLimit(requireAuth(handleUpload)),
    },
    '/f/:public_id': {
      GET: withRateLimit(handleFileRedirect),
    },
    '/file/:public_id/info': {
      GET: withRateLimit(handleFileInfo),
    },
    '/health': {
      GET: handleHealth,
    },
    '/docs': {
      GET: handleSwaggerHtml,
    },
    '/swagger.json': {
      GET: handleSwaggerJson,
    },
    '/': {
      GET: (req: Request) => {
        const headers = Object.fromEntries(req.headers);
        if (shouldHandleS3(req, headers)) {
          return handleS3Request(req, getS3RouteBucket(req));
        }
        return handleHome();
      },
      PUT: handleMaybeS3Root,
      HEAD: handleMaybeS3Root,
      DELETE: handleMaybeS3Root,
      POST: handleMaybeS3Root,
      OPTIONS: handleMaybeS3Root,
    },
    '/api/v1/auth/login': {
      POST: withRateLimit(handleLogin),
    },
    '/api/v1/auth/logout': {
      POST: handleLogout,
    },
    '/api/v1/auth/me': {
      GET: handleMe,
    },
    '/api/v1/*': {
      GET: requireAuth(handleWebApiV1),
      POST: requireAuth(handleWebApiV1),
      DELETE: requireAuth(handleWebApiV1),
      PUT: requireAuth(handleWebApiV1),
    },
  },
  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));
    }
    return new Response('Not Found', { status: 404 });
  },
});

const bot = await startBot();

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');
  server.stop();

  logger.info('Stopping Telegram bot');
  bot.stop(signal);

  logger.info('Server shutdown complete');
  process.exit(0);
};

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

// Periodic maintenance intervals
setInterval(cleanupRateLimitCache, 60000);
setInterval(
  () => {
    const removed = fileInfoCache.cleanup();
    if (removed > 0) {
      logger.info(`Cleaned up ${removed} expired cache entries`);
    }
  },
  5 * 60 * 1000,
);
setInterval(
  () => {
    const snapshot = metricsCollector.getSnapshot();
    logger.info('Metrics snapshot', {
      uploadLatency: snapshot.uploadLatency,
      uploadThroughput: snapshot.uploadThroughput.toFixed(2),
      errorRate: snapshot.errorRate.toFixed(2),
      cacheHitRate: snapshot.cacheHitRate.toFixed(2),
    });
  },
  5 * 60 * 1000,
);

logger.info('Application running successfully');
qx{~M}#'OFb^JNjhAN~b(N. 		x!100644 auth.ts kl%h|m"f$V14100644 files.ts =fX=Oubh100644 health.ts )pvr'*C100644 home.ts $GL&JY' R100644 s3.ts Ǹlp3/	'.}100644 swagger.ts  )>Ȧ˕768|100644 upload.ts &*kk#Y100644 web-api.ts BiYZtFC"HcQ]|mpx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,
  });
};
sKyximport { createReadStream } from 'node:fs';
import { unlink } from 'node:fs/promises';
import { nanoid } from 'nanoid';
import { findFileByPublicId } from '../db/files';
import { fileInfoCache } from '../utils/cache';
import { formatCreatedAt, getErrorMessage } from '../utils/file';
import logger from '../utils/logger';
import { getBot } 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);

  if (!fileInfo) {
    const bot = getBot();
    const apiFileInfo = await bot.telegram.getFile(telegramFileId);
    fileInfo = {
      file_size: (apiFileInfo as any).file_size || 0,
      mime_type: (apiFileInfo as any).mime_type || 'application/octet-stream',
      file_path: (apiFileInfo as any).file_path || '',
    };
    fileInfoCache.set(cacheKey, fileInfo);
    logger.debug('File info cached', { public_id, cacheKey });
  } else {
    logger.debug('File info from cache', { public_id, cacheKey });
  }

  return fileInfo;
};

const buildTelegramFileUrl = (filePath: string): string =>
  `https://api.telegram.org/file/bot${process.env.BOT_TOKEN}/${filePath}`;

const cleanupTempFile = async (tempPath: string): Promise<void> => {
  try {
    await unlink(tempPath);
  } catch (err) {
    logger.warn('Failed to cleanup temp file', { tempPath, error: getErrorMessage(err) });
  }
};

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');
    }

    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));

      if (!archiveResponse.ok) {
        logger.error('Archive download failed', { public_id, status: archiveResponse.status });
        return fail(500, 'Server error');
      }

      const tempZipPath = `/tmp/teleuploader-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 any, {
        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 tgResponse = await fetch(buildTelegramFileUrl(fileInfo.file_path));

    if (!tgResponse.ok) {
      logger.error('File download failed', { public_id, status: tgResponse.status });
      return fail(502, 'Server error');
    }

    return new Response(tgResponse.body, {
      status: 200,
      headers: {
        'Content-Type': file.mimeType || 'application/octet-stream',
        'Content-Disposition': `attachment; filename="${sanitizeFilenameHeader(file.fileName)}"`,
        'Content-Length': String(file.sizeBytes),
      },
    });
  } 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');
  }
};
<v9pxj)'5`XcreateChunkedObjectResponse } from '../utils/chunked-storage';
import { cleanupTempFile,iWmetricsCollector } from '../utils/metrics';
import { getFileInfo, type TelegramFileInfoQQ\ as TelegramFileInfo | null;

  if (!fileInfo) {
    metricsCollector.recordCacheMiss();
   FileInfo(telegramFileId){"metricsCollector.recordCacheHit();^
, botToken;botToken}/${filePath}` 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 createChunkedObjectRe!sponse({ file, range, reqId: '' }s"	
        	* , archiveInfo.bot_token),
      	unknown as ReadableStream, {
  ^redirectUrl = 'I, fileInfo.bot_token);

    return new Response(null, {
      status: 302Location: redirectUrl7$x ''
filedropn	 "x(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 });
  }
};
P(x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',
    },
  });
};
b.Rŵxz}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 {
  abortMultipartUpload,
  completeMultipartUpload,
  createMultipartUpload,
  findMultipartUpload,
  insertMultipartPart,
  listMultipartParts,
  listMultipartUploadsByBucket,
} from '../db/multipart';
import type { File } from '../db/schema';
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 { verifyPresignedUrl, verifySignature } from '../utils/s3/auth';
import { S3_CORS_HEADERS, s3Headers } from '../utils/s3/headers';
import { createGetObjectResponse, type ObjectPartSource } from '../utils/s3/object-stream';
import { parseRangeHeader, unsatisfiedContentRange } from '../utils/s3/range';
import {
  bucketVersioningConfigurationXml,
  completeMultipartUploadXml,
  copyObjectResultXml,
  deleteResultXml,
  initiateMultipartUploadXml,
  listBucketResultXml,
  listBucketsXml,
  listBucketV2ResultXml,
  listMultipartUploadsXml,
  listPartsXml,
  parseCompleteMultipartBody,
  parseDeleteObjectsBody,
  s3ErrorResponse,
} from '../utils/s3/xml';
import { forwardToStorage, getFileInfo } from '../utils/telegram';

const REGION = config.s3DefaultRegion || 'us-east-1';
const REQUEST_ID = () => nanoid(16);

const s3Response = (
  body: string | null,
  status: number,
  reqId: string,
  extraHeaders: Record<string, string> = {},
): Response => new Response(body, { status, headers: s3Headers(reqId, extraHeaders) });

const s3OptionsResponse = (): Response =>
  new Response(null, { status: 204, headers: S3_CORS_HEADERS });

const parseS3Path = (pathname: string): { bucket: string | null; key: string | null } => {
  const parts = pathname.split('/').filter(Boolean);
  if (parts.length === 0) return { bucket: null, key: null };
  if (parts.length === 1) return { bucket: parts[0], key: null };
  return { bucket: parts[0], key: parts.slice(1).join('/') };
};

const headersToRecord = (req: Request): Record<string, string> => {
  const record: Record<string, string> = {};
  for (const [key, value] of req.headers.entries()) {
    record[key.toLowerCase()] = value;
  }
  return record;
};

// ─────── Main Dispatcher ───────

export const handleS3Request = async (
  req: Request,
  virtualHostBucket: string | null = null,
): Promise<Response> => {
  const method = req.method;
  const url = new URL(req.url);
  const pathname = url.pathname;
  const { bucket, key } = virtualHostBucket
    ? {
        bucket: virtualHostBucket,
        key: pathname === '/' ? null : decodeURIComponent(pathname.slice(1)),
      }
    : parseS3Path(pathname);
  const headers = headersToRecord(req);
  const searchParams = url.searchParams;
  const reqId = REQUEST_ID();

  if (method === 'OPTIONS') {
    return s3OptionsResponse();
  }

  const isPresigned = searchParams.has('X-Amz-Signature');
  const authResult = isPresigned
    ? await verifyPresignedUrl({
        url: req.url,
        method,
        headers,
        s3AccessKey: config.s3AccessKey,
        s3SecretKey: config.s3SecretKey,
        region: REGION,
      })
    : await verifySignature(
        method,
        req.url,
        headers,
        null,
        config.s3AccessKey,
        config.s3SecretKey,
        REGION,
      );

  if (!authResult.isValid) {
    const status = authResult.errorCode === 'NotImplemented' ? 501 : 403;
    const message =
      authResult.errorCode === 'NotImplemented'
        ? 'aws-chunked streaming payloads are not supported.'
        : isPresigned
          ? 'Presigned URL verification failed'
          : 'Authentication required';
    return s3ErrorResponse(
      authResult.errorCode || 'AccessDenied',
      message,
      pathname,
      status,
      reqId,
    );
  }

  try {
    // Root: ListBuckets
    if (!bucket) {
      if (method === 'GET') {
        return handleListBuckets(reqId);
      }
      return s3ErrorResponse(
        'MethodNotAllowed',
        'The specified method is not allowed against this resource.',
        '/',
        405,
        reqId,
      );
    }

    // Bucket-level operations
    if (!key) {
      if (method === 'GET') {
        if (searchParams.has('versioning')) {
          return handleGetBucketVersioning(bucket, reqId);
        }
        if (searchParams.has('uploads')) {
          return handleListMultipartUploads(bucket, searchParams, reqId);
        }
        const listType = searchParams.get('list-type');
        if (listType === '2') {
          return handleListObjectsV2(bucket, searchParams, reqId);
        }
        return handleListObjectsV1(bucket, searchParams, reqId);
      }
      if (method === 'PUT') return handleCreateBucket(bucket, reqId);
      if (method === 'HEAD') return handleHeadBucket(bucket, reqId);
      if (method === 'DELETE') return handleDeleteBucket(bucket, reqId);
      if (method === 'POST') {
        if (searchParams.has('delete')) {
          const body = await req.text();
          return handleDeleteObjects(bucket, body, reqId);
        }
        if (searchParams.has('tagging')) {
          return s3Response(null, 204, reqId);
        }
      }
      return s3ErrorResponse(
        'MethodNotAllowed',
        'The specified method is not allowed against this resource.',
        `/${bucket}`,
        405,
        reqId,
      );
    }

    // Object-level: multipart checks
    if (searchParams.has('uploads') && method === 'POST') {
      return handleCreateMultipartUpload(bucket, key, searchParams, reqId);
    }
    if (searchParams.has('uploadId') && searchParams.has('partNumber') && method === 'PUT') {
      return handleUploadPart(bucket, key, searchParams, req, reqId);
    }
    if (searchParams.has('uploadId') && method === 'POST') {
      const body = await req.text();
      return handleCompleteMultipartUpload(bucket, key, searchParams, body, reqId);
    }
    if (searchParams.has('uploadId') && method === 'DELETE') {
      return handleAbortMultipartUpload(bucket, key, searchParams, reqId);
    }
    if (searchParams.has('uploadId') && method === 'GET') {
      return handleListParts(bucket, key, searchParams, reqId);
    }

    // Standard object operations
    if (method === 'GET') return handleGetObject(bucket, key, searchParams, headers, reqId);
    if (method === 'HEAD') return handleHeadObject(bucket, key, reqId);
    if (method === 'PUT') return handlePutObject(bucket, key, searchParams, headers, req, reqId);
    if (method === 'DELETE') return handleDeleteObject(bucket, key, reqId);

    return s3ErrorResponse(
      'MethodNotAllowed',
      'The specified method is not allowed against this resource.',
      `/${bucket}/${key}`,
      405,
      reqId,
    );
  } catch (error: unknown) {
    logger.error('S3 operation error', { bucket, key, error: getErrorMessage(error) });
    return s3ErrorResponse(
      'InternalError',
      'We encountered an internal error. Please try again.',
      pathname,
      500,
      reqId,
    );
  }
};

// ─────── Bucket Operations ───────

const handleListBuckets = async (reqId: string): Promise<Response> => {
  const buckets = await listBuckets();
  const xml = listBucketsXml(buckets, reqId);
  return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' });
};

const handleCreateBucket = async (bucketName: string, reqId: string): Promise<Response> => {
  if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucketName)) {
    return s3ErrorResponse(
      'InvalidBucketName',
      'The specified bucket is not valid.',
      `/${bucketName}`,
      400,
      reqId,
    );
  }
  const existing = await findBucketByName(bucketName);
  if (existing) {
    return s3ErrorResponse(
      'BucketAlreadyExists',
      'The requested bucket name is not available.',
      `/${bucketName}`,
      409,
      reqId,
    );
  }
  await createBucket(bucketName);
  return s3Response(null, 200, reqId);
};

const handleHeadBucket = async (bucketName: string, reqId: string): Promise<Response> => {
  const bucket = await findBucketByName(bucketName);
  if (!bucket) {
    return s3ErrorResponse(
      'NoSuchBucket',
      'The specified bucket does not exist.',
      `/${bucketName}`,
      404,
      reqId,
    );
  }
  return s3Response(null, 200, reqId);
};

const handleDeleteBucket = async (bucketName: string, reqId: string): Promise<Response> => {
  const bucket = await findBucketByName(bucketName);
  if (!bucket) {
    return s3ErrorResponse(
      'NoSuchBucket',
      'The specified bucket does not exist.',
      `/${bucketName}`,
      404,
      reqId,
    );
  }
  const objCount = await countBucketObjects(bucket.id);
  if (objCount > 0) {
    return s3ErrorResponse(
      'BucketNotEmpty',
      'The bucket you tried to delete is not empty.',
      `/${bucketName}`,
      409,
      reqId,
    );
  }
  await deleteBucket(bucketName);
  return s3Response(null, 204, reqId);
};

const handleGetBucketVersioning = async (bucketName: string, reqId: string): Promise<Response> => {
  const bucket = await findBucketByName(bucketName);
  if (!bucket) {
    return s3ErrorResponse(
      'NoSuchBucket',
      'The specified bucket does not exist.',
      `/${bucketName}`,
      404,
      reqId,
    );
  }
  return s3Response(bucketVersioningConfigurationXml(), 200, reqId, {
    'content-type': 'application/xml',
  });
};

// ─────── Object Operations ───────

const handleGetObject = async (
  bucket: string,
  key: string,
  _searchParams: URLSearchParams,
  headers: Record<string, string>,
  reqId: string,
): Promise<Response> => {
  const bucketRecord = await findBucketByName(bucket);
  if (!bucketRecord)
    return s3ErrorResponse(
      'NoSuchBucket',
      'The specified bucket does not exist.',
      `/${bucket}/${key}`,
      404,
      reqId,
    );

  const file = await findFileByBucketAndKey(bucketRecord.id, key);
  if (!file)
    return s3ErrorResponse(
      'NoSuchKey',
      'The specified key does not exist.',
      `/${bucket}/${key}`,
      404,
      reqId,
    );

  if (file.storageBackend === 'chunked') {
    const totalSize = Number(file.sizeBytes);
    const range = parseRangeHeader(headers.range || null, totalSize);
    if (range.type === 'invalid') {
      return s3ErrorResponse(
        'InvalidRange',
        'The requested range is not satisfiable.',
        `/${bucket}/${key}`,
        416,
        reqId,
        {
          'content-range': unsatisfiedContentRange(totalSize),
        },
      );
    }
    try {
      return await createChunkedObjectResponse({ file, range, reqId });
    } catch (error) {
      logger.warn('Chunked object content fetch failed', { key, error: getErrorMessage(error) });
      return s3ErrorResponse(
        'InternalError',
        'Failed to fetch object content from storage',
        `/${bucket}/${key}`,
        502,
        reqId,
      );
    }
  }

  if (file.multipartUploadId) {
    return handleGetMultipartObject(file, bucket, key, headers, reqId);
  }

  const fileInfo = await getFileInfo(file.telegramFileId);
  const redirectUrl = `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`;

  const totalSize = file.sizeBytes;
  const range = parseRangeHeader(headers.range || null, totalSize);
  if (range.type === 'invalid') {
    return s3ErrorResponse(
      'InvalidRange',
      'The requested range is not satisfiable.',
      `/${bucket}/${key}`,
      416,
      reqId,
      {
        'content-range': unsatisfiedContentRange(totalSize),
      },
    );
  }

  if (!config.proxyS3Get) {
    // Legacy 302 redirect path (when proxy is disabled)
    return s3Response(null, 302, reqId, { location: redirectUrl });
  }

  const part: ObjectPartSource = {
    telegramFileId: file.telegramFileId,
    telegramUrl: redirectUrl,
    sizeBytes: file.sizeBytes,
    partNumber: 1,
  };

  try {
    return await createGetObjectResponse({
      reqId,
      contentType: file.mimeType,
      etag: file.fileHash || '',
      lastModified: file.createdAt instanceof Date ? file.createdAt : new Date(file.createdAt),
      totalSize: file.sizeBytes,
      parts: [part],
      range,
    });
  } catch (error) {
    logger.warn('Telegram content fetch failed', {
      fileId: file.telegramFileId,
      error: getErrorMessage(error),
    });
    return s3ErrorResponse(
      'InternalError',
      'Failed to fetch object content from storage',
      `/${bucket}/${key}`,
      502,
      reqId,
    );
  }
};

const handleGetMultipartObject = async (
  file: File,
  bucket: string,
  key: string,
  headers: Record<string, string>,
  reqId: string,
): Promise<Response> => {
  const uploadId = file.multipartUploadId!;
  const parts = await listMultipartParts(uploadId);

  if (parts.length === 0) {
    return s3ErrorResponse(
      'InternalError',
      'Multipart object has no parts.',
      `/${bucket}/${key}`,
      500,
      reqId,
    );
  }

  const totalSize = parts.reduce((sum, p) => sum + Number(p.sizeBytes), 0);
  const range = parseRangeHeader(headers.range || null, totalSize);
  if (range.type === 'invalid') {
    return s3ErrorResponse(
      'InvalidRange',
      'The requested range is not satisfiable.',
      `/${bucket}/${key}`,
      416,
      reqId,
      {
        'content-range': unsatisfiedContentRange(totalSize),
      },
    );
  }

  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,
      partNumber: part.partNumber,
    });
  }

  if (!config.proxyS3Get) {
    return s3Response(null, 302, reqId, { location: sources[0].telegramUrl });
  }

  try {
    return await createGetObjectResponse({
      reqId,
      contentType: file.mimeType,
      etag: file.fileHash || parts.map((p) => p.etag).join('-'),
      lastModified: file.createdAt instanceof Date ? file.createdAt : new Date(file.createdAt),
      totalSize,
      parts: sources,
      range,
    });
  } catch (error) {
    logger.warn('Telegram multipart content fetch failed', {
      uploadId: file.multipartUploadId,
      error: getErrorMessage(error),
    });
    return s3ErrorResponse(
      'InternalError',
      'Failed to fetch object content from storage',
      `/${bucket}/${key}`,
      502,
      reqId,
    );
  }
};

const handleHeadObject = async (bucket: string, key: string, reqId: string): Promise<Response> => {
  const bucketRecord = await findBucketByName(bucket);
  if (!bucketRecord)
    return s3ErrorResponse(
      'NoSuchBucket',
      'The specified bucket does not exist.',
      `/${bucket}/${key}`,
      404,
      reqId,
    );

  const file = await findFileByBucketAndKey(bucketRecord.id, key);
  if (!file)
    return s3ErrorResponse(
      'NoSuchKey',
      'The specified key does not exist.',
      `/${bucket}/${key}`,
      404,
      reqId,
    );

  return s3Response(null, 200, reqId, {
    'content-type': file.mimeType,
    'content-length': String(file.sizeBytes),
    etag: `"${file.fileHash || nanoid(16)}"`,
    'last-modified':
      file.createdAt instanceof Date ? file.createdAt.toUTCString() : new Date().toUTCString(),
    'accept-ranges': 'bytes',
    'cache-control': 'public, max-age=31536000',
  });
};

const handlePutObject = async (
  bucket: string,
  key: string,
  searchParams: URLSearchParams,
  headers: Record<string, string>,
  req: Request,
  reqId: string,
): Promise<Response> => {
  const bucketRecord = await findBucketByName(bucket);
  if (!bucketRecord)
    return s3ErrorResponse(
      'NoSuchBucket',
      'The specified bucket does not exist.',
      `/${bucket}/${key}`,
      404,
      reqId,
    );

  if (searchParams.has('tagging')) {
    return s3Response(null, 204, reqId);
  }

  const copySource = headers['x-amz-copy-source'];
  if (copySource) {
    return handleCopyObject(bucket, key, copySource, headers, bucketRecord.id, reqId);
  }

  // Read raw body — S3 clients send raw binary, not multipart/form-data
  const body = await req.arrayBuffer();
  const fileBuffer = Buffer.from(body);
  const contentType = headers['content-type'] || 'application/octet-stream';
  const hash = computeHash(fileBuffer);

  const existing = await findFileByBucketAndKey(bucketRecord.id, key);
  if (existing) {
    return s3Response(null, 200, reqId, { etag: `"${hash}"` });
  }

  return await storeFileToTelegram(fileBuffer, hash, key, bucketRecord, contentType, reqId);
};

const storeFileToTelegram = async (
  buffer: Buffer,
  hash: string,
  key: string,
  bucketRecord: { id: string; name: string },
  contentType: string,
  reqId: string,
): Promise<Response> => {
  const tempPath = `/tmp/filedrop-s3-${nanoid()}`;
  await Bun.write(tempPath, buffer);

  const signatureBuffer = buffer.subarray(0, 16);
  const fileName = key.split('/').pop() || 'file';
  const { fileName: finalFileName, mimeType } = ensureExtension(
    fileName,
    signatureBuffer,
    contentType,
  );

  const bucketId = bucketRecord.id;
  const partFileNamePrefix = `s3-${bucketRecord.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,
      s3Key: key,
    });
    await cleanupTempFile(tempPath);
    return s3Response(null, 200, reqId, { etag: `"${file.fileHash}"` });
  }

  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,
    s3Key: key,
    storageBackend: 'telegram',
    isDeleted: false,
    createdAt: new Date(),
    updatedAt: new Date(),
  });

  await cleanupTempFile(tempPath);

  return s3Response(null, 200, reqId, { etag: `"${hash}"` });
};

const handleCopyObject = async (
  _destBucket: string,
  destKey: string,
  rawCopySource: string,
  headers: Record<string, string>,
  destBucketId: string,
  reqId: string,
): Promise<Response> => {
  const copySource = decodeURIComponent(rawCopySource);
  const sourcePath = copySource.startsWith('/') ? copySource.slice(1) : copySource;
  const parts = sourcePath.split('/');
  const sourceBucket = parts[0];
  const sourceKey = parts.slice(1).join('/');

  const sourceBucketRecord = await findBucketByName(sourceBucket);
  if (!sourceBucketRecord)
    return s3ErrorResponse(
      'NoSuchBucket',
      'The specified bucket does not exist.',
      copySource,
      404,
      reqId,
    );

  const sourceFile = await findFileByBucketAndKey(sourceBucketRecord.id, sourceKey);
  if (!sourceFile)
    return s3ErrorResponse(
      'NoSuchKey',
      'The specified key does not exist.',
      copySource,
      404,
      reqId,
    );

  if (sourceFile.storageBackend === 'chunked') {
    // Copying chunked objects is not yet supported.
    return s3ErrorResponse(
      'NotImplemented',
      'Copying chunked objects is not yet implemented.',
      copySource,
      501,
      reqId,
    );
  }

  // Conditional copy: if-match / if-none-match checks
  const ifMatch = headers['x-amz-copy-source-if-match'];
  const ifNoneMatch = headers['x-amz-copy-source-if-none-match'];
  if (ifMatch && sourceFile.fileHash && ifMatch !== `"${sourceFile.fileHash}"`) {
    return s3ErrorResponse(
      'PreconditionFailed',
      'The preconditions you specified did not hold.',
      copySource,
      412,
      reqId,
    );
  }
  if (ifNoneMatch && sourceFile.fileHash && ifNoneMatch === `"${sourceFile.fileHash}"`) {
    return s3ErrorResponse(
      'PreconditionFailed',
      'The preconditions you specified did not hold.',
      copySource,
      412,
      reqId,
    );
  }

  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: destBucketId,
    s3Key: destKey,
    storageBackend: 'telegram',
    isDeleted: false,
    createdAt: new Date(),
    updatedAt: new Date(),
  });

  const xml = copyObjectResultXml(sourceFile.fileHash || nanoid(16), new Date());
  return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' });
};

const handleDeleteObject = async (
  bucket: string,
  key: string,
  reqId: string,
): Promise<Response> => {
  const bucketRecord = await findBucketByName(bucket);
  if (!bucketRecord)
    return s3ErrorResponse(
      'NoSuchBucket',
      'The specified bucket does not exist.',
      `/${bucket}/${key}`,
      404,
      reqId,
    );

  await softDeleteFile(bucketRecord.id, key);
  return s3Response(null, 204, reqId);
};

const handleDeleteObjects = async (
  bucket: string,
  body: string,
  reqId: string,
): Promise<Response> => {
  const bucketRecord = await findBucketByName(bucket);
  if (!bucketRecord)
    return s3ErrorResponse(
      'NoSuchBucket',
      'The specified bucket does not exist.',
      `/${bucket}`,
      404,
      reqId,
    );

  const { keys, quiet } = parseDeleteObjectsBody(body);
  const deletedKeys: string[] = [];
  for (const key of keys) {
    const ok = await softDeleteFile(bucketRecord.id, key);
    if (ok) deletedKeys.push(key);
  }
  const xml = quiet ? deleteResultXml([], []) : deleteResultXml(deletedKeys, []);
  return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' });
};

// ─────── Object Listing ───────

const handleListObjectsV1 = async (
  bucket: string,
  searchParams: URLSearchParams,
  reqId: string,
): Promise<Response> => {
  const bucketRecord = await findBucketByName(bucket);
  if (!bucketRecord)
    return s3ErrorResponse(
      'NoSuchBucket',
      'The specified bucket does not exist.',
      `/${bucket}`,
      404,
      reqId,
    );

  const prefix = searchParams.get('prefix') || '';
  const delimiter = searchParams.get('delimiter') || null;
  const maxKeys = Math.min(parseInt(searchParams.get('max-keys') || '1000', 10), 1000);
  const marker = searchParams.get('marker') || null;
  const encodingType = searchParams.get('encoding-type') || null;

  const { objects, prefixes: commonPrefixes } = await listObjectsByPrefix(
    bucketRecord.id,
    prefix,
    delimiter,
    maxKeys,
    marker,
  );

  const isTruncated = objects.length > maxKeys;
  const displayObjects = objects.slice(0, maxKeys);
  const nextMarker = isTruncated
    ? (displayObjects[displayObjects.length - 1]?.s3Key ?? null)
    : null;

  const xml = listBucketResultXml(
    bucket,
    displayObjects.map((o) => ({
      key: o.s3Key ?? '',
      sizeBytes: o.sizeBytes,
      etag: o.fileHash || nanoid(16),
      lastModified: o.createdAt instanceof Date ? o.createdAt : new Date(),
      mimeType: o.mimeType,
    })),
    commonPrefixes,
    isTruncated,
    marker,
    maxKeys,
    prefix,
    delimiter,
    nextMarker,
    reqId,
    encodingType,
  );

  return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' });
};

const handleListObjectsV2 = async (
  bucket: string,
  searchParams: URLSearchParams,
  reqId: string,
): Promise<Response> => {
  const bucketRecord = await findBucketByName(bucket);
  if (!bucketRecord)
    return s3ErrorResponse(
      'NoSuchBucket',
      'The specified bucket does not exist.',
      `/${bucket}`,
      404,
      reqId,
    );

  const prefix = searchParams.get('prefix') || '';
  const delimiter = searchParams.get('delimiter') || null;
  const maxKeys = Math.min(parseInt(searchParams.get('max-keys') || '1000', 10), 1000);
  const continuationToken = searchParams.get('continuation-token') || null;
  const startAfter = searchParams.get('start-after') || null;
  const encodingType = searchParams.get('encoding-type') || null;

  const { objects, prefixes: commonPrefixes } = await listObjectsByPrefix(
    bucketRecord.id,
    prefix,
    delimiter,
    maxKeys,
    continuationToken || startAfter,
  );

  const isTruncated = objects.length > maxKeys;
  const displayObjects = objects.slice(0, maxKeys);
  const nextContinuationToken = isTruncated
    ? (displayObjects[displayObjects.length - 1]?.s3Key ?? null)
    : null;

  const xml = listBucketV2ResultXml(
    bucket,
    displayObjects.map((o) => ({
      key: o.s3Key ?? '',
      sizeBytes: o.sizeBytes,
      etag: o.fileHash || nanoid(16),
      lastModified: o.createdAt instanceof Date ? o.createdAt : new Date(),
      mimeType: o.mimeType,
    })),
    commonPrefixes,
    isTruncated,
    maxKeys,
    prefix,
    delimiter,
    continuationToken,
    nextContinuationToken,
    displayObjects.length,
    reqId,
    encodingType,
  );

  return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' });
};

// ─────── Multipart Upload ───────

const handleCreateMultipartUpload = async (
  bucket: string,
  key: string,
  _searchParams: URLSearchParams,
  reqId: string,
): Promise<Response> => {
  const bucketRecord = await findBucketByName(bucket);
  if (!bucketRecord)
    return s3ErrorResponse(
      'NoSuchBucket',
      'The specified bucket does not exist.',
      `/${bucket}/${key}`,
      404,
      reqId,
    );

  const uploadId = await createMultipartUpload(bucketRecord.id, key, 's3');

  const xml = initiateMultipartUploadXml(bucket, key, uploadId);
  return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' });
};

const handleUploadPart = async (
  bucket: string,
  key: string,
  searchParams: URLSearchParams,
  req: Request,
  reqId: string,
): Promise<Response> => {
  const uploadId = searchParams.get('uploadId')!;
  const partNumber = parseInt(searchParams.get('partNumber')!, 10);
  if (partNumber < 1 || partNumber > 10000) {
    return s3ErrorResponse(
      'InvalidArgument',
      'Part number must be an integer between 1 and 10000',
      `/${bucket}/${key}`,
      400,
      reqId,
    );
  }

  const multipart = await findMultipartUpload(uploadId);
  if (!multipart || multipart.s3Key !== key) {
    return s3ErrorResponse(
      'NoSuchUpload',
      'The specified upload does not exist.',
      `/${bucket}/${key}`,
      404,
      reqId,
    );
  }

  const body = await req.arrayBuffer();
  const buffer = Buffer.from(body);

  if (buffer.byteLength > config.telegramChunkSizeBytes) {
    return s3ErrorResponse(
      'EntityTooLarge',
      `Your proposed upload part size (${buffer.byteLength} bytes) exceeds the maximum allowed part size (${config.telegramChunkSizeBytes} bytes) for this storage backend. Use smaller part sizes.`,
      `/${bucket}/${key}`,
      400,
      reqId,
    );
  }

  const tempPath = `/tmp/filedrop-mp-${nanoid()}`;
  await Bun.write(tempPath, buffer);

  const forwardResult = await forwardToStorage(
    createReadStream(tempPath),
    `mp-${uploadId}-part-${partNumber}`,
    'document',
  );

  await cleanupTempFile(tempPath);

  const etag = computeHash(buffer);
  await insertMultipartPart({
    uploadId,
    partNumber,
    telegramFileId: forwardResult.telegramFileId,
    telegramFileUniqueId: forwardResult.telegramFileUniqueId,
    storageMessageId: forwardResult.storageMessageId,
    sizeBytes: buffer.byteLength,
    etag,
  });

  return s3Response(null, 200, reqId, { etag: `"${etag}"` });
};

const handleCompleteMultipartUpload = async (
  bucket: string,
  key: string,
  searchParams: URLSearchParams,
  body: string,
  reqId: string,
): Promise<Response> => {
  const uploadId = searchParams.get('uploadId')!;
  const multipart = await findMultipartUpload(uploadId);
  if (!multipart) {
    return s3ErrorResponse(
      'NoSuchUpload',
      'The specified upload does not exist.',
      `/${bucket}/${key}`,
      404,
      reqId,
    );
  }

  const parts = parseCompleteMultipartBody(body);
  const storedParts = await listMultipartParts(uploadId);

  // Validate ascending part order
  const partNumbers = parts.map((p) => p.partNumber);
  if (partNumbers.length > 1 && partNumbers.some((n, i) => i > 0 && n <= partNumbers[i - 1])) {
    return s3ErrorResponse(
      'InvalidPartOrder',
      'The list of parts was not in ascending order.',
      `/${bucket}/${key}`,
      400,
      reqId,
    );
  }

  if (parts.length !== storedParts.length) {
    return s3ErrorResponse(
      'InvalidPart',
      'One or more specified parts could not be found.',
      `/${bucket}/${key}`,
      400,
      reqId,
    );
  }

  const totalSize = storedParts.reduce((sum, p) => sum + p.sizeBytes, 0);

  const publicId = nanoid();
  const { db, files: fileSchema } = await import('../db/index');

  await db.insert(fileSchema).values({
    publicId,
    telegramFileId: storedParts[0].telegramFileId,
    telegramFileUniqueId: storedParts[0].telegramFileUniqueId,
    storageChatId: config.storageChatId,
    storageMessageId: storedParts[0].storageMessageId,
    fileName: key.split('/').pop() || 'file',
    mimeType: 'application/octet-stream',
    sizeBytes: totalSize,
    fileType: 'document',
    uploaderId: 0,
    bucketId: multipart.bucketId,
    s3Key: key,
    storageBackend: 'telegram',
    isDeleted: false,
    multipartUploadId: uploadId,
    createdAt: new Date(),
    updatedAt: new Date(),
  });

  await completeMultipartUpload(uploadId);

  const location = `${config.baseUrl}/${bucket}/${key}`;
  const combinedEtag = storedParts.map((p) => p.etag).join('-');
  const xml = completeMultipartUploadXml(bucket, key, combinedEtag, location);

  return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' });
};

const handleListMultipartUploads = async (
  bucket: string,
  searchParams: URLSearchParams,
  reqId: string,
): Promise<Response> => {
  const bucketRecord = await findBucketByName(bucket);
  if (!bucketRecord)
    return s3ErrorResponse(
      'NoSuchBucket',
      'The specified bucket does not exist.',
      `/${bucket}`,
      404,
      reqId,
    );

  const maxUploads = Math.min(parseInt(searchParams.get('max-uploads') || '1000', 10), 1000);
  const keyMarker = searchParams.get('key-marker') || null;
  const { uploads, isTruncated, nextKeyMarker } = await listMultipartUploadsByBucket(
    bucketRecord.id,
    maxUploads,
    keyMarker,
  );

  const xml = listMultipartUploadsXml(
    bucket,
    uploads.map((u) => ({
      key: u.s3Key,
      uploadId: u.uploadId,
      initiatedAt: u.initiatedAt,
      initiatedBy: u.initiatedBy,
    })),
    maxUploads,
    isTruncated,
    nextKeyMarker,
    reqId,
  );

  return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' });
};

const handleAbortMultipartUpload = async (
  bucket: string,
  key: string,
  searchParams: URLSearchParams,
  reqId: string,
): Promise<Response> => {
  const uploadId = searchParams.get('uploadId')!;
  const multipart = await findMultipartUpload(uploadId);
  if (!multipart) {
    return s3ErrorResponse(
      'NoSuchUpload',
      'The specified upload does not exist.',
      `/${bucket}/${key}`,
      404,
      reqId,
    );
  }

  await abortMultipartUpload(uploadId);
  return s3Response(null, 204, reqId);
};

const handleListParts = async (
  bucket: string,
  key: string,
  searchParams: URLSearchParams,
  reqId: string,
): Promise<Response> => {
  const uploadId = searchParams.get('uploadId')!;
  const multipart = await findMultipartUpload(uploadId);
  if (!multipart) {
    return s3ErrorResponse(
      'NoSuchUpload',
      'The specified upload does not exist.',
      `/${bucket}/${key}`,
      404,
      reqId,
    );
  }

  const parts = await listMultipartParts(uploadId);
  const maxParts = Math.min(parseInt(searchParams.get('max-parts') || '1000', 10), 1000);

  const xml = listPartsXml(
    bucket,
    key,
    uploadId,
    parts.map((p) => ({
      partNumber: p.partNumber,
      etag: p.etag,
      sizeBytes: p.sizeBytes,
      createdAt: p.createdAt,
    })),
    maxParts,
    false,
    reqId,
  );

  return s3Response(xml, 200, reqId, { 'content-type': 'application/xml' });
};
(x#-import { config } from '../env';

const errorSchema = (example: string) => ({
  type: 'object',
  properties: {
    error: { type: 'string', example },
  },
});

const jsonContent = (schema: object) => ({
  'application/json': { schema },
});

export const handleSwaggerJson = async (): Promise<Response> => {
  const spec = {
    openapi: '3.0.0',
    info: {
      title: 'TeleUploader API',
      version: '1.0.0',
      description: 'Telegram-backed file uploader API with redirect-based downloads.',
    },
    servers: [
      {
        url: '/',
        description: 'Current environment',
      },
    ],
    paths: {
      '/health': {
        get: {
          summary: 'Health Check',
          description: 'Checks database connectivity status.',
          responses: {
            '200': {
              description: 'Database is healthy',
              content: jsonContent({
                type: 'object',
                properties: {
                  status: { type: 'string', example: 'ok' },
                },
              }),
            },
            '500': {
              description: 'Database or server is unhealthy',
              content: jsonContent({
                type: 'object',
                properties: {
                  status: { type: 'string', example: 'error' },
                  error: { type: 'string', example: 'DB Connection Failed' },
                },
              }),
            },
          },
        },
      },
      '/api/upload': {
        post: {
          summary: 'Upload File',
          description: 'Uploads a file to Telegram storage via multipart/form-data or JSON base64.',
          requestBody: {
            required: true,
            content: {
              'multipart/form-data': {
                schema: {
                  type: 'object',
                  required: ['file'],
                  properties: {
                    file: {
                      type: 'string',
                      format: 'binary',
                      description: 'File binary payload.',
                    },
                    fileName: {
                      type: 'string',
                      description: 'Optional file name override.',
                    },
                  },
                },
              },
              'application/json': {
                schema: {
                  type: 'object',
                  required: ['file'],
                  properties: {
                    file: {
                      type: 'string',
                      description: 'Base64 encoded file content.',
                    },
                    fileName: {
                      type: 'string',
                      default: 'file',
                      description: 'Optional file name.',
                    },
                  },
                },
              },
            },
          },
          responses: {
            '200': {
              description: 'Successful upload metadata.',
              content: jsonContent({
                type: 'object',
                properties: {
                  public_id: { type: 'string', example: 'xYz123' },
                  telegram_file_id: { type: 'string', example: 'BQACAgQAAxkBA...' },
                  telegram_file_unique_id: { type: 'string', example: 'AgAD8w...' },
                  storage_chat_id: { type: 'integer', example: -1001234567890 },
                  storage_message_id: { type: 'integer', example: 42 },
                  file_name: { type: 'string', example: 'document.pdf' },
                  mime_type: { type: 'string', example: 'application/pdf' },
                  size_bytes: { type: 'integer', example: 1048576 },
                  file_type: { type: 'string', example: 'document' },
                  uploader_id: { type: 'integer', example: 0 },
                  created_at: {
                    type: 'string',
                    format: 'date-time',
                    example: '2026-05-18T10:00:00.000Z',
                  },
                  download_url: {
                    type: 'string',
                    example: `${config.baseUrl}/f/xYz123`,
                  },
                },
              }),
            },
            '400': {
              description: 'Bad request.',
              content: jsonContent(errorSchema('No file provided')),
            },
            '500': {
              description: 'Internal server error.',
              content: jsonContent(errorSchema('Upload failed')),
            },
          },
        },
      },
      '/f/{public_id}': {
        get: {
          summary: 'Redirect to Telegram File URL',
          description:
            'Gets a fresh Telegram download URL and redirects with 302. Rate-limited by IP.',
          parameters: [
            {
              name: 'public_id',
              in: 'path',
              required: true,
              description: 'Permanent public file ID.',
              schema: { type: 'string' },
            },
          ],
          responses: {
            '302': {
              description: 'Redirect to Telegram CDN URL.',
              headers: {
                Location: {
                  schema: {
                    type: 'string',
                    example: 'https://api.telegram.org/file/botTOKEN/documents/file_0.pdf',
                  },
                },
              },
            },
            '404': {
              description: 'File not found.',
              content: jsonContent(errorSchema('File not found')),
            },
            '429': {
              description: 'Rate limit exceeded.',
              content: jsonContent(errorSchema('Rate limit exceeded')),
            },
            '500': {
              description: 'Internal server error.',
              content: jsonContent(errorSchema('Server error')),
            },
          },
        },
      },
      '/file/{public_id}/info': {
        get: {
          summary: 'Get File Info',
          description: 'Gets saved file metadata by public ID.',
          parameters: [
            {
              name: 'public_id',
              in: 'path',
              required: true,
              description: 'Permanent public file ID.',
              schema: { type: 'string' },
            },
          ],
          responses: {
            '200': {
              description: 'File metadata.',
              content: jsonContent({
                type: 'object',
                properties: {
                  public_id: { type: 'string', example: 'xYz123' },
                  file_name: { type: 'string', example: 'document.pdf' },
                  mime_type: { type: 'string', example: 'application/pdf' },
                  size_bytes: { type: 'integer', example: 1048576 },
                  file_type: { type: 'string', example: 'document' },
                  uploader_id: { type: 'integer', example: 0 },
                  created_at: {
                    type: 'string',
                    format: 'date-time',
                    example: '2026-05-18T10:00:00.000Z',
                  },
                },
              }),
            },
            '400': {
              description: 'Missing public ID.',
              content: jsonContent(errorSchema('Missing file id')),
            },
            '404': {
              description: 'File not found.',
              content: jsonContent(errorSchema('File not found')),
            },
            '500': {
              description: 'Internal server error.',
              content: jsonContent(errorSchema('Server error')),
            },
          },
        },
      },
    },
  };

  return Response.json(spec, {
    status: 200,
    headers: {
      'access-control-allow-origin': '*',
    },
  });
};

export const handleSwaggerHtml = async (): Promise<Response> => {
  const html = `<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>TeleUploader API Documentation</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.17.14/swagger-ui.css">
    <style>
      html { box-sizing: border-box; overflow-y: scroll; }
      *, *::before, *::after { box-sizing: inherit; }
      body { margin: 0; background: #fafafa; }
    </style>
  </head>
  <body>
    <div id="swagger-ui"></div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.17.14/swagger-ui-bundle.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.17.14/swagger-ui-standalone-preset.js"></script>
    <script>
      window.onload = function() {
        window.ui = SwaggerUIBundle({
          url: '/swagger.json',
          dom_id: '#swagger-ui',
          deepLinking: true,
          presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
          plugins: [SwaggerUIBundle.plugins.DownloadUrl],
          layout: 'BaseLayout'
        });
      };
    </script>
  </body>
</html>`;

  return new Response(html, {
    status: 200,
    headers: {
      'content-type': 'text/html; charset=utf-8',
      'access-control-allow-origin': '*',
      'x-content-type-options': 'nosniff',
    },
  });
};
S{ZR`x4G>Qconst publicIdParameter = {
  name: 'public_id',
  in: 'path',
  required: true,
5.o!};

const fileInfoProperties = {
'6:=P5604a';},
};

const uploadProperties = {
  ...fileInfoProperties,
kGEAh8download_url: {
0)/},
};

const objectSchema = (properties: objectF&
objectSchema(uploadProperties/publicIdParameter publicIdParameter'XobjectSchema(fileInfoProperties')YZx>;0FileDrop3File upload API with stream<;
           w
Li!
/413"equest body too large1'_Request body too large"lDownU
$-Redirects to Telegram CDN for direct downloadh0 status: 200FileDrop^04"|\Lxi&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/teleuploader-${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/teleuploader-${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 });
  }
};
7fmwx# LLfiledropfiledropMx+Kimport { 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/teleuploader-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);
  }
};
*>Bx WWsfiledrop5b5 x340031QH,-+)f7o&Du<nQ
R{+B=y:de%U30?pSڧkVUVsX,`5Mx$ lL[s]cG
rS@ӟ1,M]ŠrSK2Ax
>"-5ĒT%5*nu=	B0u@*Aj.zƱ)E<\61 bc>y>?]2<QKu椦%ؿʇB[l13Д)O\nG䇢2^L:W '?1)G8UmSv-Ə9PU@:Câ=vM<EdYӢx5import { 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);
  };
};
Obx,// 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);

// Cleanup expired cache entries every 5 minutes
setInterval(
  () => {
    const removed = fileInfoCache.cleanup();
    if (removed > 0) {
      console.log(`Cleaned up ${removed} expired cache entries`);
    }
  },
  5 * 60 * 1000,
);

export { Cache };
H9x 
,export { Cache };
yxX[o8~W	i2]Rvv"J'qf'_&N|.>;nKE\^=B;TvfHQNq`I\]Hk>⚕1ϧ.C.YK+ϣ?Cjyj?.5A"H#ep'p>\P~뎠-CϝN{ZnX-EQZVUF؇a'X=vw9]Y̢ryXu(zGsy^a,WАWW/rVDm +ICV\w|{|DڲͪT7hf
(3ieVT]J7R,	/@R?Ug*O*\2Oxv?EZDN%"bTNxZ!9(QzJUR(BJL]YDXngXFJ)ސgcH-%\yNa*J8e쵟wDUǎu7iV!\hUl Y>"Z<5n,Tk9O(^B\ky{B9oy6;cpŮA͕O;D%z#_/ÝO`~褯khٶ 9eh܀xlҬ]GߛPBX:!^Ra+FYh2ؤ51I L,*b"u0&fNR\YhB,FRY>i[Lwm6LƘDv"O,TΨJs|:[puu\ gX '^W4oyGJi'=OD/g3ƿ,_wA)I]G̬Mφtܲ>9 Xj0sivs7WJ(iz
Kp67ȔYƳK<_8\%bk&)뉶0b3XB\36LTL[\"7 0وs<eN:e9^Bonmnd;>7NnVe!L{:ZV/ӱ*?S2~/xx eS +I
Pu4O0NwJa񣙅oEWDl&gVM jmk\PbZRSM𹿃X{샲H;V9Vdh5SyԂl2Ym˫iʅAL_Wnl9.ooɬ@4GD]lh̭;-]waM*wipNV=aR&6OX*{PjAtwJmbc~EuWC+iŰCƼ\Erj.&	˟C,FL=!HM}9qonE$t	afI"xDA 7'RT8
j,TΛ;z<dm+27SǚpKq9/VJO]eY
p[d^K<ivߗ#p6`u˔?@3!̃N^"HucZh˞6m<-蠋/_%=]fhۨ֘*lZ[^d^X]{Eb67;0[	Y/yS7k);QyWp[[gg_e%ew;jh~ߐԆv}Esϓ1п]+xݛ+MXݭ+cy5Wڦ374jpJ2H]WoaaוKs&RC<o+ZSheU+zU@9l4zF`<dܷ&~:l@`7	 xYmo6_]edM5M Z4͇!Ym5I4_$RXQ@sG^]YH%#ʎ"+iYJɀ>4K]9|Kv)(pAG4L[I}>B;ٌwvb	=,py5K4RhBEBp?2Zeh0djF!Om`EuExEwm6ʓ,ha.4`4= Ώ5h#trzv<|aVDox(oqh(MA4C2ˌe>P-sG4UAň۳P!
x8K`Ւ--~4ctFLʍN_wRI݉btEB<(̓^]O<ew8
J:pǵTy Vk[<rxxH0ꂎ,w㣡uiT-*kX.'Y֨cm3pZ}`gXVkR7S;"hhu9<
'lkD"In-H6'}(4Z9ЩyNŃeYBL$^t4J^cP6tOmi^9,ۥiYx7lCͪK;DJDc5P8C#tt<;Z<U&7Fy4w01\ytxo9oy
t|H:k6ʓ NL19r]׬U PQ+,(~7$/](D*'8âII5eP鱫}E}Q%ըګ):V^V[#VLuDƖ2=XM+avlyyɉsУBiEˠ3K:5G8 {yg^B[@3C/9ԑڛyoԧhO6l`s-ir z<n]L\e[QG.mVjdN|B~[	x89F>?RY޿PLV%'oQ 7c	Hq{Ӷ<o\^abCkZ^^5 Z)[FhjV7Lo| :
aQkcu3U*(8FfSNRē&u5M@_։!Er44qq5*ōB,%bVʞpPõ;YL\d{b[ړʍCw1.wqbJ֦0Ɇ	xNkSrEz%<k(1lCveyS'o/h^BlyAy.ۼ`J/XVm0[r^1}0V)d9>r2-W
3WQj7lɏi,1wLmʱ̒OccI8xS┙]BbSlXj;(f1X[t/S[>i]@Kԯ}M>)6A[<-Xdc+ѓ;t=ݧ]PCW*O(jk*	=f]u2]\RY;yZ{j`/nU{g.<3"i\4@Saнs&E{88A;~]U,?M.Y=R#_\lo^wbSrf]K>BB}QLȁ4\U^~]=lTM[kIM.E*[{0$jSo|_6 rF<w;)SRĜ2ؠ:ꋋx/0&P?v0Gۮj\uKi.K1[Vi-Fp~%G h8$0cf4F͍
%>xH76Kimport { unlink } from 'node:fs/promises';
import logger from './logger';

export const cleanupTempFile = async (tempPath: string): Promise<void> => {
  try {
    await unlink(tempPath);
  } catch (err)a {
    logger.warn('Failed to cleanup temp file', { tempPath, error: getErrorMessage(err) });
  }}U:zKh&rbxm?k0w$5RI3
JgW`Kܺq=-:{zKW8[S&v z@-,1*1Tj4r2cǤMgf .+2 vOj.$R[)ŒO
ɩ9fQŜ6ʘ 5wQo<#m%Rw`KX}hmt/ɴop4SM4x@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: 'teleuploader' },
  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;
~Mx filedrop%P'xwimport logger from './logger';

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();

// Log metrics every 5 minutes
setInterval(
  () => {
    const snapshot = metricsCollector.getSnapshot();
    logger.info('Metrics snapshot', {
      uploadLatency: snapshot.uploadLatency,
      uploadThroughput: snapshot.uploadThroughput.toFixed(2),
      errorRate: snapshot.errorRate.toFixed(2),
      cacheHitRate: snapshot.cacheHitRate.toFixed(2),
    });
  },
  5 * 60 * 1000,
);

export { MetricsCollector };
6#yx] P// No imports needed — logger used only by setInterval which moved to index.ts
Zd"xU]O@|ϯXbЪ}h*Ң
&ݙ$lc	û3	L,
Qtq8p;-Y/RB_u	4[PVGz<B\a4~gB=<BA;BB<Qi>FgngJK.VViN̫?norvCOoz˹dsTlK/7AzZp>!N	L(,3	ye]%µ` |jN]NT Qꄓ2s|8̿VR$lݽPDxrs/RO|ۆgκRe]+M̦]a}NA&g4[$)	1<I%QDv\NF@Y;ʅFB*>O.ˌ^Eؤzb=mhw9#zgʕ+2|WꛑacSqyH
;IR.8jp=8ζwPʷNLeRC#:Vuň	&CzDΒtKtg[͡-D((9OY3(dq|"46{Qр|zڋBw|ωe묛/<l WGU&2zg3.޽PM*^a"Ul=zVFdͲT#^S/ia	mŒY=>6Zqg%;tRxϏt`ȘYcryzq͎mЧ񫻸Wس2vѨ9£i;ᴚIf$_xU]o0}WIH(eHZxxMf;U>%$@&	||7$[L@/A9vNPYbXh%HN9<w 2l hcJ3DpmgGC$"dZ^̋tov3 
@5uybDNpyuz]M/M ,fj$So z^~ {ƶ=z?hu17I<1jÅq-0 	]nG`r-u â`}Z8-;h2f7Aׅ?N/A4DQ4ӁA2tuJRUl\4<'\P+s±ʍĥ6du7hWfi:
|l,
h!.-%TJm$r)G"!p2և3q;:rϖ[FDHVb$\Ju7z$nӞT	ѶƘ`Nh?.QwF$TySs/l!hY7bu\#F-JI	Dn^тTgs|Na׼;Jڎ0_Xbj&2{;CNj+u,Z^vP|MdiOSm:|&?z7SAݹdu%e"sfHm
}52wmwF(Mɐ!*I{&ur`4eJ*ZDR<j*9haOCu-;ئx340031QH,-+)f6A/n.5Ѧw$#51%a_ܿ}١RKtKRsAjO)ʴVym뇚UBv@%楧Xɫ{dzYyP5eE%9% LI+WխnIZJ+rs@*ucwL=6=ͪr
 ٵ[xZ{s_Q !AL2kb+(9m:G1 (ݳ{/dǣzCnnwo]XE$Yŋ93N==*Iw,M3 G<0e_*@GH<J^\'Eq>9*ME^QSlvvxwH/GEz-WRUĜE("z&gz9OFۤ,9(j%DYY9w'GΡO5z~|tt~)ΰ/+^ y?JJ6Ky?>pz˰HU_J0,[3f91%(}Tlj>]!H^6r=\BY,.T"C+`<CXKs8(
x҄2f r-qL "_$%W\,wY+-7,)*լJy%WpY;~5n-9V@k8OVxT*,ly3yU>{%+*qq]~ϓs]KacZcѮk&z<eɮUTuI݀th+Y/ü'['t/9rdlތ@(=iE;I6giňN輐An;GG-*4.rC,%_
lS
zP$1PŒѥs%Ԣ<_OnKW+XU"yLE:$/̭N0&	5
9ߒ% 3GVN_Q|35=L7
c,`Co[o?  \Qvo2d]0{AB׫۔'gwoz>8lQ[8j1y4Jȏ3UcY쪊CQ꽅L=Ԭvc5oS" LH2aDmz0E׫(6Ԏg$XgI3	[Ј[Hsŋ(:dL܃	e4gD0]KsQåG+:piH?5KWP|Nb^{-ueW{pVl x56CW {T Q=dĈ%,/PH|pêE Tρ ZRP0$.Y8I?#U\t($]kEʣHY ěȻ6ZZ{	r?(2:EBdo+a^(m/8c&JQvFX52aр-Q.^ ?*`~̫:
#0KdlbLybpT?@+OA80e8:Xأ{rMyބ9zs&3x]O[fe{콿9;Ysɬ^qPpOM@!C2.%;>O:6+"њKz+ZݸKU5S.A u`z29]k:0RA;efg&5n(wi5SN(4ʮI_%b򗤊=>VB}2AO$U M(դ/yE쀾I`.<Lkr1'$G{4A`@^|6"UI Iˍj9DrI9@1.$03;߶N_FӇO+ds?c_MepMV}d|~v<W{\b3"+V"l-MV.lː3^-(gbr-rۛ"Mvھr,\%}(h۶Kq/Lffw+dFFHx.stKܹh+$6!@5Ly$v;A*!ت?MK>gW=0*]+Ilf)YK:[]O"߶ 9i2]JB5oR^ Y#NoޯӍ?Ϲ~7bKGF59k=g)F$
ԃ<ب_Eed^yJgŌ	^G~@ NyE%8c]( "_J?tՀU}ШM-A,YCL|慲Ͱg@$kL4WxZ0B#McAwnWVw-4dXz,:u-ijbn 2iך![ItTJGHzLqE:tCݏ*SXmԮI$KԮ>"}3cIw{T=Ēfd<7o_߀~\nw4᷵BQ*9ٛz3#uz^T#X>nԖ<wD(f)&оޕ>>n:݊E̓X~Sl}(.Opl7SlȏDvmy
(emuf#CST|1mjnWUʙ&iNZh5t|ѿ_<zyißL	clG[s}~$cŗc3˫O]m[QRö&:]X8)O{v˴YZ0=RcHxSao0_q߀
jݦnRTTU3YV; $վ ~ݽ󁫅R%2%WQF*}5V9aopx1rV9yb*sohg*3G4EYz G	a͸_'H͐g;$m-Jn	<ƆO4rZ665J+p21Dȴ}+筡;f'qx}yF/-lۿ968@9ck|1Vnlmq?K$ӜSBSJڜw:\MsvLFYV¸wwPs?R# lvNc|Isfz$^,Lvb2$"bTzT`4;o\K5#6U[_~(mkAY'0hݽʶf{veI:/nʟb"xWmo6_A-4X1uvV	(J>ZeI#(na_{,d;\l5,ņb	,F̶_/AT-mݚN\ڸ%+ܖw\TpUIGPW~5"1? %E-`wVo~N3xJ4_͜Uz)MB(wV\XK..6Je*D*$0|#Y6;i~E^D}Lܮ1`1Hr3^ɷ2MR@}!yL~ԁF;Y.yKŬV"%jyq~$uQ	[I
,KU| J(Zm"]2s$gld$8GSN>Y5"Bg1鎤񭑤6ޘT(02G|s?FԀiǩcxC):R"ޑk+E۲4؆Ͼ?>L4CSd-rBLfD8\Wpu@0Rr0C?k%]0lIR93
EBҸD6n{¶u1;#K0Pm0]e/vai^qòTg=\C̞.l?̺5͕Ag`lt	c! "7>~x)bD<xẸUIFrUL)iJ-@WC
	qS#W|dagr-;mk!
^jbf	q=)֭̋%YfE:I@xX.c8:xBpsYae{c9V{dZW^>P6&=CTy/^m޻10L19 ΰ#3`1z|SRUjAx6/ \A:`793 O-F7QRjQrFa4HM6Gʶa澖qݕ29HgW`}s<Fc='<d82RlYJq;]y*}ٮe}4u	|W #[#V͋ʭĳ"Ĥ__"LQ,Z"iX9`590Ysh KUG2]5D2\;}fx~_q|M:o
Y7Uه)tc p+~t"1`,@e4썕k'حkbVHR@tBbv_0&Pz4^{j@^bn|ԟcJ=D&I~v)*6Fi׾(aAu9sYڍo3"ZOP~-80n&u ox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}`;
=D5xu[k0)@llT)`c>ۃsۨa1䔉ﾶi\~XiAI-H=< 1w"3h?.cjaX{RqoBQ/7OPג<~VOayqјL18،˼Y8Zr&1nR~i8;Ceou)r%@]*imS_}!͇fo7hX7nWj݁DP2'ARVp\*Ж'%xʂKS̵;:yU&y. :\5FCQԠ
Yy*qp:6DgmP+E^F%W\5I9\ݦy*i)SxZ{o_b!!*Ea2;;l_CSkqNOO^{h "ggfg5EҜHg4ȚܤlA,ǽk2,rBO;m|t@3O$jXE2F1OEysh*-CC1`-.ajÌ9϶	4'3'goO`Julq?
i._JX2TEĽj^34ȮpAbb.#"I`U|9a	M<B1
\Hf2` lCBoB[g{/Y_\o)T,?i*ށ_ gs=3<??wEGKi	͊(' g;<O]7r+ϙ;crsO7opߞ0X5$OlL}=V
aӍUkc\R|-0_\+'0FʾYۖ5ZJ6X{haRX{q>oy!rCECC՝	g>,[)}ld3w9)0?eY+})⚦Bs^2D~_nB*fBV@VGқtA䚱1/#Mk" EfsA30oUӻK{[	{S
݌ԣJY:fzOS!-\Wq.#YUEJ|B,)+,epXSE^؀cZ_c*X"add1^VϠ6p
C?+e]sM1UE*B?HWzk1x
=cA	xRNalzzw|wr &YP"ܪ轱R tTXFemoº55}6`s_+\͉@;eK%D;'b1Zm}q^߃ϊ87OKG#Aź)!W`K7ƪ`N2M
<7G#tOVҝlFeaxć6tsDy٢Γu*=~(>lg-ª4#,(4\.xzaTS!ToW>ކ8صYhMtv@x'kmoFpGEEEGp\?mК8e9c}"^4I$MBX	݌`IZ/"N}tp&tW{T"8C="SoW<hc\
5ZLRiCWuΒ؞8/&ȃRfU.YBsu/*ԐnHH><<4oR-|A%l4:L˘cZ f|NV	ƕLQ2p(^`MJ"ٲ{>*ۭ{ZUBņqcV]ړp{߽3ZA?P~#MS6?.oeP=&xD<	ajswߑK9'3&{9:	_45d@ża?YWr(6TO<4. 9	ٽA_Խm!oWqW-GNp`Ehh'[m-k[s#cO^e=g[.rI9wEF-QK>YEe>L`YwyA	X:ԇw
̫5ƫoϚbbsر6q\10U ƭBbkO2lh$B?]H!KqsquD1e䇣7 dhxf5_`30!|Dּ,V\15{i/*σ۽(]z'#ݝmdրO/`J-N
zQYQ1myisų5n>bڔE卥dBN7~@*~dF\ņQ4gFM^:YĻsB许.Oqww^e?TQèZ@y(nJ#YUJf5/QAr1Jlv!Ke:KDR[[Yn! $Evk+R58׊ hEr`(?iȿX[ʬG0EO_xZ"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));
const TELEGRAM_API_URL = `https://api.telegram.org/bot${config.botToken}/`;

let currentBotIndex = 0;

const rotateBot = (): { previousIndex: number; nextIndex: number } => {
  const previousIndex = currentBotIndex;
  currentBotIndex = (currentBotIndex + 1) % bots.length;
  return { previousIndex, nextIndex: currentBotIndex };
};

const sleep = (seconds: number): Promise<void> => {
  return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
};

const executeWithBotRetry = async <T>(
  action: (botInstance: Telegraf) => Promise<T>,
  retries = 5,
  attemptedBots = 0,
): Promise<T> => {
  const currentBot = bots[currentBotIndex];
  try {
    return await action(currentBot);
  } catch (error: unknown) {
    const errorStr = error instanceof Error ? error.message : String(error);
    const match = errorStr.match(/retry after (\d+)/i);

    if (match) {
      const { previousIndex, nextIndex } = rotateBot();
      attemptedBots++;

      if (attemptedBots < bots.length) {
        logger.info(
          `Bot Index ${previousIndex} hit 429. Instantly rotating to Bot Index ${nextIndex}...`,
        );
        return executeWithBotRetry(action, retries, attemptedBots);
      }

      // If all bots in the pool have been tried and hit 429, sleep
      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;
}

interface TelegramFileInfo {
  file_size: number;
  mime_type: string;
  file_path: string;
}

interface TelegramGetFileResponse {
  ok: boolean;
  description?: string;
  result: {
    file_id: string;
  };
}

interface TelegramGetInfoResponse {
  ok: boolean;
  description?: string;
  result: TelegramFileInfo;
}

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, keyof Telegraf['telegram']> = {
  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;
};

const getMediaGroupType = (fileType: string): string => {
  if (fileType === 'photo') return 'photo';
  if (fileType === 'video') return 'video';
  if (fileType === 'audio') return 'audio';
  return 'document';
};

interface MediaGroupPayloadItem {
  type: string;
  media: string;
  caption: string;
}

const buildMediaGroup = (items: MediaGroupItem[]): MediaGroupPayloadItem[] => {
  return items.map((item) => ({
    type: getMediaGroupType(item.fileType),
    media: item.fileId,
    caption: item.fileName,
  }));
};

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);

      const uploadResult = await executeWithBotRetry((activeBot) => {
        const telegram = activeBot.telegram as unknown as Record<string, SendMethod>;
        return telegram[sendMethod](config.storageChatId, filePayload, payload);
      });

      currentBotIndex = (currentBotIndex + 1) % bots.length;
      return uploadResult;
    });

    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 interface MediaGroupItem {
  fileId: string;
  fileName: string;
  fileType: string;
}

export const forwardMediaGroupToStorage = async (
  items: MediaGroupItem[],
): Promise<{
  storageMessageId: number;
  telegramFileIds: string[];
  telegramFileUniqueIds: string[];
}> => {
  try {
    const result = await enqueueUpload(async (): Promise<TelegramMessageResult[]> => {
      const mediaGroup = buildMediaGroup(items);

      const uploadResult = await executeWithBotRetry((activeBot) => {
        const sendMediaGroup = activeBot.telegram.sendMediaGroup as unknown as (
          chatId: number,
          media: MediaGroupPayloadItem[],
        ) => Promise<TelegramMessageResult[]>;
        return sendMediaGroup(config.storageChatId, mediaGroup);
      });

      currentBotIndex = (currentBotIndex + 1) % bots.length;
      return uploadResult;
    });

    const messages = Array.isArray(result) ? result : [result];
    const storageMessageId = messages[0]?.message_id || 0;

    const telegramFileIds: string[] = [];
    const telegramFileUniqueIds: string[] = [];

    for (let i = 0; i < messages.length; i++) {
      const uploadedFile = extractUploadedFile(messages[i], items[i]?.fileType || 'document');
      telegramFileIds.push(uploadedFile?.file_id || '');
      telegramFileUniqueIds.push(uploadedFile?.file_unique_id || '');
    }

    return {
      storageMessageId,
      telegramFileIds,
      telegramFileUniqueIds,
    };
  } catch (error: unknown) {
    logger.error('Failed to forward media group to storage', {
      error: error instanceof Error ? error.message : String(error),
    });
    throw error;
  }
};

export const getFileInfo = async (
  telegramFileId: string,
  telegramFileUniqueId: string,
): Promise<TelegramFileInfo> => {
  try {
    const result = await fetch(`${TELEGRAM_API_URL}getFile`);
    const data = (await result.json()) as TelegramGetFileResponse;

    if (!data.ok) {
      throw new Error(data.description || 'Telegram API error');
    }

    const fileId = data.result.file_id === telegramFileId ? telegramFileId : telegramFileUniqueId;
    const fileResult = await fetch(`${TELEGRAM_API_URL}getInfo`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ file_id: fileId }),
    });
    const fileInfo = (await fileResult.json()) as TelegramGetInfoResponse;

    if (!fileInfo.ok) {
      throw new Error(fileInfo.description || 'Telegram info error');
    }

    return {
      file_size: fileInfo.result.file_size,
      mime_type: fileInfo.result.mime_type,
      file_path: fileInfo.result.file_path,
    };
  } catch (error: unknown) {
    logger.error('Failed to get file info', {
      error: error instanceof Error ? error.message : String(error),
    });
    throw error;
  }
};

export const getBot = (): Telegraf => bots[0];
b@^hxD@@type FileInfoResult = {
  result: unknown;
  botToken: string;
}?
let nextBotIndex = 0;

const claimBotIndex = (): number => {
  const botIndex = nextBotIndex;
  nextBotIndex = (nextBotIndex ++botIndext, botToken: stringAPcbotIndex = claimBotIndex();
  const currentBot = bots[botIndex];
  const currentToken = botTokens[b4, currentToken]nextIndex = nextBotIndex;
      const nextAttemptedBots = attemptedBots + 1;

      if (nextAJbotCnextAttemptedBots);
      }
 export \  bot_token: string 	string 9let lastError: unknown;
  for (const activeBot of bots) {
    try {
      const result = await activeBot.telegram.getFile(telegramFileId);
      const fileData = result as unknown as Omit<TelegramFileInfo, 'bot_token'>;
      return {
        file_size: !Data.file_size || 0,
        mime_type: fileData.mime_type || 'application/octet-stream',
        file_path: fileData.file_path@ || '',
        bot_token: activeBot.telegram.token,
      };
  !  lastError = error;
  Q  if (
        errorStr.includes('wrong file_id') ||
        errorStr.includes('file is temporarily unavailable') ||
        erarorStr.includes('retry after')
      ) {
        continue;
      }
      throw error;
    }
  }

Lget file info from any bot', {
    error: lastError instanceof Error ? lastEa$lastError),
  });
  throw lastError;&"0NnextBotIndex];

export const getCurrentBotIndex = (): number => nextBotIndex;
Owx @04kNxj@s'نRPEtr=Qi+J[}uZuw|g<Vj[ԩZ.bHlFfB>AdFY?1[?q^Y`oݯg+p	\/kp	=L܃LTDh%5Ai^A@dz|
	VK4Г8 	AvQd0BB4X+Jgq3JrKjҚl+,r47$Xfiц|\\6q!R90MsNb߿	BK>"L<ʘyK<iO
ܻwZhGx OF	(ﴃ5|)WI,;nҳj>4ZetMa]%Zds15z3-Ļ
u=F.BIzD-/aYaƸeq/>+zh~QK?y$yxB 	%{ config } from '../env#0config.uploadConcurrencyQxW[o6~aUɊ=qM<$b)#WR%*iD6Eυ#nVhr.lȲd,Mx:▷cxVAEˁU}Fb3/ؚ:<?,0dOo7:씨Œ|6&}gm[SxUZhYO-\mIZSțL΄l=o:8~ղ|T5-Ɍ|"ٺaB:rvYǿgɺ	p%[v/6JV\~Q&hoWrBT7ex+<xZLc<DZ.N-@]|xw 2Q_lve]]=|&)G(ba°&E$<opw1_M՟w|q"?ݝ8SWz{+&I{
)<۩\V}8jA`1^tL"	SU XQ1*fehvϢ I\)&UA1
'ʥ'b7TH_a'[
D"՗ꍾK}ϫ2jx85a+O\Sm5-KzC{[c8R'{I'$QWPh'i6ڶ۸1.~KE)*]wzl^ ☒k"WӦPkqX,DS%i+w,"w3f
 U[4js92_2.lُ9STxL.APqF!u`OjfOF60` P /*ZATidt#1D,ed
O0k#P^1 ;fB5#zCn0(uH65_&IS	ޞ瀫{U+fޜgcT	͋[`kt;u8}$cVWqY~|=hF%!gC8nmf2km-!a#]oK b IÈ$9ke2|M#z%A:I8$h
cw.ڊtos&J`|wmQX?z4%¥ YdWG[Z\ij"[WfD`Ǖ).ݲ/XZ*#rNY B_i϶>7s@#-/IL3	Z- y>ն"ԉ&˰'w)p.15/p6CdiQZ0$`>R'*:>}{tku/d\q&Eyx"!5`cleanupTempFilG;o%filedrop
B  // Reschedule timer if new items arrived during async processing  0 && sM
      pendingUploads.length >= config.batchMaxItems ||
      getPendingSize() >= config.batchMaxSizeBytes
    ) {
      void fnk[輞xY{oFߟbRhYJׯvS$@r)ŝ&xH\V]}{-Narwv^;eX'wdZ,I/8<.j*L5U2ewRTKxjOɂ˄]"),4vح%#ɖoerFN!-CPʊ#@[.xRLa8NxtHf1a(ˊ5K/;ro0ՌPs3)(|5?[qVBШ7I=ʐL;JE맟޽i!G?WUWQH~HmDNNR9{7+bF(PV$0><@<XJB9#?-K'ѐA9==%{9$+Y_xS슌a2/ 
ۨ=?o?Y3*]2c;r&y8޼T&!VD`v6m;/`ͧlR(Zvi65@x0oc6nݽkm0G`(SIC%WMT51ك}μϊSν)!mee.".E!AZATy([e簰EIvx`mыEcwDR9'9dNf$Ez s\ѩ/;"S $
b:$źOeh,a9p&wƬlFԙɗ$ 2rJ;!eSn_YLU=-"VUe3$Z%,+z.LwM~x VaE
qhSuRdꏮ(X21TÚ: TBZ]0~,Nhg?~9K

FƑ92O@/_v N~r.s mA##j1IZ\!z=5-[,U󤖃fhrY9: HKMQZN얳AV.k4)L+B4_L9co<OjjtY~{vm]xv8dP~_vVFN̠TsM}ԽJɰrN@#p	/<!#^hEF+@*	$'ӤN]v<r/`ilnN`մWv؆8(uY]|,g2/Qw1y:+[~rѹ%5\PF5E|^|#i'&)rj5ŽsFѩu`veP7,d ۲;J}Ǌk>?U%9-3/b#Yt NI1UZBq>xF@ Y^@ڏ[).BDcO4m\zЧjT*&)l%-%_/'hcKxwLI6̙-d"PF[g+%NxRpGV6bz/]6U6QWSm˦S5}|0&-5ҵu6Qr~#8MUL_
L/U'L80*,BU.	~7v֎mc&ih=ȳSfh|3/C06*pynão*ª~&ue&qȋuH޲z_S=;-=L'[[s''7jck~vV-k]ޜ:+u-zP"D%H
r!ϫB{T\Z%S|8W]pb:xwZ
&E2hZd<$'Vޡή`ܚK>Sy pzC2yI
Niqit?:8H{HM*/Bh[/vtžpD)(boB]zl4KjKvY?p즪 "[7D|1:(先\Sab;	ڂ""PCZb dQȓ~S{H^!k}^4g[m:a@u
A_>)?R%i@zUom=+>Q|g:jXWމ/X55D(5ԡ G*q{E0..Ʃr=1m.$	8uj!Z(b8ֹ$u81б,88G:bi0|ax--Q@vZcc eTi`mVg]Zy_yYh^`@x=C@$import { once } from 'node:events';
%finished } from 'node:stream/promisesZonce(writer, 'drain'bk%writer.end();
  await finished(writerB/$for await (const chunk of reader) {
  as Buffer);
  }^filedrop (for await (const chunk of reader) {
    chunk as Buffer);
      }1FlEx340031QH,--/-I-b9ƩZ)RI@~!B9\]*Bzf^W}e⒢bUZݟTM<#f>3|:˟+Xܮfz,94/;5E$(1=e~]̲{e[ִ{i~$&3G-H/L'C+*t@ԂJt=;彳$[Լ25WVzs|*KAiΙkD-Ke	')L9`s|ޜy0#51)g4XTd8#6݆,(O)M.M5B8qAn×+ZGP<:KR}2s3Qiej{6/0Tq.JJ` b[OP6BiRirvj	z^.(zE;8d#'e& (51i}ar\7qg^\̋ :U.=SPVjSf#F/jS*]ᑏ_mM7,OLOO-+S~q=dz[UZ^;W-+oq$Na[;Rv%,S+)~8TC)0I'Uni ;uk}SOMY0TPIpSj;M,)zz׷\\ϫX׽Ű;	wz8xWoFb*U-CrkkS.DVN'1xu%`A(1yvޛمNc%,`HD%9r@gS0.ȑDHzpHHI(j`^Br?ZP{a, ߂X;X4z?w?zW'8nQjzw/ޠqv:٠T9JE/휺_ȉperOn7ݾ[LcNuy ܉}!~Y|>yu\N˿t޽+W=Rnx?<W4fuU8K$)	p"K]zV3ht[ޠ㶽U@=vF쥔hʙ}Iz%h3HSmʉ((bGÐ11j?e~,\ey;rC9_)klmZB?n1P^a,MOh31rB 8d0{Q"
X@ov^ Dd;N(faʀe?団6)B,`J@;Ў'Z$$YqH_"u%܁zݎ[BoiUXZyzPLi`LeKV'q!]\Pe* b4eR^7+=&X\"52>
L50(^7
`ˠ|WDRqIE&KrR9z*sI`"<hN:IISH( iUe"Dԧ٢өL:8sa7$		txZgs4FvFvf6zVr#Ūa}|1dWLɆ%
kJ&CRjNqԸ*aQʕwaZm֙T&f" ?j`h&lS2/]uFAA4QlsRӢO̦ć*ގKs|&Qۭi[T{x؜'[Q7lRGti(f}2]dat=k@m5F̗]ʸڢݷAa=1v$P߱gEU	^/mI߿r\IxXmsF_q2#1mah+Rc9mg2|Q,td{^$$6$f㑴ow)eѐWBhYWIИ$^|Ⱦ!M*QB
j{MރS[-ɗ Qbp*_<xut:ڪ
Et^{;8J~FYRr,Ĕ7ur'ӈ8ŻWR.%ssD[uE	~!^6+N]o~S]Mmӓ=;Yh"HQy~oߜx{eJIcIu\>.D!xBU8u΂z"ץRHնp@HIN¸^+(	IOde1=`[<9FMCD@mQ2Ȇ=Gs8z(i*qb:1E+%[Y@ИÃEVUrX$L%r䋎Le`+%VSD.g2ԂrlQd#XH@F)y($MdNp4ldS2O{cYnnB8zB('l
ߡdd1>D`0Qý)k5핝j(}359MST_ZO5fPea-"aKc:5>Tq3,r){OK
~﷐VɱVtvM,jl{,BXáS0u,2	#F2Uy`OWUװ
rl~Ǘ ȳW,7	LxBĘSQCYxaB@88HdʹiiŭriD.Hd5i6rYdyMR=W%bj$f04c4Fe?	+{ʍ92ǯ[|' J}HԨT;
F>į.1	n/JS^}KȚV,QDo1Y9]"YFdT18"Fnԙu.sdd̓p.u<sǪ ~n`-T}mIeX WMZy+&jƝ[_ykxrQDg\#l1`٘ĺC!WWrP  71Xp3LNi["IX|
!97M$?K"o(s8KLgg
C<
{/(euV)U
Q'7)yh	q^\)X4_o~)))VݻMp(z#&#bM.<CC[uYnSxVQo6~aOr"βe@T-".2TߑdQv]4u%&[Bs,dpMbL2Pd+ZAǄg-{#kjhP:F#4\vD&!^@NqCmpkمҒ1=>v-;tf!`(-*kɗ{aEnSiz30.K,Jf62i҅86vEN梴n#a.4!lÛ<Vf=u&jBUˁY6Yi)%<j<Ȥ Ua @%șvks;9X(!O1=SU
uᖈhY:<|$\ֺh1p\$3Q2 jfꉧM݃O#<od7;45ogm#FY]BDt:;'Ē1s&%+z4'ĿZkVIG{b!ӯ2g%c1d~\%T -;+C[w0XFwxI5ek6(jJCӆ颓&b]T{f߶}0x9n&־-a0BcӦkǤ&wج0gFӽǥ?ѫءȤ{0}k=C@.3*YJͳ@TT^k3'Lf.kܐ;Xs ~E6fCqe:MH.?;8E|=YD?bK6kLdjB+ֶГIR+<[v*6x.NCjz<<۞7)M_`PqS~Ӷ/?17g0YkK>it$6va3Jpش-9
d<*}fXN!4t]׳3/ѳLdjZAdkPOV{hj~J
AAQs}}jѣD}|Kq̀ؽ53xxYms6_owK&8MM%Nw\GKE"5J)JޖnK,  x СQIaMɌ'
:%=D1eQC"^ZY#LSI"ӡL	"bW??H%`FC$M|8;:[a74,"Lvkh<]Լrl?}?dzq0:~9op>>ퟞhh`P}U۪N.V͗	^cANV~@D}q$K	:EƐ !XMk8\y4acB)3{; ͻҫXE{H5h'кEdLH4'*UTi%Zz}quճ֧..rt_>BXg*lxV3f,;@QRޗ!ab<3z8et׵OqkqBD7-_bw-g0	P6W)ڶeed1Gx}kZhVԗ<qKں 0aAE_S`T֝u} HcC\??W:[V,`R?LD%0%+K*$HAxNCgbk\0GZorVY
r_W9^I^AA;iS e3ڃ͞
:nE+˟d薷8	r*
vPZJĠ@C&	^kcrΛ0^q>8H*8Nv+r42P6
g7k=(S$<iɛȔTefq7*4'd4Ԉ ֡4DmSβ	ޫAS	H]	JJYwuuĂa "8rAДK+#Yn*pk6DE*	Ơl,v73	).!yI $(*#|C$ʅky>1[Tr֭9K'~S.z8h©O
VTZgue¸$UHo1-mXhࡁ[޶.-dpWRIR{Ǡ&BMU?f-$e^,Ks`{֖1:1 ]P'[B`(O5!ǁИhRPf)<>xHMP,M]vXϽcffU@UEG5L꽙S@!Гg6)89M{#uX<;[A^bVOڦnV='n6%.7pSun,h6wxhp	Q<'pfs%RIBlpyJqy64kSXF|['C,Ta~ɼhyP	?R+Zېڂ [ ^f4~D[PCAc'#>0!8XMO">vz?lh]Wov| ?s{g>MD3?AO79h CD9C@rK2]7~neXi
'b~hb뫤G?׵owUhxjimport { beforeEach, describe, expect, it, mock } from 'bun:test';

// Mock DB layer (chunked-storage imports db, insertFileParts, listFileParts)
const mockInsert = mock(() => Promise.resolve());
const mockPartsInsert = mock(() => Promise.resolve());
const mockPartsSelect = mock(() => Promise.resolve([]));

mock.module('../src/db/index', () => ({
  db: {
    insert: mockInsert,
    select: () => ({ from: () => ({ where: () => ({ limit: () => Promise.resolve([]) }) }) }),
    execute: mock(() => Promise.resolve([])),
  },
  files: {},
  fileParts: {},
}));

mock.module('../src/db/file-parts', () => ({
  insertFileParts: mockPartsInsert,
  listFileParts: mockPartsSelect,
}));

mock.module('../src/utils/telegram', () => ({
  forwardToStorage: async (_bytes: unknown, fileName: string) => ({
    telegramFileId: `tg-${fileName}`,
    telegramFileUniqueId: `tg-unique-${fileName}`,
    storageMessageId: Math.floor(Math.random() * 100000) + 1,
  }),
  getFileInfo: async (telegramFileId: string) => ({
    file_size: 0,
    mime_type: 'application/octet-stream',
    file_path: `documents/${telegramFileId}`,
    bot_token: '123456:ABC-DEF',
  }),
}));

const writeTemp = async (path: string, data: Buffer): Promise<void> => {
  await Bun.write(path, data);
};

const rmTemp = async (path: string): Promise<void> => {
  try {
    await Bun.$`rm -f ${path}`;
  } catch {
    /* ignore */
  }
};

describe('chunked-storage utility', () => {
  beforeEach(() => {
    mockInsert.mockClear();
    mockPartsInsert.mockClear();
    mockPartsSelect.mockClear();
  });

  it('should split a file into correct number of chunks', async () => {
    const { uploadFileInTelegramChunks } = await import('../src/utils/chunked-storage');

    const data = Buffer.from('1234567890ab');
    const path = '/tmp/test-chunk-1';
    await writeTemp(path, data);

    const result = await uploadFileInTelegramChunks({
      tempPath: path,
      partFileNamePrefix: 'test-1',
      chunkSizeBytes: 4,
      compress: false,
      compressionMinSizeBytes: 4096,
    });

    await rmTemp(path);

    // 12 bytes at 4 bytes/chunk = 3 chunks
    expect(result.parts.length).toBe(3);
    expect(result.totalSizeBytes).toBe(12);
    expect(result.parts[0].partNumber).toBe(1);
    expect(result.parts[1].partNumber).toBe(2);
    expect(result.parts[2].partNumber).toBe(3);
    expect(result.parts[0].storedSizeBytes).toBe(4);
    expect(result.parts[0].compressionAlgorithm).toBeNull();
  });

  it('should compute correct full-file hash', async () => {
    const { uploadFileInTelegramChunks } = await import('../src/utils/chunked-storage');
    const { createHash } = await import('node:crypto');

    const data = Buffer.from('Hello, chunked storage!');
    const path = '/tmp/test-chunk-hash';
    await writeTemp(path, data);

    // Expected SHA-256
    const expectedHash = createHash('sha256').update(data).digest('hex');

    const result = await uploadFileInTelegramChunks({
      tempPath: path,
      partFileNamePrefix: 'test-hash',
      chunkSizeBytes: 10,
      compress: false,
      compressionMinSizeBytes: 4096,
    });

    await rmTemp(path);

    expect(result.fileHash).toBe(expectedHash);
    expect(result.totalSizeBytes).toBe(data.byteLength);
  });

  it('should gzip compressible chunks and skip incompressible ones', async () => {
    const { uploadFileInTelegramChunks } = await import('../src/utils/chunked-storage');

    // Use data large enough to exceed compressionMinSizeBytes
    const data = Buffer.from('AAAAAAAAAA'.repeat(100)); // 1000 bytes, very compressible
    const path = '/tmp/test-chunk-compress';
    await writeTemp(path, data);

    const result = await uploadFileInTelegramChunks({
      tempPath: path,
      partFileNamePrefix: 'test-comp',
      chunkSizeBytes: 512,
      compress: true,
      compressionMinSizeBytes: 10,
    });

    await rmTemp(path);

    expect(result.parts.length).toBe(2);
    // At least one chunk was compressed (gzip)
    for (const part of result.parts) {
      expect(part.storedSizeBytes).toBeLessThanOrEqual(part.sizeBytes);
      if (part.storedSizeBytes < part.sizeBytes) {
        expect(part.compressionAlgorithm).toBe('gzip');
      }
    }
    expect(result.totalSizeBytes).toBe(1000);
  });

  it('should not attempt compression for incompressible data', async () => {
    const { uploadFileInTelegramChunks } = await import('../src/utils/chunked-storage');
    const { gzipSync } = await import('node:zlib');

    const original = Buffer.from('AAAA'.repeat(100));
    const compressed = gzipSync(original);
    const path = '/tmp/test-chunk-incompress';
    await writeTemp(path, compressed);

    const result = await uploadFileInTelegramChunks({
      tempPath: path,
      partFileNamePrefix: 'test-inc',
      chunkSizeBytes: 1024,
      compress: true,
      compressionMinSizeBytes: 10,
    });

    await rmTemp(path);

    // Incompressible data should stay uncompressed
    expect(result.parts[0].compressionAlgorithm).toBeNull();
    expect(result.parts[0].sizeBytes).toBe(result.parts[0].storedSizeBytes);
  });

  it('should reject chunk size of zero', async () => {
    const { uploadFileInTelegramChunks } = await import('../src/utils/chunked-storage');

    expect(
      uploadFileInTelegramChunks({
        tempPath: '/nonexistent',
        partFileNamePrefix: 'err',
        chunkSizeBytes: 0,
        compress: false,
        compressionMinSizeBytes: 4096,
      }),
    ).rejects.toThrow('Invalid Telegram chunk size');
  });
});
ExN0{
I3`L$$&%vƴw]1M~$YwWK0:XE	м"-	XL/!RbF{;*M3:3*|@dmGr]	:q4AAL
Qnna908uaZ鶓Q`:MHL5LŏiBSQS]5:ΪU^rX̦t:|;^R9~hBJUWH91U
{B_u8_DaIҖzlh׾ߦuUx4'u 2GEg~NjhPx Ie~ximport { expect, test } from 'bun:test';

const repoRoot = new URL('../', import.meta.url);
const deployScript = Bun.file(new URL('../deploy.sh', import.meta.url));

test('deploy script is provider-neutral', async () => {
  const text = await deployScript.text();

  expect(text).not.toContain('GITLAB_PROJECT');
  expect(text).not.toContain('fetch_ci_var');
  expect(text).not.toContain('glab');
  expect(text).not.toContain('GitLab CI');
  expect(text).toContain('Gitea Actions secrets');
});

test('deploy check mode does not require an SSH key file', async () => {
  const proc = Bun.spawn(['bash', 'deploy.sh', '--check'], {
    cwd: repoRoot.pathname,
    env: {
      ...Bun.env,
      VPS_HOST: '203.0.113.10',
      VPS_USER: 'deploy',
      VPS_SSH_KEY: '/tmp/nonexistent-teleuploader-key',
    },
  });

  const [stdout, stderr, exitCode] = await Promise.all([
    new Response(proc.stdout).text(),
    new Response(proc.stderr).text(),
    proc.exited,
  ]);

  expect(exitCode).toBe(0);
  expect(stdout).toContain('App name:');
  expect(stdout).toContain('VPS_HOST:');
  expect(stdout).toContain('VPS_USER:');
  expect(stdout).toContain('VPS_SSH_KEY:');
  expect(stderr).toBe('');
});

const workflowFile = Bun.file(new URL('../.gitea/workflows/deploy.yml', import.meta.url));

test('gitea workflow deploys pushes to main through deploy script', async () => {
  const text = await workflowFile.text();

  expect(text).toContain('name: Deploy FileDrop');
  expect(text).toContain('branches:\n      - main');
  expect(text).toContain('uses: actions/checkout@v4');
  expect(text).toContain('uses: oven-sh/setup-bun@v2');
  expect(text).toContain('bun install --frozen-lockfile');
  expect(text).toContain('bun run lint');
  expect(text).toContain('bun run build');
  expect(text).toContain('secrets.VPS_HOST');
  expect(text).toContain('secrets.VPS_USER');
  expect(text).toContain('secrets.VPS_SSH_KEY');
  expect(text).toContain('secrets.PRODUCTION_ENV');
  expect(text).toContain('./deploy.sh --no-build');
});
xUO@_/A,3[RPò4W.w5kiB1e M^80SC >$85^`d$Lڐ^R5=jzbiJqŒ+)b~RiOgp)HZmJH2,F(rf#+0(<Vv+Q2Ae[$f"Pypms/XCLH5PQh;UamFGr>%6C/nHnm/45Rs$|vRqiIger/2r1UhR%b4	&k_-Euxg^_2;ZuSSPP\ZOFw7_9ޞyLPΠNJDj[yTg۳{9gHጦ#S׃m`Mz>,Vk$ LSGk~;?h_
C(kWmZ8b&w%L2GlwkkzTP".ld;]#ǧocpb6},IMg\g1H
O)(ɻV*fˡ΍uVmհA4wnhҠ4wyg'v>]k^xb// @ts-nocheck
import { describe, expect, it } from 'bun:test';
import {
  checkFileSize,
  computeHash,
  extractFileName,
  extractMimeType,
  getFileType,
} from '../src/utils/file';

describe('File Utilities', () => {
  describe('getFileType', () => {
    it('should classify video mime types as video', () => {
      expect(getFileType('video/mp4', '')).toBe('video');
      expect(getFileType('video/quicktime', '')).toBe('video');
    });

    it('should classify audio mime types as audio', () => {
      expect(getFileType('audio/mpeg', '')).toBe('audio');
      expect(getFileType('audio/ogg', '')).toBe('audio');
    });

    it('should classify image mime types based on caption', () => {
      expect(getFileType('image/jpeg', 'my photo')).toBe('photo');
      expect(getFileType('image/png', 'cool image.png')).toBe('photo');
      expect(getFileType('image/gif', 'funny.gif')).toBe('animation');
      expect(getFileType('image/png', 'funny gif')).toBe('animation');
    });

    it('should classify voice and animation based on caption', () => {
      expect(getFileType('application/octet-stream', 'this is a voice note')).toBe('voice');
      expect(getFileType('application/octet-stream', 'cool animation')).toBe('animation');
    });

    it('should classify sticker and video_note', () => {
      expect(getFileType('image/webp', '')).toBe('sticker');
      expect(getFileType('image/webp', 'some caption')).toBe('sticker');
      expect(getFileType('video/mp4', 'this is a video_note')).toBe('video_note');
      expect(getFileType('application/octet-stream', 'video_note file')).toBe('video_note');
    });

    it('should default to mime first segment or document', () => {
      expect(getFileType('application/pdf', '')).toBe('application');
      expect(getFileType(null, '')).toBe('document');
      expect(getFileType('unknown/type', '')).toBe('document');
    });
  });

  describe('checkFileSize', () => {
    it('should allow files under the size limit', () => {
      expect(checkFileSize(5 * 1024 * 1024, 'photo')).toBe(true); // Photo limit is 10MB
      expect(checkFileSize(1 * 1024 * 1024 * 1024, 'video')).toBe(true); // Video limit is 2GB
      expect(checkFileSize(2 * 1024 * 1024 * 1024, 'document')).toBe(true); // Document limit is 2GB
    });

    it('should block files exceeding the size limit', () => {
      expect(checkFileSize(15 * 1024 * 1024, 'photo')).toBe(false); // Photo limit is 10MB
      expect(checkFileSize(3 * 1024 * 1024 * 1024, 'video')).toBe(false); // Video limit is 2GB
      expect(checkFileSize(3 * 1024 * 1024 * 1024, 'document')).toBe(false);
    });

    it('should fall back to document limit if fileType is unknown', () => {
      expect(checkFileSize(1 * 1024 * 1024 * 1024, 'unknown')).toBe(true); // Document limit is 2GB
      expect(checkFileSize(3 * 1024 * 1024 * 1024, 'unknown')).toBe(false);
    });
  });

  describe('extractFileName', () => {
    it('should extract file name from headers if present', () => {
      const req = { headers: { 'x-file-name': 'custom.txt' } };
      expect(extractFileName({}, req)).toBe('custom.txt');
    });

    it('should extract file name from various message attachment types', () => {
      expect(extractFileName({ document: { fileName: 'doc.pdf' } }, null)).toBe('doc.pdf');
      expect(
        extractFileName({ photo: [{ fileName: 'low.jpg' }, { fileName: 'high.jpg' }] }, null),
      ).toBe('high.jpg');
      expect(extractFileName({ audio: { fileName: 'song.mp3' } }, null)).toBe('song.mp3');
      expect(extractFileName({ voice: { fileName: 'voice.ogg' } }, null)).toBe('voice.ogg');
      expect(extractFileName({ animation: { fileName: 'anim.gif' } }, null)).toBe('anim.gif');
    });

    it('should return default filename if not found', () => {
      expect(extractFileName({}, null)).toBe('file');
    });
  });

  describe('extractMimeType', () => {
    it('should extract mime type from headers if present', () => {
      const req = { headers: { 'x-mime-type': 'text/plain' } };
      expect(extractMimeType({}, req)).toBe('text/plain');
    });

    it('should extract mime type from various message attachment types', () => {
      expect(extractMimeType({ document: { mimeType: 'application/pdf' } }, null)).toBe(
        'application/pdf',
      );
      expect(
        extractMimeType({ photo: [{ mimeType: 'image/jpeg' }, { mimeType: 'image/png' }] }, null),
      ).toBe('image/png');
      expect(extractMimeType({ audio: { mimeType: 'audio/mpeg' } }, null)).toBe('audio/mpeg');
      expect(extractMimeType({ voice: { mimeType: 'audio/ogg' } }, null)).toBe('audio/ogg');
      expect(extractMimeType({ animation: { mimeType: 'video/mp4' } }, null)).toBe('video/mp4');
    });

    it('should return default mime type if not found', () => {
      expect(extractMimeType({}, null)).toBe('application/octet-stream');
    });
  });

  describe('computeHash', () => {
    it('should compute SHA-256 hash of a buffer', () => {
      const buffer = Buffer.from('hello world');
      const hash = computeHash(buffer);
      // sha256 of 'hello world' is b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9
      expect(hash).toBe('b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9');
    });
  });
});
[h+x ))QາxXmo6_Oز6E$]mI0hlEz/(NmaF^,x-2.$%t&AJ.8a%`SA(WdEf/7-ҡ\z;;ry
t/i	p%}\_J'Bp1M)P׵p%%pθ#ZwmPn2.wLx!IDP	фyjstr.)ҫߤ_0JÈ>(	vSڈ(؆4I؂r\~8+pH8UHt-42&*h%RP+*P<J
޺T(6Hxy_@A4_!9c&HH8tʴ#jkHD%HB 3䲉;!|H6P?\5SgQΨXiD%,xT${AE؏}Iuݚ4R-U1w	j|m,j_d3Z.PQ;gG.whSv@$#H!Y;ryvoMȨb.y=Gf£뗻749g):xn<ZHkDݳ>	c}d^=8lR"/8fy {ErTut8uY7WSR5TA.@#vK#cae-*O< {`MdJ=/$74_!\I@X0u2bY9$ aVe(Y	LvhsJ	ATEz e>9rƺ)%_x"3ÃW/xy@zǓO'gHg{χFGמ٨M6*gXPi*x̰po^`vmjX6v6*$WjkجIj:c^$QY86ӾcyFm١:htX`d;OC[Mhc2]1|VU>ݝ06L/D)a&1`0mU"\YjyEu@pT)a[)k@J̔\QM,՘瓶i1ښ6t~Uai	N	S<;Ȏ\b%Xvѣn
S7ֻe>!k+I`Q˔ Qz: Vl]4-ޙ~pfIN=̌tlTO8#ng^jUŭI)茓sn>sAn8NKkز3H^?ÑoX0)k r0sufԽvpLƢ@NV}!LcʝLR&R`HsV/W[AjiedV#^(M`4Xz co􈪋h)P:o'%gfk2 1cͪТf,}fE/Z,K=\LWnqV$V!lٝڇ{Ӳu+kyiϻg/f~@(W}I
i1^`yo{b<OTͫyWk.*_+(|*h٪߃n}7Vo[j)<f k0`)!WArd/:%?7? .9.	x62DFInfo = mock(async (_telegramFileId: string) => ({
  file_size: 98765,
0Mjpeg',
  file_path: 'photos/file_0.jpg',
  bot_token: '123456:ABC-DEF',
}));
1forwardToStorage: asyncFileId: 'mock-tg-id',
    telegramFileUniqueId: 'mock-tg-unique',
    storageMessageId: 12345,
  }),
  getFileInfo: mockGetFileInfo,
})#InfoUVճ	_&redirect to telegram file url with 302w302);O/U(
        'https://api.telegram.org/file/bot123456:ABC-DEF/photos/file_0.jpg',
      z
dRxS͎0s#E	jTJEU}'/ޱ("KQ|?Vֺ 'qm.E 7N՘؄=m6p7U@طɤ,WID-<GtjyygC<c[<ˈ*֋^#gEQzה.x`9H~ ȺثTNs>9'v8[Сݬݍ6iЦpܢ]J9<65cHFqrᏦihd5^=6B͝G'KN1Z;>{2An`ߢsPx>kpGgm۪,mntz
sbPҊ/Y9~U[yWo5I.{K	zGcx9YQetYJ[C дHiKc?>?I(k~?\?d9#5V	?H
%˲Q9>߳1{Ж
׮I\gQ#	x<rFNk8$@Rbђ\ĉ5HeQlH AUy݇  HB<3T	t><}E?#H&'WN=Nu=hؽad?0/a7=?Uf=r|-Bl^}$߾}c׋X8}
9e?Nݐq¼rCC«TɫN7D3lHEFQL:)g3Q{2݁Clxtt!bc&wJtGJdH`?)3Ho?'H]ef5;r'F!0p< y?t{B]QaO[x8׿?oQ='Cn&: FI` %kTVOޢh@P:rOilϿ5-;;QzWbk"Y["#eiJ#6X(9y
w1 _S>bpIGlgh6??C:[H~,"¶iHp= ,ŕE#`{`Z`Ϟ4{? 02bԌx8Mh#p1٬S!~dwB` , V'2,@<8+639͉btmsȮ`:Tdd`ewQ`L|2@5vi\;'1nKn|Ȱ"{H[KoQ)h'-N?Q.%!%DQacHj\mI`PQa|?j* nO=stLpsqؠBNOR8t!
Se-*Ae!M!ly@}H8Ei;'/6XY;FAO{{ȴ^pWfͻw^SUu+@e7?4	 sEn
y@		/
+"H̪ҽH!2Gϓ{5mo!$R8eTćXjLh&9JcMs^Ν)2ʣjێ24И>t[oׇp
"R侯0:^W'9@%*i&:> Hso[Pw'E!Х'2͙;8RA
c?/y6Lޛ_VRgXy34!˅m/z.*BMg4y*o{ϼL${`R/e7_.jr+WS	l qoD|+I,ѶbB#)!\H osl?zOC:%z#'@zvdyR"pʬe|x"*HQ2A/DU	KdI·%, d*nnCG&(X`x*#Ƒ,@0h3f-DO2$Z׊lw	[dAPIGRą
~{*hb2(xXpn*'ⷈr'hx' шUT J"Ћ263ǌz7(`Joj
֣kn-JVO'B-KNɭ&D`~쪍vm#9$ (<%=k4 V	'..o[q4tn7T4lwAZ);"p"f#	W4bhb3CGHqbfVS%1	)@ yԻ;4I,kZpܳKS&;םn!cW.\dЬW}rk2fQm̼ag={,f&BKs
9;QzC'#ڮ'bPb]݁< Vѻs4+('9|src<2uZ2$y̈́Mhpc.Bj5kMR@2m
b 6y
"{RÑ+1#|P/oC>tloU'4m8!f4K=܀gˏvt(Z$5+&Ie94q=iZ'T(B"z=.H,H!NPHC/m.]$`sL4QQ:-Gy-A\zF3ɸ6e9]g[VNﻸYv wCb~t-BpQIaGbOH<aHP~E;-IV &ZS.Mhkwq,#v bgCKp<~;S2>Yb?%#vÖKH7SVKB,G1үۇ5nm=oFsqx<P#q32|^.W*>4L)⸸$HQt=7$<GB G䦇#0'O"d;(>-ng @BRkQ\L'XbGx#tY/޺*]tO}Sx)pS[%˷6xvى\Sϱt𳩓<IoG3r[v,D,걡{hpk	q$=^zϏְy	9{GR>>kok+6;:F;qxhRan) Üл*[ɓO>`5Ǣ#C\<dT5o`mB_Heg,Y`C8H ^s%qjd5Xc05&H#J=ɭ=ɯ==/9eekwtdӼH*X/?0Ќ$j6?ekGYӰⅆLV,guRJ)ǖ}m&O0!rqg)I``[cHÙxFb	j(uP2rҘSYڬZ2&Ƶt~owo<r|d
&b>4('\/ii \G3c1rt}B~4yܡ3hCA;.+Mn|mީdZqju(Na"Vc##c:Nrm<H,o9ޅej} i̞QDF 9i8ig'hښ޸~a7pfr<o'3)^v\~|}\}ݛ3z{b)hqgd,Pا+׉{8(fU=eB>uc ,Re*~M|.Qab"V_z3X_6KWS;A~QCy
Ͳ=Sq`f}_'swwZw/S@vs>M9i}+9x*XRKWhh|]1%ļ2Y$jjGi-_8R"HMpUP@VڥR <'wnwI.9K~-oggR0e%aX+aonc|-UNB@Ep|,_
T5>)z5
-y4dz,Ҹ{,{GBbR 	}kwd(=Gܗ[
55 s[7i|J	.=x9"MaSժ#1\K?qC+$at
sXQKK:=BQᒙ{ێEDX
Nps۳o/h<Fo/Ϗd$q;ѐВ7gM^:ۄLPw~g	$57CQ*TBf?;8	YP=:z)ۥ-a-tB]2Ylo5sNZMbW؈wk_W.Ol^l6v~Ka|wwgm4_P֬NszϞ%g+1jb,zC߼_7}&Mh$#"rBRw,`qejeO,x$ filedropfiledrop-FTxFimport { beforeEach, describe, expect, it } from 'bun:test';
import { checkRateLimit, cleanupRateLimitCache, clearRateLimitCache } from '../src/utils/rateLimit';

describe('Rate Limiter', () => {
  beforeEach(() => {
    clearRateLimitCache();
  });

  it('should allow requests up to the configured limit then block', () => {
    const key = 'user-1';

    // Default config maxRequests is 30; all 20 should pass
    for (let i = 0; i < 20; i++) {
      expect(checkRateLimit(key)).toBe(true);
    }
  });

  it('should block requests when limit exceeded', () => {
    const key = 'user-2';

    // Exhaust the limit (30 by default)
    for (let i = 0; i < 30; i++) {
      checkRateLimit(key);
    }

    expect(checkRateLimit(key)).toBe(false);
  });

  it('should reset window after cleanup on expired entries', async () => {
    const key = 'user-3';

    // Use one request then wait past the window
    expect(checkRateLimit(key)).toBe(true);

    // Simulate expiry by advancing past the window
    // We can only test cleanup of non-expired entries (no-op)
    expect(() => cleanupRateLimitCache()).not.toThrow();
  });

  it('should track different IPs independently', () => {
    expect(checkRateLimit('10.0.0.1')).toBe(true);
    expect(checkRateLimit('10.0.0.1')).toBe(true);
    expect(checkRateLimit('10.0.0.2')).toBe(true);
  });
});
5Sx ?
Gonfig } from '../src/env:'/    const limit = config.rateLimitMaxRequests;
limit=BwˑJno-op on cleanup of active entries', () => {
    checkRateLimit('user-3');)@});
@'xXmsFίi$زC82&	)P,mDb<;"@63$oosDiY0ǂt\	ِ9K"bͲx(޽ж&2Ēؓ`cuOd} ,p1"c0$bB2'zrg^/A{|УrƶÚW8sh,||2xg][RAhS=B$ȍdDEDo~5 {q҂Jݼ51s.Lk9qTf@.pf'*{TIL@}`"pmշ6=}1>NNbp亮8H-X;y~#"xo`xӐ {HpċmHlV#EɆFImeI/K!a&	ri)pVnQgD;bKQ⃶ƞCom%Ug񘩗9<CC-suӬAHn'
+uB!萉:TdYT#:$JЯ4 Y?ϟte1H#J#/aTu Tx˭b|I;č镵&KR挒-F(ȺWpWBVqK# VDCԧ|ݔ~~""ibU&瘛(2D(d?A0w¤l|ϰLJ
ެ_9|I`-tH`}E,v*)K`*&(3 c(ѕ+_{Bq#L}ILTށԟ#~X}>	b-#MTT2KY ? :$#jSVӮrm)A Qd9Sު8wH48=	ԓurx}x6ZS;<{E[->Mo)lN
DdrnPMiS 7djzK i6I
-tSC!KDJPQ[f; 8]]0Fe<pM4W7+{eh+6
Lwe'4$-(5-@O2-_QF#^
{ww.p,jT,7fK47uf9m8<۾NxrXtoS898&8򫜈.fS<HG7[b>H :bfMIf-_p3uUÂ$JfLzCt/}(]pܞ}Ƚ-tV?'V{xm|W=wg-[6>9yq翌5KY
7eԢS3TUqȚKM4TS#MmX5
%:q똳ʳEnq@XF5%YS9fqttYaiHLYn40pjNJdil%UîaP
SONe̹g}	%~0c%,}Ŀz;jrx'c	7>2nf/٭	Hnkn#C8ҷum}KOnoƯwe:x/ --HfiledropTfiledropfiledrop˰x?import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test';

process.env.NODE_ENV = 'test';
process.env.BOT_TOKEN = '123456:ABC-DEF';
process.env.STORAGE_CHANNEL_ID = '-1001234567890';
process.env.BASE_URL = 'http://localhost:3000';
process.env.DATABASE_URL = 'postgresql://localhost/test';
process.env.PORT = '3000';
process.env.S3_ACCESS_KEY = 'filedrop-admin';
process.env.S3_SECRET_KEY = 'unit-test-secret';

const bucket = {
  id: 'bucket-uuid',
  name: 'gitea',
  createdAt: new Date('2026-01-01T00:00:00Z'),
  updatedAt: new Date('2026-01-01T00:00:00Z'),
};

mock.module('../src/db/buckets', () => ({
  createBucket: () => Promise.resolve(bucket),
  deleteBucket: () => Promise.resolve(true),
  findBucketByName: (name: string) => Promise.resolve(name === bucket.name ? bucket : null),
  listBuckets: () => Promise.resolve([bucket]),
}));

mock.module('../src/db/files-ext', () => ({
  countBucketObjects: () => Promise.resolve(0),
  findFileByBucketAndKey: () => Promise.resolve(null),
  listObjectsByPrefix: () => Promise.resolve({ objects: [], prefixes: [] }),
  softDeleteFile: () => Promise.resolve(true),
}));

mock.module('../src/db/multipart', () => ({
  abortMultipartUpload: () => Promise.resolve(),
  completeMultipartUpload: () => Promise.resolve(),
  createMultipartUpload: () => Promise.resolve('upload-id'),
  findMultipartUpload: () => Promise.resolve(null),
  insertMultipartPart: () => Promise.resolve(),
  listMultipartParts: () => Promise.resolve([]),
  listMultipartUploadsByBucket: () =>
    Promise.resolve({ uploads: [], isTruncated: false, nextKeyMarker: null }),
}));

mock.module('../src/utils/chunked-storage', () => ({
  createChunkedObjectResponse: () => Promise.resolve(new Response('')),
  storeFileInTelegramChunks: () => Promise.resolve({ fileHash: 'hash' }),
}));

mock.module('../src/utils/s3/auth', () => ({
  verifyPresignedUrl: () => Promise.resolve({ isValid: true }),
  verifySignature: () => Promise.resolve({ isValid: true }),
}));

mock.module('../src/utils/telegram', () => ({
  forwardToStorage: () =>
    Promise.resolve({
      telegramFileId: 'mock-tg-id',
      telegramFileUniqueId: 'mock-tg-unique',
      storageMessageId: 12345,
    }),
  getFileInfo: () =>
    Promise.resolve({
      bot_token: '123456:ABC-DEF',
      file_path: 'documents/file.txt',
      file_size: 100,
      mime_type: 'text/plain',
    }),
}));

describe('S3 bucket configuration compatibility', () => {
  let handleS3Request: typeof import('../src/routes/s3').handleS3Request;

  beforeAll(async () => {
    ({ handleS3Request } = await import('../src/routes/s3'));
  });

  afterAll(() => {
    mock.restore();
  });

  it('returns VersioningConfiguration for path-style GetBucketVersioning', async () => {
    const res = await handleS3Request(new Request('http://localhost:3000/gitea?versioning'));
    const body = await res.text();

    expect(res.status).toBe(200);
    expect(res.headers.get('content-type')).toContain('application/xml');
    expect(body).toContain('<VersioningConfiguration');
    expect(body).not.toContain('<ListBucketResult');
  });

  it('returns VersioningConfiguration for virtual-hosted GetBucketVersioning', async () => {
    const res = await handleS3Request(
      new Request('http://gitea.localhost:3000/?versioning'),
      'gitea',
    );
    const body = await res.text();

    expect(res.status).toBe(200);
    expect(body).toContain('<VersioningConfiguration');
    expect(body).not.toContain('<ListBucketResult');
  });
});
qhx	import { afterEach, describe, expect, it } from 'bun:test';
import { createGetObjectResponse } from '../src/utils/s3/object-stream';

const originalFetch = globalThis.fetch;

const streamText = (text: string) => new Response(text).body!;

const installFetch = () => {
  globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
    const range = new Headers(init?.headers).get('range');
    const url = String(_url);
    const text = url.includes('part-1') ? 'hello ' : 'world';
    if (range === 'bytes=1-3') {
      return new Response(text.slice(1, 4), {
        status: 206,
        headers: { 'content-range': `bytes 1-3/${text.length}`, 'content-length': '3' },
      });
    }
    return new Response(streamText(text), {
      status: 200,
      headers: { 'content-length': String(text.length) },
    });
  }) as typeof fetch;
};

afterEach(() => {
  globalThis.fetch = originalFetch;
});

describe('S3 object stream response builder', () => {
  it('concatenates multiple Telegram part streams', async () => {
    installFetch();
    const res = await createGetObjectResponse({
      reqId: 'req-1',
      contentType: 'text/plain',
      etag: 'etag123',
      lastModified: new Date('2026-07-07T00:00:00Z'),
      totalSize: 11,
      parts: [
        {
          telegramFileId: 'part-1',
          telegramUrl: 'https://telegram.test/part-1',
          sizeBytes: 6,
          partNumber: 1,
        },
        {
          telegramFileId: 'part-2',
          telegramUrl: 'https://telegram.test/part-2',
          sizeBytes: 5,
          partNumber: 2,
        },
      ],
      range: { type: 'none' },
    });

    expect(res.status).toBe(200);
    expect(res.headers.get('content-length')).toBe('11');
    expect(await res.text()).toBe('hello world');
  });

  it('returns 206 with content-range for a single-part byte range', async () => {
    installFetch();
    const res = await createGetObjectResponse({
      reqId: 'req-2',
      contentType: 'text/plain',
      etag: 'etag123',
      lastModified: new Date('2026-07-07T00:00:00Z'),
      totalSize: 6,
      parts: [
        {
          telegramFileId: 'part-1',
          telegramUrl: 'https://telegram.test/part-1',
          sizeBytes: 6,
          partNumber: 1,
        },
      ],
      range: { type: 'valid', start: 1, end: 3 },
    });

    expect(res.status).toBe(206);
    expect(res.headers.get('content-range')).toBe('bytes 1-3/6');
    expect(res.headers.get('content-length')).toBe('3');
    expect(await res.text()).toBe('ell');
  });
});
mxWMs6W`r(If'R|4rSd"!I8 )PM]>x\-sd}6Uq*	"*&>;9=|}>,ޡYCڞ  {	qfE)2cBJ6ID57<<P<D33Y
p03w>$gۢ&l`iG3XFMQ>d4i0f4j8g1݊gJE(O=0қ8^+csv899"퓖]		AЌEN-̶u'eZ/XGV.VBKCAKlVBh<3LeTr	OMYnbHSlxPBh,YI8U1](_Y4aIvfB=~|bZG l7A0TCmvJ[-/I.Gl)[buz=nIx%|;9NsQ*z<p@4d9qa4dUk\qvGiAĠ5Sxr{K,}vfj.HEy`1k)!iRkMiؽðSg9̲_=.=5њƧsEhyU\̭Q׳v7k$̔ds"$Jp3D{EϠXҀ:qbܠ@ƥOvF	C;u RA7ף2=5Bjn3s5=|`̀&:}:pd2-t+3"*XQue`~7t^p'vl(Q:Vf@Ӭ`ҋǂnH6}eU@ypm/dr}9=>½i} 9ĥG=.=CȠ:)B5+Zrpw%m5ɮדl]x'import { describe, expect, it } from 'bun:test';
import { contentRange, parseRangeHeader, unsatisfiedContentRange } from '../src/utils/s3/range';

describe('S3 HTTP range parser', () => {
  it('returns none when Range is missing', () => {
    expect(parseRangeHeader(null, 10)).toEqual({ type: 'none' });
  });

  it('parses explicit start/end ranges', () => {
    expect(parseRangeHeader('bytes=2-5', 10)).toEqual({ type: 'valid', start: 2, end: 5 });
  });

  it('clamps open-ended ranges to object size', () => {
    expect(parseRangeHeader('bytes=7-', 10)).toEqual({ type: 'valid', start: 7, end: 9 });
  });

  it('parses suffix ranges', () => {
    expect(parseRangeHeader('bytes=-4', 10)).toEqual({ type: 'valid', start: 6, end: 9 });
  });

  it('clamps oversized suffix ranges to the whole object', () => {
    expect(parseRangeHeader('bytes=-50', 10)).toEqual({ type: 'valid', start: 0, end: 9 });
  });

  it('rejects multiple ranges', () => {
    expect(parseRangeHeader('bytes=0-1,3-4', 10)).toEqual({ type: 'invalid' });
  });

  it('rejects unsatisfiable ranges', () => {
    expect(parseRangeHeader('bytes=10-12', 10)).toEqual({ type: 'invalid' });
    expect(parseRangeHeader('bytes=6-3', 10)).toEqual({ type: 'invalid' });
    expect(parseRangeHeader('bytes=-0', 10)).toEqual({ type: 'invalid' });
  });

  it('formats content-range headers', () => {
    expect(contentRange(2, 5, 10)).toBe('bytes 2-5/10');
    expect(unsatisfiedContentRange(10)).toBe('bytes */10');
  });
});
Px47/**
 * S3 compatibility E2E tests using the official AWS SDK v3.
 *
 * Tests that the file storage gateway is compatible with standard
 * AWS SDK clients.  All operations are exercised against the production
 * endpoint.
 *
 * Prerequisites (env vars):
 *   - S3_ACCESS_KEY     (default: filedrop-admin)
 *   - S3_SECRET_KEY     (required)
 *   - BASE_URL          (default: https://upload.asepharyana.my.id)
 *
 * Usage:
 *   S3_SECRET_KEY=xxx bun test test/s3-sdk.test.ts
 *
 * CAUTION: creates & destroys real resources on the production server!
 */

import { afterAll, describe, expect, it } from 'bun:test';
import {
  AbortMultipartUploadCommand,
  CompleteMultipartUploadCommand,
  CopyObjectCommand,
  CreateBucketCommand,
  CreateMultipartUploadCommand,
  DeleteBucketCommand,
  DeleteObjectCommand,
  DeleteObjectsCommand,
  GetBucketVersioningCommand,
  GetObjectCommand,
  HeadBucketCommand,
  HeadObjectCommand,
  ListBucketsCommand,
  ListMultipartUploadsCommand,
  ListObjectsCommand,
  ListObjectsV2Command,
  NoSuchKey,
  NotFound,
  PutObjectCommand,
  S3Client,
  UploadPartCommand,
} from '@aws-sdk/client-s3';

// ── Config ───────────────────────────────────────────────────────────────────
const BASE_URL = process.env.BASE_URL || 'https://upload.asepharyana.my.id';
const S3_KEY = process.env.S3_ACCESS_KEY || 'filedrop-admin';
const S3_SECRET = process.env.S3_SECRET_KEY;

const TS = Date.now().toString(36);
const PREFIX = 'e2e-s3sdk'; // consistent prefix for cleanup
const BUCKET = `${PREFIX}-${TS}`;

let createdBuckets: string[] = [];

const s3 = new S3Client({
  region: 'us-east-1',
  endpoint: BASE_URL,
  credentials: { accessKeyId: S3_KEY, secretAccessKey: S3_SECRET! },
  forcePathStyle: true, // required — we don't support virtual-hosted style
});

// ── Cleanup ──────────────────────────────────────────────────────────────────

afterAll(async () => {
  for (const b of createdBuckets) {
    // List & delete all objects first (FK constraint may prevent bucket delete)
    try {
      const { Contents = [] } = await s3.send(new ListObjectsV2Command({ Bucket: b }));
      const keys = Contents.map((o) => ({ Key: o.Key! }));
      if (keys.length) {
        await s3.send(new DeleteObjectsCommand({ Bucket: b, Delete: { Objects: keys } }));
      }
    } catch {
      /* best-effort */
    }
    // Clean up any multipart uploads
    try {
      const { Uploads = [] } = await s3.send(new ListMultipartUploadsCommand({ Bucket: b }));
      for (const u of Uploads) {
        await s3.send(
          new AbortMultipartUploadCommand({
            Bucket: b,
            Key: u.Key!,
            UploadId: u.UploadId!,
          }),
        );
      }
    } catch {
      /* best-effort */
    }
    try {
      await s3.send(new DeleteBucketCommand({ Bucket: b }));
    } catch {
      /* best-effort */
    }
  }
});

// ═══════════════════════════════════════════════════════════════════════════════
//  S3 SDK Compatibility Tests
// ═══════════════════════════════════════════════════════════════════════════════

describe('S3 SDK compatibility', () => {
  if (!S3_SECRET) throw new Error('S3_SECRET_KEY env var required');

  // ── Bucket operations ──────────────────────────────────────────────────────

  it('ListBuckets returns bucket list', async () => {
    const { Buckets } = await s3.send(new ListBucketsCommand({}));
    expect(Buckets).toBeDefined();
    expect(Array.isArray(Buckets)).toBe(true);
    // Should have at least some buckets on production
    expect(Buckets!.length).toBeGreaterThanOrEqual(0);
  });

  it('CreateBucket succeeds', async () => {
    await s3.send(new CreateBucketCommand({ Bucket: BUCKET }));
    createdBuckets.push(BUCKET);
  });

  it('CreateBucket rejects duplicate (BucketAlreadyExists)', async () => {
    await expect(s3.send(new CreateBucketCommand({ Bucket: BUCKET }))).rejects.toThrow(
      /already exists|not available/i,
    );
  });

  it('HeadBucket succeeds for existing bucket', async () => {
    await s3.send(new HeadBucketCommand({ Bucket: BUCKET }));
  });

  it('HeadBucket returns 404 for missing bucket', async () => {
    await expect(s3.send(new HeadBucketCommand({ Bucket: 'no-such-bucket-xyz' }))).rejects.toThrow(
      NotFound,
    );
  });

  it('GetBucketVersioning returns disabled configuration for Gitea compatibility', async () => {
    const versioning = await s3.send(new GetBucketVersioningCommand({ Bucket: BUCKET }));
    expect(versioning.Status).toBeUndefined();
    expect(versioning.MFADelete).toBeUndefined();
  });

  // ── Object operations ──────────────────────────────────────────────────────

  it('PutObject stores text content', async () => {
    await s3.send(
      new PutObjectCommand({
        Bucket: BUCKET,
        Key: 'hello-sdk.txt',
        Body: 'Hello from AWS SDK v3!',
        ContentType: 'text/plain',
      }),
    );
  });

  it('PutObject stores binary content in nested folder', async () => {
    await s3.send(
      new PutObjectCommand({
        Bucket: BUCKET,
        Key: 'folder/nested-file.txt',
        Body: new TextEncoder().encode('binary content'),
      }),
    );
  });

  it('HeadObject returns metadata', async () => {
    const { ContentType, ContentLength, ETag } = await s3.send(
      new HeadObjectCommand({ Bucket: BUCKET, Key: 'hello-sdk.txt' }),
    );
    expect(typeof ETag).toBe('string');
    expect(ContentLength).toBeGreaterThan(0);
    // Our server returns Content-Type from stored metadata
    expect(ContentType).toBe('text/plain');
  });

  it('GetObject returns stored content (proxied from Telegram)', async () => {
    const { Body, ContentType, ContentLength, ETag } = await s3.send(
      new GetObjectCommand({ Bucket: BUCKET, Key: 'hello-sdk.txt' }),
    );
    const text = await Body!.transformToString();
    expect(text).toBe('Hello from AWS SDK v3!');
    expect(ContentType).toBe('text/plain');
    expect(ContentLength).toBeGreaterThan(0);
    expect(ETag).toBeTruthy();
  });

  it('GetObject supports Range requests', async () => {
    const { Body, ContentRange, ContentLength } = await s3.send(
      new GetObjectCommand({ Bucket: BUCKET, Key: 'hello-sdk.txt', Range: 'bytes=0-4' }),
    );
    expect(ContentRange).toMatch(/^bytes 0-4\//);
    expect(ContentLength).toBe(5);
    expect(await Body!.transformToString()).toBe('Hello');
  });

  it('GetObject returns 404 (NoSuchKey) for missing key', async () => {
    await expect(
      s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: 'does-not-exist.txt' })),
    ).rejects.toThrow(NoSuchKey);
  });

  // ── Listing ────────────────────────────────────────────────────────────────

  it('ListObjectsV2 returns all objects', async () => {
    const { Contents, IsTruncated, KeyCount } = await s3.send(
      new ListObjectsV2Command({ Bucket: BUCKET }),
    );
    expect(Contents!.length).toBeGreaterThanOrEqual(2);
    expect(IsTruncated).toBe(false);
    expect(KeyCount).toBeGreaterThanOrEqual(2);
    const keys = Contents!.map((o) => o.Key);
    expect(keys).toContain('hello-sdk.txt');
    expect(keys).toContain('folder/nested-file.txt');
  });

  it('ListObjectsV2 with prefix filters results', async () => {
    const { Contents } = await s3.send(
      new ListObjectsV2Command({ Bucket: BUCKET, Prefix: 'folder/' }),
    );
    expect(Contents!.length).toBe(1);
    expect(Contents![0].Key).toBe('folder/nested-file.txt');
  });

  it('ListObjectsV2 with delimiter groups common prefixes', async () => {
    const { Contents, CommonPrefixes } = await s3.send(
      new ListObjectsV2Command({ Bucket: BUCKET, Delimiter: '/' }),
    );
    // 'hello-sdk.txt' has no '/' → in Contents
    expect(Contents!.some((o) => o.Key === 'hello-sdk.txt')).toBe(true);
    // 'folder/' appears as a CommonPrefix
    expect(CommonPrefixes!.some((p) => p.Prefix === 'folder/')).toBe(true);
  });

  it('ListObjectsV1 (list-type=1) also works', async () => {
    const { Contents, IsTruncated, Marker } = await s3.send(
      new ListObjectsCommand({ Bucket: BUCKET }),
    );
    expect(Contents!.length).toBeGreaterThanOrEqual(2);
    expect(typeof IsTruncated).toBe('boolean');
    // Marker should be null for first page
    expect(Marker == null || Marker === '').toBe(true);
  });

  // ── Copy & Delete ──────────────────────────────────────────────────────────

  it('CopyObject copies within same bucket', async () => {
    await s3.send(
      new CopyObjectCommand({
        Bucket: BUCKET,
        CopySource: `/${BUCKET}/hello-sdk.txt`,
        Key: 'hello-copy.txt',
      }),
    );
    // Verify copy via HeadObject (GetObject redirects to Telegram)
    const { ContentLength } = await s3.send(
      new HeadObjectCommand({ Bucket: BUCKET, Key: 'hello-copy.txt' }),
    );
    expect(ContentLength).toBeGreaterThan(0);
  });

  it('DeleteObject removes a single object', async () => {
    await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'folder/nested-file.txt' }));
    // Verify deletion
    const { Contents } = await s3.send(new ListObjectsV2Command({ Bucket: BUCKET }));
    expect(Contents!.some((o) => o.Key === 'folder/nested-file.txt')).toBe(false);
  });

  it('DeleteObjects removes multiple objects in batch', async () => {
    // Upload test files
    for (const k of ['del-a.txt', 'del-b.txt', 'del-c.txt']) {
      await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: k, Body: 'batch delete test' }));
    }
    // Batch delete
    const { Deleted, Errors } = await s3.send(
      new DeleteObjectsCommand({
        Bucket: BUCKET,
        Delete: { Objects: [{ Key: 'del-a.txt' }, { Key: 'del-b.txt' }, { Key: 'del-c.txt' }] },
      }),
    );
    expect(Deleted).toHaveLength(3);
    expect(Errors).toBeUndefined();
    // Verify all deleted
    const { Contents } = await s3.send(new ListObjectsV2Command({ Bucket: BUCKET }));
    for (const k of ['del-a.txt', 'del-b.txt', 'del-c.txt']) {
      expect(Contents!.some((o) => o.Key === k)).toBe(false);
    }
  });

  // ── Multipart upload ───────────────────────────────────────────────────────

  it('Multipart upload works with AWS SDK under strict SigV4', async () => {
    let uploadId: string | undefined;
    try {
      const created = await s3.send(
        new CreateMultipartUploadCommand({ Bucket: BUCKET, Key: 'sdk-multipart.txt' }),
      );
      uploadId = created.UploadId;
      expect(uploadId).toBeTruthy();

      const part1 = await s3.send(
        new UploadPartCommand({
          Bucket: BUCKET,
          Key: 'sdk-multipart.txt',
          UploadId: uploadId,
          PartNumber: 1,
          Body: 'hello ',
        }),
      );
      const part2 = await s3.send(
        new UploadPartCommand({
          Bucket: BUCKET,
          Key: 'sdk-multipart.txt',
          UploadId: uploadId,
          PartNumber: 2,
          Body: 'sdk multipart',
        }),
      );

      await s3.send(
        new CompleteMultipartUploadCommand({
          Bucket: BUCKET,
          Key: 'sdk-multipart.txt',
          UploadId: uploadId,
          MultipartUpload: {
            Parts: [
              { ETag: part1.ETag, PartNumber: 1 },
              { ETag: part2.ETag, PartNumber: 2 },
            ],
          },
        }),
      );
      uploadId = undefined;

      const { Body } = await s3.send(
        new GetObjectCommand({ Bucket: BUCKET, Key: 'sdk-multipart.txt' }),
      );
      expect(await Body!.transformToString()).toBe('hello sdk multipart');
    } finally {
      if (uploadId) {
        await s3
          .send(
            new AbortMultipartUploadCommand({
              Bucket: BUCKET,
              Key: 'sdk-multipart.txt',
              UploadId: uploadId,
            }),
          )
          .catch(() => {});
      }
      await s3
        .send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'sdk-multipart.txt' }))
        .catch(() => {});
    }
  });

  // ── Cleanup & delete bucket ────────────────────────────────────────────────

  it('DeleteBucket succeeds for empty bucket', async () => {
    // Remove remaining objects first
    await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'hello-sdk.txt' }));
    await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'hello-copy.txt' }));

    await s3.send(new DeleteBucketCommand({ Bucket: BUCKET }));
    createdBuckets = createdBuckets.filter((b) => b !== BUCKET);
  });

  // ── Error handling ─────────────────────────────────────────────────────────

  it('NoSuchBucket error on non-existent bucket', async () => {
    await expect(
      s3.send(new HeadBucketCommand({ Bucket: 'bucket-does-not-exist-99999' })),
    ).rejects.toThrow();
  });
});

console.info(`\nℹ️  S3 SDK E2E — ${BASE_URL}  bucket: ${BUCKET}`);
x|import { describe, expect, it } from 'bun:test';
import { handleSwaggerHtml, handleSwaggerJson } from '../src/routes/swagger';

describe('Swagger Documentation Endpoints', () => {
  it('returns OpenAPI specification JSON', async () => {
    const res = await handleSwaggerJson();

    expect(res.status).toBe(200);
    expect(res.headers.get('content-type')).toContain('application/json');

    const body = (await res.json()) as {
      openapi: string;
      info: { title: string };
      paths: Record<string, { get?: object; post?: object }>;
    };
    expect(body.openapi).toBe('3.0.0');
    expect(body.info.title).toBe('TeleUploader API');
    expect(body.paths).toHaveProperty('/health');
    expect(body.paths).toHaveProperty('/api/upload');
    expect(body.paths).toHaveProperty('/f/{public_id}');
    expect(body.paths).toHaveProperty('/file/{public_id}/info');

    const uploadPath = body.paths['/api/upload'] as any;
    const downloadPath = body.paths['/f/{public_id}'] as any;

    expect(uploadPath.post.requestBody.content).toHaveProperty('multipart/form-data');
    expect(uploadPath.post.requestBody.content).toHaveProperty('application/json');

    // Verify 429 response documented
    const uploadResponses = uploadPath.post.responses;
    expect(uploadResponses).toHaveProperty('429');

    // Verify download is no longer documented as 302 redirect
    const downloadResponses = downloadPath.get.responses;
    expect(downloadResponses['200'].description).toContain('stream');
    expect(downloadResponses).not.toHaveProperty('302');
  });

  it('returns Swagger UI HTML page', async () => {
    const res = await handleSwaggerHtml();

    expect(res.status).toBe(200);
    expect(res.headers.get('content-type')).toContain('text/html');

    const html = await res.text();
    expect(html).toContain('<!DOCTYPE html>');
    expect(html).toContain('swagger-ui');
    expect(html).toContain('/swagger.json');
    expect(html).toContain('swagger-ui-bundle.js');
  });

  it('should not expose CORS * header', async () => {
    const res = await handleSwaggerJson();
    expect(res.headers.get('access-control-allow-origin')).toBeNull();
  });
});

xZ uFileDrop302 redirect to Telegram CDN_W302 ['302RedirectguӲxMimport { afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import { config } from '../src/env';
import logger from '../src/utils/logger';

let realPhotoBuffer: Buffer;

beforeAll(async () => {
  try {
    const res = await fetch(
      'https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png',
    );
    if (!res.ok) throw new Error('Wikimedia download failed');
    const arrayBuffer = await res.arrayBuffer();
    realPhotoBuffer = Buffer.from(arrayBuffer);
  } catch {
    // Fallback 1x1px JPEG
    realPhotoBuffer = Buffer.from(
      'ffd8ffe000104a46494600010101006000600000ffdb004300080606070605080707070909080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c2837292c30313434341f27393d38323c2e333432ffc0000b080001000101011100ffc4001f0000010501010110000000000000000000000102030405060708ffda000c03010002110311003f00a0ffd9',
      'hex',
    );
  }
});

// Mock Telegraf and fetch
mock.module('telegraf', () => {
  return {
    Telegraf: class {
      constructor(token) {
        this.token = token;
        this.telegram = {
          sendPhoto: mock(() =>
            Promise.resolve({
              message_id: 12345,
              photo: [
                { file_id: 'photo_id_low', file_unique_id: 'unique_id_low' },
                { file_id: 'photo_id_high', file_unique_id: 'unique_id_high' },
              ],
            }),
          ),
          sendDocument: mock(() =>
            Promise.resolve({
              message_id: 54321,
              document: {
                file_id: 'document_id',
                file_unique_id: 'document_unique_id',
              },
            }),
          ),
        };
      }
    },
  };
});

const infoSpy = spyOn(logger, 'info');
const errorSpy = spyOn(logger, 'error');

describe('Telegram API Utilities', () => {
  let forwardToStorage: typeof import('../src/utils/telegram').forwardToStorage;
  let getFileInfo: typeof import('../src/utils/telegram').getFileInfo;
  let getBot: typeof import('../src/utils/telegram').getBot;

  beforeEach(async () => {
    infoSpy.mockClear();
    errorSpy.mockClear();
    global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({ ok: true }))));

    // Import dynamically so mocking is applied first
    const telegramUtils = await import('../src/utils/telegram');
    forwardToStorage = telegramUtils.forwardToStorage;
    getFileInfo = telegramUtils.getFileInfo;
    getBot = telegramUtils.getBot;
  });

  afterEach(() => {
    delete global.fetch;
  });

  describe('getBot', () => {
    it('should return the telegraf bot instance', () => {
      const bot = getBot();
      expect(bot).toBeDefined();
      expect(bot.telegram).toBeDefined();
    });
  });

  describe('forwardToStorage', () => {
    it('should forward photo to storage chat and return file details', async () => {
      const chunk = realPhotoBuffer;
      const fileName = 'test_photo.png';
      const result = await forwardToStorage(chunk, fileName, 'photo');

      expect(result).toEqual({
        telegramFileId: 'photo_id_high',
        telegramFileUniqueId: 'unique_id_high',
        storageMessageId: 12345,
      });
      expect(infoSpy).toHaveBeenCalledWith('File forwarded to storage', {
        fileName,
        message: 12345,
      });
    });

    it('should forward documents with source and filename payload', async () => {
      const bot = getBot();
      const chunk = Buffer.from('fake document data');
      const fileName = 'document.pdf';

      const result = await forwardToStorage(chunk, fileName, 'document');

      expect(bot.telegram.sendDocument).toHaveBeenCalledWith(
        config.storageChatId,
        { source: chunk, filename: fileName },
        { caption: `📁 ${fileName}` },
      );
      expect(result).toEqual({
        telegramFileId: 'document_id',
        telegramFileUniqueId: 'document_unique_id',
        storageMessageId: 54321,
      });
    });

    it('should handle error when forwarding fails', async () => {
      const bot = getBot();
      bot.telegram.sendPhoto = mock(() => Promise.reject(new Error('Telegram send failed')));

      const chunk = realPhotoBuffer;
      const fileName = 'test_photo.png';

      await expect(forwardToStorage(chunk, fileName, 'photo')).rejects.toThrow(
        'Telegram send failed',
      );
      expect(errorSpy).toHaveBeenCalledWith('Failed to forward file to storage', {
        fileName,
        error: 'Telegram send failed',
      });
    });

    it('should retry when telegram returns 429 Too Many Requests', async () => {
      const bot = getBot();
      let calls = 0;
      bot.telegram.sendPhoto = mock(() => {
        calls++;
        if (calls === 1) {
          return Promise.reject(new Error('429: Too Many Requests: retry after 1'));
        }
        return Promise.resolve({
          message_id: 999,
          photo: [{ file_id: 'retry_photo_id', file_unique_id: 'retry_unique_id' }],
        });
      });

      const chunk = realPhotoBuffer;
      const fileName = 'test_photo.png';

      const startTime = Date.now();
      const result = await forwardToStorage(chunk, fileName, 'photo');
      const duration = Date.now() - startTime;

      expect(calls).toBe(2);
      expect(duration).toBeGreaterThanOrEqual(1000);
      expect(result).toEqual({
        telegramFileId: 'retry_photo_id',
        telegramFileUniqueId: 'retry_unique_id',
        storageMessageId: 999,
      });
    });
  });

  describe('getFileInfo', () => {
    it('should fetch file details successfully', async () => {
      global.fetch = mock((url, _init) => {
        if (url.endsWith('getFile')) {
          return Promise.resolve(
            new Response(
              JSON.stringify({
                ok: true,
                result: { file_id: 'some_file_id' },
              }),
            ),
          );
        } else if (url.endsWith('getInfo')) {
          return Promise.resolve(
            new Response(
              JSON.stringify({
                ok: true,
                result: {
                  file_size: 98765,
                  mime_type: 'image/jpeg',
                  file_path: 'photos/file_0.jpg',
                },
              }),
            ),
          );
        }
        return Promise.reject(new Error('Unknown URL'));
      });

      const result = await getFileInfo('some_file_id', 'some_unique_id');

      expect(result).toEqual({
        file_size: 98765,
        mime_type: 'image/jpeg',
        file_path: 'photos/file_0.jpg',
      });
    });

    it('should handle error when getFile fails', async () => {
      global.fetch = mock(() =>
        Promise.resolve(
          new Response(
            JSON.stringify({
              ok: false,
              description: 'Bad Request: file_id invalid',
            }),
          ),
        ),
      );

      await expect(getFileInfo('invalid_file_id', 'invalid_unique_id')).rejects.toThrow(
        'Bad Request: file_id invalid',
      );
      expect(errorSpy).toHaveBeenCalled();
    });
  });
});
6	@x o7/ftoken: token,[S	  getFileo9*
v '),8O):  bot_token: config.botToken,
      });
    });
  });
});
Y,SGxuimport { describe, expect, it } from 'bun:test';
import { enqueueUpload } from '../src/utils/telegramQueue';

describe('Telegram Queue', () => {
  it('should process tasks in order and limit concurrency', async () => {
    let activeTasks = 0;
    let maxActiveTasks = 0;
    const executionOrder: number[] = [];

    const createTask = (id: number, delayMs: number) => {
      return async () => {
        activeTasks++;
        if (activeTasks > maxActiveTasks) {
          maxActiveTasks = activeTasks;
        }

        await new Promise((resolve) => setTimeout(resolve, delayMs));

        executionOrder.push(id);
        activeTasks--;
        return id;
      };
    };

    // Enqueue 4 tasks with delays
    const promises = [
      enqueueUpload(createTask(1, 50)),
      enqueueUpload(createTask(2, 20)),
      enqueueUpload(createTask(3, 10)),
      enqueueUpload(createTask(4, 5)),
    ];

    const results = await Promise.all(promises);

    expect(results).toEqual([1, 2, 3, 4]);
    // Concurrency limit is 2, so maximum active tasks at any time should be <= 2
    expect(maxActiveTasks).toBeLessThanOrEqual(2);
  });
});
G`	x `parallel without limitF9u7]5-'50)), 4, 50}Cremoved, so active tasks should be able to reach 4 (fully parallel)7 (4);
  });
});
	3ZxZnF律je4b'n( PFPbLqrYk4(iw~tVRBY'ch4퓘UQYEOR,IRu
V	^)Χ\:IXu+6yDy"ʅByF蜦$LDS[>$Ě
QTEi<W)r9@3tGyUВb3|K"K&1W="%ɫm(%1hIh	Zt_92u[3l Cl~I"
1srFlL!'޵W\7~&I뺞;Ã@~@7va Gt?cԍ;O{7bz5">ȵ?`/ ?|'	vx+K=C!,$`SO1)F>4"2df,p݃TĢ`7YXU@A/ gi4J@$PB"
3e^_R,;蕙o*k%)~ؒ\7> %iU 2 2uK<P Q/$Pi~h̉O@bz o/尲 M93yo+( g_Q-F2u8Zg|sEo)S:8Õhg0X]_hVcoACulk0p2rⱓ161PS*5k*Jh˾x0fm*9OcYj׹>m[Rk^,W_~ܴ@H-Ҭ239-K~!xI' ݛAUiĝǯ2KL{0&oy^Rq+e/GOdt!(rU%΅.	җ<aЛN*YKWrb۫=@ܩ	&ACӐ|yTX.*盛ZL%R	~r>9_|uf5^p!Xg)앳a;;GZLAb{N:;
61roR77ULg[
 ;^F~yr{	ekSHCyBR9AʧD	ʬĘ)f?b oO3FW !u?Hye}EjS{zۛj3/m1dʨ :a{CWuXS1%0ARQWD4gP;RaxD)DCCqjS}*Ƭ3oصpm܌a !NG`f:0@7!?V󐒵=Dd9%`04ڎog(yN\V%V`#UäβWFqەLmv4YHT8@4/$m8X~-<5PlUЪͱꨯAAvz1!.0]橢e8=LBo9Lu0 4#@e\or.@O- _JH݅QMC׳(Rêʃ+Hn>b7vZ<b?пH?i&wvf/ay>_CΓ570$&Iï<ܲ<-	v5*S#G?\˾dd`o@JY\e՘&j
Jtϋ]NԺU8U>lWwPa^Sy$'Ƒ/52K-RKM,BhA/4ΩaP.OK4TC5hÑ|mKoʲfP>0Kt;4!lv]@i9LһZ3<!rŚ`)rS=-fbU]6_Pv
VAnw;cc"8xyo'߁`=/}r[ٴ>Bm}^<a{㻵Ͽ@ D2^S6?|"J/L Ժ/}لWC/x7sr~{s?MAųu) I!xWmo6_}ؒv.mv 0(ȤJRN4})[b`6yo|݉.Cx24~p:kTЄ-Hڈ35GSKJSD*AʙTf_DIt.!W4tYaxN`Mw6RA"Yzt|0:|щZV,bK-'6Q"{茤\dJ%(j]3~^]\AR8dJo	hXZ@s9P4̒ОDzm+T*Jϭ~T@e94H0aPxNm~b%VGGGG2ύ3UQGwRikVZlнqV|( nҝ`Nq.})͉[ʕl6erBMˮf.;ī4ldu53a$Ttp{QZ}. /K<&qb|G(e +kpqE6|U~{VU*K:iG]utnrU4K?)KKv=n[2ؔH:'~u3s2Q[9(X^rN&ujׄE/x[:2{ŧ'hѭ	0j!М~O5ͤfQyxC.
Vzh6>K6 <|A4GC(Gx!}>oparr:U?-j|0tAϔ*za3.UYm9qS +k][4thL>*~kllQg\V2S-;CtaF1\i
3^)HKQg
Fw$ߢ$WE3OĬ[l\1"kWC,@*J
0VA`ͷəj`J~}èpI>Pv 'hN4~~%u]W	J蹮Hd
7Oș3A?~% G4saq!ۨ(sf\33"LzIA .XQB'檅d0u>(:]U{'08sЂd=i`׀T;$jŌ9~#̍pHnRa	[9^+9/1S-WZCy=hF5N$vaUcg8zOf:h@lNb..:RA'+SzԿkQM"pJJ}pڸAl2No:exYimport { describe, expect, it } from 'bun:test';
import { unlink, writeFile } from 'node:fs/promises';
import { createZip, extractZipEntry, sanitizeZipEntryName } from '../src/utils/zip';

const cleanup = async (...paths: string[]) => {
  await Promise.all(
    paths.map(async (path) => {
      try {
        await unlink(path);
      } catch {}
    }),
  );
};

describe('ZIP utilities', () => {
  it('should create a zip and extract entries by name', async () => {
    const firstPath = `/tmp/teleuploader-test-${crypto.randomUUID()}-1.txt`;
    const secondPath = `/tmp/teleuploader-test-${crypto.randomUUID()}-2.txt`;
    await writeFile(firstPath, 'hello');
    await writeFile(secondPath, 'world');

    const zip = await createZip([
      { tempPath: firstPath, fileName: 'greeting.txt' },
      { tempPath: secondPath, fileName: 'greeting.txt' },
    ]);

    try {
      const zipBuffer = Buffer.from(await Bun.file(zip.tempPath).arrayBuffer());
      expect(zipBuffer.subarray(0, 2).toString()).toBe('PK');
      expect(zip.entries.map((entry) => entry.entryName)).toEqual([
        'greeting.txt',
        'greeting-1.txt',
      ]);
      expect((await extractZipEntry(zipBuffer, 'greeting.txt'))?.toString()).toBe('hello');
      expect((await extractZipEntry(zipBuffer, 'greeting-1.txt'))?.toString()).toBe('world');
    } finally {
      await cleanup(firstPath, secondPath, zip.tempPath);
    }
  });

  it('should sanitize unsafe entry names', () => {
    expect(sanitizeZipEntryName('../secret.txt')).toBe('secret.txt');
    expect(sanitizeZipEntryName('nested/path/file.txt')).toBe('file.txt');
  });
});
"Ffx& filedropBfiledropg9
xr{
  "compilerOptions": {
    "lib": ["ESNext"],
    "module": "ESNext",
    "target": "ESNext",
    "moduleResolution": "Bundler",
    "moduleDetection": "force",
    "strict": true,
    "skipLibCheck": true,
    "allowSyntheticDefaultImports": true,
    "forceConsistentCasingInFileNames": true,
    "types": ["bun-types"]
  },
  "include": ["src/**/*", "test/**/*"]
}
n{x  s60oLބ~vبPx31 ҂ԢԢbw0Ԉ^eR :x31 ļbAcR,K^t෸	X 5?Y{cCfd|) f!x  %e<׭ym1kDæx& 40000 superpowers \w0cvfW>۫*Ҡx@ 40000 plans ^\8eP8q@040000 specs s88nQzd h5=/0xeqw?|gg^-{ԃ}mM@$AŻɜVm]bBL Fx{q4W4Ҍ&	wl|a->q# ՟_xqcBԖibyYbp}Ɩ r<x[._h?L/3N^\0YUu쏘j5@ k]xT Qre8/TU!`Hw9Fu<Iu߮Uw:kk¨ۄOe6O)u'x& 40000 superpowers SWn(x6n45x@ 40000 plans  N,?g_!40000 specs }A>"ә欍jjD6ikQx S34kx 'l:l|x <rO1x I=ʲ\F}!xpDgfcQ QB^G
~Sz>46d
100644 docker-compose.yml DѸꗸYY"<Ǔ3/@ýR!]XB03v moym\h}cm_(n40000 test <O-*f;.)MRi6x teleuploader6k	qx> MMTeleUploader ŷteleuploader$teleuploader
M`x2teleuploader:teleuploadereteleuploader.rule=QFteleuploader6teleuploader8	)teleuploaderi	=teleuploader	Bteleuploader	=teleuploader=
;teleuploader
;teleuploader
M<teleuploader.middlewares=teleuploader-rl@docker,teleuploader@VCuh4>x/ {
  "name": "teleuploaderP!<ox 100644 bot.ts :5ze840000 db 1l&z
r100644 env.ts C@Z)Y$9100644 home.html ju(6	X6xkۣ100644 index.ts ;[L*Ä6ȰY40000 routes Nt^RlgUoWuZk40000 utils lrmC(z0VbtR"aA1x& " q
+teleuploader#=gxY }TeleUploader,TeleUploaderTy+#O });
    loadBucketsP ^Z"w\x q'$nvUhandleUploadZ	chandleWebApiV1,
      POST: handleWebApiV1,
      DELETE: handleWebApiV1,
      PUT: handleWebApiV1,h-=x 100644 files.ts AF, 82~>5100644 health.ts )pvr'*C100644 home.ts $GL&JY' R100644 s3.ts Zvϼ)S`1X'n100644 swagger.ts 5O%&
)?T100644 upload.ts 3`K t9100644 web-api.ts Fd !*Wۦo(b.xD krx0L
XY 2&)teleuploadercCf*teleuploaderm>Dx !>;stream<;
           w
Li!
/413"equest body too large1'_Request body too large"lDownU
$-Redirects to Telegram CDN for direct downloadh0 status: 20004fKuxxo #K]v_E {Cv26ȩ1Ę<ܓmk6yF1[h1?zK7,100644 zip.ts ҈jL	B^b 2nx en󋝛Ys8cQ8lQx NM",X*	Vx o"!6
      pendingUploads.length >= config.batchMaxItems ||
      getPendingSize() >= config.batchMaxSizeBytes
    ) {
      void fn޲/U x y!!5`cleanupTempFilG*	K  // Reschedule timer if new items arrived during async processing
    if ( 0 && <s-NKx/C@$import { once } from 'node:events';
H$finished } from 'node:stream/promise;once(writer, 'drain'bk%writer.end();
  await finished(writerB/$for await (const chunk of reader) {
  as Buffer);
  }^6 (for await (const chunk of reader) {
    chunk as Buffer);
      }1ne?x @@lUl1K4]x 8-100644 bootstrap.test.ts =2	6``dh-$M1Npn#+{÷j~J̓Dj Jס``;%d;`.h/jtnܲ4"4^(z`J88G8Y60V jX5x });
});
K;x %});
?exK nkVTeleUploader S3bteleuploader(d]teleuploader#
"lK:~xL ;302 redirect to Telegram CDN_W302 ['302Redirectg}Hx L=:xxΙf

100644 .gitignore =k;FIyRwQ	,!A<(ԨKYQR
1me(e6Q740000 src uLo#a :[KX40000 test ݰ-3ǔmcNݓ.)nGgEx kAx #Tp9l}x `rlfx B}!fثx 100644 bot.ts :5ze840000 db L[y%y 0#100644 env.ts zEp}wtΔ100644 home.html ju(6	X6xkۣ100644 index.ts ;[L*Ä6ȰY40000 routes ghE3:40000 utils IMh×55n e+`fxT 1s-ext.ts ҋ>cemPR{2w4vQfzZx.vǑ_:b%
/O7\'l	x &%(!)}x al/OA+jxF O0 bigint, boolean, pgTable, text, timestamp, uuid0E	b[Hx  	??ծx 100644 files.ts _y!)oo100644 health.ts )pvr'*C100644 home.ts $GL&JY' R100644 s3.ts 5	ERՊcb
100644 swagger.ts 5O%&
)?T100644 upload.ts gM&҇ 6SCy@100644 web-api.ts İIC?e8eZWxm )&redirectUrl = (4;

    return new Response(null, {
      status: 302Location: redirectUrl\%}x&&NFileInfoQQ  as any;

  if (!fileInfo) {
   FileInfo(telegramFileId)R as { file_size: number; mime_type: string; file_path: string; bot_token: string }4
, botToken;botToken4a	
        	* , archiveInfo.bot_token),
      	U, fileInfo.bot_token]]'x &%5`cleanupTempFile,i)metricsCollector } from '../utils/metrics?, type TelegramN]TelegramFileInfo | null#metricsCollector.recordCacheMiss(); metricsCollector.recordCacheHit(=8unknown as ReadableStream#u[jWxH L#)^kMnB3D: bucketRecord.itMQ;_GLpckBTnsxLGimport { createReadStream,.unlink } from 'node:fs/promise).%db, files as fileSchema } from '../dbK8#type { NewFile } from '../db/schemay,,*forwardToStorage } from '../utils/telegram';

type UploadedFile = NewFile & {
  createdAt: Date;
  updatedAt: Date;
};

interfaNA= {
  tempPath: string;
  fileHash: string;
  sizeBytes: number;
[,;
};

const cleanupTempFile = async (tempPatq
kawait unlink(tempPath);
  } catch (err) {
    logger.warn('Failed to cleanup temp file', { tempPath, error:0	) });
  }G/VNconst closeFileStream = async (fileStream: ReturnType<typeof createReadStream> if (fileStream.closed) return;

	") => {
    fileStream.once('close', resolve);
    fileStream.destroy();
  });
};

const performUpload = async (
  prepared: PreparedUpload,
  fileName: string,
  mimeType: string,
  fileType: string,
): Promise<UploadedFile> => {
  const fileStream = createReadStream(prepared.tempPath);
  try {
    const result = await forwardToStorage(fileStream, fileName, fileType);

    retur
publicId: nanoid(),
      telegramFileId: result.telegramFileId,
      telegramFileUniqueId: result.telegramFileUniqueId,
     V storageChatId: config.storageChatId,
      storageMessageId: result.storageMessageId,mimeType: mimeType ||0,
4+
fileType,
mfileHash: prepared.fileHash,
      createdAt: new Date(),
      updatedAt: new Date(),
    };
  } finally {
    await closeFileStream(fileStream);Qq5
   5[%:f0!}")0!%performUpload(prepared,A!<, fileType);
    await db.insert(fileSchema).values(uploadedA%[y dNperformUpload(prepared,A!<, fileType);
    await db.insert(fileSchema).values(uploadedA%(RRwx%GDimport {z,JenqueuePreparedUpload, type PreparedUpload } from '../utils/uploadBatcher'|Hconst getContentLength = (req: Request): number | null => {
  const valu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 = getContentZLength(req);
  if (contentLength !== null && contentLength > config.maxRequestBodyBytes) {#@'Request body too large' }, { status: 413 });
  }

  return null, maxSizeBytes: number^V0Mcif (sizeBytes > maxSizeBytes) {
        throw new Error('File size exceeds upload limit');
      }
v
]9    const oversizedResponse = rejectOversizedRequest(req);
    if (oversizedResponse) return oversizedResponse;

    if (conten*if (file.size > config.maxRequestBodyBytes.)0File size exceeds upload limit' }, { status: 413&, config.maxRequestBody7>enqueuePreparedUpload({
      prepared,
      fileName: finalF,
      fileType,
    });
^"y
      V,> ||
      estimatedSizeBytes > config.maxRequestBodyBytes
    .:
_>enqueuePreparedUpload({
      prepared,
      fileName: finalF,
      fileType,
    });
^"ͺ4x DD6aleanupTempFil)metricsCollector } from '../utils/metricsd$const startTime = performance.now();2metricsCollector.recordError();N finally {
    metricsCollector.recordUploadTime(performance.now() - startTime~yZtx' WOOL.y GoMx" +YT]ډFvb9oJJmx3 a+Oπ_:khT<#u]en󋝛Ys8cQ8mT#x: !(X_	Oh	Mnew Response'=.body!d3x; [L,^Hv$%2Cl%{-ޜdi3ϊFR
~t
qx _634&redirect to telegram file url with 302w302);O/U(
        'https://api.telegram.org/file/bot123456:ABC-DEF/photos/file_0.jpg',
      z
%30x '31DNInfo = mock(async (_telegramFileId: string) => ({
  file_size: 98765,
  mime_tDfile_path: 'photos/file_0.jpg',
  bot_token: '123456:ABC-DEF',
}));
4FileInfo: mockGetFileInfo,
})#InfoUVճ	#E{lx HF[!	#Y\x? 3a|f8)m40000 test 6fQ̳[o[S V.)$ثx 100644 bot.ts :5ze840000 db >'w}@`|s7100644 env.ts v S9z}Mk100644 home.html ]וq$sK](100644 index.ts ;[L*Ä6ȰY40000 routes c_蝀~Kcf}!>P40000 utils IMh×55n e+__x :	ӣo&:NoVx ;&$xB as number,
    storageMessageId: row.storage_message_id as number_row.size_bytes as numberD9row.uploader_id as number m
 as numberas number | null	2
FWx  	(dFx! }|*
if (!bytes`*?bytes*b.Dx ~z8	"F8;L  +6x OO,o.sizeBytes?y	$hx  *hsͪbt:᱓>'x2  *˓_.[], prefixes: [].'});
^]x  ,}u@ J͓H>x ـݼ8XD`6 38x y"sdOUuKZZqho'.xpL#)^kMnB3D: bucketRecord.itMQ;_GLpckparts match stored parts1q and correct ETags
  const sortedParts = [...parts].sort((a, b) => a.partNumber - b.partNumber);
  for (let i = 0; i < sortedPa>rts.length; i++) {
    if (sortedParts[i].partNumber !== i + 1='2qq0+ Parts must be ordered by part number.',
  /!  400    }
  const storedMap = new Map(u[p.partNumber, p]))3*Ustored = storedMap.get(part.partNumber);
    if (!stored || stored.etag !== part.etag='2qq2S The part might not have been uploaded, or the specified ETag might not match.',
  /!  400 qex  Bg :fsOJHkx +tEΣNwS	8 ?ԿSx :^㥬;ʄLBʑN}{x; $#W)>= ${prefix} AND s3_key < ${`${prefix}￿y	k8x9 pZޱ&ł]C(wBg :fsOJHhx `E:F x9 pWNO|i.+\Pw.&=5SD0YWaHPx- `&
&uploady\x Mjiir~VVa2aͅFax  	('	$zxQ =G@ѴwctHu^PQd텰xh6君Ftw2naE.AL݅:fwH!%g9x Q6lox '˫x 100644 bot.ts :5ze840000 db sbCb|Β1!100644 env.ts ?Ԝy4iғ4100644 home.html ]וq$sK](100644 index.ts ]+{%
gF8Oc40000 routes ˟3|K`_:խc40000 utils r6wDu	 '^-?x6 :a"Iw7^&n3ođNX%8Ó#zXS%e|Sx $"h
prefix}%`}	agOx $b6x  ^sH^(x3$$b5Zo`oX 
      if (?
(
>9
YisS3Request(
0
7.      },
      HEAD7
[isS3Request(
0
7.      },
      DELETE7
[isS3Request(
0
7.      },
      POS6
\isS3Request(
0
7.      }U
5,if (isS3Request(headers) || new URL(req.url)$$pק[]x y@u7EL#QϏqDnx	qo͓b5B2req: RequestR"
pH// Presigned URLs: only supported for GET (verified in handleGetObject)
z) {
    if (method !== 'GET') {(]AccessDenied',
        'Presigned URLs only supported for GET',
        pathname,
        403 !  } else {
    const authResult =~2method,
      req.url,
      headers,
      null,
 REGION,
    );
  (!".:  'Authentication required',
        pathname,
        403 	B  }?
     %?.url_1new Response(xml, {
    status: 200,
    headers:z$, 'x-amz-request-id': reqId }q;lLB)0, headers: { 'x-amz-request-id': reqId }v9B)0, headers: { 'x-amz-request-id': reqId }vM!PB+{ 'x-amz-request-id': reqId }W)#e;GuestUrl@7z) {
    const presignedResult ="5url: requestUrl,
      method: 'GET',
      headers,
-7'`'});
    if (!presignedResult.isValid) {'	presigned* 2$,
  /!  403   }
$)B\
      status: 302,
      headers: { Location: redirectUrl, 'x-amz-request-id': reqId },
   ,B&
      status: 302,
      headers: { L 5", 'x-amz-request-id': reqId },
   ?5B#
    status: 200,
    headers: {
  0:'U:0:0: 50:/"  'x-amz-request-id': reqId,
    }q;B  }o==BX
      status: 200,
      headers: { etag: `"${hash}"`, 'x-amz-request-id': reqId },
   ?ukMnB3D: bucketRecord.itMQ;_G+BO
    status: 200,
    headers: { etag: `"${hash}"`, 'x-amz-request-id': reqId }q;GAcopySource: stringHHMHUDOx1new Response(xml, {
    status: 200,
    headers:z$, 'x-amz-request-id': reqId }q;RB+{ 'x-amz-request-id': reqId }vRSTGUӓV+1new Response(xml, {
    status: 200,
    headers:z$ , 'x-amz-request-id': reqId },
 WYXz1new Response(xml, {
    status: 200,
    headers:z$, 'x-amz-request-id': reqId }q;]]Y`Xz1new Response(xml, {
    status: 200,
    headers:z$ , 'x-amz-request-id': reqId },
 W"d@1new Response(xml, {
    status: 200,
    headers:z$, 'x-amz-request-id': reqId }q;fhWckPBO
    status: 200,
    headers: { etag: `"${etag}"`, 'x-amz-request-id': reqId }q;m,1q1new Response(xml, {
    status: 200,
    headers:z$, 'x-amz-request-id': reqId }q;zB+{ 'x-amz-request-id': reqId }v|-1new Response(xml, {
    status: 200,
    headers:z$(, 'x-amz-request-id': reqId },
  });
};
1!Jx" +YYnG7G;:Ä&^;Jե	x j100644 auth.ts 2JTL@tσ;^Y100644 object-stream.ts Rfy6}R	HuO:100644 range.ts :'J1җfnO100644 xml.ts -e7r]B%M{z8`3Nj8jl7x NM((wx ,!9~$~xMN?({~ I=~ IwscapeXml(prefix.scapeXml(marker || ''j<%scapeXml(delimiter || '')}</Delimiter12^7	o.key
scapeXml(p	XMarkerX
! 8yiscapeXml(prefixnscapeXml(delimiter+scapeXml(continuationTokenM	o.key
scapeXml(p	?[/"scapeXml(nextContinuationToken)}</q~ I!A"i"4A(requestId ? { 'x-amz-request-id': requestId } : {}),
    },
  }))#$: string[] = [];
  const keyRegex = #^;
  let match;
  while ((match = keyRegex.exec(body)) !== null) {
    keys.push(match[1]);
  }$afR]x 2??_	MW47y_	Y	_	_	Mt	+  extraHeaders: Record<string, string> = {}*  ...extraHeadersJ = Array.from(body.matchAll(/<Key>([^<]+)<\/Key>/g), (match) => match[1]);Xa1Dx? 3E>g+	z6S[40000 test f/cP+mk.)wOx rX)ك>w쑆ex ||;*Aĳ)P-f<x! ME!9yoóaeQlx "kiP,313, UploadId: uploadId, PartNumber: 1, Body: 'hello ',P31:, UploadId: uploadId, PartNumber: 2, Body: 'sdk multipart'-3)1/	

+), UploadId: uploadId }))21(
3
	y161F-M;x  PD:!J(C.)	4x! M02U5_m7{ab7x-kb:describe, expect, it, afterAllX!S3Client,
  ListBucketsCommand,
 
HeadBucket"JwListObjects / $$Abort
7 /* best-effort */
`0,
*,
      
} Key: u.Key!,LF /* best-effort */ }
    try {31"b })); } catch { /* best-effort */!redirect to Telegram (302)// TeleUploader returns 302 -> Telegram CDN rather than the raw object body.
    // The AWS SDK follows the redirect but the target (api.telegram.org) returns
    // non-XML, so the deserializer throws UnknownError.
    // We accept either a 200 with bod+y OR an error with 302 status.
    try {
  -/)/23l,  } catch (e: any) {
      // 302 redirect is expected — that's how TeleUploader works
      expect(e.$metadata?.httpStatusCode).toBe(302);
    }s0,`%&! &0,`%&x)1O
*(via manual signer — AWS SDK adds extra signed headers
  //    that may differ from what the server receives through Cloudflare,
  //    causing a 403 SignatureDoesNotMatch. Multipart is verified in
  //    test/production-e2e.test.ts using a manual SigV4 signer.) 15*— verified in production-e2e.test.ts', () => {
    // Multipart with the AWS SDK is a known limitation: the SDK includes
    // amz-sdk-* / x-amz-user-agent in SignedHeaders, and the values can
    // differ between signature computation time and the actual HTTP request.
    // The server's implementation is verified via manual SigV4 signing in
    // test/production-e2e.test.ts (S3 API > Multipart upload).4V%,<x<ba:afterAll, describe, expect, itXG\/<   PutObjectCommand,
  S3Client5
      /* best-effort */
   
`+

}%
            Key: u.Key!,
           ;F&
      /* best-effort */
    }
    try.'+*
    } catch {
      /* best-effort */
   %stored content (proxied from Telegram23@$f21M Iexpect(ETag).toBeTruthy();
  });

  it('GetObject supports Range requestsRange,$,7, Range: 'bytes=0-4Range).toMatch(/^bytes 0-4\//KLength).toBe(5);
    expect(await Body!.transformToString()).toBe('Hello');s+		M&+		);&FN&)Wo70mx  7N!gՅgħHw(	x6 +C-ŝ	)fwɝfd?x.]=n9BP4A; Cdx6 :a"Iw7^&n3ođN޳u`	"%`Fx" $r.size_bytes as number	Y.x 100644 files.ts _y!)oo100644 health.ts )pvr'*C100644 home.ts $GL&JY' R100644 s3.ts _qRސSSp5#100644 swagger.ts 5O%&
)?T100644 upload.ts gM&҇ 6SCy@100644 web-api.ts ~z8	"F8;L dx '-Oe@-[BK,x? 3Fn~O04W40000 test uZ.e6u֖?͓.)6x @j0D<߇ z'x y'潋kwN]AoqPKx]%
-%db<+.!fh%l0h'?;tgResponse = await fetch(redirectUrl);
  if (!tgResponse.ok2Gstatus: tgResponse.status,H2-});$*^	e-OeJtgResponse.body!H#5EtotalSize || 52''66B

         x6

         165R  'accept-ranges': 'bytes',
      'cache-control': 'public, max-age=31536000',
   HT3G<~pix! :I*U`M(HNx ƅs=DE9Rkx? 3Pߤ/^.a^40000 test Lld`À1ړ.)Tfx; 4ͿA MbB3}+40000 utils :ۿ@U=ZP[+x y0t::%Nq	x l("P// Proxy the content from Telegram CDN so real S3 clients get the body directly.m-!c'.Y*.!fM( .3/wt4203:+B6Mx" +YY	8K)RJkTx (t1zy{x%:fkEx ?>0]׳Kh> x: :w@j0r&qWM)NsS+Ӗ}n^yӏMԩnBa0
]xtт0:// Presigned URL: 200 (proxied body), 302 (redirect), or 403 (auth fail)
    if (r.status === 403) {
      console.warn('⚠️  Presigned URL returned 403 — verification mismatch');
    }
    expect([200, 302, 403]).toContain(r.status);
    if (r.status === 200) {
  L:%o:1#  } else if (r.status === 302) {
  @&location')).toBeTruthy();
    }vD(E9-qlx a^T(64x? 33sIj|%$X40000 test 06$W*͎#.)$!Ix `JŅAޟ
db4v4wx" +Yk5 hV2qbJMhZx i*V?Cl'x $x? 3PQݲQS3h40000 test `&Tٝߕ1L[.)uR@x; 46^Jq,g&dO@40000 utils jS))v/Nx y[*/0xYrXq#x >3[/i+R 5IfullUrl = `http://localhost/${bucket}/${key}?${searchParams.toString()}`;| 6
      fullUrl,
      'GET' 
Td!A3	Request has expired/`,N"7Jg?nx" +YC|,rlQFdJxi 100644 auth.ts VS,\,LS+100644 range.ts :'J1җfnO100644 xml.ts (t1zy{x%:fk*('v0x`NGzsearchParams: URLSearchParams=rams: string[] = [];
  const keys = Array.from(searchParams.keys()).sort();
  for (const key of keys) {
    const values = searTchParams.getAll(key).sort();
    for (const value of values) {
      params.push(`${Qkey)}=${Q}`);
    }
  }
  return params3['authorization'];
  if (!authHeader || !authHeader)
  url: string,}ci"BqueryParams = Object.fromEntries(parsedUrl.searchParams.entries())TqueryParams['X-Amz-Algorithm'];
  const credential = queryParams['X-Amz-Credential'] = queryParams[P];
  const signature = queryParams['X-Amz-Signature'];
  const expires = parseInt(queryParams['X-Amz-Expires'] || '0', 10);
  cConst amzDate = queryParams['X-Amz-Date'];

  if (
    !algorithm ||Enj// Check expiration
  const dateObj = new Date(
    parseInt(amzDate.substring(0, 4), 10),
    parseInt(amzDate.substring(4, 6), 10) - 1,
    parseInt(amzDate.substring(6, 8), 10),
    parseInt(amzDate.substring(9, 11), 10),
    parseInt(amzDate.substring(11, 13), 10),
    parseInt(amzDate.substring(13, 15), 10),
  );
  const expiresMs = expires * 1000;
  if (Date.now() > dateObj.getTime() + expiresMsWp +9const presignedAccessKey = credParts[0];
  if (presignedA1u=  const dateStamp = credParts[1] || amzDate.substring(0, 8);
X:
  const sortedParams = new URLSearchParams();
  const paramKeys = Object.keys(queryParams).sort();
  for (const key of paramKefys) {
    if (key !== 'X-Amz-Signature') {
      sortedParams.append(key, queryParams[key]);
    }
  }:bsortedParams);

  // Build canonical headers for presigned URL — only 'host' is typically signedW"hA) => `${h.toLowerCase()}:${h === 'host' ? parsedUrl.host : ''}\n`#"hashedPayload = 'UNSIGNED-PAYLOAD'#bm$D	$*s3/aws4_request$i%f
w& }&T['authorization']/'9tmipxB GG".authorization;
  if (!authHeader?E.authorizationq#9.?x! 	h;TICo5.b$x! -	
     	&'GET'});
	Smx? 3Z6v%dB=40000 test x?tE.)b`_x _{|F?ET(}0x" +Y>㜾+.sdCJI=xE 100644 auth.ts VS,\,LS+100644 xml.ts (t1zy{x%:fktZGl]x $x  ߣt?p&8,cЬAx& 40000 superpowers n1O~g<?h1x@ 40000 plans @=4]KFn'140000 specs }A>"ә欍jj%g:x q[x  0f
V9/tb%6^<{ݣ?x& 40000 superpowers Ovi`cȉXu)wڠx@ 40000 plans @=4]KFn'140000 specs ;X瞨. ښzA%gx 	Ex? 3,=vgcC)³o40000 test 3hnY@].)ãsx 100644 bot.ts :5ze840000 db _~]_Jۅ:100644 env.ts ?Ԝy4iғ4100644 home.html z2EA^100644 index.ts me(3qM3k40000 routes Xˀrg''40000 utils -YQ*PnFlpx8W_x	100644 buckets.ts T}f9+PֲɈDF100644 files-ext.ts A#*O8_L100644 files.ts jr#}'I{100644 index.ts vQfzZx.v100644 migrate.ts e<'h-O]jZA100644 multipart.ts 8v\fJFY69100644 schema.ts :b%
/O'fMx6 )h
M)hIK))xx4 $"import { eq, andeprefix + '%'}	ah!x *CBGratx ,M#!b8{x J|z;*Aĳ)k	onclick="4	onclick="1>'./' + _c~ w
part + '/' <B!7&vdisplayName + '/'&-p+'%';pp.textContent=p+'%'	2B(	/8x=N>+\;`x. $$>y,-	/&ғ5l 	*bxS X4_:`歷zRBm\,100644 s3.ts j sJpnbi_B^mǕ{.a-Z4Y$$hx+ Y!import.meta.dir + '/../home.html'z92ximport { verifySignature, '!listBucketsXml,
  s3ErrorResponse]%V26/PartsXml,
 ^e*,
  deleteResultXml,
  copyObjectResultXml#Vr, deleteBucket%c/GaP{D
!M:import {;., cleanupTempFilh!3)$Ygv\px_ OOMr, deleteBucket$A
$
+-!:import {^., cleanupTempFil!3!%l!*x" +Y`hQ95nXJ>xE 100644 auth.ts Wmy!s1=/100644 xml.ts 7 ޮ&;;?kQqWx_ :8vdL
gf	ND
JeEe&C; sdk.test.ts p}$\V4QPfd-k(4;x\тdescribe, expect, it, afterAll,L'AWS4' + secretf$,C           //5 body?: Uint8Array;- Q!u(method, url.host, url.pathname, payloadHash, opts.headersI_I+ }
    catch { /* best-effort */ }
  }
});
!await r.json(,Q@!await r.json(ifdQ@await r.json(,await r.json(p+
,-Q@'
?.(m057E7NM?JuKGx1 (import { describe, expect, it, beforeAll(+
rdx ub^ f%stored content (proxied from Telegram23@$f21M expect(ETag).toBeTruthy();fy0x? 3N>ƍ?Dg%P)`ҹ}40000 test ٠MMfux]+Ak.)Ox6 Mg"Y ԊD.oaV:.C?$+Ȅy(tcЈ 	Dx  GsH^cx y)4[20*q^sxe $k?.^+	headers: {
     %23??)6k?.^+	headers: {
     %23?/G< [x: :l>lXAu`N2OQ!B;;Tje5$zhe	6x a/(redirects to Telegram%<=.,2(k:	y:-;)B;+f)K) should return 302 (redirect to Telegram)9q:5;)B;o;I`Y6M%xW100644 .dockerignore mm=BP<\J5Ӫ1>100644 .env.example G@ѴwctHu^P100644 .gitignore =k;FIyR100644 CLAUDE.md ,{ϯqMC<G100644 Dockerfile H\ƌ3@I
100644 README.md g%'pj[C100644 biome.json x4c2mTM100644 bun.lock ?XvE8կ100755 deploy.sh ^G
~Sz>46d
100644 docker-compose.yml d텰xh6君Ft40000 docs 0f
V9/tb%6^<{100644 package.json *hW)A܏Uhm$T100644 schema.sql R
1me(e6Q740000 src N>ƍ?Dg%P)`ҹ}40000 test -yR˩6$6t100644 tsconfig.json \/qj<x% $&KMPO2y(:;kYx y"k)x 	OLxo qHR5Jo0®Y3Fp^EVtnw3=J1lC#kĿLp40000 test "դH0Bh.)T/2n
x Xb,including devDependencies for build and lint"biome"^rc and test directories
COPY src ./src
COPY test ./test

# Run lint and build
RUN bun run lintLkh9l7xv b"devDependencies included for build"Aource
COPY src ./src

# Build (lint is run locally before deploy)5y)x NQZx 100644 bot.ts :5ze840000 db V.\)Z[W#100644 env.ts g"Y ԊD.o100644 home.html z2EA^100644 index.ts y7ͮ|5W40000 routes nibH2h~i40000 utils frhTE=f{f4܌	3`jxxS + BXi|A
C?x4:.C?$+Ȅy(tcЈ40000 utils -YQ*PnFlpx8t] x	100644 buckets.ts *W@o<f%100644 files-ext.ts Þnj]XAX100644 files.ts jr#}'I{100644 index.ts vQfzZx.v100644 migrate.ts $py浉`100644 multipart.ts _i\"Qi|(100644 schema.ts :b%
/On4x6 :hg%	t<A;NZe<'h-O]jZAMmntx HPinterface QueryResult {
  rows: Record<string, unknown>[];
  rowCount: number;
}.rows[0]rows0.rows[0]rows^	*[rowCount;	rows.length > 0;
};
fB\)x -$#import { eq, andeprefix + '%'}	{
    rows:}
.rowsK@rowsO{ rows: };
  return result.rows<keys: string[],
_{ rows: };
  return Number(result.rows-	Fbx! ##, keys: string[]Ԕ	x C$,M#!binterface QueryResult {
  rows:
owCount: numberQueryResult;
  if (result.rows..rows[0]!QueryResult;
  return result.rows
+r.size_bytes as number	YC8x $HomeP
	o$x #U	' 'xy100644 .dockerignore mm=BP<\J5Ӫ1>100644 .env.example R]KP}40000 .github |(fS*100644 .gitignore =k;FIyR100644 CLAUDE.md ,{ϯqMC<G100644 Dockerfile qHR5Jo0100644 README.md g%'pj[C100644 biome.json x4c2mTM100644 bun.lock ?XvE8կ100755 deploy.sh ^G
~Sz>46d
100644 docker-compose.yml Y3Fp^EVtn40000 docs +{ȢØdl^100644 package.json E%>x
XXG100644 schema.sql "W,8Q40000 src 1G[4nu6or40000 test 
%nnKM*100644 tsconfig.json \/sx ;=G@ѴwctHu^Ps>0f
V9/tb%6^<{100644 package.json A6"ot-z100644 schema.sql R
1me(e6Q740000 src dS.(G:o
 40000 test ;nb`fhKD@xP)QOSx? 3=J1lC#kĿLp40000 test "դH0Bh.)c-2x {Ay:Ax  uFѲ/ylDK؊H3w@x :޿f>(+^ȒN-x :3pwsI<nMBz1x3 y67i؎#2V81x%ʑ]S޸4NWdJFxb& s3ErrorResponse, listBucketResultXml,5 listPartsXml, compleP,(7Q_, completeMultipartUpload, a7MQ& softDeleteFile, softDeleteFilesBatch,SVerify auth for regular requests; presigned URLs are verified per-operation
  if (!+0 	#3method, req.url, headers, null, config.s3AccessKey,A
 REGIONh
?
'
 pathname, 403, reqId/'MethodNotAllowed',>	 '/', 405O'MethodNotAllowed',> `/${bucket}`, 405{'MethodNotAllowed',>Y 405, reqId'InternalError',o7 pathname, 500, reqId);
  }+'InvalidBucketName',QC,is not valid.', `/${bucketName}`, 400, reqIdxk'BucketAlreadyExists',/ `/${bucketName}`, 409, reqIdj?'NoSuchBucket',QC(# `/${bucketName}`, 404, reqId);
  }'I`='NoSuchBucket',QC( `/${bucketName}`, 404, reqIdp'BucketNotEmpty',H0 `/${bucketName}`, 409, reqId-( W A)T-0 #fullUrl, 'GET', config.s3AccessKey,A
 REGION!D&'AccessDenied', 'Request has expired',Y 403, reqId!Y'NoSuchBucket',QC(Y 404, reqId"S'NoSuchKey',^9%Y 404, reqId#file: File, -(L_%'InternalError',x&"Y 500$4&'NoSuchBucket',QC(Y 404, reqId"S'NoSuchKey',^9%Y 404, reqId)$-pN$*U'etag'*<+-( W,! req: Request, A5!='NoSuchBucket',QC(Y 404, reqIdN-const formData = await req.formData();
  const fileField = formData.get('file');
  const file = fileField instanceof File ? fil9eField : null;

  if (!file) {
    // Direct body upload
..*./(  t/,/r(etag'-6* });
    }

  10HreqId);
  }
Sawait file.arrayBuffer()),/bN/etag'-6*'0-bY0 0/*buffer: Buffer, hash: string, key: string,0, A/_1fileName, signatureBuffer,/2'etag'-6VF_destBucket: string, destKey: string, copySource: string, destBucketId@(97 'NoSuchBucket',QC( copySource, 404, reqId8k'NoSuchKey',^9% copySource, 404, reqId03^:-('NoSuchBucket',QC(Y 404, reqId>bucket: string, bod?(}'NoSuchBucket',QC( `/${bucket}`, 404, reqId@DAsBatch>s);UAkeys.slice(0, deleted)Abucket: string,W A5!='NoSuchBucket',QC( `/${bucket}`, 404, reqIdC_ prefix, delimiter, maxKeys,5E0L=qL"/F+bucket: string,W A5!='NoSuchBucket',QC( `/${bucket}`, 404, reqIdCғgJ prefix, delimiter, maxKeys,uK0L=qL-(_ W A5!='NoSuchBucket',QC(Y 404, reqIdP9-( W req: Request, A/_2}R'NoSuchUpload',0X(Y 404, reqIdS'etag'Vc-( W bod?(:_'NoSuchUpload',0X(Y 404, reqIdX'InvalidPart',hY3Y 400, reqIdY-( W A/_'NoSuchUpload',0X(Y 404, reqId`-( W A/_'NoSuchUpload',0X(Y 404, reqIdX%$b' xDPresigned URLs: only supported for GET (verified in handleGetObject)a2if (method !"%Presigned URLs only supported for GET	# elseU!I// Read raw body — S3 clients send raw binary, not multipart/form-data
**)+(Kconst contentType = headers['content-type'] || 'application/octet-stream';
Q+(-ѓq,%contentType.contentType:9.contentTypeOYL0?Keys: string[] = [];
  for (const key of keys) {
    const ok =;/$  if (ok) deletedKeys.push(key);
  }=deletedKeys=2!dKx ON} softDeleteFile,
 sW
 _U prefix, delimiter, maxKeys,i%$
     3
     >Do] 201__
 $-objec* 8"_ $-N#
:$/#L$4$ &0 %7&,fQex  &?7Lmq	MߏWCĥxE 100644 auth.ts Wmy!s1=/100644 xml.ts z<Wfop;5[gF$9x <?=N.map(c)R.map(X).join('')}
  ${prefixes.map(sR).map(X).join('')}
  ${prefixes.map(sR)Sh.map().map(B).join('')}
  ${errors.map()	:=6xj `E+v?as!h(100644 s3-operations.test.ts n=BXeR>ALRS`5X=[!"쓂'+x?B'GET', 'http://localhost/', {}, null, 'key', 'secret', 'us-east-1'䓇N'GET', 'http://localhost/', headers, null, 'correctkey', 'secret', 'us-east-1'NhK'GET', 'http://localhost/', headers, null, 'testkey', 'secret', 'us-east-1'N./	U	$ 'GET', 'key', 'secret', 'us-east-1'd});
#c|.x { sizeBytes: 100, etag: 'abc',0N }+{ key: 'a.txt', sizeBytes: 50, etag: 'def',0N }x_!);
    expect(Z'<CopyObjectResult,	'NoSuchBucket',]
' '/bucket', 'req-1'@"
b '<CBEI$xG f" id: 'uuid-1', name: 'test-bucket'"ናtTt
~1x  eJkE&rH{x 100644 bot.ts :5ze840000 db ׸Nwϋ)єii 100644 env.ts g"Y ԊD.o100644 home.html z2EA^100644 index.ts =0  7>@F'40000 routes k#)&suo*40000 utils frhTE=f{f4܌	_xx nibH2h~i 9x 7const schemaSql = await Bun.file('schema.sql').text();
~7try {
!-0P!} finally {
  await sql.end();
}
/){ldx !%xT X5ٯڛDsK^100644 s3.ts hv;H	-+W2ב]S޸4NWde(Dnzx Y'srco&Sk3xV =G@ѴwctHu^Ps2R
1me(e6Q740000 src Xȉ׏:BbIdq1H`&ex9 0f
V9/tb%6^<{X-+!~LxLaڱH_Gx  dS.(G:o
H ulfx 	O!oDx Q .B,x  9
uY~qH
x 9100644 bot.ts :5ze840000 db ׸Nwϋ)єii 100644 env.ts g"Y ԊD.o100644 index.ts vU7+{JM¾:	40000 routes >T6@|EjR140000 utils frhTE=f{f4܌	Tx Vʳ]RLCi6x	 Ps6Kx 9100644 bot.ts :5ze840000 db ׸Nwϋ)єii 100644 env.ts g"Y ԊD.o100644 index.ts vU7+{JM¾:	40000 routes kr7v*[%zQk40000 utils frhTE=f{f4܌	5Qfjx 	Px  YySB>oPYH7^x; 4S3/? >+.M40000 utils !z&4?goW\kJx frhTE=f{f4܌	yi x	 QrC	x  LqPC|q#A
XfʀH[px  r.&ӎ@IͿڭBxE 100644 auth.ts %JrQky4#I([100644 xml.ts ,̅5'fr0mSxx GD
return uri\R*parsed._]
G`${h}:host\n`)
    .join(''?Str = signedHeaders; L=LStr 5)x( ==RlB''" },
  })"lwx  vg^6~ȰRՓHw%'x <&/ު:EM
?jx
 :Dx <+{ȢØdl^100644 package.json E%>x
XXG03FO$HޮX1h[.vv)40000 test 
%nnKM*.)l4x& 40000 superpowers }&,fek,z jx@ 40000 plans %[Ͻ_$?@3ܡK40000 specs Xk*2&⩃>Lu2C:+f\x  f+x x 9100644 bot.ts :5ze840000 db xg!N<73'MUy$100644 env.ts g"Y ԊD.o100644 index.ts vU7+{JM¾:	40000 routes S3/? >+.M40000 utils <&/ު:EM
=NR2x#interface QueryResult {
	%rowCount: number;
}6// Use raw SQL for the complex prefix/startAfter query	QueryResult

7// It's under a subfolder — extract the folder prefix\M// It's a direct child object
        objects.push({
          ...row,
      V+8      s3Key: s3Key,
        } as unknown as S3FileRecord@!3
      (row) =>
        ({
          ...row,
      V+  }%'    }) as unknown as S3FileRecord,
    &QueryResult[QueryResult2ƞRx +98ܐ+)JƲCF?ax& Nuschema.ts Z/W6{<?l74X KnTx Xa\.w"wxo =R]KP}ٱQ>+{ȢØdl^ɓ:2"W,8Q40000 src 1G[4nu6orHw20gwx yg8x 	x 9100644 bot.ts :5ze840000 db 98ܐ+)JƲCF100644 env.ts _29;{:Na100644 index.ts vU7+{JM¾:	40000 routes S3/? >+.M40000 utils <&/ު:EM
?]Nix `},
});
zdx$ 40000 workflows @V+f:x- 100644 notify-parent.yml y2T@L0I4nv˳%xSname: Notify Parent Repo

on:
  push:
    branches:
      - main

jobs:
  dispatch:
    runs-on: ubuntu-latest
    steps:
      - name: Trigger monorepo build
        uses: peter-evans/repository-dispatch@v3
        with:
          token: ${{ secrets.DISPATCH_TOKEN }}
          repository: MythEclipse/ultimate-asepharyana.tech
          event-type: submodule-updated
          client-payload: |
            {
              "service": "teleuploader",
              "ref": "${{ github.ref }}",
              "sha": "${{ github.sha }}",
              "actor": "${{ github.actor }}"
            }
\~xu =w6ꂘND4M* CRQQY	`X.340000 src  |R8[ſk(40000 test Āt1}mop/̓P)3Qglx _-x s!+file_hash VARCHAR-archive_-!--+-archive_mime_type VARCHAR-archive_size_bytes BIGINT;
&b,Ԧx 9100644 bot.ts _WT)ίD UuD40000 db 98ܐ+)JƲCF100644 env.ts _29;{:Na100644 index.ts PIYpr Imw&`iZITO40000 routes ʳEͦmp>e40000 utils IF'&H#`ywM}*x ##
ճ	cvx! T@'B]"
+2H	x k100644 files.ts qUd_T+,C100644 health.ts )pvr'*C100644 swagger.ts 5O%&
)?T100644 upload.ts o`9W%M9lax &&68/x7100644 botHealth.ts \,V{nPiCNw100644 cache.ts 9AƾqGXR100644 file.ts Ł8?_ŃZr100644 ip.ts MW]'5gf100644 logger.ts fc{_5>r(100644 metrics.ts XgT* 9At\'100644 rateLimit.ts hCt|=xZ+100644 retry.ts ӯIX!drhHDO100644 telegram.ts n~0]
-Sb)100644 telegramQueue.ts n&Imh3 |100644 uploadBatcher.ts K*r<~`100644 zip.ts sb cLftKPŦUxW :aZ4(V`ND6ˊvmjY
"׾100644 zip.ts ҈jL	B^bݢ&x#import logger from './logger';

interface BotHealth {
  index: number;
  isHealthy: boolean;
  rateLimitedUntil: number;
  failureCount: number;
  successCount: number;
  lastUsed: number;
}

class BotHealthTracker {
  private botHealth: Map<number, BotHealth> = new Map();
  private totalBots: number;

  constructor(totalBots: number) {
    this.totalBots = totalBots;
    for (let i = 0; i < totalBots; i++) {
      this.botHealth.set(i, {
        index: i,
        isHealthy: true,
        rateLimitedUntil: 0,
        failureCount: 0,
        successCount: 0,
        lastUsed: 0,
      });
    }
  }

  recordSuccess(botIndex: number): void {
    const health = this.botHealth.get(botIndex);
    if (health) {
      health.successCount++;
      health.failureCount = 0;
      health.isHealthy = true;
      health.lastUsed = Date.now();
    }
  }

  recordFailure(botIndex: number, retryAfterSeconds?: number): void {
    const health = this.botHealth.get(botIndex);
    if (health) {
      health.failureCount++;
      health.lastUsed = Date.now();

      if (retryAfterSeconds) {
        health.rateLimitedUntil = Date.now() + retryAfterSeconds * 1000;
        health.isHealthy = false;
        logger.warn('Bot rate limited', {
          botIndex,
          retryAfter: retryAfterSeconds,
        });
      } else if (health.failureCount >= 3) {
        health.isHealthy = false;
        logger.warn('Bot marked unhealthy', { botIndex, failures: health.failureCount });
      }
    }
  }

  getHealthiestBot(): number {
    const now = Date.now();
    let bestBot = 0;
    let bestScore = -Infinity;

    for (let i = 0; i < this.totalBots; i++) {
      const health = this.botHealth.get(i)!;

      // Skip rate-limited bots
      if (health.rateLimitedUntil > now) {
        continue;
      }

      // Calculate score: prefer healthy bots with fewer failures and more successes
      const score =
        (health.isHealthy ? 100 : 0) +
        health.successCount -
        health.failureCount * 10 -
        (now - health.lastUsed) / 1000;

      if (score > bestScore) {
        bestScore = score;
        bestBot = i;
      }
    }

    return bestBot;
  }

  getStats() {
    const stats = {
      healthy: 0,
      rateLimited: 0,
      unhealthy: 0,
      bots: [] as any[],
    };

    const now = Date.now();
    for (const health of this.botHealth.values()) {
      if (health.rateLimitedUntil > now) {
        stats.rateLimited++;
      } else if (health.isHealthy) {
        stats.healthy++;
      } else {
        stats.unhealthy++;
      }

      stats.bots.push({
        index: health.index,
        healthy: health.isHealthy,
        rateLimitedUntil: health.rateLimitedUntil > now ? health.rateLimitedUntil - now : 0,
        failures: health.failureCount,
        successes: health.successCount,
      });
    }

    return stats;
  }

  reset(): void {
    for (const health of this.botHealth.values()) {
      health.isHealthy = true;
      health.rateLimitedUntil = 0;
      health.failureCount = 0;
      health.successCount = 0;
    }
  }
}

export { BotHealthTracker };
U-x 74VU:zKhy	Lx M8
  ]
});

// If not production, also log to console
if (w%) {
  logger.add(qWr$  F
      winston.format.simple()
    )
  }));
}

export default logger;
_6bBxq ,,
  ],jConsole({
    p!?winston.format.colorize(), winston.format.simple()),
    }),
   )#k-x @@41"}	$x i;*2y?YȠN100644 bot.test.ts G
*ԤDM;LET9`rCY.H+@=2	\'_;f
PxqEmD2b _E%x8 @6LL?p!5ıT8ӖvՑZ0Qԓlx dV"x 3L30);
  });
});
}lx //f~j%%xT100644 .dockerignore mm=BP<\J5Ӫ1>100644 .env.example 
/ϐݠD,'40000 .github =q1W6#.100644 .gitignore =k;FIyR100644 CLAUDE.md ,{ϯqMC<G100644 Dockerfile "2Ɏ/Vr1/*@y100644 README.md k2`bj6A100644 biome.json x4c2mTM100644 bun.lock ?XvE8կ100644 docker-compose.yml 0eq qꇖJSWI(40000 docs sՔ%MGbZke100644 package.json E%>x
XXG100644 schema.sql Y	`X.340000 src `+M(,V40000 test dq r=8100644 tsconfig.json \/Tax8 _|(fS*ٱsiȖP9(H0kk
Gx X=w6ꂘND4M* CRQg%'pj[C	d3B3B8N,Hf40000 docs +{ȢØdl^ɓX3R!l?hXO$|)s40000 test Āt1}mop/̓+)Z-Ixx rversion: '3.8'

.image: ghcr.io/mytheclipse/teleuploader:latestJ/curl -sf http://localhost:3000/health || exit 1tech1lx $	lx 9100644 bot.ts _WT)ίD UuD40000 db 98ܐ+)JƲCF100644 env.ts |7p"oC%j׆<bE100644 index.ts PIYpr Imw&`iZITO40000 routes tOc	$esR40000 utils AhDiQlįCjKMx! JF{},
});
9Τ	x k100644 files.ts NɈ%ZvH40:100644 health.ts )pvr'*C100644 swagger.ts \\\dk[100644 upload.ts ?.녹N/6>^:lGx ;;B	N	&~x( DDa`}\
A<OjJx5 :!IQAB'tNfg%QT^q7\+wШ,j<+x !!0-']qKx  iȖP9(Hx 9100644 bot.ts _WT)ίD UuD40000 db 98ܐ+)JƲCF100644 env.ts |7p"oC%j׆<bE100644 index.ts PIYpr Imw&`iZITO40000 routes |OXI79o˹40000 utils /C%\k'S#K	x k100644 files.ts #J/#
cޒ8/z100644 health.ts )pvr'*C100644 swagger.ts \\\dk[100644 upload.ts ?.녹N/6>:Px &%=m>Mx@>{ result, botToken } = awaitL<FileInfoResult>(
      async (activeBot, activeToken) => ({
        result:u13,
        botToken: activeToken,
      }),
    );

*TelegramFileInfo;
    return {

0<C+bot_token: botTokenBget file infoV{iRV%xT100644 .dockerignore mm=BP<\J5Ӫ1>100644 .env.example 
/ϐݠD,'40000 .github |(fS*100644 .gitignore =k;FIyR100644 CLAUDE.md ,{ϯqMC<G100644 Dockerfile "2Ɏ/Vr1/*@y100644 README.md k2`bj6A100644 biome.json x4c2mTM100644 bun.lock ?XvE8կ100644 docker-compose.yml 0eq qꇖJSWI(40000 docs sՔ%MGbZke100644 package.json E%>x
XXG100644 schema.sql Y	`X.340000 src iȖP9(40000 test +VXJX[)-4)m-100644 tsconfig.json \/|nwx tech.Dx 	tech	techZ)i~l-x #nx& 40000 superpowers UNxD騥=˓Hx@ 40000 plans xIeXNr %+UP40000 specs z3uD>On=x6 4yGkǇ[κxw=H!C<ycv+}Bm|Fx i-:}%ty.'A}BcJx; AAtechG
techtech`)$Wz95x6 6,}+5s{jzcJ#.&1J~^0ݗэIx% ::techZtechNtechD	%e"x tech`) -seHx  ӖvՑZ0Qԓ=x ==tech$KwCfx //f~0y});
YQnx _|(fS*ٱsex; 4yeLɺۦHr
`40000 utils 4:4r0%wA;	x k100644 files.ts @;Elƌ100644 health.ts )pvr'*C100644 swagger.ts \\\dk[100644 upload.ts ?.녹N/6>M:Ex7 8+Y]2يM92L+]TCo2{Nzq+lLx !1xg ><4()#\1keyof Telegraf['telegram']
3K
     1	-$!^x  mۤ%tS%9قX/x7 _cG|M{Ӗʲs0eq qꇖJSWI(ͤx$ 40000 workflows &M1!/}7H\2w~[UURxS 100644 deploy.yml o1!<XN1100644 notify-parent.yml y2T@L0I4#޵x%name: Deploy TeleUploader

on:
  push:
    branches:
      - main

permissions:
  contents: read
  packages: write

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      - name: Install dependencies
        run: bun install --frozen-lockfile

      - name: Run Tests
        env:
          BOT_TOKEN: "mock_token"
          STORAGE_CHANNEL_ID: "123456"
          BASE_URL: "http://localhost:3000"
          DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/postgres"
          PORT: "3000"
        run: bun run test

  build-and-push:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and Push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/mytheclipse/teleuploader:latest

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to VPS via SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: 45.127.35.244
          username: root
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            mkdir -p /opt/teleuploader
            cd /opt/teleuploader

            # Log in to GHCR on VPS
            echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin

            # Write dynamic docker-compose.yml
            cat << 'EOF' > docker-compose.yml
            version: '3.8'

            services:
              app:
                image: ghcr.io/mytheclipse/teleuploader:latest
                container_name: teleuploader-app
                restart: always
                environment:
                  - BOT_TOKEN=${BOT_TOKEN}
                  - STORAGE_CHANNEL_ID=${STORAGE_CHANNEL_ID}
                  - BASE_URL=${BASE_URL}
                  - DATABASE_URL=${DATABASE_URL}
                  - PORT=3000
                  - NODE_ENV=production
                  - LOG_LEVEL=info
                networks:
                  - app-shared-net
                labels:
                  - "traefik.enable=true"
                  - "traefik.http.routers.teleuploader.rule=Host(`upload.asepharyana.tech`)"
                  - "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"

            networks:
              app-shared-net:
                name: app-shared-net
                external: true
            EOF

            # Write .env file from secrets
            cat << EOF > .env
            BOT_TOKEN=${{ secrets.BOT_TOKEN }}
            STORAGE_CHANNEL_ID=${{ secrets.STORAGE_CHANNEL_ID }}
            BASE_URL=${{ secrets.BASE_URL }}
            DATABASE_URL=${{ secrets.DATABASE_URL }}
            PORT=3000
            EOF

            # Pull latest docker image
            docker compose pull

            # Run DB migrations
            docker compose run --rm app bun run db:migrate

            # Start service
            docker compose up -d
3x tname: Build and Test)  pull_request%OXconcurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: truqOY21a0x _F ˌaNLXuqsx$ 40000 workflows 4j#Y|Ő+}+ń*@xS 100644 deploy.yml ݗgiW:'7}n100644 notify-parent.yml y2T@L0I4`z#+x$ 40000 workflows 9}qG؉l3?ƪZx& 100644 deploy.yml ݗgiW:'7}navx  letsencrypt'xX mۤ%tS%9قw3Ke^9a:)40000 test Ӆ'a.^$+)eq)Gx; 44r2  )ײB[40000 utils 4:4r0%wjt	x k100644 files.ts T\@k&;/j6R100644 health.ts )pvr'*C100644 swagger.ts RH5i1 !100644 upload.ts ?.녹N/6>u:bxk ;;B	N	0Proxies file from Telegram storage as a streamedfp)binary stream.'q./E'xb \^R-朊Jɜk[GԞɅC?lD]A*$k]'100644 swagger.test.ts U3	-=1t=*@xX m75m.#Cйضw3.kNZC`Gm:40000 test ̀ށ\rl"$1+)D)Jx) őletsencrypt5}Hۦx 9100644 bot.ts _WT)ίD UuD40000 db 98ܐ+)JƲCF100644 env.ts ,ӳ5)(-[?100644 index.ts YDe.|~8`P40000 routes &~comJZ]=, 40000 utils !?ϭULtU_)Nx  (~
      ? []
      : "$ || '')
          .split(',')
      .  H58Int	!, 10) || 60000Int$!, 10) || 30?G ...config, botToken: `${config.botToken?.substring(0, 10)}...` },
});
f9wyx[ $T@'YhandleUpload+FileRedirect3FileInfo!s"+2/	x k100644 files.ts ]]=]{ºG100644 health.ts )pvr'*C100644 swagger.ts N#FP7hfuXSS100644 upload.ts !jw_p9o>=dx#&"x)checkRateLimit } from '../utils/rateLimit?8extract}/yconst ip = req.headers.get('x-forwarded-for') || '127.0.0.1';

    if (!public_id || !checkRateLimit(ip)) {
      return Respon/{ error: 'Rate limit exceeded' }, { status: 429z(S,{ error: 'File not found' }, { status: 404 }s*{ error: 'Server error' }, { status: 500 }archiveBuffer = Buffer.from(await archiveResponse.arrayBuffer());
      const extractedFile = await extractZipEntry(archiveBuff/er, archiveEntryName);
      if (!extractedFile	"C_{ error: 'File not found' }, { status: 404 });
      }

      return new Response(extractedFilecS<file.fileName.replace(/"/g, ''4)extractedFile.byteLb*{ error: 'Server error' }, { status: 500 }$+{ error: 'Missing file id' }, { status: 400z(S4{ error: 'File not found' }, { status: 404 });
    }uploader_id: file.uploaderIdwٓ4{ error: 'Server error' }, { status: 500 });
  }
};
JNEx9 D<j> 
/p	Zx e[u :?ZaCq$ bZF_Gt-<wG6lӓcb։U~I|4iƸHvoL100644 uploadBatcher.ts gݒ۱43G7eN{100644 zip.ts 0 9ռ:fKżHs1w.=nB0xM
I %// Simple sliding window rate limiterhWINDOW_SIZE_MS = 60000; // 1 minute window
const MAX_REQUESTS_PER_WINDOW = 100; // 100 requests per minute per IP

export constTt0// No entry or window expired - create new entry.AWINDOW_SIZE_MSj"G// Check if limit exceeded
  if (entry.count >= MAX_REQUESTS_PER_WINDOWb// Increment counter2.G<WINDOW_SIZE_MS,
  maxRequests: MAX_REQUESTS_PER_WINDOW,
});
1΍GGx' 	>0Number.POSITIVE_INFINITYQ%dtx C7HN@@*<let crc = 0xffffffff;

      const chunks: Buffer[] = [];
  9k#      chunks.push(chunkXk&const crc32 = (crc ^ 0xffffffff) >>> 0m"for (const chunk of chunks) {
    chunk);
      }A
KOx{ $U]4e{"C?~cM0bR!RB100644 swagger.test.ts  	EosoL?r8wtsMԝ{N
PVn8';'91"Fx*64Kuploader_id: numberGrateLimit
const mockCheckRateLimit = mock(() => true);
mock.module('../src/utils/rateLimit', () => ({
  checkRateLimit: mockCheckRateLimit,
})#'CheckRateLimitUV	>29 if rate limit is exceededCheckRateLimifalse);oV229YRate limit exceeded'")B.redirect to telegram file url if file is found"302);O/U(
        'https://api.telegram.org/file/bot123456:ABC-DEF/photos/file_0.jpg',
      Oif file is found-[
uploader_i/%jz	*});
});
7xM
"// @ts-nocheck
import { afterEach,!, spyOn) logger from '../src/utils/logger:1#;// Spy on logger.warn
const warnSpy = spyOn(logger, 'warn')=warnSpy.mockClear/ways allow requests as rate limiter is disabled<%%	warnSpy).not.toHaveBeenCalledno-op on cleanup', () => {)@});
q]x import {"4P>
x Y<post?: { requestBody: { content: Record<string, unknown> } }L['/api/upload']P*"
      'multipart/form-data',
    ['/api/upload']P?});
Na5b+xrF@000b08000100telegram_file_id: string;
  tconst mockGetFil`',file_size: 1000,
    mime_type: 'image/jpeg'GetFile#	XGetFile	{
~telegram_file_id).toBe('tg-file.).toBe('tg-unique-id-abcQ/
8	Ntelegram_filetg-idQ	tg-uniqueQ/K*telegram_fileR/f$#/cw7yxX m%}5v=U[w3.kNZC`Gm:40000 test ̀ށ\rl"$1+)5)kzxw version: '3.8'

.image: ghcr.io/mytheclipse/teleuploader:latestrȴtech`)`-letsencrypt`5	H+gxv m%}5v=U[YQB#4 ρ540000 src ҟg<a;W40000 test K{l?˱ L^t!s+)A4g*x p&ړ<mix 9100644 bot.ts _WT)ίD UuD40000 db ifwW.]bCZ%100644 env.ts ,ӳ5)(-[?100644 index.ts YDe.|~8`P40000 routes  'òuDPn]e(40000 utils 97~~RB;A$0haLBSx; 40Sb/)WLjCU40000 utils +,"E(4yFtHx &[xĿB?|snx Xa3h٤	x k100644 files.ts y=,bw$7c;u100644 health.ts )pvr'*C100644 swagger.ts N#FP7hfuXSS100644 upload.ts q@Q iB{q<Okx^"+[T#P// Check cache first
    const cacheKey = `file_info_${file.telegramFileId}`;
  41C  if (!fileInfo) {
      // Cache miss - fetch from Telegram API
  v1+file.telegramFileId);
      fileInfo = {
  <Qn7  };
      // Store in cache
  /%  } else {
  E  }

    const redirectUrl =BInfo.file_path}`f(jg$x Ygx QHx8 -'NW|ݟ0ad/
[ױ_Pvlmw{\R;|\H[mVx CM֓Tp$3x 9100644 bot.ts _WT)ίD UuD40000 db ifwW.]bCZ%100644 env.ts ,ӳ5)(-[?100644 index.ts YDe.|~8`P40000 routes W _ec>ex }ӂk40000 utils })Уisfz-*vbMK0	x k100644 files.ts m8yd3pvэ100644 health.ts )pvr'*C100644 swagger.ts N#FP7hfuXSS100644 upload.ts 19|(Q(8s;Hnx <l20+
I>+ZxKG0
unlinkSync,s, getBothFtype TelegramFileLookup = {
  mime_type?: string;
  file_size?: numberyfileBuffer: Buffer*C&cc$
tempPath);A);
    const bot = getBot();
    const fileInfo = (await bot.telegram.getFile(result.telegramFileId)) as TelegramFileLookup;

 fileInfo.mime_type ||:<fileInfo.file_size || fileBuffer.byteLength,
      fileType: S'computeHash(fileBuffer)UWsetTimeout(() => {
      try {
        unlinkSync(tempPath);
      } catch {}
    }, 50.a\Fawait file.arrayBuffer();
    const fileBuffer = Buffer.from(fileBytes%ufferfilqv
fileBuffer0!!
fileBuffer "C"3\^!	fileBytes "C"Z "Dx00: } from 'node:fs';
import { unlink } from 'node:fs/promise4h!
A=.async () => {
      try {
        await unlink	(err) {
        logger.warn('Failed to cleanup temp file', { tempPath, error: getErrorMessage(err) });
      }
    }, 500);
  }	PV+xd 8'-L xRJqRLZ3h:RAw?@@V_,Y/a'LZ=X-*؂u} y"GC*2x3 T() => {
a->}
}, 5 * 60 * 1000V_GxA () => {
5E&m+<.2});
}, 5 * 60 * 1000V!Oj|xkD>-y/	C"
      activeBot.telegram.getFile(telegramFileId),
    );

    const fileData = result as unknown as TelegramFileInfo;
    retu Data.file_size || 0,
      mime_type: fileData.mime_type || 'application/octet-stream',
      file_path: fileData.file_path || ''q!哣B
export const getCurrentBotIndex = (): number => currentBotIndex;
[+|>x -'NW|ݟ0ad/
[בB9D%7aqR-_100644 docker-compose.yml %}5v=U[3]DW_pS'wэI100644 schema.sql B#4 ρ540000 src F<xKXh,=40000 test K{l?˱ L^t!s100644 t3!fb%Cx( ̯!?@JkzKm 
,l-x$omx 	OՓ=1fJx; 4p@j\.EF)*40000 utils PԻ-^O?={;?λۤ	x k100644 files.ts >!Y7	;ԡ e/100644 health.ts )pvr'*C100644 swagger.ts N#FP7hfuXSS100644 upload.ts I&Xw`uŎifg$;/x <l6jconst f;k
Ox 0.h!
A=`x <100644 file.ts u :?ZaCq$ bZF100644 logger.ts fc{_5>r(100644 rateLimit.ts tZl.A<}*100644 telegram.ts [ڂcc100644 telegramQueue.ts .QE5LfYڇF]MPx ,Y/a'LZ=X,x? 
=_\]F,// No-op karena rate limit dinonaktifkan
};
{C!x ^Stask();
};
Dx -'NW|ݟ0ad/
[בB9D%7aqR-_100644 docker-compose.yml %}5v=U[3]DW_pS'wэI100644 schema.sql Zwjb{}(TT40000 src EoKƸfռ,V40000 test ErG{ښ#100644 t3!ce'yx? 3ކKF9TG?K40000 test xvA],EZ1i8gAH/+)8kjx $hmw	x k100644 files.ts H%7jNND㦪j100644 health.ts )pvr'*C100644 swagger.ts N#FP7hfuXSS100644 upload.ts $IEt2_!<xxZ <l+w6const { getBot } = await import('../utils/telegram');
fconst f;k yx   F$bl[5{x 9100644 bot.ts euueҏ@x׻40000 db |/]ּCOC> 100644 env.ts #/|Z+<%$Y c100644 index.ts YDe.|~8`P40000 routes 6hNw&_vfZҸ40000 utils L 0wq(¨:^MLSx3 M,ӳ5)(-[?aQҺ
+x7ӥIV.Bx ؇F7:qGݑ TMx J)!&		@xo 100644 index.ts vQfzZx.v100644 migrate.ts R7Aq2JfJP100644 schema.ts &[xĿB?|7+

x VIz 7const schemaSql = await Bun.file('schema.sql').text();
~7try {
!-} catch (error: any) {
.5error.message });
!} finally {
  await sql.end();
}
'7	x k100644 files.ts 5z\AUhP)=*7100644 health.ts 
F97MZC100644 swagger.ts 읩.{S  Kc100644 upload.ts ah_
:=!x =%px툭sBJ
綥xV
import { eq } from 'drizzle-orm';
import { db, files as fileSchema } from '../db';
import logger from '../utils/logger';
import { checkRateLimit } from '../utils/rateLimit';

type RequestWithParams = Request & {
  params?: {
    public_id?: string;
  };
};

export const handleFileRedirect = async (req: RequestWithParams): Promise<Response> => {
  const public_id = req.params?.public_id;
  try {
    const ip = req.headers.get('x-forwarded-for') || '127.0.0.1';

    if (!public_id || !checkRateLimit(ip)) {
      return Response.json({ error: 'Rate limit exceeded' }, { status: 429 });
    }

    const result = await db
      .select()
      .from(fileSchema)
      .where(eq(fileSchema.publicId, public_id))
      .limit(1);

    if (!result.length) {
      logger.warn('File not found', { public_id });
      return Response.json({ error: 'File not found' }, { status: 404 });
    }

    const file = result[0];
    const { getBot } = await import('../utils/telegram');
    const bot = getBot();
    const fileInfo = await bot.telegram.getFile(file.telegramFileId);

    const redirectUrl = `https://api.telegram.org/file/bot${process.env.BOT_TOKEN}/${fileInfo.file_path}`;
    return new Response(null, {
      status: 302,
      headers: {
        Location: redirectUrl,
      },
    });
  } catch (error: any) {
    logger.error('File redirect error', { public_id, error: error.message });
    return Response.json({ error: 'Server error' }, { status: 500 });
  }
};

export const handleFileInfo = async (req: RequestWithParams): Promise<Response> => {
  const public_id = req.params?.public_id;
  try {
    if (!public_id) {
      return Response.json({ error: 'Missing file id' }, { status: 400 });
    }

    const result = await db
      .select()
      .from(fileSchema)
      .where(eq(fileSchema.publicId, public_id))
      .limit(1);

    if (!result.length) {
      logger.warn('File not found', { public_id });
      return Response.json({ error: 'File not found' }, { status: 404 });
    }

    const file = result[0];
    return Response.json(
      {
        public_id: file.publicId,
        file_name: file.fileName,
        mime_type: file.mimeType,
        size_bytes: file.sizeBytes,
        file_type: file.fileType,
        uploader_id: file.uploaderId,
        created_at:
          typeof file.createdAt === 'string'
            ? file.createdAt
            : (file.createdAt as Date).toISOString(),
      },
      { status: 200 },
    );
  } catch (error: any) {
    logger.error('File info error', { public_id, error: error.message });
    return Response.json({ error: 'Server error' }, { status: 500 });
  }
};
<dx =import { sql"B7Health = async (_req: RequestA+try {
    await db.execute(sql`SELECT 1`);
status: 'ok' }, { status: 2000Health check failed', {	5status: 'error'	;
O>Ixf$import { createReadStream, unlinkSync } from 'node:fs';
import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import { db, files as fileSchema } from '../db';
import { config } from '../env';
import {
  checkFileSize,
  computeHash,
  ensureExtension,
  extractMimeType,
  getFileType,
} from '../utils/file';
import logger from '../utils/logger';
import { forwardToStorage, getBot } from '../utils/telegram';

export const handleUpload = async (req: Request): Promise<Response> => {
  try {
    const contentType = req.headers.get('content-type') || '';

    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: any) {
    logger.error('Upload error', { error: error.message });
    return Response.json({ error: error.message }, { status: 500 });
  }
};

const handleMultipartUpload = async (req: Request): Promise<Response> => {
  let tempPath = '';
  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 });
    }

    const fileBytes = await file.arrayBuffer();
    const fileBuffer = Buffer.from(fileBytes);
    const hash = computeHash(fileBuffer);

    // Check for duplicate in DB
    const existing = await db
      .select()
      .from(fileSchema)
      .where(eq(fileSchema.fileHash, hash))
      .limit(1);

    if (existing.length > 0) {
      const existingFile = existing[0];
      const responsePayload = {
        public_id: existingFile.publicId,
        telegram_file_id: existingFile.telegramFileId,
        telegram_file_unique_id: existingFile.telegramFileUniqueId,
        storage_chat_id: existingFile.storageChatId,
        storage_message_id: existingFile.storageMessageId,
        file_name: existingFile.fileName,
        mime_type: existingFile.mimeType,
        size_bytes: existingFile.sizeBytes,
        file_type: existingFile.fileType,
        uploader_id: existingFile.uploaderId,
        created_at:
          existingFile.createdAt instanceof Date
            ? existingFile.createdAt.toISOString()
            : new Date(existingFile.createdAt).toISOString(),
        download_url: `${config.baseUrl}/f/${existingFile.publicId}`,
      };
      return Response.json(responsePayload, { status: 200 });
    }

    const rawMimeType = file.type || extractMimeType({}, req) || 'application/octet-stream';
    const { fileName: finalFileName, mimeType } = ensureExtension(
      fileName,
      fileBuffer,
      rawMimeType,
    );
    const fileType = getFileType(mimeType, finalFileName);

    if (!checkFileSize(fileBuffer.byteLength, fileType)) {
      return Response.json({ error: `File size exceeds ${fileType} limit` }, { status: 400 });
    }

    // Write file to disk temporarily
    tempPath = `/tmp/teleuploader-${nanoid()}`;
    await Bun.write(tempPath, fileBuffer);

    const fileStream = createReadStream(tempPath);
    const result = await forwardToStorage(fileStream, finalFileName, fileType);
    const bot = getBot();
    const fileInfo = (await bot.telegram.getFile(result.telegramFileId)) as any;

    const uploaded = {
      publicId: nanoid(),
      telegramFileId: result.telegramFileId,
      telegramFileUniqueId: result.telegramFileUniqueId,
      storageChatId: config.storageChatId,
      storageMessageId: result.storageMessageId,
      fileName: finalFileName,
      mimeType: fileInfo.mime_type || mimeType || 'application/octet-stream',
      sizeBytes: fileInfo.file_size || fileBuffer.byteLength,
      fileType: fileType,
      uploaderId: 0,
      fileHash: hash,
      createdAt: new Date(),
      updatedAt: new Date(),
    };

    await db.insert(fileSchema).values(uploaded);

    const responsePayload = {
      public_id: uploaded.publicId,
      telegram_file_id: uploaded.telegramFileId,
      telegram_file_unique_id: uploaded.telegramFileUniqueId,
      storage_chat_id: uploaded.storageChatId,
      storage_message_id: uploaded.storageMessageId,
      file_name: uploaded.fileName,
      mime_type: uploaded.mimeType,
      size_bytes: uploaded.sizeBytes,
      file_type: uploaded.fileType,
      uploader_id: uploaded.uploaderId,
      created_at: uploaded.createdAt.toISOString(),
      download_url: `${config.baseUrl}/f/${uploaded.publicId}`,
    };

    return Response.json(responsePayload, { status: 200 });
  } catch (error: any) {
    logger.error('Multipart upload error', { error: error.message });
    return Response.json({ error: error.message }, { status: 500 });
  } finally {
    if (tempPath) {
      const p = tempPath;
      setTimeout(() => {
        try {
          unlinkSync(p);
        } catch {}
      }, 50);
    }
  }
};

const handleJSONUpload = async (req: Request): Promise<Response> => {
  let tempPath = '';
  try {
    const { file, fileName = 'file' } = (await req.json()) as any;

    if (!file || typeof file !== 'string') {
      return Response.json(
        { error: 'Invalid JSON. Must include "file" (base64) and optional "fileName"' },
        { status: 400 },
      );
    }

    let base64Data = file;
    let rawMimeType = 'application/octet-stream';
    if (file.startsWith('data:')) {
      const match = file.match(/^data:([^;]+);base64,(.+)$/);
      if (match) {
        rawMimeType = match[1];
        base64Data = match[2];
      }
    }

    const fileBytes = Buffer.from(base64Data, 'base64');
    const hash = computeHash(fileBytes);

    // Check for duplicate in DB
    const existing = await db
      .select()
      .from(fileSchema)
      .where(eq(fileSchema.fileHash, hash))
      .limit(1);

    if (existing.length > 0) {
      const existingFile = existing[0];
      const responsePayload = {
        public_id: existingFile.publicId,
        telegram_file_id: existingFile.telegramFileId,
        telegram_file_unique_id: existingFile.telegramFileUniqueId,
        storage_chat_id: existingFile.storageChatId,
        storage_message_id: existingFile.storageMessageId,
        file_name: existingFile.fileName,
        mime_type: existingFile.mimeType,
        size_bytes: existingFile.sizeBytes,
        file_type: existingFile.fileType,
        uploader_id: existingFile.uploaderId,
        created_at:
          existingFile.createdAt instanceof Date
            ? existingFile.createdAt.toISOString()
            : new Date(existingFile.createdAt).toISOString(),
        download_url: `${config.baseUrl}/f/${existingFile.publicId}`,
      };
      return Response.json(responsePayload, { status: 200 });
    }

    const { fileName: finalFileName, mimeType } = ensureExtension(fileName, fileBytes, rawMimeType);
    const fileType =
      getFileType(mimeType, finalFileName) === 'application'
        ? 'document'
        : getFileType(mimeType, finalFileName);

    if (!checkFileSize(fileBytes.byteLength, fileType)) {
      return Response.json({ error: `File size exceeds ${fileType} limit` }, { status: 400 });
    }

    // Write file to disk temporarily
    tempPath = `/tmp/teleuploader-${nanoid()}`;
    await Bun.write(tempPath, fileBytes);

    const fileStream = createReadStream(tempPath);
    const result = await forwardToStorage(fileStream, finalFileName, fileType);
    const bot = getBot();
    const fileInfo = (await bot.telegram.getFile(result.telegramFileId)) as any;

    const uploaded = {
      publicId: nanoid(),
      telegramFileId: result.telegramFileId,
      telegramFileUniqueId: result.telegramFileUniqueId,
      storageChatId: config.storageChatId,
      storageMessageId: result.storageMessageId,
      fileName: finalFileName,
      mimeType: fileInfo.mime_type || mimeType || 'application/octet-stream',
      sizeBytes: fileInfo.file_size || fileBytes.byteLength,
      fileType: fileType,
      uploaderId: 0,
      fileHash: hash,
      createdAt: new Date(),
      updatedAt: new Date(),
    };

    await db.insert(fileSchema).values(uploaded);

    const responsePayload = {
      public_id: uploaded.publicId,
      telegram_file_id: uploaded.telegramFileId,
      telegram_file_unique_id: uploaded.telegramFileUniqueId,
      storage_chat_id: uploaded.storageChatId,
      storage_message_id: uploaded.storageMessageId,
      file_name: uploaded.fileName,
      mime_type: uploaded.mimeType,
      size_bytes: uploaded.sizeBytes,
      file_type: uploaded.fileType,
      uploader_id: uploaded.uploaderId,
      created_at: uploaded.createdAt.toISOString(),
      download_url: `${config.baseUrl}/f/${uploaded.publicId}`,
    };

    return Response.json(responsePayload, { status: 200 });
  } catch (error: any) {
    logger.error('JSON upload error', { error: error.message });
    return Response.json({ error: error.message }, { status: 500 });
  } finally {
    if (tempPath) {
      const p = tempPath;
      setTimeout(() => {
        try {
          unlinkSync(p);
        } catch {}
      }, 50);
    }
  }
};
5x <100644 file.ts 12NFR)9 100644 logger.ts fc{_5>r(100644 rateLimit.ts tZl.A<}*100644 telegram.ts S:O{¼cc	100644 telegramQueue.ts q)=o@9);}p%ȾIPx .QE5LfYڇpe,x"7
Name = (msg: any, request: any): string => {
  if (request?.headers?.['x-file-name']) {
    return request.headers['x-file-name'];
  }Sany, request: any): string => {
  if (request?.headers?.['x-mime-type']) {
    return request.headers['x-mime-type'];
  }
  retoS_]x
6D=!	 = async /anyO2anyrany.message ||<`B// 429 rate limit hit! Rotate bot index instantly
      const prev3A
const nextoC?5*!anyRa$: any = await enqueueUpload(async ()WBphoto') {
          return?Photo}$={
            caption: fileName,
          });
        } else{
          return?Audio}$j{
            caption: fileName,
          });
        } else if (fileType === 'video') {
          return?Video}$j{
            caption: fileName,
          });
        } else if (fileType === 'voice') {
          return?Voice}${
            caption: fileName,
          });
        } else if (fileType === 'animation') {
          return activeBot.telegrP	Animation}$l{
            caption: fileName,
          });
        } else if (fileType === 'sticker') {
          return?Sticker}"$);
        } else {
          return?Document}${
           T,
          });
        }
      });

      // Advance round-robin index for next job0>m)let uploadedFile: any;
  OIuploadedFile = result.document;
    else if (result.photo) uploadedFile =  else if (result.video) uploadedFile = result.video;
    else if (result.audio) uploadedFile = result.audio;
    else if (resu/lt.voice) uploadedFile = result.voice;
    else1euploadedFile = result.animation;
    else if (result.sticker) uploadedFile = result.sticker;
    elseLuploadedFile = result.video_note;
    else uploadedFile = result[fileType];
Vany{; fileName, error: error.messageS: any = await enqueueUpload(async () => {
      const mediaGroup: any = items.map((item) => {
        let type: 'photo' | 'video' | 'audio' | 'document' = 'document';
        if (item.fileType === 'photo') type = 'photo';
        else if (item.fileType === 'video') type = 'video';
        else if (item.fileType === 'audio') type = 'audio';

        return {
          type,
          media: item.fileId,
            };
      }Rreturn?"lm8)msg = messages[i];
      const fileType =!;
      let uploadedFile: any;

      if (msg.document) uploadedFile = msg.document;
      else if (msg.photo) uploadedFile = msg    else if (msg.video) uploadedFile = msg.video;
      else if (msg.audio) uploadedFile = msg.audio;
      else if (msg.voice) uploadedFile = msg.voice;
      else if (msg.animation) uploadedFile = msg.animation;
      else if (msg.sticker) uploadedFile = msg.sticker;
      else if (msg.video_note) uploadedFile = msg.video_note;
      else uploadedFile = msg[fileType];

      tany{( error: error.message%: any = await result.json(): any = await fileResult.json()z any{ ! error: error.message%2"('I>x100644 bootstrap.test.ts ĕJBt*ۧfW100644 bot.test.ts o{ePc J$100644 db.test.ts g l<)6Ϫ	c+100644 env.test.ts WZף UK5~۫100644 file.test.ts ͹FT|:ā100644 files.test.ts fxqybq100644 health.test.ts !X"
Tg100644 rateLimit.test.ts e[r?Z[5
(g100644 swagger.test.ts TF0l~*uwv/`v100644 telegram.test.ts Cm.Eg[v*ď100644 telegramQueue.test.ts {j`\z 100644 upload.test.ts hT.lrnTC@e̒x ('XVFz	Ko撍x; // @ts-nocheck
N)E<7?});
});
-&4+xO=:// @ts-nocheck
[/tech'mock.module('telegraf', () => {
  return {
    Telegraf: class {
      constructor(token) {
        this.token = token;
       ( this.launch = mockLaunch;
        this.I      this.on = mockOn;
        this.use = mockUse;
      }
    },
  };
}Rdconst mockLimit0[]));
const mockWhere = mock(() => ({
  limit: mockLimit,
}));
const mockFrom = mock(() => ({
  where: mockWhere,
}));
const mo/ckSelect = mock(() => ({
  from: mockFrom,
}));~K^  select: mockSelect,
  },
  files: {
    telegramFileUniqueId: 'telegram_file_unique_id',
  }3QLimit!	!Limit.mockResolvedValue([]);
    	:[1]7pF$W7p87p8W<4// Mock DB to return an existing match
    mockLimitm[
      {
  ,(.  },
    ]);
o9Zi7p87pin$ xI// @ts-nocheck
import { describe, it, expect, beforeAll } from "bun:test"/"../src/env";

describe("a ", () => {
  it("&
"botToken"Q%"storageChatId"Q%	"baseUrl"Q%"databaseUrl"Q%"port"Q%	"nodeEnv"Q%
"logLevel"Q%"rateLimitWindowMs"Q%&"rateLimitMaxRequests");
  });

  it("08$);
  });

  it("H+"number"Q[<, 10));
  });

  it("5"number"P"test", "development"x"info");
  });

  it("<k,"'3k	30);
  });
});
Ox<"Nexpect, it } from 'bun:test';
import { config } from '../src/env';

describe('{ ', () => {
  it('&'
'botToken'=%'storageChatId'=%	'baseUrl'=%'databaseUrl'=%'port'=%	'nodeEnv'=%
'logLevel'=%'rateLimitWindowMs'=%&'rateLimitMaxRequests');
  });

  it('8'O'1H'"'number'd'5'port).toBe('number'P'test', 'development'x'info');
  });

  it('}<'\	#'<'\*{+P;x6-// @ts-nocheck
NAmockSelect = mock(() => ({
  from: mock(() => ({
    where: mock(mock(])),
    })),
  })),
}));

mock.module('../src/db/index', () => ({
  db: {
    select: mockSelect,
  },
  files: {
    publicIdC: {
      equals: (val) => ({ type: 'equals', value: val }),
    },rateLimit
const mockCheckRateLimit = mock(() => true);
mock.module('../src/utils/rateLimit', () => ({
  checkRateLimit: mockCheckRateLimit,
})#Fany, handleFileInfo: any_CheckRateLimitUV	>29 if rate limit is exceededCheckRateLimi%false);
      const req = new Request	);
    'test-id' };
229.json(N"Rate limit exceeded'"new Request/Q'missing-id' }3@.json(N",,direct to telegram file url if file is found"Mnew Request	);
    'test-id' }3302);O/U(
        'https://api.telegram.org/file/bot123456:ABC-DEF/photos/file_0.jpg',
      new Request	);
    'test-id' }3".json(Nynew Request);
    'missing-id' }$:K.json(N"@if file is found-new Request*);
    'test-id' }$:)".json(
uploader_i/new Request*);
    'test-id' }$:)".json(NC	'});
});
wAx  

// @ts-nocheck
!anyS	jx
 #+/xm 
any)B['/api/upload']P*"
      'multipart/form-data',
    ['/api/upload']P?});
*!9x? 76// @ts-nocheck
^"any, getFileInfo: any, getBot: any[
4x S@<// @ts-nocheck
c
any[] = []7{any		res.json(	res.json(:!-kB	res.json(%res.json();=+	res.json(:	res.json(:.*d:%xT100644 .dockerignore mm=BP<\J5Ӫ1>100644 .env.example 8!`aHu?40000 .github =q1W6#.100644 .gitignore -'NW|ݟ0ad/
[100644 CLAUDE.md vLٝi~~r(	100644 Dockerfile "2Ɏ/Vr1/*@y100644 README.md k2`bj6A100644 biome.json x4c2mTM100644 bun.lock 9D%7aqR-_100644 docker-compose.yml %}5v=U[40000 docs 8<O dU&42?100644 package.json ]DW_pS'wэI100644 schema.sql Zwjb{}(TT40000 src &qt85j@JͶj40000 test 7k(8qG%Y100644 tsconfig.json \/@Zax x=
/ϐݠD,'ёQYjZ
ǈ+@!/sՔ%MGbZkeX3g̈1O
c@>&[I40000 test E__&.zzaJ
c)+)9bxW FM1U^;zPñ:30m]G:{Q/40000 test 9>|-OX-硐bN_+)$fx? 3EoKƸfռ,V40000 test ErG{ښ#+)lRx !~mx(HE8Hconst isDocument =
      finalFileName.endsWith('.pdf') ||
      finalFileName.endsWith('.txt') ||
      !['photo', 'video', 'a.udio', 'voice', 'animation'].includes(fileType1!Buffer, finalFileName, isDocumenth.<glconst isDocument =
      finalFileName.endsWith('.pdf') ||
      finalFileName.endsWith('.txt') ||
      !['photo', 'video', 'a.udio', 'voice', 'animation'].includes(fileType1 Bytes, finalFileName, isDocument
};
qgx4 EB(;fileType!%fileType!p+x? <<Wn/'mocked-nanoid-id'vBe?Be?&3xW Dqy9i6m:30m]G:{Q/40000 test 9>|-OX-硐bN_+)	H&Lx M<FlxUkontrol oleh antrian in-memory (`TelegramQueue` di `src/utils/telegramQueue.ts`) dengan batas konkurensi = 2 untuk mencegah rate limit berlebih.
- Mendukung pengiriman batch upload (Media Group) di `src/bot.ts` dengan membungkus berkas yang memiliki `media_group_id` yang sama menggunakan debounce timer 600ms, lalu mengunggahnya secara sekaligus via `forwardMediaGroupToStorage` di `src/utils/telegram.ts`M$jx? "100644 bot.ts  (}{ؿ&*0"p:WU'SҟzEF׺DxJtype QueueTask<T> = {
  task: () => Promise<T>;
  resolve: (value: T | PromiseLike<T>) => void;
  reject: (reason?: any) => void;
};

class TelegramQueue {
  private activeCount = 0;
  private queue: QueueTask<any>[] = [];
  private concurrency: number;

  constructor(concurrency = 2) {
    this.concurrency = concurrency;
  }

  public enqueue<T>(task: () => Promise<T>): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      this.queue.push({ task, resolve, reject });
      this.processNext();
    });
  }

  private async processNext(): Promise<void> {
    if (this.activeCount >= this.concurrency || this.queue.length === 0) {
      return;
    }

    const item = this.queue.shift()!;
    this.activeCount++;

    try {
      const result = await item.task();
      item.resolve(result);
    } catch (error) {
      item.reject(error);
    } finally {
      this.activeCount--;
      this.processNext();
    }
  }
}

const telegramQueue = new TelegramQueue(2);

export const enqueueUpload = <T>(task: () => Promise<T>): Promise<T> => {
  return telegramQueue.enqueue(task);
};
+]
RxN iߟRm|E6HzS2)ہzcB~>nV% c1Ȭm V7!b	Ex b70// @ts-nocheck
%"any, getFileInfo: any, getBot: anyDphoto data')l%jpphoto data')l%jpphoto data')l%jpT4`?xV <6(35g&$pDtxttype).toBe('documentfYQtxt	U62x8 0|Q:Vi:(y1qrX-1HWlx LSD`x W֡5@'P@Sx Z9qX,E>,hTx =,	B?-ztxW 0|Q:Vi:3H6BP;P,>c40000 test PEYޯOR`v|$t+)&x 9100644 bot.ts euueҏ@x׻40000 db |/]ּCOC> 100644 env.ts iM6'p? +}W
100644 index.ts YDe.|~8`P40000 routes 6hNw&_vfZҸ40000 utils )"Ņܧo8~PG@BMYx+ %? []
8.split(',')
&4[|x Z:ocTu>ee'4,Ox =+1<QH5	Bx  5H΃C)'IDtgxo <&xgӮ1msՔ%MGbZkeX3OL)WAlkNP^$B-M40000 test *cF^T/<+)$/eEx !jDtech8Bx lhelper `withRetry50rx eFqv&5ȧ"U	 x k"Rs1I4Int&
!!, 10) || 60000O
Intt
$!, 10) || 30g?G ...config, botToken: `${config.botToken?.substring(0, 10)}...` },
});
,36x OcCP.>7/4x=#e)
const bot = new Telegraf(config.botToken*POconst withRetry = async <T>(fn: () => Promise<T>, retries = 5): Promise<T> => {Ofn() i     if (match && retries > 0) {
F4Clogger.warn(`Telegram 429 Too Many Requests detected. Retrying afte$});
Q"return withRetry(fn, retries - 1);O=let result: any;

    result = await withRetry(() => b
	C
}));
!
0audio') {
      result = await withRetry(() => b	C
}));
!
- result = await withRetry(() => b`
C
}));
!

 result = await withRetry(() => bC
}));
!
 result = await withRetry(() => bG
}));
!
G result = await withRetry(() => bk:6);
    } else {
      result = await withRetry(() => bF#
}));
    }	<;
?)kvx #xo =8!`aHu?QYvLٝi~~r(	:3&qt85j@JͶj40000 test UA r,3mLva+)61nx )L > 	x h100644 file.ts 12NFR)9 100644 logger.ts fc{_5>r(100644 rateLimit.ts ܴĒgh"100644 telegram.ts :ib2Ue**͔'d-t!8Fxmimport logger from './logger';

interface RateLimitRecord {
  count: number;
  reset: number;
}

const rateLimitMap = new Map<string, RateLimitRecord>();

export const checkRateLimit = (key: string): boolean => {
  const now = Date.now();
  const windowMs = parseInt(process.env.RATE_LIMIT_WINDOW_MS!, 10) || 60000;
  const maxRequests = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS!, 10) || 30;

  if (!rateLimitMap.has(key)) {
    rateLimitMap.set(key, { count: 0, reset: now + windowMs });
  }

  const record = rateLimitMap.get(key)!;

  if (now > record.reset) {
    record.count = 0;
    record.reset = now + windowMs;
  }

  if (record.count >= maxRequests) {
    logger.warn('Rate limit exceeded', { key, count: record.count, reset: record.reset });
    return false;
  }

  record.count++;
  return true;
};

export const cleanupRateLimitCache = (): void => {
  const now = Date.now();
  const keysToDelete: string[] = [];

  for (const [key, record] of rateLimitMap.entries()) {
    if (now > record.reset) {
      keysToDelete.push(key);
    }
  }

  for (const key of keysToDelete) {
    rateLimitMap.delete(key);
  }
};
ܥlXxT #޳<5__ c_>?_#ct!;$	l	B
x \@:#0aqrO!;100644 db.test.ts BOUAAe^W%Cz;"7}ө1n}$đldPE,2I8Rn3|JfJICHhR
vRx)l*v#sYz%C~(T?C2x8 fg l<)6Ϫ	c+zC,$t*4ѓ*)GxK @ bva6ˎ	(=
Ta͹FT|:ānV% c1Ȭm ?(#Yx8 @o{ePc J$T8fѯ|OᏓ*bx// @ts-nocheck
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
import { checkRateLimit, cleanupRateLimitCache } from "../src/utils/rateLimit";
import logger from "../src/utils/logger";

// Spy on logger.warn
const warnSpy = spyOn(logger, "warn");

describe("Rate Limiter", () => {
  beforeEach(() => {
    warnSpy.mockClear();
    // Set custom env variables for predictable tests
    process.env.RATE_LIMIT_WINDOW_MS = "100"; // 100ms window
    process.env.RATE_LIMIT_MAX_REQUESTS = "3"; // max 3 requests
  });

  afterEach(() => {
    delete process.env.RATE_LIMIT_WINDOW_MS;
    delete process.env.RATE_LIMIT_MAX_REQUESTS;
  });

  it("should allow requests under the limit", () => {
    const key = "user-1";
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);
    expect(warnSpy).not.toHaveBeenCalled();
  });

  it("should block requests exceeding the limit and log a warning", () => {
    const key = "user-2";
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);

    // 4th request exceeds limit of 3
    expect(checkRateLimit(key)).toBe(false);
    expect(warnSpy).toHaveBeenCalled();
    const callArgs = warnSpy.mock.calls[0];
    expect(callArgs[0]).toBe("Rate limit exceeded");
    expect(callArgs[1].key).toBe(key);
  });

  it("should reset request count after the window passes", async () => {
    const key = "user-3";
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(false); // blocked

    // Wait for window to expire (100ms)
    await new Promise((resolve) => setTimeout(resolve, 110));

    // Should be allowed again
    expect(checkRateLimit(key)).toBe(true);
  });

  it("should cleanup rate limit cache of expired keys", async () => {
    const key1 = "cleanup-1";
    const key2 = "cleanup-2";

    // Populate keys
    expect(checkRateLimit(key1)).toBe(true);
    expect(checkRateLimit(key2)).toBe(true);

    // Run cleanup immediately (none should be expired yet as 100ms hasn't passed)
    cleanupRateLimitCache();

    // Verify still tracked (counts shouldn't reset, e.g., if we consume remaining limits)
    expect(checkRateLimit(key1)).toBe(true); // request 2
    expect(checkRateLimit(key1)).toBe(true); // request 3
    expect(checkRateLimit(key1)).toBe(false); // request 4 (blocked)

    // Wait for window to expire
    await new Promise((resolve) => setTimeout(resolve, 110));

    // Run cleanup
    cleanupRateLimitCache();

    // Since they were deleted from the map, they should be initialized as new records
    // If they were cleaned up, we should be able to do 3 requests again
    expect(checkRateLimit(key1)).toBe(true); // 1
    expect(checkRateLimit(key1)).toBe(true); // 2
    expect(checkRateLimit(key1)).toBe(true); // 3
    expect(checkRateLimit(key1)).toBe(false); // 4 (blocked)
  });
});
فxSafterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import logger from '../src/utils/logger';
import { checkRs''../src/utils/rateLimit'7!'warn');

describe('Rate Limiter'4'100'<'3''%''user-1''PXP	$';''user-2''PXP'Rate limit exceeded'k6'2' = 'user-3''PXP,'|/'!)'cleanup-1';
    const key2 = 'cleanup-2'+_Dx 0(92$'6x& 40000 superpowers VTVH-x@ 40000 plans I4bE~l
*<40000 specs z3uD>Onx8 @å"8%tP.GT8fѯ|OᏓ*4[xX :92'8605908810:AAFpUzlIBktfd_7wpEj7zMJob2CFxvG-ZGY';
= '-1003996572954Mx? 3?*X~"$'}G40000 test ,0hhN)WT~L`K+)T%x 9100644 bot.ts Y{]0R?9^440000 db |/]ּCOC> 100644 env.ts #/|Z+<%$Y c100644 index.ts YDe.|~8`P40000 routes ۱4Q)REs_7@U40000 utils 5.΍IC HgZ+dLiSx v|¶p)|R~윑 :+x J)!&	c.x JZ/Rk#G/_ CV<	Kx 3?b9o)ҕp!@xDe)
const bot = new Telegraf(config.botToken*Puғ*!0any,
  fileName: string,
  forceDocument = falsez39caption = forceDocument ? `📁 ${fileName}` : fileName;
GIconst result: any = forceDocument
      ? await bot.telegram.sendDocument}$1{ caption })
      : await bot.telegram.sendPhoto}${ caption });
!forceDocument ? result.document :Vany{; fileName, error: error.message%: any = await result.json(): any = await fileResult.json()z any{ ! error: error.message%2"";
!YXx 99$0
e._.xG 0(k#d%false+true);

false^ۓ2$'\Vx ((kd*Dk{x =8!`aHu?QY:vLٝi~~r(	100644 Dockerfile ]4erroPhGDrTI[Z`W2%-Ab2]1100644 schema.sql _oQEûҭ/{n"[40000 src }+LɢpZ40000 test GxEŠ100644 t3!GWm	xX 8<O dU&42?X3?*X~"$'}G40000 test ,0hhN)WT~L`K+)"jQx
 Cktx? <L psql $DATABASE_URL -f schema.sqlOkՓYyOx" Bbun src/db/migrate.tsb1cx- CREATE TABLEMe6GD?:/H<x  rTI[Z`x& 40000 superpowers ͌sTx@ 40000 plans jbZRn!Ǳ40000 specs z|Uח7.6x} 100644 2026-05-17-telegram-uploader-backend-plan.md yGkǇ[κxw=100644 2026-05-18-deploy-plan.md C<ycv+,ax ~100644 2026-05-17-telegram-uploader-backend-design.md ,}+5s{jzc100644 2026-05-18-deploy-design.md .&1J~^0ݗэ/l/x ,_2zߥ;ؿaB06xs]LB_x<6^rTI[Z`W2%-Ab2]1100644 schema.sql _oQEûҭ/{n"[40000 src }+LɢpZ40000 test 3,a"JnE=dV100644 t3!Xbxo _=q1W6#.ڑs]]4erroPhGD3HYth5i<ϡk40000 test z"	Uʯ_&pf+)1N2P~x? 31XFNb"`40000 test <M	3E:Î+)yx ~+ӇΚHEH溂Bxt EEK checkFileSize, computeHash, ensureExtension, extractMimeType, getFileType ;251hгo
251hX	!*@*x NŎP!CBJ97
txV ))HK checkFileSize, computeHash, extractFileName, extractMimeType, getFileType f x 9100644 bot.ts ^@;^ZJP_40000 db |/]ּCOC> 100644 env.ts #/|Z+<%$Y c100644 index.ts YDe.|~8`P40000 routes ۱4Q)REs_7@U40000 utils 5.΍IC HgZ+dOKE<x tJ"&fFT 'document' | 'photo' | 'video' | 'audio' | 'voice' | 'animation' = ctx
            9
'animation>=!+k1,sx NŎP!CBJ97
t.x nV% c1Ȭm 
}?x,=!// @ts-nocheck
[P2'8605908810:AAFpUzlIBktfd_7wpEj7zMJob2CFxvG-ZGY';
L= '-1003996572954}tech'mock.module('telegraf', () => {
  return {
    Telegraf: class {
      constructor(token) {
        this.token = token;
       ( this.launch = mockLaunch;
        this.I      this.on = mockOn;
        this.use = mockUse;
      }
    },
  };
}Rd`;J	]A:[1]7pF$W7p8ȓ<7p8W/w`$x? 3m+`c=aoWQ840000 test %.tr:3vuhp
Kv+)E#x 9100644 bot.ts ^@;^ZJP_40000 db |/]ּCOC> 100644 env.ts #/|Z+<%$Y c100644 index.ts YDe.|~8`P40000 routes Ȍ7%At_D40000 utils 5.΍IC HgZ+dWK'x ʅzww?cdx' E2"<V
`R
8I`!"{@'SdxWF// @ts-nocheck
$5
// Mock db%/'mocked-nanoid-id'(F	(() =>
  o(		  }),
  )T!any	>
	res.json(o%3	res.json(%$telegram_file_id).toBe('tg-file.).toBe('tg-unique-id-abcQ/testM/+document{	res.json(oóMres.json();@@txt#<p@mHx? 3槮Lw<rzGn͸>40000 test  >AptA,fRђD+)x 9100644 bot.ts ^@;^ZJP_40000 db |/]ּCOC> 100644 env.ts #/|Z+<%$Y c100644 index.ts YDe.|~8`P40000 routes Ȍ7%At_D40000 utils SdLWXN}N,	x h100644 file.ts ~A0rvƧ-~L 100644 logger.ts fc{_5>r(100644 rateLimit.ts ܴĒgh"100644 telegram.ts 3?b9o)ҿ};/x kƳh||`qRxi )!H> checkFileSize, extractFileName, extractMimeType, getFileType Xn	zO
});
P$x D]4erroPhGDrTI[Z`W2%-Ab2]1100644 schema.sql _oQEûҭ/{n"[40000 src  (=40000 test GxEŠ100644 t3!Scx +zrN)
	U-?kx oo[҄ @H\p,F)
\nVx Xa1:mx  C,$t*4ѓ*kNx 	Ox 9100644 bot.ts ^@;^ZJP_40000 db zrN)
	U-100644 env.ts #/|Z+<%$Y c100644 index.ts YDe.|~8`P40000 routes n0 wH7{=40000 utils nL';QR	1Ҳd9B{Jx &"O#^7z%Cx\H.Z{- checkFileSize, extractMimeType, getFileType +Um
Y#const isDocument =
      fileName.endsWith('.pdf') ||
      fileName.endsWith('.txt') ||
      !['photo', 'video', 'audio', 'vo$ice', 'animation'].includes(fileType1Buffer, fileName, isDocumenttIhG' !|.<g1Buffer.from(file, 'base64');
    const mimeType =)Q&Kconst isDocument =
      fileName.endsWith('.pdf') ||
      fileName.endsWith('.txt') ||
      !['photo', 'video', 'audio', 'vo$ice', 'animation'].includes(fileType1Bytes, fileName, isDocumenttIȳ' 
};
Ƅ	x h100644 file.ts .V/ Ou`,C100644 logger.ts fc{_5>r(100644 rateLimit.ts ܴĒgh"100644 telegram.ts 3?b9o)p:x17Ɠ]||Γany, request: any): string => {
  if (request?.headers?.['x-file-name']) {
    return request.headers['x-file-name'];
  }
  retSany, request: any): string => {
  if (request?.headers?.['x-mime-type']) {
    return request.headers['x-mime-type'];
  }
  retg xW _!Z]6tdh8@B}s3}+LɢpZ40000 test GxEŠۓ+)g)x$ 40000 workflows #Y4eHu`^(px& 100644 deploy.yml ;Tqh_y0?KZ  j:x
 Ct,x8 _!Z]6tdh8@B}suvȠI^
+)/x  WoZ|=3IK*x7 _!Z]6tdh8@B}s]]4erroPhGDpdCOx ?7(// @ts-nocheck
%B$"any, getFileInfo: any, getBot: anyDphoto data')l%jpAfalse+trueJ-1003996572954{photo data')l%jp:false0x:k=x$ 40000 workflows vppȐDYN$x& 100644 deploy.yml 56Y"ǉYqCx Ct1.1SH?x !1.1-!H1.1-iCks!xP _2zߥ;ؿaB06xs]LB_x<6^3tvNu[[.+)&
x \@:#0aqrO!;100644 db.test.ts N',1QneTz;"7}ө1n}$đldPE,2I8Rn3|JfJICHhR
vRx)l*v#sYz%C~(.?jx) o(schemaFiles$	O7,i)xG )h40000 docs G#MnJN=\\^ZewtvNu[[.+)4x& 40000 superpowers 4inҴ~YN ʠx@ 40000 plans ^p@mU+Q40000 specs I*9 йtBdx }HH8ex JJ$x# QstvNu[[.+)9Ix 6Qs]LB_x<6^o40000 docs rTI[Z`W2%-Ab2]1100644 schema.sql _oQEûҭ/{n"[40000 src }+LɢpZ40000 test tvNu[[.100644 t3!~VYx  <G#MnJN=\\^ZePƠ/x= )zh.100644 docker-compose.yml %}5v=U[1f+kzx )z;9+x< )zɛuW*hl ;ںx0 S'?Q zoG9CH9x +}Z'F̅T@T{ײ?"xI 100644 index.ts vQfzZx.v100644 schema.ts ҄ @H\p,F)3Nx? Y3g~F*9d:i40000 test Tc%8X
g蓌)m>Qx @?64N wCf/x Đ&-aJk^%|J;
x Pinput: any = forceDocument
      ? { document: fileChunk, caption }
      : { photo: [fileChunk], caption };

    const result  ainput)x-%92x  F։E뽻*5HBx (![E*/x  YN1x͇*J5;dmH*x ar~7}; ݊[Yx \ 

J=҈--9QG/o%
4YxI GF"	tele.asepharyana.tech/f/xYz123!
%},
  });
};
/xX 	)vwh|tq=V)03T@+iɨ΁xN40000 test 'Qi?u=N6)(Ol	x p"vx 9100644 bot.ts ^@;^ZJP_40000 db }Z'F̅T@T{ײ100644 env.ts #/|Z+<%$Y c100644 index.ts m{JͺcoŞ958, 40000 routes /V7%kH\uN-6^40000 utils @?64N wCf5?Ighx 
,=7hRx nPv	k:x P{OI#x `}]"٣|x ̯ %
T`7
Xx   "7_Xrgd)L=Kx !src/index.t+EVE\xV j}p}V	e&13=<MgN-40000 test {>R*ʄ
e)M$kxQ ,&1.8.3/schema.json",
  "organizeImportsd,
0 RD
    }
  }
}
.gx 9100644 bot.ts 7->{س|40000 db iNnٟ MA|100644 env.ts v[#[u	'100644 index.ts oNwJ%	֮40000 routes N֪8TcOd840000 utils ="~ǽCkQ+LxMimport { Telegraf, type Context@'[2	!T(Nw)>mtry {
^ .message.document ? 'document' :!photo ? 'photo' :! ? 'video' :! ? 'audio' :!6oice ? 'voice' : 'animation';

        const fileObj =kX;const fileName =0.Z..0. ..9const maxSize = fileType === 'photo' ? 10 * 1024 * 1024 :!i ? 200 * 1024 * 1024 :!ivoice' ? 200 * 1024 * 1024$>%eRB
	$0	O	o	1	=	/
5I
i
=
 

#!2
        };

Y?7P%8
ax&= publicId, fileType, fileName, uploader: ctx.from.id });
    @7eF}
    }OL y!e! + '...'*gxI 100644 index.ts ֗`zh
@6~T100644 schema.ts %Sao/^9eR/ix	 d5x[ import { pgTable, text, bigind1*import type { InferSelectModel, InferInser*n{ xx( 72K n! + '...' } });

Tx\ 	$T(I&"&.${ handleFileRedirect, handleFileInfoS{<Q@0

    }
  }?}xn 100644 files.ts Gˁ-AL8Q=100644 health.ts An=@6wu6[100644 upload.ts |;krs'c\utuCx[*3Rx S/+/.".select().from(fileSchema)*0˳N'Location': redirectUrl
      }.select().from(fileSchema)*0{
&C e " $created_at:	# ? file.createdAtT	)
    }, { status: 2005	xJG`x ?&##en88	mx j../*1	!5import { getFileType,{ Y$co@B
}<  ? 'document'ۓ@wyB
}몃4l	x h100644 file.ts (gB"Q&cc!100644 logger.ts gT`x]ɲU:SE100644 rateLimit.ts ܴĒgh"100644 telegram.ts i3ō,W,jP`i/9>
%x Smsg.document?.fileName ||.=     msg.voice?.fileName || msg.animation?.fileName || 'file'].% msg.audio?.mimeType ||
     o;
};
~7+x3 ,M%!e#=Xl1wx`100644 bootstrap.test.ts Iɛxqb֦F0lpq4100644 bot.test.ts QKe-AeLfNuO100644 db.test.ts 'fOj^"ygg>100644 env.test.ts $$poWS{&h100644 file.test.ts 'Qρ9&z:100644 files.test.ts \Yqc>G0xOW100644 health.test.ts 2ыJٵcH1100644 rateLimit.test.ts W
2!n	10u100644 telegram.test.ts *
˵䍣F&4G100644 upload.test.ts }H?Sr?U(Q%] tx Bdescribe, it, expect, mock, beforeEach, afterAll } from "bun:test"Zxc@ Promise.resolve({
  stop: mock()
}));

mock.module("../src/bot"#)
}));

mock.module("../src/routes/upload"(
}));

mock.module("../src/routes/files").)
}));

mock.module("../src/routes/health"!+
}));

mock.module("../src/utils/rateLimit"*"
}));

describe("Bootstrap Server""-"!"../src/index""port""routes"3"/api/upload"3"/f/:public_id"3""3"/health");
  });
});
*=%x-! Idescribe, it, expect, mock, spyOn, beforeEach, afterAll } from "bun:test"a"../src/utils/logger"/"."#"-1003996572954""7"U
"telegraf"d#
}));
mock.module("../src/db/index"q)
  },
  files: {}G<&{
  telegramFileId: "stored_file_id",
1"stored_unique_id",
_
}));
mock.module(""3%"info""*"error");

describe("Telegram Bot Handler"P
"[$"6"../src/bot"r"start";I;"document", "photo", "video", "audio", "voice", "animation"g"should handle /start command"6"../src/bot"$+call => call[0] === "start"	c1	V"Halo"));
  });

  it("	."6"../src/bot"Ó(	"doc_123"P"doc_uniq_123"z2"application/pdf""cv.pdf"
        }y' 1["doc_123", "cv.pdf"YK="File berhasil diupload"), expect.any(Object));
  });

  it("`."6"../src/bot" {
          file_id: "photo_123"P"photo_uniq_123",
LI"image/jpeg"
        }]y' 1F[	"exceeds"l<*
Kx \"it, expect } from "bun:test">"../src/db/index"k'>"../src/db/schema";

describe("Database Layer", () => {
  it("""5"oi"%"T35yxj! "3it, expect } from "bun:test";
import { getFileType,H0k } from "../src/utils/file";

describe("File Utilities", () => {
  describe("getFileType", () => {
    it(")" "video/mp4", "")).toBe("video");8"video/quicktime", "")).toBe("video");
    });

    it(")"!"audio/mpeg", "")).toBe("audio");2"audio/ogg", "")).toBe("audio");
    });

    it("i1")"image/jpeg", "my photo")).toBe("photo");."image/png", "cool image.png")).toBe("photo");-"image/gif", "funny.gif")).toBe("animation");?"image/png", "funny gif")).toBe("animation");
    });

    it("4"C"application/octet-stream", "this is a voice note")).toBe("voice");S"application/octet-stream", "cool animation")).toBe("animation");
    });

    it("0","application/pdf", "")).toBe("application");null, "")).toBe("document"!"checkFileSize", () => {
    it("'"6"photo".Y"video"5"+"%"photo".Z"video"6"9"%o	"unknown"F[	"unknown"*#"extractFileName", () => {
    it("		")"x-file-name": "custom.txt"0

	 "custom.txt");
    });

    it("	>"
&
$"doc.pdf" } }, null)).toBe("doc.pdf"

@"low.jpg" }, { fileName: "high.jpg" }] }, null)).toBe("high.jpg"
&"song.mp3" } }, null)).toBe("song.mp3"
d("voice.ogg" } }, null)).toBe("voice.ogg"
:"anim.gif" } }, null)).toBe("anim.gif");
    });

    it("+"

f"file"#"extractMimeType", () => {
    it("0")"x-mime-type": "text/plain"02 "text/plain");
    });

    it(" "?4"application/pdf" } }, null)).toBe("application/pdf""pE"image/jpeg" }, { mimeType: "image/png" }] }, null)).toBe("image/png"5*"audio/mpeg" } }, null)).toBe("audio/mpeg""R("audio/ogg" } }, null)).toBe("audio/ogg"#<"video/mp4" } }, null)).toBe("video/mp4");
    });

    it(","(W"application/octet-stream"vP](x(-,Bdescribe, it, expect, mock, beforeEach, afterAll } from "bun:test"Z
    }))
  }))"../src/db/index"%)OA!"equals", value: val })
    }
  }Z<"photos/file_0.jpg" }));
mock.module("../src/utils/telegram"JH
    }
  })R"../src/utils/rateLimit"
/%
}));

describe("File Route Handlers"_", handleFileInfo"123456:ABC-DEF"\'"../src/routes/files"}&"handleFileRedirect", () => {
    it(";+"}R""!#	"test-id" )"Rate limit exceeded");
    });

    it("J2"}
          })
        })i+"""!#"missing-id"aSI$"File not found");
    });

    it("	5"}C{
              id: "uuid-123",
              publicId: "test-id",
P
"tg-file-id",

2"test.jpg"
            }])
          })
        })i+""!#	"test-id"aS#"Location")).toBe("A");
    });

    it("{0"}>"DB Connection Error",""!#	"test-id"a"Server error"@	""handleFileInfo", () => {
    it("J2"}
          })
        })i+"y"!#"missing-id"a&)I$"File not found");
    });

    it("x-"9G"test-id",
        fileName: "image.png",
        mimeType: "image/png"&-"photo"Z9"2026-05-18T00:00:00.000Z")
          })
        })i+"]"!#	"test-id"a&)8[I"test-id",
        file_name: "image.png",
        mime_type: "image/png"/"photo"2"2026-05-18T00:00:00.000Z"_"{0"}>"DB Connection Error","]"!#	"test-id"a&r"Server error"@Cpyxx+
	8describe, it, expect, mock, beforeEach } from "bun:test"PZ"../src/db/index"+F
  }
}));

describe("Health Route Handler", () => {
  let handleHealth3`"../src/routes/health"<"2"-""+IL"ok"=":E"V""!""+"error""");
  });
});
ZY6YWxn(describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
import logger from "../src/utils/logger";
import { config } from "../src/env"*
"telegraf"[{ file_id: "photo_id_low", file_unique_id: "unique_id_low" },
              { file_id: "photo_id_high", file_unique_id: "unique@_id_high" }
            ]
          }))
        };
      }
    })"info"","error");

describe("Telegram API Utilities" , getFileInfo, getBot`"../src/utils/telegram""getBot", () => {
    it("l'",+_$"forwardToStorage", () => {
    it("C<"0"fake photo data""test_photo.jpg""photo_id_high",Q
"unique_id_high"123456"File forwarded to storage"	,\	")"	8="Telegram send failed"&"fake photo data""test_photo.jpg"P"Telegram send failed"*With("!"	error: "Telegram send failed"%"getFileInfo", () => {
    it("5&"url, $	"getFile"&*D"some_file_id" }
          }))#	"getInfo"&*Jf c"image/jpeg",
              file_path: "photos/file_0.jpg"
            }
          })));
        }
9"Unknown URL"U6 "some_file_id", "some_unique_id"/+%4"image/jpeg",
        file_path: "photos/file_0.jpg"\	"L&".	ok: false,
""
      })))s#:"invalid_file_id", "invalid_unique_id")).rejects.toThrow(""@N9xBdescribe, it, expect, mock, beforeEach, afterAll } from "bun:test"ZV"../src/db/index")
  },
  files: {}""nanoid";"mocked-nanoid-id"i*"../src/utils/telegram"){
    telegramFileId: "tg-file-id-123",
    telegramFileUniqueId: "tg-unique-id-abc",
    storageMessageId: 98765
  })),
  getBt6%{
        file_id: "tg-file-id-123",
!Rmime_type: "image/jpeg"
      }))
    }
  })
}));

describe("Upload Route Handler"o_""<"B7" 	,"a ", {
      method: "POST"C"content-type": "text/plain"
      },
      body: "plain text data"@"UQ");
  });

  it("0" 	,"a ", {
      method: "POST"""content-type": "application/json"AH"hello world").toString("base64"),
        fileName: "test.txt"
      })@Oz)!"mocked-nanoid-id"*"tg-file-id-123"!i"tg-unique-id-abc"#
"test.txt"#"document");
  });

  it("*" 	,"a ", {
      method: "POST"""content-type": "application/json"3Name: "test.txt"
      })@"Invalid JSON");
  });

  it("
," 	F)"multipart hello")], { type: "text/plain"%"file", fileBlob, "test_multi.txt");
/	"a /", {
      method: "POST",
      body: formData@OzI"mocked-nanoid-id"#"test_multi.txt";^rOx? Y3rv'1aZ^40000 test l>8g]d7YI)b+x 9100644 bot.js rP&8y\IJ_*m40000 db |Qc[/100644 env.js M g`'~06~>100644 index.js [J&(:J󛢳ИGx40000 routes jt	\ }[Y
cx40000 utils  QI>4jM&NnFLxDimport { Telegraf } from 'telegraf';
import logger from './utils/logger.js';
import { config } from './env.js';
import { db, files as fileSchema } from './db/index.js';
import { nanoid } from 'nanoid';
import { forwardToStorage } from './utils/telegram.js';

export const startBot = async () => {
  try {
    const bot = new Telegraf(config.botToken);

    bot.command('start', async (ctx) => {
      await ctx.reply(
        `👋 Halo! Kirimkan file (document, photo, video, audio, voice, animation) ke bot ini. ` +
        `File akan disimpan di private channel dan kamu dapat download link permanen.`
      );
    });

    bot.on(['document', 'photo', 'video', 'audio', 'voice', 'animation'], async (ctx) => {
      try {
        const fileType = ctx.message.document ? 'document' :
                        ctx.message.photo ? 'photo' :
                        ctx.message.video ? 'video' :
                        ctx.message.audio ? 'audio' :
                        ctx.message.voice ? 'voice' : 'animation';

        const fileObj = fileType === 'photo' ? ctx.message.photo.slice(-1)[0] : ctx.message[fileType];
        const { file_id, file_unique_id, file_size, mime_type } = fileObj;
        const fileName = ctx.message.document?.file_name ||
                        ctx.message.photo?.slice(-1)[0]?.file_name ||
                        ctx.message.video?.file_name ||
                        ctx.message.audio?.file_name ||
                        ctx.message.voice?.file_name ||
                        'file';

        const maxSize = fileType === 'photo' ? 10 * 1024 * 1024 :
                       fileType === 'audio' ? 200 * 1024 * 1024 :
                       fileType === 'voice' ? 200 * 1024 * 1024 : 2 * 1024 * 1024 * 1024;

        if (file_size > maxSize) {
          return ctx.reply(`File size exceeds ${maxSize / (1024 * 1024)}MB limit`);
        }

        const result = await forwardToStorage(file_id, fileName);
        const publicId = nanoid();

        const uploaded = {
          public_id: publicId,
          telegram_file_id: result.telegramFileId,
          telegram_file_unique_id: result.telegramFileUniqueId,
          storage_chat_id: config.storageChatId,
          storage_message_id: result.storageMessageId,
          file_name: fileName,
          mime_type: mime_type,
          size_bytes: file_size,
          file_type: fileType,
          uploader_id: ctx.from.id,
          created_at: new Date().toISOString(),
          updated_at: new Date().toISOString()
        };

        await db.insert(fileSchema).values(uploaded);

        const url = `${config.baseUrl}/f/${publicId}`;
        await ctx.reply(`File berhasil diupload! 📎\n\nDownload: ${url}`, {
          reply_parameters: { message_id: ctx.message.message_id }
        });

        logger.info('File uploaded via bot', { publicId, fileType, fileName, uploader: ctx.from.id });
      } catch (error) {
        logger.error('Bot file handler error', { error: error.message, chat_id: ctx.chat?.id });
        await ctx.reply('❌ Gagal mengupload file. Coba lagi nanti.');
      }
    });

    bot.use((ctx, next) => {
      logger.info('Telegram event received', { type: ctx.update.type, chat_id: ctx.chat?.id });
      return next();
    });

    await bot.launch();

    logger.info('Telegram bot started', { botToken: config.botToken?.substring(0, 10) + '...' });

    return bot;
  } catch (error) {
    logger.error('Failed to start bot', { error: error.message });
    throw error;
  }
};
3SxI 100644 index.js A0q+I,WXVe100644 schema.js ">chF}$མӿxoimport { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import logger from '../utils/logger.js';
import { files } from './schema.js';

const client = postgres(process.env.DATABASE_URL, {
  max: 10,
  idle_timeout: 20,
  connect_timeout: 10
});

export const db = drizzle(client, { schema: { files } });
export { files };
export default db;}t6xfimport { pgTable, text, bigint, 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(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull()
});!'dJx[import logger from './utils/logger.js';

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,
  PORT: process.env.PORT
};

const missing = Object.entries(requiredEnv)
  .filter(([_, value]) => value === undefined || value === '')
  .map(([key]) => key);

if (missing.length > 0) {
  logger.error('Missing required environment variables:', missing);
  throw new Error(`Missing environment variables: ${missing.join(', ')}`);
}

export const config = {
  botToken: process.env.BOT_TOKEN,
  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,
  nodeEnv: process.env.NODE_ENV || 'development',
  logLevel: process.env.LOG_LEVEL || 'info',
  rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000,
  rateLimitMaxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 30
};

logger.info('Environment variables loaded', { config: { ...config, botToken: config.botToken?.substring(0, 10) + '...' } });
Oximport { serve } from 'bun';
import logger from './utils/logger.js';
import { config } from './env.js';
import { startBot } from './bot.js';
import { handleUpload } from './routes/upload.js';
import { handleFileRedirect, handleFileInfo } from './routes/files.js';
import { handleHealth } from './routes/health.js';
import { cleanupRateLimitCache } from './utils/rateLimit.js';

const server = serve({
  port: config.port,
  routes: {
    '/api/upload': {
      POST: handleUpload
    },
    '/f/:public_id': {
      GET: handleFileRedirect
    },
    '/file/:public_id/info': {
      GET: handleFileInfo
    },
    '/health': {
      GET: handleHealth
    }
  }
});

const bot = await startBot();

logger.info('Server started', { port: config.port, url: config.baseUrl });

const gracefulShutdown = async (signal) => {
  logger.info('Graceful shutdown signal received', { signal });

  logger.info('Closing HTTP server');
  server.stop();

  logger.info('Stopping Telegram bot');
  await bot.stop();

  logger.info('Server shutdown complete');
  process.exit(0);
};

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

setInterval(cleanupRateLimitCache, 60000);

logger.info('Application running successfully');
Eekxn 100644 files.js w,#Sh>x&100644 health.js TBus<U?100644 upload.js )Y )ap5!j,Vx6	import logger from '../utils/logger.js';
import { db, files as fileSchema } from '../db/index.js';
import { checkRateLimit } from '../utils/rateLimit.js';
import { eq } from 'drizzle-orm';

export const handleFileRedirect = async (req, ctx) => {
  const public_id = ctx?.params?.public_id;
  try {
    const ip = req.headers.get('x-forwarded-for') || '127.0.0.1';

    if (!public_id || !checkRateLimit(ip)) {
      return Response.json({ error: 'Rate limit exceeded' }, { status: 429 });
    }

    const result = await db.select().from(fileSchema).where(eq(fileSchema.publicId, public_id)).limit(1);

    if (!result.length) {
      logger.warn('File not found', { public_id });
      return Response.json({ error: 'File not found' }, { status: 404 });
    }

    const file = result[0];
    const { getBot } = await import('../utils/telegram.js');
    const bot = getBot();
    const fileInfo = await bot.api.getFile(file.telegramFileId);

    const redirectUrl = `https://api.telegram.org/file/bot${process.env.BOT_TOKEN}/${fileInfo.file_path}`;
    return new Response(null, {
      status: 302,
      headers: {
        'Location': redirectUrl
      }
    });
  } catch (error) {
    logger.error('File redirect error', { public_id, error: error.message });
    return Response.json({ error: 'Server error' }, { status: 500 });
  }
};

export const handleFileInfo = async (req, ctx) => {
  const public_id = ctx?.params?.public_id;
  try {
    if (!public_id) {
      return Response.json({ error: 'Missing file id' }, { status: 400 });
    }

    const result = await db.select().from(fileSchema).where(eq(fileSchema.publicId, public_id)).limit(1);

    if (!result.length) {
      logger.warn('File not found', { public_id });
      return Response.json({ error: 'File not found' }, { status: 404 });
    }

    const file = result[0];
    return Response.json({
      public_id: file.publicId,
      file_name: file.fileName,
      mime_type: file.mimeType,
      size_bytes: file.sizeBytes,
      file_type: file.fileType,
      uploader_id: file.uploaderId,
      created_at: file.createdAt.toISOString ? file.createdAt.toISOString() : file.createdAt
    }, { status: 200 });
  } catch (error) {
    logger.error('File info error', { public_id, error: error.message });
    return Response.json({ error: 'Server error' }, { status: 500 });
  }
};
	Dx a4I#sql+DHealth = async (req) => {
  try {
    await db.execute(sql`SELECT 1`status: 'ok'u<Health check failed', {5status: 'error'""3xoimport logger from '../utils/logger.js';
import { db, files as fileSchema } from '../db/index.js';
import { nanoid } from 'nanoid';
import { forwardToStorage, getBot } from '../utils/telegram.js';
import { getFileType, checkFileSize, extractFileName, extractMimeType } from '../utils/file.js';
import { config } from '../env.js';

export const handleUpload = async (req) => {
  try {
    const contentType = req.headers.get('content-type') || '';

    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) {
    logger.error('Upload error', { error: error.message });
    return Response.json({ error: error.message }, { status: 500 });
  }
};

const handleMultipartUpload = async (req) => {
  try {
    const formData = await req.formData();
    const file = formData.get('file');
    const fileName = formData.get('fileName') || (file instanceof File ? file.name : null) || 'file';

    if (!file || !(file instanceof File)) {
      return Response.json({ error: 'No file provided' }, { status: 400 });
    }

    const fileBytes = await file.arrayBuffer();
    const fileBuffer = Buffer.from(fileBytes);
    const mimeType = file.type || extractMimeType({}, req) || 'application/octet-stream';
    const fileType = getFileType(mimeType, fileName);

    if (!checkFileSize(fileBuffer.byteLength, fileType)) {
      return Response.json({ error: `File size exceeds ${fileType} limit` }, { status: 400 });
    }

    const isDocument = fileName.endsWith('.pdf') || fileName.endsWith('.txt') || !['photo', 'video', 'audio', 'voice', 'animation'].includes(fileType);
    const result = await forwardToStorage(fileBuffer, fileName, isDocument);
    const bot = getBot();
    const fileInfo = await bot.api.getFile(result.telegramFileId);

    const uploaded = {
      publicId: nanoid(),
      telegramFileId: result.telegramFileId,
      telegramFileUniqueId: result.telegramFileUniqueId,
      storageChatId: config.storageChatId,
      storageMessageId: result.storageMessageId,
      fileName: fileName,
      mimeType: fileInfo.mime_type || mimeType || 'application/octet-stream',
      sizeBytes: fileInfo.file_size || fileBuffer.byteLength,
      fileType: fileType,
      uploaderId: 0,
      createdAt: new Date(),
      updatedAt: new Date()
    };

    await db.insert(fileSchema).values(uploaded);

    // Prepare response matching original snake_case fields as expected in task description
    const responsePayload = {
      public_id: uploaded.publicId,
      telegram_file_id: uploaded.telegramFileId,
      telegram_file_unique_id: uploaded.telegramFileUniqueId,
      storage_chat_id: uploaded.storageChatId,
      storage_message_id: uploaded.storageMessageId,
      file_name: uploaded.fileName,
      mime_type: uploaded.mimeType,
      size_bytes: uploaded.sizeBytes,
      file_type: uploaded.fileType,
      uploader_id: uploaded.uploaderId,
      created_at: uploaded.createdAt.toISOString(),
      download_url: `${config.baseUrl}/f/${uploaded.publicId}`
    };

    return Response.json(responsePayload, { status: 200 });
  } catch (error) {
    logger.error('Multipart upload error', { error: error.message });
    return Response.json({ error: error.message }, { status: 500 });
  }
};

const handleJSONUpload = async (req) => {
  try {
    const { file, fileName = 'file' } = await req.json();

    if (!file || typeof file !== 'string') {
      return Response.json(
        { error: 'Invalid JSON. Must include "file" (base64) and optional "fileName"' },
        { status: 400 }
      );
    }

    const fileBytes = Buffer.from(file, 'base64');
    const mimeType = 'application/octet-stream';
    const fileType = getFileType(mimeType, fileName) === 'application' ? 'document' : getFileType(mimeType, fileName);

    if (!checkFileSize(fileBytes.byteLength, fileType)) {
      return Response.json({ error: `File size exceeds ${fileType} limit` }, { status: 400 });
    }

    const isDocument = fileName.endsWith('.pdf') || fileName.endsWith('.txt') || !['photo', 'video', 'audio', 'voice', 'animation'].includes(fileType);
    const result = await forwardToStorage(fileBytes, fileName, isDocument);
    const bot = getBot();
    const fileInfo = await bot.api.getFile(result.telegramFileId);

    const uploaded = {
      publicId: nanoid(),
      telegramFileId: result.telegramFileId,
      telegramFileUniqueId: result.telegramFileUniqueId,
      storageChatId: config.storageChatId,
      storageMessageId: result.storageMessageId,
      fileName: fileName,
      mimeType: fileInfo.mime_type || mimeType || 'application/octet-stream',
      sizeBytes: fileInfo.file_size || fileBytes.byteLength,
      fileType: fileType,
      uploaderId: 0,
      createdAt: new Date(),
      updatedAt: new Date()
    };

    await db.insert(fileSchema).values(uploaded);

    // Prepare response matching original snake_case fields as expected in task description
    const responsePayload = {
      public_id: uploaded.publicId,
      telegram_file_id: uploaded.telegramFileId,
      telegram_file_unique_id: uploaded.telegramFileUniqueId,
      storage_chat_id: uploaded.storageChatId,
      storage_message_id: uploaded.storageMessageId,
      file_name: uploaded.fileName,
      mime_type: uploaded.mimeType,
      size_bytes: uploaded.sizeBytes,
      file_type: uploaded.fileType,
      uploader_id: uploaded.uploaderId,
      created_at: uploaded.createdAt.toISOString(),
      download_url: `${config.baseUrl}/f/${uploaded.publicId}`
    };

    return Response.json(responsePayload, { status: 200 });
  } catch (error) {
    logger.error('JSON upload error', { error: error.message });
    return Response.json({ error: error.message }, { status: 500 });
  }
};
Dt	x h100644 file.js .%L$PESO
~100644 logger.js gT`x]ɲU:SE100644 rateLimit.js 
ŵ_}gM4100644 telegram.js 3:ߎْZ	I;ްcx0const FILE_TYPES = {
  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
};

export const getFileType = (mime, caption) => {
  const mimeUpper = mime?.split('/')[0]?.toLowerCase();
  const captionLower = caption?.toLowerCase();

  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 || 'document';
};

export const checkFileSize = (sizeBytes, fileType) => {
  const limit = FILE_TYPES[fileType] || FILE_TYPES.document;
  return sizeBytes <= limit;
};

export const extractFileName = (msg, request) => {
  if (request?.headers?.['x-file-name']) {
    return request.headers['x-file-name'];
  }
  return msg.document?.fileName || msg.photo?.slice(-1)[0]?.fileName || msg.audio?.fileName ||
         msg.voice?.fileName || msg.animation?.fileName || 'file';
};

export const extractMimeType = (msg, request) => {
  if (request?.headers?.['x-mime-type']) {
    return request.headers['x-mime-type'];
  }
  return msg.document?.mimeType || msg.photo?.slice(-1)[0]?.mimeType || msg.audio?.mimeType ||
         msg.voice?.mimeType || msg.animation?.mimeType || 'application/octet-stream';
};
<Cx1import logger from './logger.js';

const rateLimitMap = new Map();

export const checkRateLimit = (key) => {
  const now = Date.now();
  const windowMs = parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000;
  const maxRequests = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 30;

  if (!rateLimitMap.has(key)) {
    rateLimitMap.set(key, { count: 0, reset: now + windowMs });
  }

  const record = rateLimitMap.get(key);

  if (now > record.reset) {
    record.count = 0;
    record.reset = now + windowMs;
  }

  if (record.count >= maxRequests) {
    logger.warn('Rate limit exceeded', { key, count: record.count, reset: record.reset });
    return false;
  }

  record.count++;
  return true;
};

export const cleanupRateLimitCache = () => {
  const now = Date.now();
  const windowMs = parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000;
  const keysToDelete = [];

  for (const [key, record] of rateLimitMap.entries()) {
    if (now > record.reset) {
      keysToDelete.push(key);
    }
  }

  keysToDelete.forEach(key => rateLimitMap.delete(key));
};
AW!}x$import { Telegraf } from 'telegraf';
import logger from './logger.js';
import { config } from '../env.js';

const bot = new Telegraf(config.botToken);
const TELEGRAM_API_URL = `https://api.telegram.org/bot${config.botToken}/`;

export const forwardToStorage = async (fileChunk, fileName, forceDocument = false) => {
  try {
    const caption = forceDocument ? `📁 ${fileName}` : fileName;
    const input = forceDocument ? { document: fileChunk, caption } : { photo: [fileChunk], caption };

    const result = await bot.api.sendPhoto(config.storageChatId, input);

    logger.info('File forwarded to storage', { fileName, message: result.message_id });

    return {
      telegramFileId: result.photo?.slice(-1)[0]?.file_id,
      telegramFileUniqueId: result.photo?.slice(-1)[0]?.file_unique_id,
      storageMessageId: result.message_id
    };
  } catch (error) {
    logger.error('Failed to forward file to storage', { fileName, error: error.message });
    throw error;
  }
};

export const getFileInfo = async (telegramFileId, telegramFileUniqueId) => {
  try {
    const result = await fetch(`${TELEGRAM_API_URL}getFile`);
    const data = await result.json();

    if (!data.ok) {
      throw new Error(data.description || 'Telegram API error');
    }

    const fileId = data.result.file_id === telegramFileId ? telegramFileId : telegramFileUniqueId;
    const fileResult = await fetch(`${TELEGRAM_API_URL}getInfo`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ file_id: fileId })
    });
    const fileInfo = await fileResult.json();

    if (!fileInfo.ok) {
      throw new Error(fileInfo.description || 'Telegram info error');
    }

    return {
      file_size: fileInfo.result.file_size,
      mime_type: fileInfo.result.mime_type,
      file_path: fileInfo.result.file_path
    };
  } catch (error) {
    logger.error('Failed to get file info', { error: error.message });
    throw error;
  }
};

export const getBot = () => bot;
[(x`100644 bootstrap.test.js 'sRݵaX3.8100644 bot.test.js 'y<v}GA@
d100644 db.test.js tVɧi^AmgF100644 env.test.js g3S.s(D_ B%100644 file.test.js d4#4b2c0100644 files.test.js wD3ݳO
100644 health.test.js {h2cǈ\7\|100644 rateLimit.test.js 7p	x~ٸ100644 telegram.test.js 9p+	q100644 upload.test.js .-?%%=_-3o?ʦ1aximport { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";

const mockServe = mock((options) => {
  return {
    port: options.port,
    routes: options.routes,
    stop: mock()
  };
});

const originalServe = Bun.serve;
Bun.serve = mockServe;

const mockStartBot = mock(() => Promise.resolve({
  stop: mock()
}));

mock.module("../src/bot.js", () => ({
  startBot: mockStartBot
}));

mock.module("../src/routes/upload.js", () => ({
  handleUpload: mock()
}));

mock.module("../src/routes/files.js", () => ({
  handleFileRedirect: mock(),
  handleFileInfo: mock()
}));

mock.module("../src/routes/health.js", () => ({
  handleHealth: mock()
}));

mock.module("../src/utils/rateLimit.js", () => ({
  cleanupRateLimitCache: mock()
}));

describe("Bootstrap Server", () => {
  beforeEach(() => {
    mockServe.mockClear();
    mockStartBot.mockClear();
  });

  afterAll(() => {
    Bun.serve = originalServe;
  });

  it("should bootstrap the application successfully", async () => {
    await import("../src/index.js");

    expect(mockServe).toHaveBeenCalled();
    expect(mockStartBot).toHaveBeenCalled();

    const serveCallArgs = mockServe.mock.calls[0][0];
    expect(serveCallArgs).toHaveProperty("port");
    expect(serveCallArgs).toHaveProperty("routes");
    expect(serveCallArgs.routes).toHaveProperty("/api/upload");
    expect(serveCallArgs.routes).toHaveProperty("/f/:public_id");
    expect(serveCallArgs.routes).toHaveProperty("/file/:public_id/info");
    expect(serveCallArgs.routes).toHaveProperty("/health");
  });
});
¾x^import { describe, it, expect, mock, spyOn, beforeEach, afterAll } from "bun:test";
import logger from "../src/utils/logger.js";

// Mock environment
process.env.BOT_TOKEN = "8605908810:AAFpUzlIBktfd_7wpEj7zMJob2CFxvG-ZGY";
process.env.STORAGE_CHANNEL_ID = "-1003996572954";
process.env.BASE_URL = "https://tele.asepharyana.tech";

// Mock Telegraf
const mockLaunch = mock(() => Promise.resolve());
const mockCommand = mock();
const mockOn = mock();
const mockUse = mock();

mock.module("telegraf", () => {
  return {
    Telegraf: class {
      constructor(token) {
        this.token = token;
        this.launch = mockLaunch;
        this.command = mockCommand;
        this.on = mockOn;
        this.use = mockUse;
      }
    }
  };
});

// Mock database
const mockInsert = mock(() => ({
  values: mock(() => Promise.resolve())
}));
mock.module("../src/db/index.js", () => ({
  db: {
    insert: mockInsert
  },
  files: {}
}));

// Mock forwardToStorage
const mockForwardToStorage = mock(() => Promise.resolve({
  telegramFileId: "stored_file_id",
  telegramFileUniqueId: "stored_unique_id",
  storageMessageId: 9999
}));
mock.module("../src/utils/telegram.js", () => ({
  forwardToStorage: mockForwardToStorage
}));

const infoSpy = spyOn(logger, "info");
const errorSpy = spyOn(logger, "error");

describe("Telegram Bot Handler", () => {
  beforeEach(() => {
    mockLaunch.mockClear();
    mockCommand.mockClear();
    mockOn.mockClear();
    mockUse.mockClear();
    mockInsert.mockClear();
    mockForwardToStorage.mockClear();
    infoSpy.mockClear();
    errorSpy.mockClear();
  });

  it("should initialize and launch the bot", async () => {
    const { startBot } = await import("../src/bot.js");
    const bot = await startBot();

    expect(bot).toBeDefined();
    expect(mockCommand).toHaveBeenCalledWith("start", expect.any(Function));
    expect(mockOn).toHaveBeenCalledWith(
      ["document", "photo", "video", "audio", "voice", "animation"],
      expect.any(Function)
    );
    expect(mockUse).toHaveBeenCalled();
    expect(mockLaunch).toHaveBeenCalled();
  });

  it("should handle /start command", async () => {
    const { startBot } = await import("../src/bot.js");
    await startBot();

    const startHandler = mockCommand.mock.calls.find(call => call[0] === "start")[1];
    const replyMock = mock(() => Promise.resolve());
    const ctx = {
      reply: replyMock
    };

    await startHandler(ctx);
    expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("Halo"));
  });

  it("should process document uploads and save to db", async () => {
    const { startBot } = await import("../src/bot.js");
    await startBot();

    const fileHandler = mockOn.mock.calls[0][1];
    const replyMock = mock(() => Promise.resolve());
    const ctx = {
      message: {
        message_id: 42,
        document: {
          file_id: "doc_123",
          file_unique_id: "doc_uniq_123",
          file_size: 1024,
          mime_type: "application/pdf",
          file_name: "cv.pdf"
        }
      },
      from: {
        id: 999
      },
      reply: replyMock
    };

    await fileHandler(ctx);
    expect(mockForwardToStorage).toHaveBeenCalledWith("doc_123", "cv.pdf");
    expect(mockInsert).toHaveBeenCalled();
    expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("File berhasil diupload"), expect.any(Object));
  });

  it("should reject uploads exceeding max size limit", async () => {
    const { startBot } = await import("../src/bot.js");
    await startBot();

    const fileHandler = mockOn.mock.calls[0][1];
    const replyMock = mock(() => Promise.resolve());
    const ctx = {
      message: {
        message_id: 42,
        photo: [{
          file_id: "photo_123",
          file_unique_id: "photo_uniq_123",
          file_size: 20 * 1024 * 1024, // 20MB exceeds 10MB limit
          mime_type: "image/jpeg"
        }]
      },
      from: {
        id: 999
      },
      reply: replyMock
    };

    await fileHandler(ctx);
    expect(mockForwardToStorage).not.toHaveBeenCalled();
    expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("exceeds"));
  });

  afterAll(() => {
    mock.restore();
  });
});
gyDxJimport { describe, it, expect } from "bun:test";
import { db, files } from "../src/db/index.js";
import { files as schemaFiles } from "../src/db/schema.js";

describe("Database Layer", () => {
  it("should export db instance", () => {
    expect(db).toBeDefined();
  });

  it("should export files schema from both index and schema", () => {
    expect(files).toBeDefined();
    expect(schemaFiles).toBeDefined();
    expect(files).toBe(schemaFiles);
  });

  it("should have correct schema properties", () => {
    expect(files.id).toBeDefined();
    expect(files.publicId).toBeDefined();
    expect(files.telegramFileId).toBeDefined();
    expect(files.telegramFileUniqueId).toBeDefined();
    expect(files.storageChatId).toBeDefined();
    expect(files.storageMessageId).toBeDefined();
    expect(files.fileName).toBeDefined();
    expect(files.mimeType).toBeDefined();
    expect(files.sizeBytes).toBeDefined();
    expect(files.fileType).toBeDefined();
    expect(files.uploaderId).toBeDefined();
    expect(files.createdAt).toBeDefined();
    expect(files.updatedAt).toBeDefined();
  });
});
f#gxzimport { describe, it, expect, beforeAll } from "bun:test";
import { config } from "../src/env.js";

describe("Environment Variables Validation", () => {
  it("config should have all required fields", () => {
    expect(config).toHaveProperty("botToken");
    expect(config).toHaveProperty("storageChatId");
    expect(config).toHaveProperty("baseUrl");
    expect(config).toHaveProperty("databaseUrl");
    expect(config).toHaveProperty("port");
    expect(config).toHaveProperty("nodeEnv");
    expect(config).toHaveProperty("logLevel");
    expect(config).toHaveProperty("rateLimitWindowMs");
    expect(config).toHaveProperty("rateLimitMaxRequests");
  });

  it("config.botToken should return BOT_TOKEN from process.env", () => {
    expect(config.botToken).toBe(process.env.BOT_TOKEN);
  });

  it("config.storageChatId should be parsed as integer from STORAGE_CHANNEL_ID", () => {
    expect(typeof config.storageChatId).toBe("number");
    expect(config.storageChatId).toBe(parseInt(process.env.STORAGE_CHANNEL_ID, 10));
  });

  it("config.port should default to 3000 when not specified", () => {
    expect(typeof config.port).toBe("number");
  });

  it("nodeEnv should be 'test' or 'development'", () => {
    expect(["test", "development"]).toContain(config.nodeEnv);
  });

  it("logLevel should default to 'info'", () => {
    expect(config.logLevel).toBe("info");
  });

  it("rateLimitWindowMs should default to 60000 when not specified", () => {
    expect(config.rateLimitWindowMs).toBe(60000);
  });

  it("rateLimitMaxRequests should default to 30 when not specified", () => {
    expect(config.rateLimitMaxRequests).toBe(30);
  });
});
)AxVimport { describe, it, expect } from "bun:test";
import { getFileType, checkFileSize, extractFileName, extractMimeType } from "../src/utils/file.js";

describe("File Utilities", () => {
  describe("getFileType", () => {
    it("should classify video mime types as video", () => {
      expect(getFileType("video/mp4", "")).toBe("video");
      expect(getFileType("video/quicktime", "")).toBe("video");
    });

    it("should classify audio mime types as audio", () => {
      expect(getFileType("audio/mpeg", "")).toBe("audio");
      expect(getFileType("audio/ogg", "")).toBe("audio");
    });

    it("should classify image mime types based on caption", () => {
      expect(getFileType("image/jpeg", "my photo")).toBe("photo");
      expect(getFileType("image/png", "cool image.png")).toBe("photo");
      expect(getFileType("image/gif", "funny.gif")).toBe("animation");
      expect(getFileType("image/png", "funny gif")).toBe("animation");
    });

    it("should classify voice and animation based on caption", () => {
      expect(getFileType("application/octet-stream", "this is a voice note")).toBe("voice");
      expect(getFileType("application/octet-stream", "cool animation")).toBe("animation");
    });

    it("should default to mime first segment or document", () => {
      expect(getFileType("application/pdf", "")).toBe("application");
      expect(getFileType(null, "")).toBe("document");
    });
  });

  describe("checkFileSize", () => {
    it("should allow files under the size limit", () => {
      expect(checkFileSize(5 * 1024 * 1024, "photo")).toBe(true); // Photo limit is 10MB
      expect(checkFileSize(1 * 1024 * 1024 * 1024, "video")).toBe(true); // Video limit is 2GB
    });

    it("should block files exceeding the size limit", () => {
      expect(checkFileSize(15 * 1024 * 1024, "photo")).toBe(false); // Photo limit is 10MB
      expect(checkFileSize(3 * 1024 * 1024 * 1024, "video")).toBe(false); // Video limit is 2GB
    });

    it("should fall back to document limit if fileType is unknown", () => {
      expect(checkFileSize(1 * 1024 * 1024 * 1024, "unknown")).toBe(true); // Document limit is 2GB
      expect(checkFileSize(3 * 1024 * 1024 * 1024, "unknown")).toBe(false);
    });
  });

  describe("extractFileName", () => {
    it("should extract file name from headers if present", () => {
      const req = { headers: { "x-file-name": "custom.txt" } };
      expect(extractFileName({}, req)).toBe("custom.txt");
    });

    it("should extract file name from various message attachment types", () => {
      expect(extractFileName({ document: { fileName: "doc.pdf" } }, null)).toBe("doc.pdf");
      expect(extractFileName({ photo: [{ fileName: "low.jpg" }, { fileName: "high.jpg" }] }, null)).toBe("high.jpg");
      expect(extractFileName({ audio: { fileName: "song.mp3" } }, null)).toBe("song.mp3");
      expect(extractFileName({ voice: { fileName: "voice.ogg" } }, null)).toBe("voice.ogg");
      expect(extractFileName({ animation: { fileName: "anim.gif" } }, null)).toBe("anim.gif");
    });

    it("should return default filename if not found", () => {
      expect(extractFileName({}, null)).toBe("file");
    });
  });

  describe("extractMimeType", () => {
    it("should extract mime type from headers if present", () => {
      const req = { headers: { "x-mime-type": "text/plain" } };
      expect(extractMimeType({}, req)).toBe("text/plain");
    });

    it("should extract mime type from various message attachment types", () => {
      expect(extractMimeType({ document: { mimeType: "application/pdf" } }, null)).toBe("application/pdf");
      expect(extractMimeType({ photo: [{ mimeType: "image/jpeg" }, { mimeType: "image/png" }] }, null)).toBe("image/png");
      expect(extractMimeType({ audio: { mimeType: "audio/mpeg" } }, null)).toBe("audio/mpeg");
      expect(extractMimeType({ voice: { mimeType: "audio/ogg" } }, null)).toBe("audio/ogg");
      expect(extractMimeType({ animation: { mimeType: "video/mp4" } }, null)).toBe("video/mp4");
    });

    it("should return default mime type if not found", () => {
      expect(extractMimeType({}, null)).toBe("application/octet-stream");
    });
  });
});
Pcx-import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";

// Mock database layer
const mockSelect = mock(() => ({
  from: mock(() => ({
    where: mock(() => ({
      limit: mock(() => Promise.resolve([]))
    }))
  }))
}));

mock.module("../src/db/index.js", () => ({
  db: {
    select: mockSelect
  },
  files: {
    publicId: {
      equals: (val) => ({ type: "equals", value: val })
    }
  }
}));

// Mock telegram utils
const mockGetFile = mock(() => Promise.resolve({ file_path: "photos/file_0.jpg" }));
mock.module("../src/utils/telegram.js", () => ({
  getBot: () => ({
    api: {
      getFile: mockGetFile
    }
  })
}));

// Mock rateLimit
const mockCheckRateLimit = mock(() => true);
mock.module("../src/utils/rateLimit.js", () => ({
  checkRateLimit: mockCheckRateLimit
}));

describe("File Route Handlers", () => {
  let handleFileRedirect, handleFileInfo;

  beforeEach(async () => {
    mockSelect.mockClear();
    mockGetFile.mockClear();
    mockCheckRateLimit.mockClear();

    // Set up mock token
    process.env.BOT_TOKEN = "123456:ABC-DEF";

    const filesRoute = await import("../src/routes/files.js");
    handleFileRedirect = filesRoute.handleFileRedirect;
    handleFileInfo = filesRoute.handleFileInfo;
  });

  describe("handleFileRedirect", () => {
    it("should return 429 if rate limit is exceeded", async () => {
      mockCheckRateLimit.mockImplementationOnce(() => false);
      const req = new Request("http://localhost:3000/f/test-id");

      const res = await handleFileRedirect(req, { params: { public_id: "test-id" } });
      expect(res.status).toBe(429);
      const body = await res.json();
      expect(body.error).toBe("Rate limit exceeded");
    });

    it("should return 404 if file is not found in database", async () => {
      mockSelect.mockImplementationOnce(() => ({
        from: () => ({
          where: () => ({
            limit: () => Promise.resolve([])
          })
        })
      }));

      const req = new Request("http://localhost:3000/f/missing-id");
      const res = await handleFileRedirect(req, { params: { public_id: "missing-id" } });
      expect(res.status).toBe(404);
      const body = await res.json();
      expect(body.error).toBe("File not found");
    });

    it("should redirect to telegram file url if file is found", async () => {
      mockSelect.mockImplementationOnce(() => ({
        from: () => ({
          where: () => ({
            limit: () => Promise.resolve([{
              id: "uuid-123",
              publicId: "test-id",
              telegramFileId: "tg-file-id",
              fileName: "test.jpg"
            }])
          })
        })
      }));

      const req = new Request("http://localhost:3000/f/test-id");
      const res = await handleFileRedirect(req, { params: { public_id: "test-id" } });
      expect(res.status).toBe(302);
      expect(res.headers.get("Location")).toBe("https://api.telegram.org/file/bot123456:ABC-DEF/photos/file_0.jpg");
    });

    it("should return 500 on database or external errors", async () => {
      mockSelect.mockImplementationOnce(() => {
        throw new Error("DB Connection Error");
      });

      const req = new Request("http://localhost:3000/f/test-id");
      const res = await handleFileRedirect(req, { params: { public_id: "test-id" } });
      expect(res.status).toBe(500);
      const body = await res.json();
      expect(body.error).toBe("Server error");
    });
  });

  describe("handleFileInfo", () => {
    it("should return 404 if file is not found in database", async () => {
      mockSelect.mockImplementationOnce(() => ({
        from: () => ({
          where: () => ({
            limit: () => Promise.resolve([])
          })
        })
      }));

      const req = new Request("http://localhost:3000/file/missing-id/info");
      const res = await handleFileInfo(req, { params: { public_id: "missing-id" } });
      expect(res.status).toBe(404);
      const body = await res.json();
      expect(body.error).toBe("File not found");
    });

    it("should return file info JSON if file is found", async () => {
      const dbFile = {
        publicId: "test-id",
        fileName: "image.png",
        mimeType: "image/png",
        sizeBytes: 2048,
        fileType: "photo",
        uploaderId: 99999,
        createdAt: new Date("2026-05-18T00:00:00.000Z")
      };

      mockSelect.mockImplementationOnce(() => ({
        from: () => ({
          where: () => ({
            limit: () => Promise.resolve([dbFile])
          })
        })
      }));

      const req = new Request("http://localhost:3000/file/test-id/info");
      const res = await handleFileInfo(req, { params: { public_id: "test-id" } });
      expect(res.status).toBe(200);
      const body = await res.json();
      expect(body).toEqual({
        public_id: "test-id",
        file_name: "image.png",
        mime_type: "image/png",
        size_bytes: 2048,
        file_type: "photo",
        uploader_id: 99999,
        created_at: "2026-05-18T00:00:00.000Z"
      });
    });

    it("should return 500 on database or external errors", async () => {
      mockSelect.mockImplementationOnce(() => {
        throw new Error("DB Connection Error");
      });

      const req = new Request("http://localhost:3000/file/test-id/info");
      const res = await handleFileInfo(req, { params: { public_id: "test-id" } });
      expect(res.status).toBe(500);
      const body = await res.json();
      expect(body.error).toBe("Server error");
    });
  });

  afterAll(() => {
    mock.restore();
  });
});
benOximport { describe, it, expect, mock, beforeEach } from "bun:test";

// Mock database layer
const mockExecute = mock(() => Promise.resolve());

mock.module("../src/db/index.js", () => ({
  db: {
    execute: mockExecute
  }
}));

describe("Health Route Handler", () => {
  let handleHealth;

  beforeEach(async () => {
    mockExecute.mockClear();
    const healthRoute = await import("../src/routes/health.js");
    handleHealth = healthRoute.handleHealth;
  });

  it("should return status 200 and ok when DB is healthy", async () => {
    const req = new Request("http://localhost:3000/health");
    const res = await handleHealth(req);

    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body).toEqual({ status: "ok" });
    expect(mockExecute).toHaveBeenCalled();
  });

  it("should return status 500 and error details when DB health check fails", async () => {
    mockExecute.mockImplementationOnce(() => Promise.reject(new Error("DB Connection Failed")));
    const req = new Request("http://localhost:3000/health");
    const res = await handleHealth(req);

    expect(res.status).toBe(500);
    const body = await res.json();
    expect(body.status).toBe("error");
    expect(body.error).toBe("DB Connection Failed");
  });
});
RŴximport { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
import { checkRateLimit, cleanupRateLimitCache } from "../src/utils/rateLimit.js";
import logger from "../src/utils/logger.js";

// Spy on logger.warn
const warnSpy = spyOn(logger, "warn");

describe("Rate Limiter", () => {
  beforeEach(() => {
    warnSpy.mockClear();
    // Set custom env variables for predictable tests
    process.env.RATE_LIMIT_WINDOW_MS = "100"; // 100ms window
    process.env.RATE_LIMIT_MAX_REQUESTS = "3"; // max 3 requests
  });

  afterEach(() => {
    delete process.env.RATE_LIMIT_WINDOW_MS;
    delete process.env.RATE_LIMIT_MAX_REQUESTS;
  });

  it("should allow requests under the limit", () => {
    const key = "user-1";
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);
    expect(warnSpy).not.toHaveBeenCalled();
  });

  it("should block requests exceeding the limit and log a warning", () => {
    const key = "user-2";
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);

    // 4th request exceeds limit of 3
    expect(checkRateLimit(key)).toBe(false);
    expect(warnSpy).toHaveBeenCalled();
    const callArgs = warnSpy.mock.calls[0];
    expect(callArgs[0]).toBe("Rate limit exceeded");
    expect(callArgs[1].key).toBe(key);
  });

  it("should reset request count after the window passes", async () => {
    const key = "user-3";
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(true);
    expect(checkRateLimit(key)).toBe(false); // blocked

    // Wait for window to expire (100ms)
    await new Promise((resolve) => setTimeout(resolve, 110));

    // Should be allowed again
    expect(checkRateLimit(key)).toBe(true);
  });

  it("should cleanup rate limit cache of expired keys", async () => {
    const key1 = "cleanup-1";
    const key2 = "cleanup-2";

    // Populate keys
    expect(checkRateLimit(key1)).toBe(true);
    expect(checkRateLimit(key2)).toBe(true);

    // Run cleanup immediately (none should be expired yet as 100ms hasn't passed)
    cleanupRateLimitCache();

    // Verify still tracked (counts shouldn't reset, e.g., if we consume remaining limits)
    expect(checkRateLimit(key1)).toBe(true); // request 2
    expect(checkRateLimit(key1)).toBe(true); // request 3
    expect(checkRateLimit(key1)).toBe(false); // request 4 (blocked)

    // Wait for window to expire
    await new Promise((resolve) => setTimeout(resolve, 110));

    // Run cleanup
    cleanupRateLimitCache();

    // Since they were deleted from the map, they should be initialized as new records
    // If they were cleaned up, we should be able to do 3 requests again
    expect(checkRateLimit(key1)).toBe(true); // 1
    expect(checkRateLimit(key1)).toBe(true); // 2
    expect(checkRateLimit(key1)).toBe(true); // 3
    expect(checkRateLimit(key1)).toBe(false); // 4 (blocked)
  });
});
e:Hxvimport { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
import logger from "../src/utils/logger.js";
import { config } from "../src/env.js";

// Mock Telegraf and fetch
mock.module("telegraf", () => {
  return {
    Telegraf: class {
      constructor(token) {
        this.token = token;
        this.api = {
          sendPhoto: mock(() => Promise.resolve({
            message_id: 12345,
            photo: [
              { file_id: "photo_id_low", file_unique_id: "unique_id_low" },
              { file_id: "photo_id_high", file_unique_id: "unique_id_high" }
            ]
          }))
        };
      }
    }
  };
});

const infoSpy = spyOn(logger, "info");
const errorSpy = spyOn(logger, "error");

describe("Telegram API Utilities", () => {
  let forwardToStorage, getFileInfo, getBot;

  beforeEach(async () => {
    infoSpy.mockClear();
    errorSpy.mockClear();
    global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({ ok: true }))));

    // Import dynamically so mocking is applied first
    const telegramUtils = await import("../src/utils/telegram.js");
    forwardToStorage = telegramUtils.forwardToStorage;
    getFileInfo = telegramUtils.getFileInfo;
    getBot = telegramUtils.getBot;
  });

  afterEach(() => {
    delete global.fetch;
  });

  describe("getBot", () => {
    it("should return the telegraf bot instance", () => {
      const bot = getBot();
      expect(bot).toBeDefined();
      expect(bot.api).toBeDefined();
    });
  });

  describe("forwardToStorage", () => {
    it("should forward photo to storage chat and return file details", async () => {
      const chunk = Buffer.from("fake photo data");
      const fileName = "test_photo.jpg";
      const result = await forwardToStorage(chunk, fileName, false);

      expect(result).toEqual({
        telegramFileId: "photo_id_high",
        telegramFileUniqueId: "unique_id_high",
        storageMessageId: 12345
      });
      expect(infoSpy).toHaveBeenCalledWith("File forwarded to storage", {
        fileName,
        message: 12345
      });
    });

    it("should handle error when forwarding fails", async () => {
      const bot = getBot();
      bot.api.sendPhoto = mock(() => Promise.reject(new Error("Telegram send failed")));

      const chunk = Buffer.from("fake photo data");
      const fileName = "test_photo.jpg";

      await expect(forwardToStorage(chunk, fileName, false)).rejects.toThrow("Telegram send failed");
      expect(errorSpy).toHaveBeenCalledWith("Failed to forward file to storage", {
        fileName,
        error: "Telegram send failed"
      });
    });
  });

  describe("getFileInfo", () => {
    it("should fetch file details successfully", async () => {
      global.fetch = mock((url, init) => {
        if (url.endsWith("getFile")) {
          return Promise.resolve(new Response(JSON.stringify({
            ok: true,
            result: { file_id: "some_file_id" }
          })));
        } else if (url.endsWith("getInfo")) {
          return Promise.resolve(new Response(JSON.stringify({
            ok: true,
            result: {
              file_size: 98765,
              mime_type: "image/jpeg",
              file_path: "photos/file_0.jpg"
            }
          })));
        }
        return Promise.reject(new Error("Unknown URL"));
      });

      const result = await getFileInfo("some_file_id", "some_unique_id");

      expect(result).toEqual({
        file_size: 98765,
        mime_type: "image/jpeg",
        file_path: "photos/file_0.jpg"
      });
    });

    it("should handle error when getFile fails", async () => {
      global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
        ok: false,
        description: "Bad Request: file_id invalid"
      }))));

      await expect(getFileInfo("invalid_file_id", "invalid_unique_id")).rejects.toThrow("Bad Request: file_id invalid");
      expect(errorSpy).toHaveBeenCalled();
    });
  });
});
x import { describe, it, expect, mock, spyOn, beforeEach, afterEach } from "bun:test";
import logger from "../src/utils/logger.js";

// Mock db
const mockInsert = mock(() => ({
  values: mock(() => Promise.resolve())
}));

mock.module("../src/db/index.js", () => ({
  db: {
    insert: mockInsert
  },
  files: {}
}));

// Mock nanoid
mock.module("nanoid", () => ({
  nanoid: () => "mocked-nanoid-id"
}));

// Mock telegram utils
mock.module("../src/utils/telegram.js", () => ({
  forwardToStorage: mock(() => Promise.resolve({
    telegramFileId: "tg-file-id-123",
    telegramFileUniqueId: "tg-unique-id-abc",
    storageMessageId: 98765
  })),
  getBot: () => ({
    api: {
      getFile: mock(() => Promise.resolve({
        file_id: "tg-file-id-123",
        file_size: 1000,
        mime_type: "image/jpeg"
      }))
    }
  })
}));

describe("Upload Route Handler", () => {
  let handleUpload;

  beforeEach(async () => {
    mockInsert.mockClear();
    const uploadRoute = await import("../src/routes/upload.js");
    handleUpload = uploadRoute.handleUpload;
  });

  it("should reject unsupported content types with 400 status", async () => {
    const req = new Request("http://localhost:3000/api/upload", {
      method: "POST",
      headers: {
        "content-type": "text/plain"
      },
      body: "plain text data"
    });

    const res = await handleUpload(req);
    expect(res.status).toBe(400);
    const body = await res.json();
    expect(body.error).toContain("Unsupported content type");
  });

  it("should process JSON upload (base64) successfully", async () => {
    const req = new Request("http://localhost:3000/api/upload", {
      method: "POST",
      headers: {
        "content-type": "application/json"
      },
      body: JSON.stringify({
        file: Buffer.from("hello world").toString("base64"),
        fileName: "test.txt"
      })
    });

    const res = await handleUpload(req);
    expect(res.status).toBe(200);
    const body = await res.json();

    expect(body.public_id).toBe("mocked-nanoid-id");
    expect(body.telegram_file_id).toBe("tg-file-id-123");
    expect(body.telegram_file_unique_id).toBe("tg-unique-id-abc");
    expect(body.file_name).toBe("test.txt");
    expect(body.file_type).toBe("document");
  });

  it("should reject JSON upload without file key", async () => {
    const req = new Request("http://localhost:3000/api/upload", {
      method: "POST",
      headers: {
        "content-type": "application/json"
      },
      body: JSON.stringify({
        fileName: "test.txt"
      })
    });

    const res = await handleUpload(req);
    expect(res.status).toBe(400);
    const body = await res.json();
    expect(body.error).toContain("Invalid JSON");
  });

  it("should process multipart upload successfully", async () => {
    const formData = new FormData();
    const fileBlob = new Blob([Buffer.from("multipart hello")], { type: "text/plain" });
    formData.append("file", fileBlob, "test_multi.txt");

    const req = new Request("http://localhost:3000/api/upload", {
      method: "POST",
      body: formData
    });

    const res = await handleUpload(req);
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.public_id).toBe("mocked-nanoid-id");
    expect(body.file_name).toBe("test_multi.txt");
  });
});
IU*xU %beforeEach, afterAllAz2
  afterAll(() => {
    mock.restore();
  });
});
1	>x `un.lock 1%K{a58黑&index.ts {,dTώsx6ѦS=e$r9
JP)O=j;l83_100644 tsconfig.json I}rR%FS/xY `}]"&!package.json wv[j'Ol>8g]d7YId+xW 1$kjj!jjjj/j/otstrap.test.js#
  }
}
Ox4 рTa\;=WzOj;l83_s2Gx1 1$?types/bun": "latest 
  }
}
kAx  xJ @QLߛR!׳8vTIA6
Bv _#= IB(k,U&#ox   6@});
3f}x_ ++%OspyOn, beforeEach } from "bun:test";
import logger from "../src/utils/logger.jsJV});
tl!0x 4̯types/bun": "latest,%
bun": ["@types/bunq/#bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],

)u5`7|C(x console.log("Hello via Bun!");	-x {
  "compilerOptions": {
    // Environment setup & latest features
    "lib": ["ESNext"],
    "target": "ESNext",
    "module": "Preserve",
    "moduleDetection": "force",
    "jsx": "react-jsx",
    "allowJs": true,
    "types": ["bun"],

    // Bundler mode
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "noEmit": true,

    // Best practices
    "strict": true,
    "skipLibCheck": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,

    // Some stricter flags (disabled by default)
    "noUnusedLocals": false,
    "noUnusedParameters": false,
    "noPropertyAccessFromIndexSignature": false
  }
}
v@Psx? W3VN_y%ss/40000 test ]Vdta=r)Gmi1x	 aAh?x -rox? W3Dy&	BC40000 test ["U*|x\Z)j-x
 "?Ah<x TK8lx? W3$Y6ͼݹ40000 test \^؈OfK}?)m}x LewbiH[za#` SxI 100644 files.js w,#Sh>x&100644 upload.js )Y )ap5!k"x T#|Ux? W3(^<!G+{hO40000 test AdO&4)bpfx Lk<^؈]äl]L` x% 100644 upload.js )Y )ap5!Mkhx T|#|L0x? W3@eU1tb.j40000 test r<O+PÊ) h,x _Eff}x 'x? W3.os\K2\:40000 test ;V1ЌH6Atē)Jwx __KpZ})9aetx pp	fx 
(/x? W3(y :kv/j40000 test 667UwkC4"Γ) sx __Kruo4R*/i#FxM 100644 logger.js gT`x]ɲU:SE100644 rateLimit.js 
ŵ_}gM4h}x zT|&x? W36J	ءd	%꺎imG40000 test Enǵ]N5#U)zx __Kze1SvߕQ{>yZ2
x% 100644 logger.js gT`x]ɲU:SEKd>x zMM@Oxo ?;5n8.DQ#W7\b'>Fϓ'03)*WdZ@YUc-40000 test H_7VݰY쌈瓊)3Lx
	sk8},
      "peerltypescript": "^5. C'typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/tB7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSwW  }
}
V%xJ -drizzle-orm": "^0.29.06,
    "nanoid": "^5.0.48
  }
}
;FxB 100644 env.js M g`'~06~>40000 utils ze1SvߕQ{>yZ2B1x' 100644 env.test.js g3S.s(D_ B%sx Xwl a*t)x  40000 utils ze1SvߕQ{>yZ2~kNx M)Hgx8 (\6sɭyg-Q{'5HRK)@x # teleuploader

To install dependencies:

```bash
bun install
```

To run:

```bash
bun run index.ts
```

This project was created using `bun init` in bun v1.3.14. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
Mmx 8{
  "name": "teleuploader",
  "module": "index.ts",
  "type": "module",
  "private": true,
  "devDependencies": {
    "@types/bun": "latest"
  },
  "peerDependencies": {
    "typescript": "^5"
  }
}
4B:x(100644 .gitignore -'NW|ݟ0ad/
[100644 CLAUDE.md vLٝi~~r(	100644 README.md 6sɭyg-Q100644 bun.lock ?;5n8.DQ#40000 docs ?6'x3H۟100644 index.ts {,dTώsx6Ѧ100644 package.json '5HRK100644 tsconfig.json I}rR%~wڦx& 40000 superpowers JJ5&(zx  40000 specs I*9 йt菿ix	 uz&pɇ4R*