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

PACK     hx?O0w7N\!D)Pa(c?K|{V1w;RйҎmҘPu-e52JJ:c!+h:VBTc]25P"7j0w>D6婍s:a{yUO
4,[G'u)(c`>@ym*
K XC.{f5_.08f]Z68jS}Ӈ_Ę8jt;S'ة3=`0clFahF.Y&p/C7,x0 @<8c{>ly"TuߝLE,Y}Ȭ03Y@TӪ[<u6T]M[6#6aW=?	"xAN FbbcL< CKB4QOo{}3uJ)C~SB0.`NZ'\d8gOKdZ)6
]ck]᥶Gy:ގB_AԴ <$J)ucK3N%gׯF\r],tnPu@dPz?vS/xo0Wܷ4Ʉ	!2xvĚcGMiɐxs~2E.Iܥ%Xi9lHʺ.8 X2KyŘq7YGXsC;H2O$KxqmQO708Nʴ5zP¨k&;ǎM[m?jxX)C1 ߜ=rylk>`Fp](x99#?~ZQQ-d!.h޲#1@ @$wn쀎2Cm](Z
~#񰒖^ 9)4	ܯ7?lzs3`Y`k`B0,m=JZ".xn0~M6NR!R.UngǞM&vd;C	yR\=jnVVeQkݮEUosfh#ʢPy!+\g-"u޴U]09yZqsicdTEMo*&F%ۚ3<; 2`,lL%Fn?2vmU`0278./7z.&V:Ij~0(7ӄZCYgCoM;#{aiKH}~|gInhqt;ԧ̺]clM<ЊeJArZӳ#t@S̀O'K\/	u~9u9 
_~qx]j @wWqO)%~!`LvFJ

dL1DVM
dkFcvB$h=&e%eI'X[V[c:ގB7j;(gH'x^"q+`JU򷁽;Zak9Q
s0Np?3q?,:?X!x1O0w
(IB b_ĎPvz}7|MЩ1.d*sL!%KՆ\1e"SR̩"i6BUr+U	6-@}{FMs+H%e^(8I$cYf?)j_B`{n؀u4:Xn+"gSkl.փ 4ّ葛p|ۛq?cnwy$:;d=FoضP|Oza	Lcq
&:'s>,->C	wL{eV|ʟxAj! ﾢУ%,:W}U3Y*7)E}ʡY4)F/N\HkԆrێ׫`wme-IG(4ígSGog=?a1v;vUQ\"([jT׀R5AFo~?V$xQ͎0)]P[IBrcH}3%QS;chWGI)@H~3ǁ4+Ymd[ָѺ]+Z Ŭh9h`iQV)DmZ,+-[[nazcw]Z~eYRd33ű߂Akv3 e4-"OAω"GZ==?N4=^q\XT	γ$yYءϿb>ec^[ Cvfy׮8BA4D0nzwebrg2Þ}ui :2H!2J}gNι/v7?)`c  NxTMo6W:hvP&.z~"l6ɒTdE>JP,C4o3CFO4ϗy"h.r,.WtZNe!̡'ABt6Sbʗb+Bl('d=l5֒{c=9&uJ[ r6^ƳynbJu_豂ir!𰿆?j	bpTGiң2UE!˶n6֚g`,hk,3}	J	^z.K򃫎6X#@bj
B`T9D[tgs	)@~t|*wĵ0-ftSJ((әkMGeUޱm_OX6Gx\XcH"Ǫ?{a0Z⑧	4'bZ~17uUc_FĖq*;uLӄ=26x6'j!	0h<{3骟YbGBLM$qzyOz<,YW\[ɮ/oʵO悅pa;)::$w#%𖱴uiȸ,xuJH}!xS' KF	:5!Ѻ׉Ɩ||`2!-j_͡r	c1/j=svM{6<:۴OtW^d A=xSMo@W"NQDHԴMc{akM?_/aRZϼ#B&IER,Ũ2'U<`+<M<A:֣!FQAUpo8("e%iyFqJ=Su'iw40Ku0^[a-Wzbva׏ޮ+:6H844PzV5XjeiNs_mL
ά+JLB Ӱ(V?=99Eb}>Oo4ooo|vCń$pw# Q+E9b@Wm7G
U'Hh;L=o'_pq``QޒBEIm5Thˆ
%}`ZC,]B;#(5LṮJXp0g,6/Qٳ=ۜzYF)̰kP=$
C@^Ⅳ|:2#^]e{"R#s@E~dh];!)Òҥl	t@
۳GxMN09
%q\!t&DqڑZEXX}oG͢BEŉ-eB T{ET"Б	mQ1br(ERCQrIE%K)ny1tǨu44svr.
^sVewC;3h	:kpС.>>@=zN,}5h`i'0k_yh8ڐLkEGG8)(c9ʠOk{ko/rk?Nݒʟކ衄5@oy4zxUnF}WS`7MݬDF-ۍ@SKr$mErYԏKzf8)>Őə33ă('`#'<J8J3TQݠTփtpt~;GENt<^x]wU:TL)T\fpʔ:9KL:Ѡ;G<FQvfև	!G;zQYgEsԕv=T'`k	Yl`~vG!$iޙkșѣVԋTqP^5VvޝjI˻݊*pT	:2E:dN_}qsJw3[R;N7F8Tfצ ږUʕuP.پ 5]rv*H}0mHV-YXkN:	#Jc]h]*dNa,
LC}\df/`	-iaTl75l}-@rF1Y\M$=aJ?z$*< 9r(U0eq7ktf.t2,~xｫ"B-k2GWUVY /1X\\$lzZ|åLNhGsY+0
2SDw*xգNK$zxML3%%*k-%JJmdSVPH3ni2ZTY7NlGCջvXU2 IGkJuerʟ`!ZoeC`&5iث2NEti\'|w`}((}pNv[L!n˳z8;{!p}/a>?Wު[րqS*k1 {EB3W`-HWQ	wo7] 2-j8M%vISm+'
dDZHqpW-WLSx#HfKںRaDu2/*Fq-5,FK`[PEn3W/d
쎔FYf-??4cźÿZm B5s;6+qߌ\#?Epe\9_7߶4΢N{ɜ xJ1yU+o"h_ ;dI|'1-O=xEO̻P`'ȫ]uB(rjkK]Jbd#lbѴ\Bk.$(U
BvH]wú";F{M[m7ᜥ`b+&?Z6{1VZRHaf"ovAƘz2xK~	&&=Mqv1X"|rx'aMuC+DeT. :F]Цz
]xbH*[g+>^_b~ߺΑ3xKo0sB
8pC<ۓcxWg.]x20lniw`l7ƴֵ1veks͈LI`Fo@7o*Egn`cfx)3=&9r[<]6+AˆC' ӐOe=`p&{c EhzC@2$:I8ǨpqR>R$!wOLw3
r%bT+3-c8cf8'87?=VKl|Wk/HA͠H`
Qa֦<0aOC嚑u㨏QTOION)4HWӔ&qK&aTA%
*,Slq$5sW'89S/}.WBu#!/̗%1>{Lq^]4QG?xMn0:,6k9AQ4M" FGkQ,o'I:T6(EDr}oH"kF&YWUqx%ʢj7Fph<Mdbq+̰(*yO.4NHZ7Zᵱ{}|+^ֶ{I.u^%8xSDQ+1^ixo=tS~|Ƃy/lXS5 G>agY2;h@ht*CfG	o&ښzpL+m+ߢvDe}I9N],ZAQ8ք3BC%mP*Kkɭ}$shf`Huhs(q>x!Pdt4ޙBCΐr;oPz`jn1L	]4cL8 aY(FjP}Тϳ@w5!pmzv#ߓ10CvzP~~D4)~O'ssөi<7eP.o̒/.^z?OaxAj0 yvb%.%-76^ez+.x7f#ZOѻ491fv1QQ碀8gWkL]ю)MBXpSә>Jzmo`_4-ffc7,?e
ֳtZXKﳓJ-H9ZONyZe}"[ԜxAN0нO1K-'cW]#ؓRbGXjwr"K,B9B2m;lE~"+֢F=jtc=Z$ӨƒۀpϹqr5<\hO.%{GPmAbkL_
x)nT*%>? e1A\x5ՕO\w02;NB[Oar=JޖB7|NLK' wpAxSo0_qOMh@H0mZH]sk-tϹalH<c.w;GO5UJyUEr΋eU˼^hX,QГPEB)<;Z\*F)KYeawu$xk'ĝN%UyӼ󌫭2SpD	zCACp{{^15Z`$hc֌bGC(d:AS&*5Xt}.P {4Va #yL`%1xVfuI`Hr)&	_?=w+}#qh4NxZο$uGIBNJn|!ūtCC]vO][81<ygwE݄rPa}'Lپ VlQ#S!F;phbRm_uJc-DsYvG^Yߦ^>c$+?`r	W>:L'l^IkM×=vvTxoV#5kMWZs^pӄ:t^d3ؕ0jE^٫JlO`FxSn0+hq*YdEQ)RM4PG뻢Л(Ep@QY9_-\lQW˹X-,Y^|^΋c*Qy*W/y9fe/`:"O|c,l6;`7t_rӾZ6KxViVzߙy[dG2LE<}1-2$E5I6,%uXO0XzU}`tb6r>r#z<(G&ԄYPh'`I6[C#L
]o?mf󢄆Ȗ.xĢ'^GK̞9A1QΌ#zv$;7B@iPa d(9hlDnzoh}b42hb6i;QE"l'K%I{Z?,:,G7`CmM;p`ci.8~<ؠvi΃c<z9heM9mS`(&(/g7WQNJ>{ܘ1 Q8ߝvQSi_φҤ$G61]뗇#co<>kf{OxN0D{Ŗ tvαsBĖo()hGyo1Cpњlo8G
ڑ^;X]Hw62C<h8*\R'\#Ri{E.LTG0>8{^ks7<>M&NR>
J-MpSMs9<s<l_[]ƼESn2R_lxMN0f1i6MB5pmgZi+7	,-?%aVLl\l:6޲&$l]_ <'ŭH^cGj;Nۦ|^}Zc]u6uў6	PEyX=ϋ:}YO\_q֍wuPi]d:,X.zu-r"%81%cbLp)%cQy4bڞ&Xs3ESL4V)u$	H\NcvoCP5-/2*xRn1W:o#H".@]N[ml'3z<3 VE&Ւiٍ:)Ҹvv*L6B?K]-lյ4-X׮^k9u)n`cmy-ۿ@3CMS3z"Ow#?Px ptw8Ǹ@+!DN"&M(Jbz-$#A
f3'8l(YUCۜFP~#4q+1ć7	P+OtdUˋɖl	r^٥t;{a>{qtk)=gr2r
#^OB+- |άMJiCY0Kgt"jgޕ.~:O<޾?렟xJ0}.OӤ:݊ϐimBz3oo\p{pC<Bt1(^):nJ2頬sêkHg肷!In%JO$s;Mev{DxYK:JS+5PWj钉M /4h\mj+TB!7HsGب] 1/ qɑxJ0{b$m$g&)&!NY=z';8s܍=ucql *6g?2QG"ZO~@*\e)Ng\'\YVjg*W0ۮwfj_/I_QnmԒv|l$aRrxy:|lW]*YG"_g$xN0w?ō 6i$/pύ4G4oϥL|HT"_癒ƨTRVn2mUU@m*Te4@y\d)m[aRa&pop053sz	Ң,/T	I$ճ_),a܂|pv4HAlcI)\&>ɢfen=H"x{n[kIǁ{c%ڷ֝D]:m0Bf5b2qdt'7͹pé*?øi&2q=nA]6qF  cXBxSn0+h+hE>"G6[$Hʱ>f|E߼7R(eZlg<E%2C-*41KsKL8Ef4Ʋ(4!;ma#i>)mj#؄3$4f<<I@+=&)0^)B+C.tYjEpǄDPQEuw+y ~y ;S)x[ 꿄-c}XHx{;}=[8h<'mжYRH%\6KV'6e7}&T_qr}T)6tCvS	/qུXwz4romuU䭉5O"k)]ԯwP[(GmP֊cjh3+Ѡj0BXDчo-.ѺAq5ӓLa<Ӂ[;F q,xn ~9&w?GQt{ralx}⵶JӠo'jRUTvholz!i(AC-,6s04TuEҡA닾9>ȓp1*;c=yy@ÓN˽{([T|)ڢRl [>tnY[1H*٢a$zV,;nm_	5O0dp9YrXu`{A%xY+h)gR_CR'MHq\O ]PO^ef^Nv'{k;t*66D`uҘy~R(2rm~mA#ֈ|ϟr{y'4pU i3d pxN0D=H*BR^ԶDāN#4zAd7fk(cJ(X)16Ezv2hMZQOq%ܦ\L{L|~:S^oik{K	yALGsYCiT3iE4C\zսXʔ14ǉp'no S/[c7Bs{>\?.eAU>O|yxj0D=ɶ")6oXI+lp$YC5{i`fH9f9B'S`wY S!>9{Yٹ0"u&h:	ε1-v*#eJ8o:E;&?
 S-w5`ƍXk;|Ne'	U<eW+exJ0ETd$Mtdq%%yi2):o\pu¹np4UhEb舽R;(BS6̀}^GJ9*$dG8؎6
rr)2G'Eգ<H#enO!/4'(6Ӷ>6KB#a|
w_/	mfxJ0ETdiddq%5yNSWo\pu=HavV55v=bGtf*<	-[}6Qkh*vmh!ӔvI}=Fk4CUDKoUҘ$#m;3 /+u͇ˏ$v=|7y$7,fxj0D=IZrmΥ߰^#Y+
=yc]4Վ57a0֎#BER~8A#H}?zhS.pi甋,JN%/7~|<ڮq=<ijVTQKuj\V`ki%~%粭`>sU2P2e1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+x31 ҔT7yP[Y׆f&&
z)٩EyEs~u
oe*ty_;Լ2Ԋ܂T{/|Ui?{CB&+3KRd7XfVEvA&ZS3CMϻkt`ԘJh3˃w*ؽa{d?1>.z)tW_2fӡ\K:#$c=ƛdl?b'TU/بd֟WP 7+sӑ/PEIzYyf?\a$lpwnA߽0Uyz9@K6dD\Nm@5榦
)9zwG\gl
:Kcp
;=AhM-/NիaRmtSxhk}oM끄Pq1PjZK:.a9jXAbrvb:׷ϭ_ѹ}_ܟ;@'g&0hssߞ5sωZNGˢ\!%3xrn<t-x^-I-.axXsy9ǅӻ:=O89o*89?/-3ܕЗ[g
I;E@x340031Q(N-)K/*c8|3,>ȵK ,xm=N1i,Al!
*zB(1ږGQ$8܍pF {~z3P11kg/
ܹ]FE֡|}]z@o5FB:5s|%xd.JZ?O*qu(O4j3hd<nbUdNjy2*c܃We /8x&m9J*µ2t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Ĵx340075U((JM,apx<MɮRl z<xK*S(̼ I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ْܽʒ _ql?6g-f׺ڒABJ$@PW9.I̕{:]%"Ooo#%Wug?T1
nKv\os9SG'~[$<?sk*S ʞ+O'U4M#g 'D.!2;#y+~>cO"x.'ME^7AI]>T	QV7{}/v_tϚ54?g0NW@X5+ye2
$lx|#svŐ**^>&B@oi~珳&vpL}7N '|VoQ.+u̞z}F}fڹN&/Eׇ҇:Wg>S?a1\bO&~@^WO7;KTh]4/ڎPq%A_vySKh;eM7(ζǻ&>}qu{iyەK@q
n3dHۤ~T~>"~ԏ~?¦)~vvưz59O⾀{"#ن\y%"jδ|V&A'r0.1Ѫ l' &ZfϖbhWsơD^x]p)ߪ'~Fʠة?&4o^dv҂''WW;<KՖԬ[݉RDՉ#"#țF8u0X}m(@hn~j*}_6w:u7ڋZ8m]P8|9r>ʔ5!i:LÄ/	?ע(h=u߳	ꍿ?tC1Q}3	3T".O2`8cǩ#=7T"060^詮`-VfPmqEY{i^E|s$ӣK|~g_"Ϟ'o?9?aVw~Dϥg/;J?+ܕ|꽩3454^?ŎPO#3bm);1b<u25wSî5v`CKYok`$<fPHbZzKj;QL@bf{5rcz
n)1Yz&L?y,j*AW7xzy/]y19_ʪ_k-HJt3̜n㣋b֊~c!=ĨL5Ydd}
΂
	a.Io𿨳8RUMT&z]M33)/QەI"fS6g+޳;婯o@{|IeX<^F
2teu5>3N2rKe) a</+ۥg
6W۬On,ov	G̴8;u\ĸM+8l  aY)rZGߋYuH)׽xo'8@B Z{%KܑD;A!pGpgLG@=d}
>~VpOJ%\%Ԑږb8P3 ([zNf3SB<=U}LodrX_pbpw3ʈ%ZPR &Wpiy:rtUv'c"/ٜ:x>[ǍrRCrIݏ0|3?jcD~Sɉadi9;@iŇ{ 0#\9U}ބSUQlC+C1,YǛK"?_P2&h(MN`jcjjt	E@
vj7 VYF*LEvBKh{Ic8ǻAxj'jWۤD>Ӑl.4MMO29:+FkStrZCL?̩Վ OV>GHN0&%HY%ح1ۅAF4T (`媬e<m,f,t13XdH}-Ǜ7yEsvr{ֹ쪏vFW3/(k6uu6u[o{gtZ}.yǵpS@m0k#1
)2rGtw.ƨ I$`-4:wR_THw?#-ڧ.Ir	QvVT	V)@-'.Dc3 qVF:I'g!%+GcYǕY(4,\$sՆ>$:7z7=L9di,rcxr!N۴r&W4WlI)ZV*EќPyɕmkIpilrb"WͫH ~.;s!2elH"F>u:ʥ(ͫ١4@0:h(=/Y*濓]{&-:N0lU9U"'n$;1!I`b]l$-Tqٳ.a?)T؛P<|q	N>4v(iB1Iڒ.EiOnՊ9;%Ow$/Bs6p]O+?~ԆK /5osCkJjDD[;%kc@:ohv҉ THesWޫI˭d&K</ ]s
<V6A+^g˶ԘMfVm|;V$43g\7O
_wW+V#쇋Lاէ3CcfF)IsHفe1QU,tKшd!We,IO+};vBЋ &r_ѻ-=<TJS
<N{/mpn)1VxnLCai,syrK\<;b3ذ؝[GoNmb':nR/@R1v
:U~֧W?VZܑ҄b2KpO:m~+oJ^3ލBĨA%J*K%8fcjS]Vlm/d)y6mUVγS=ϴ?gr]ۢȫdB'߈t:)3j=7Y ;kef~yɩRMmed 8G`2V:wQS:_uOiʶo~4K.lAG\㥔Hêny8?$@Z"$Ya:nqu:+9tϭjM}GQo&CY?*.K$a{+fgYEoqcKM>}am6<H%	`:E	unE		W4S2^@+B%PK݊umץО9Afdv3~:p
"{nAjuX\=@]Ú$!qYAөϦ0NXIa#hnTȌj?
Y4gI0@bOc7G@8wK Z&gϐr;'0/½0#ov)#Dj[0,G$gU:-:wmF`/*a}B>N|F7v+tJdl5|)҇aP v-Y{ZsvJmr-p<p/[jI,2}[k*1i@R&lMGZtpTXۤ繞6iǝTX%mˎ3Nja6:;b:~?Vw{Ug})aSl*jH^ޢW_T	9.1ɴ%_FZass:ByT.*Oʭ\6"ľGZ1bim46iz~LPWrTn&fVaw#k^Gp
O;x/;+G^j\ޒ1ڋK0O^~DԠ		|\.]T TKK5x@p
WxhԻ!VpOq#魙M.sOަ{N.q63H&RCNvF9;N]#W4O%㹍HIgf `Ǔq9c]@0L%Ϝ=ʧ
T
1fr	^!C_M<껴dy_ (WMUv0ϗO'(|X{,}GȜ1xbFvJ[+^ _N&0?G[_&JiɵMa2lj=
9hdq^(u7!lp,iͯ:t|WD%5c[-m=3@7^&M̉-p(׹`I M)Wf+Zrf-vtG4`aT?-^N0_syoA>]$"zRJnXuzt+((y./\RnYu^gz$=M;\Rd'1oUD)]LrK7jI|I`ؖe#WःD>9T;e4!Ґ 
ً0vΒ%[Q3lwgOe-,2Gg-j(ݔlI$NngBd3uGK`7Q,vgEJRa )v*G?+TJCbLڈ{w1BfMVTH.OGn65.miw̌e 3BR)f'"mTxX0?O?Ug})"6T39Xk¡o)W#9u+I^;O*1&Ҧ jD53:7Zo8EIrm|NaMҬZ52'BqۗRQhm6Sn)ys	lrRh˭W5,rd
ܱZgTw+\BCWm3Kw:(f׷zSGKfyQ4şXО7Xnl7z;5PfΜR!kbnǹ z@XRvȤ]I59g>g$bSWJ*WmE`$3zti|kC%r^4~-@-`[:;+}wQH/|?ʿK7X,Mu~JJc!q<[y>k27m0-Dh؅fd#k	Wf_!0̎0YP]n5N,ԲbbYXZVs:0\t1S@ueC͑,hP H֦)Zev`-{][׍%?`}涥iJ`9H$$rv<V-VK=<,+<FK[p9CBꩻٯ#oˮ!/nѽSȌ]%ߣH)#es//sTlXJY#A$
vj`2\Q'ThtC0-ѹkJ*/S?0ᯰFkKm=w*`X҄,f'xХICSz`}14#tVSj͍V_?QgyM 'G6<t*ΖPXW)I##FY(s,zuvǻ7!,OdyrGB@IW<bIz;\NqsיWi׫5]̣=U1-\\çÂuZ&n|1Ϲ=DJGw'YD!軴}@O;:9L	fLP ~&X=ڄ0BpH3$<MMĘm'y~&)r]%Lk&{ܹHESr8C9es)ی8{V+=%	]#R8sǦWKDÚ/v\lr^O(֋mN
T1Q[sɫjɵ_0SYktPUҜ[*jg!6A/)w+wra$\%ekt8:,ul5-k@p,mGt^;:C&z'aTDEe/L?#k
T,fv?IM9QJ	8gSžR!B9Me<Xx=I3RaxC#w	-R$ŅR ^,CcPy^	,T:.'\\Bt'Jlƚvե1|gySEP\1a9?$'b)ܟo^,i$$sTٲٺILDSpUѨ&|u\U3ͼȍ~w/x?a]1yVh -xh:gKE:ou0ڕ%@&aN֬彫.%Y E\ECNSEEp|;LM+lSQ5*{mjl1E˜s%~Soe)J'%q_vYSɛ-<;7׼3z0JOdM6{g:ڭ֛ rN@r@thnu 'lU.ZLWMi^z:<_=r0V Cخv.V;ieW}::G9薜A,o2C`ʫz?N	c,2ͤ&vZ-ԥp>,	vŀ9V*G1-K1m-5F|
	ܜ6. l?Q$eY%|	j+YWc$.jaeC
q7Y
`+8\NmسՃ2+%c; VMhf6UF/4?\A"E%2enueot;c\E:w8B 1i.{kՠx@,^yx _Kf?>lV幀V1j'-jlE-ڔpˤy:ggݴ4!J4Zê+ϼ85c6\3 LMtdl	#k*j/a <DeSoTH`ly*kz{0u}QFlEt:$)J^ʓjnzŏYKvmxMrm5f]m9pQUt􀿜vk
ƗO^Steh 㽚bY9ah"Z"gz#<
.I<k{1-I>^<5oM=5' .~.2ʅ	'LBaW3%"_!xd>lnH!{2'IkKiC`8xj#7A6v\@t
EM ]Wx}+lI%$8'eaKԪ)X1=&hc|Dx4	,>a'M4\+}
|Ke]֯<ڝd67zBUKpƤ6EHUn}5VD..)>NICV[^٧~a^g?=%}D3]Ts{;~/ݜz.ԃǋ ]j`{GF.aA>04ҡ'o1IYί3kK?޸Q^S=NoὋzW~ug)W~_r]y~U_=wG]&`$QTCo~ybTCo0tGoϳ'3wwGWpyz.x~=
s3gA^]WHojN|7_?k%zE.n"y#7Y4h/#_.Zi<Q4z=럥pqÙQ~GE~ӳO9q2~*k?_rB+?3?ZMo5ÉjJaTN~OSRZQ)TWtH5旮(ɥvR}_F8CaoV]s{Q8j(aDS(bcPrVCah@9֧VH}<4`~IdAKՊ"W'Ehx$&xA^1
[*FI2hyڭ0@TQPyߞ^uΕ?.d_@~9/`h6K/ڹwmM|+3/m@oُ_|`h6Kߴsu2.|% _tkq4f7Zh#PWbM.%;INF95L>N3,	27q%+j\^3?>OE"̘v-<sH;n`Pʽ'E`emKI!QA24{y:q0iAaVz_Ҥ#okI{:Cz.⚜)uhꣲU0ucB;|VeRxMrڏ=?hȯ9&-!{i4a4LłeലD$9 lR;G ;cV4LeF	7A5&)19^RqYfQP]QRwuS3Tt)O g\8 gK,Oz!
JƦ~ЋrIfkC-(ώ6 ihg](e)h1^b('<#B`Z~J:foUu I(iniv8 Yϧۧ!QSP&+,ģ;skMZZ@$N8H	^I%CFǗ{Ep67<Mp?o^*n db3e,!UdLԄav)	9cQ,)WxFYxv_UٹucmQaZ(t*5YxX&%%< 0ϘP7rq+%6.ʨDS8m{f.\2$e@\74~3l-$>'(vJe;1/mRs88ؔfH3|yJ*fR;ŝk͖E;و
},LZVA7HˊWY<] {o2nOYTwOu}м|rz ǧ\0^t_A;bOcLi&xvprqb)92d@1U[Y9[A$=6hiv녠IrDOHEyAֵ/x5c\(4W|K)Q=8ƬYVZW|' r"Zg#xtdğ!FR\r%X%eiD3tjFlfMrR,Mquԓ'Hu4R3La3l@TggV؊ʗ|3'ĐMwI_I6ϩG/h^nG[%W˵,Zw~ν{\cj2bMAqww%D츐4kgCx	vŮ 5}:"ۍ6R5V$7D>=FD`sRsw&P̳b\[txctcP-csS00Iq\@W(rpnA7\	meoax._﷠=Dx޾,"՜z[j CA(f;.r҂2|L}lVg
&4Cd솦_8<z{}2ZFAzWuېc9%(\(8|Mrc* Mv$52DӚ=Uvׇ/S<<\5siM+'վYw{g"ϓu:Wi7_Լ2v:@?oZAë!^{${급*ÄT1ule:G;pA]DHj1dZeyYby5;o@"!ㄓ:}-kKON$7McjR!ɰJ̸/I#*{lٱZf
Y[\xtKEx[K;nݞ]d)AIұuBY GP(rm:bc.B%*	1"ev-1uI#.8f8p|RYBC{R!j:z`Pt"gɺ! ;NB?u`9ߞ~u?Xz\g ZEs:PcsDbך0<K<A&׾'SQ͸B)C!OѨm`t'	[5輏g9M^g#f`<wp18QCp]-rnbY+5	-3@< +[]U7N{36s:~Yvy®?Wn>tR넬15szEބu^W1L]A*R<w%j7d-5#WsbVR]$Qq^x;OChyXqXWNq؞l7Zѣw۴'^a׭A,m^K_^o	c!X1Q ;V>6sJ51͖d2<;tjșə]Eo>šږ[&(=giȾ+d:aFIv%<m7YfOZmuwnG I֡7E
mN`(~?Ark;WJ Q An?GfbWۺS4[vgfw0[.=:"!c!*v 2 جny|XJ\!ahUKC$>f=c \U>!$zVp޵;ᇌAcl"9f0R#]Fu@ᢢӉWTͧ 7eK zy䟎zqnWuEYV{w^=ׅIc<g2FF,S+=65N{?T|Pė x6fkpwx
?M!}ٜbØ*DT:󏃸ij&+u=ch{H7&X^l
"I9]K3j4rcVjLr4հ6MÌq(
Ggmn{ȕxpN>EVkY+-}׷#<ci[^<Lɜ}=A?P$!l
Ttb;q?>#({DcKe<'1pǙM5aҚa|N8~S=-pЪ(6RJm 6Yfnuw5(8S%m.F>ظڒ
<q#77eCȽғ7!Nիi-eBf~]|vW/^XQQ?mvKL{Q]qP{U _U  Oq뻟hy<c&6}{kI-iV	9n?[	>8%;g;3/6tq35io1!>hq[TFݩǽ%Y$y*>`[a;	!4Do
-A8^Am=>>`D]8VdDKWPILi'msfw~mH-O>lnvErݾ`b=ZfwvMg"-НVQi ؘ+GҀ>lJa͵̀6 sr6<,hޠu3=]φWvX0-rN˅G5D5vfuN@K/&
ޅ!Pʄ`жKxXLR6E~إz]c){
b_oV7'5Lz4~fxq[;Oܮ_$r~Bi">TZp'<=n"ebF2 jsftV)zݥ5q6RL4b˧Bۚj7Klu{eDgI4z99:Njnސ)peQc1AwTh(#|\)zSa.w/BwJݖ&A$b+kO$ю*,eݎ(؇Wq0.bT3L!Dx/rCzk;	Pz򀼕Y`)Z`en64L:v664.*J<bw1ɗH۵<"-c**ftH8\zvKj%\8DXmMÒ-Ey>"_U53vcb)>_udh/O:Ϧ=-2ٰgôtVZ{^ؤEfNPwhv^Ϧ=kq<WC*~}ȦkqڎㅮidbT{>M'YF	%A;
C:UKG;;c$zWy-s;}epUfвeSNad&ķM<)
5y.I^Q@O+|dt3Ǜgsuw'%ZbXl%ggMp&wi1mdv)vNbl-!:фƽbZ;nuy:uObȒЉu:IyP:nr<kt'omEN#V+$F->&G!۫%ǋw`Ӿ]ۛS9>b(&P%#dZl!BhlfYCY'ּUwQV7g̼>?wڇ9m\&`e7qy`<5]x_'\@o"O$;۞>a|X-m:J: .QWB:Lu1!6O]4PV}Cv"=[DRF #+\?>`T2>k_cAI JYbgrXѝ|Ih*GĨT2sS03jL:Yа&͔Jm9cVg/O'L[8h+)je[Kc1KvR#:{jmD9\h{P/ֹMՆ~G¯=&Jhcz0_r'=s/ڹIwmJ*=3Ϡ.ҳ?J"=3/wzCVGd ?J7/ !Y!mc8x]F	XL&d$vYt|d9X
>!,,!$ The"2T`[\lz>
I 8*܈Rt>$el7d2ozJvbAF޶}_߶2|
qg' qog0rQߴpe>.07D}%{[D\96Vr[qg}/|/}F,])E(,ckMiV:0ya $i/"Q]`nX`ls"ם+oT?el,s>TB1m8N:lKz]F<OK+Ȧx)Qҍ%X8sPhiRW9+[-U	ݥC'~	\3_HەM02MpV@ė1La:mNzȦzʴP`CR(gՏRoz{.s^kK)9&x*JV6˂K7q$
{	=C6as66Nz{\KKAvx,lQr2Dh؎0 ?wmYr~=o$xCr!DrIfS!V󻇝֯/5-\<RXjRN,ngԙX:P<*u7n[*ĥGwiΥGcɭ[~W(=5WsW;C<g3Gv:P_:r`to 
BPw!k雗"}[6N״v]{2ޮxS8f:W0We0ñ~3\JյH_7.MK.MqG]V@`;ZY-sy;!D3/TWb\
bgA1J%Fީgƚ6"u^4T}.37B&ѥmit42k0]i~u$7.}kk@sX٤0CmEMn1QBK SջC2g@%[2}7d֎JfS'kZ a43Ȋsw,Ir9c"Z#A6ZSUҳeݬ7iMZ_upԦe!ys	`EKSMj}dZBa=ď~آ^-:?^7\}*qI+VT_>ZhM"h1c{%UZgq@ĩ|YfnH~meіRIJ嵢.r[ORς@<<UbGn;7{_:`!ٽned9]?yuQSJ={طsNl֏RKߥP d,hPcҠóHsttzR2QR\|=4*ڙz!"I4ÂNՔwSME&%&Y.:%I0ksֻ;Cr=X&7f{}Y?{W>"^R1AT ߵCG(+;UtappB{/pZ*3|6(s,t]<tOr}RE]v+*#u6xAyT2x`\9}!,[_v\#}ϓv?rA	Aojx)hE^܌k:	B[g%y##gRte!7v3
xcJU@۔[k'8m>W{7UDsnY,/y.a~7i<rs=ܳ߼6!B};<|W<EB}h:͑BK2
'u+<N/!$~{6]Xq"г3䬥q4j,觳G6:s=׋]5.>ۧsv<łfZY>>TzMe	TN&!;a(`jeNI׸w!H3EODWأkr<B}T8!7u+s26#eRh$S?j`{Z{!BDKT͍EW](C+Ö(|!jeFߟ#9@Gae99:inV7ר~'஖_=Q{64NSO;j3FҊ݂,}G8x֍렛_nJ H"
#vm	`Н&a-5~Y3	+NP}ݦ$Gq	rRפW~Sqz:߂v)mws'JzVSDĨRE5>Z 3Ili)p?b= 
Hmr&L)֡.2feS&Fv
"~FJt!'zJկbW }U9!,!$cq&1­=@݅hEc\lFs$r20lؗާa5#NqŪϧ WǱ#q9#Yyiufiaa
ߜɶҌ:*9),	N{WAA~9,TBf{a1M@E	7wrqә{AMw͵~짱pNBpdk>߳zHtbNmC`Do|4iu$!!$D2oۑst[?ϒe=RqhoT(jUבcCvhx5YZǊz7@$0zh"n..l1L&y*+r2N`*Km;
lRxu~²Qj]^-2H$Dނh6,RtgH%^,sRIp\wV	KhT[!$3Wu'rm+my*oA-71.|*SʾaV#Rhҹ'"d6+W[ߐǅe$'uznPCdsRtX
B6d8"DShYreɝ[o܄]eǖFa5U͐i!	
ɒ8#Sr#1՛a}v?4?0}PsZjljm0-tlPZS-oĆڴOspkz>]ZuIXgI[A&tޯx12nH$kS]r4pJlssw0b8s=}$ob]1LcwO1=. (]#vZ$Y&.U%olL}΃+)4e=n_@w8@x)D)ruxK16\5P5Jb;\{~D'%RqTrysh3Q8ٛ+kStv(6r1h G%z:Kr7ɷhΧ5.?E-w\婦tS2VVi`>P	c_O;p4y[eVd[c%Mva@~֦ݵ;w@1:3$
[a+[Չ9t,览%X>Rgnj2s
+rk b'B>QpLЪ!8ݮ0ivU_ɍ
S8`dY_WqHfmྐ}4S~]-OJxfMܿ_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'奜?j⴮x͖9o0w
BCbȐlEE,іH/IIh術w|2@(⤆h%*!K'݀LpH[)+&MFVPP(#8m]̓ZЦVBW8(KaVy8Gl.[aˑ4D`-F4ƥLh:vYR3[1z.#mݯlsټC|7MomBRk2$%T*`߸ơ^_API(=aHBJ )C)4%d"xFEd(HoIQ
Ő&+bUw7#sm!d|{9<>{1- kgL$358TZ1QD(/!_wBԴ4xcDorSdů#:<gpHtM/aUɹ)\Alz'r.X]owbG{,PhS.l*i5cNN[Ƶi78qnn/lu:+slϾ^z+B.h׳53]FЫItWC~|I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x31 ĂĒ<큒.xF&CWL2LO8bmm:W(C$1l`=kףWCPMc0nWr47h240031QH++)f'߉!?گM|"#?7U/$72͕Y~317G2RR+@Ϛ1}#-QL_̼⒢ҢTxZ<YܙqOLaIjQZbrj1L]VNZ{04sOfQEE%@!k_;4^	E){'??wTḰ((-)f<ZɊ36x-M  箮x31 |0Mdy%rԒO&`Tb;̴0n(ds-nɡx340031QH,-+)fx:!46wjN~zgiCg/Y-ydWf2;6K2sRAJw^ZeVG9yfCP% ׵
\8[~=j]*(-OL)ru87ׯ["'jX "IWBxS=O0+N]F*v		DAnrm:vd;-sZB{w~(RYVVJ P^*gLPj9&O)XEI'eTc`$dJm9:8RiDz2?8 9tXEjYGILog`r=Aib:;\a<%\ݽ#PԒҚ&60jV2x]ѦI,/XVsKd5o<kBܓlPsN@Qܣ}aoMI%b0UU%|NzמVuɫ噁}̩yv,EM ɏWL)\ǤMX퉙H٦a}<b>xOK@s`RQ{)E<fjlؙZZ^<fL?3aLڢA
_Q}O?÷XTiЋ%)g <Zֱ@DW0 o]<Ad R13Z1 pTem,CgG.HbgĻ0Kqb@*;MW3ĸi~<J<uE4NOAYTہ¬BV6f*<zZwm C3xA-mF8!P_sSƨ2Eij^RU?x'VR*R-FO-5[1ng7(.oC~	iK`5b<X0AEC]xUR0)vr`ztmi!QM֒\iMf
}>IW:&ɻ}:0K?/eY"H0V	FEW+k`	HMg,5&&
Q!9BIn ?W&!f˔?_	| $6ӽ lQC9jvF ƙY/	˥j0,\**s=UI`q5N~	]fWs[,K2gpi
v`y d^q=V!3-tr3 ґPU@$736[ WW6`DLR®*$cԢ,W'Ig8Wd?`FPJ̘^h2nC>"CX
(s"i`5;9=y	p'v*;ɬq3nFa6 =Sy&f:^GY,18$s1 nD8m=,'qH1z	nũ1:FyQ[VPEnQӾeu_ǬS>&A>xjYuT?Ld&nS-3Le[c?n_DBwrfTѥ}y[o$ZxVn0+=97-Zɡp F(idHmMl8|fƃ^=r*Ae1p_4=JXM#%ܠZiK-JA&[\@ls;#~K&Emc5zIVi,dB7c*="m:/g@؊W8-p7:IEnнlu/E;gJ/An;h|:f8Om3W$e ( !h|X`DXJ|WXϡPR(V J+Z,1 X5BX*l4?`Is=ڵᰡln%2MU1S*gݢ?;"-j%~(x)h]xW*QJ;)Z/$='̐I{3c\F:P"228&²5թOdߦ]Hw]#qni4һףG(OeGnϜ#UGU:8{/Gpd)wc|A<
׷6&/}U,\8d̫NyRqE&᣽"oa+aPO
)7*aSM;cֻjŋ=:ˠ]ڋsБc$[fuwQCW2x=62%vk_1=tcƁdUí\<~z,D%mh9]s$-y2a<iN'-c!(7g5bxTn1+F9i
jPP#(H^{k{w^'-DR3{;0mZ@B',s@"A{/@5PXD
A>@ ld?j+M-AS ºq^?Ê&>	E+WHHg	(1	RHE׷s 0δ"C޺(cBcO g4??z}qtܢ[&Y1˹[^:vRj pkh7G=Ź]| ∽7$?zt+e5^3Ss]Vc79Zt<=<(`lk.1܇aS9rp#CXA)>>m]_'\ҋ:l=*?CefWݦVCx=:nnA_"e]bQt5E+DJr.+=W9<T
}+o^|=
;Tq籃\y%'Ɨ-%GA62ETRTF1(=Rmo+w0X*CG[eXKͫݳ:~z"3?M<rN%nxȃ°T3V?͵8K HMdıxs"oĒ/tg|P욤}eo?Cx340031QH,-H+LN,I+)fP?M"DBe怕I7>s
udiwg*MKLOM*MN-{ICds~é!^Ԗd$'<m{GU?Z,U^l66odBoPue?5{_yfOu8, hxW]O+7}ϯIHEgw6qٵ^BD#K:d7jg|f|̱#Fd-Vx@itCf8=dwy 0oZןR+մnWVn]w7Jzf٤pz"(L]XtKMS\8JV\fͶ5؀[!~TZM"CSY
x^70P1NA~n:S 9ouHE؟E|~ 1~"PHZVHDs-KSPm@3bU*h	yA Ɯ6L["|}ַ8~6&>#ʝx*SX7HKԱ#U9?،;5TX:8;:|)u	4:F.W)<VAeޙA`  1ܗwf3Na%'qZW(}[h |x<x`_(B,`VZ|:=nc_@Y9=7`w)l+R3aB+\J,*VfEI~iJЛfjF(NnF\^qҲ/cW}^H| $OJ^([{rh|Ķ>]T[91QT>ΟF'h ɶyNd\TiDgvF	6"c&Ƅ;A>#jP\
w2c?ݤm#̖=`
bs:ݨ|Ǡ5>SP-ء$Ԑ.Pbhy-R/z#.ny}LZ%/w-%S3<75NCM̪1_RC;+yGkPKc#4p8%}]'̫ o,,\_QsNsn?)miK(45uyu4N쾎ϼ54}QCj`YoQ`9!ݰ{Mԍ1 z	kbsXP3Jz{+~+)wydMq9Q^{@CI%վNOzoVUGYyZOsA4ҋ.dtNvu˭i}°u72ޓŵxX[o6~ϯ_apb{lhHJRq~~~M,K6H%e%!b\Y)Yt:,)3&7
NxKX%57R@'?Qq뾪w`kEkx$|@b%A+YIE(`qT\Hy[WS=;bW\V4c$z3Ȗj_|j{jve˂gc1+Δ%iX7#hɂ	x]W^ڀvٲk|dyoω%SA"Ĺbs:eVʘTiAd3tx?#KA-+/iX~fjk*[z͊xݍZewmΝs)^B,:D'WSD&6_@ǞpП%b9W,3%b_vG2Wa_VI1؃`gACZw0	N6a8M_F!g',WErmv%34&n`^{MU$٦{ߨ!P>(dRPIفT/d;-Hc rD^SovԞ^B2)wCwEE.#[n67^]nwD{~_
qQr\K͌u`([u⛆ƟIX3hy!W}lauElԇia};+0C3yVUY~!9>!A5[^Ьch!갩}M3r ӼˣYNj*+Tl!ӦV=,3.ۥLAC{&Y"a/BQo8isō)NcS+Ƚ_Bsv.\LޠY52V9'.M9 9*I}<}1,؜EKG
X2cxNN[+)[ʍ4G3+Wdy}ѭ";"	EOM8}^rxrxѵ5,'֮T}"^rE%5B*LЊm#'hsKǅXe2/1H]Up)9L8h@ޙ6s<=fG1^hg?zAxpWQ@b b%O \	\[_lkw[7fv9Vm6u3&+`  :5sr|uA
)&	Y64Ͽb#Yo0W9MhO8҉ݨ	h˞/df}6L.kIhX4%;eַ F|~]t$TkH7/(6}2
Pb%\0:hHiϏi%L16NkTf:s^Cx{U_=Bisod9yG^Zᵐb%	7S7ߛ4vW[-;Zw<>U|ҧn9]7catr ˟858NoT{ګǕo	l\9Z]'kO}Nc(v6RSwRSx`$@v7KbwsfW{?&>z[1xWoGߟbUs	AjP[[wݽ8nޙٽ_Ij(ffg޼7;<3:Wp	Gl`f%th?q:iM٨{-T.3aȥ14p?D,-ۡ:;w:p^b\$:Ng)5WڐQmcaYXS\#S:aJH2RXrhhk7 2K	YcH6&:_Rfc.Go}8yrtl+wb7;xpwS>~T01&3~'\% <҃΂[RD]Pf-hlŊ3FggWX|,5Lhp]v 0iͣz*Gw.$1JɅ#:1`	ʃ|*neOb9UQ x#?>!-Q:jY!b)! 2ceyrRLSHAY<~Ю.W-X=mxrō!pt6)rST(E#9g+LENՂMzʇ44`&F˅
$[YQ0g3Ht+ZeߕtKgBͷ阝KFRSC~~ Ou5%NkC9Yw|tM5BTT*q"< [
%{1<2O<¶j!=:K(ho \(P7p,\ccFU80#
$)
3u	ly7ƄWN7')N%̊Th4ׂ"vpU*ɕngƪcG+ڟR
Pm[\>
C_:eb VB;?Q0"QHQT]t|2{KJRDF:!61	9h	%uo,R6For^!2s*bi-3$Eĕ>E|8SU9=SdeV2WZp&Lcq#Z5)ۘIHKtf~1B/N@]*iF-L©޸7(@shMĒ(oSS|	Y,3}we3_h{Dn=d<+gBP"Ѱ[԰_~	3eiLCƎZv q_jzv,uW&Bq\6vwxh=#n)X+`IjӖg#VܭBٮTɤR1/Y6|{Fp\>fnIQJ H< U)ٚ(il$rP-31QNO9ϯi%J,#@JߕM򋣡^[;?7L/3ui`$svuV :1.ʁmjAf3ʁvў
eZ|75[7xA}SuϼĜTu\j,
&E6GeW.M΀P 0hTj/q-( d 't  켞xZ{oߟw8Tw(8$bv"`jEI<HnOO![N^sHQkw87YYEINȚE{Z^W%L]7+2]+8n9~Z%Lee!*Ī.SM;Ov@y#+3l!hv*K:60&Ib/\6X%l[)3²]#g pvrH$)#i"؜	$f\)6#<'T(ea*㫂V1ow}*bbNӑ]w+s2!')^oWٔ1 /ľxd<?PU"WK_F$6	J!2	 
,G:6kXњ;H`9ggs$0BY41!
I$c<=}H,Q䒭,5u+l8N'#k ;4J?L#x`kXumI5My2Zd[7=E"6<x\RTfm/AZ$OEN߾̬L3k!ɑ69\O&ɯ~7]媄B/$'hv>{CKVYF!O1jAZ4
)nBk_n0U^GnnF̊7?OiVvd 	Dn'wjR-ߠWd+F(`+!ϣ?|$;=}ȂdpRg_et\器U0!ӢHT\y!rQlHI^k{ĲEڿJ>UFq}>*9_|KZT2 ,!ᘤTdZ\HBց02VmεGUIU~Ym*KF)rC[GtSDyvɒ*rm.l0ͨA$G@	A f|Q`O#,Nݑ\:DPqԳzZ"[׺$ ]-iQ\VГ6wxajVNEs
Qi#ڙL-r܂I1,9]jsY"7y'IgF+*4~	N
'g]¤7=7FҺ~5iFQ;G[pn|:%
,|Xa0Z`or5]|T#$cU;pg8nw`FH#*#tЛE9ִJ\	C֢IJ<}P!7_9 tu"~*,VKeTLyWAO'<1ЉHkOAQο\	9KxSbrDELtG; r2hidt(Gwn
tߵ>jIalf7'e}[@lS 
W	o~%F=o0ǒK;;5
H!	 !_jX!2Υː[MD̈́qmjbpXh1o
WM-@g|6m^q1qwm,]13u5yk) n\<۴~}wf8WA~Zv0h1'>'Hkע3J*oBܭw(? ޹ëHYkFĥL]1}zhAqF!U||<p3r
?!4T仃$pZ$FKxZbt&{;i9WgEvzZabNצ@RM91M5~"D] BAFB]/í|rǖ85R8u}.rf=3^8DۛIZmǙ#gq]*
#$N84v4NG :]k敎ƭN_{m_Nvth HǚQѽKkY^R)AݥeӇ#`58y{\;+t5uG/N%U\m^jcZ<be@IQe @v; DR%ZgeulhmiF#AL.s
Zdigխҋ(V-n߭UKa-}-k&BeeA `QݣwB*KOz̨T9ٛN뚔>9n[ܺ.[bv{xYWjP`rcȼ:lC8ssoF!7lAw3Z;hǀpǀ`KZ QF!:Qv:O~1:E|HŁfir &|!|Nx'{,:s@2
CRZ#mڎXF^˫ȶ4>䛜aNd	Mz(^:2ޒ"'u**'8GgCgYZDP(<<jm(&*>aKmk;YnA2	B9Pn0s}VܦVx,~o7E`_U~4!(P?Y{w|]&L9TMtbuOH;kئ䴥^'/'o_{tm_`,a2y%"QOMjt3xMW}V(77Gc׷4n_&4zO2<	@898tqR:lcB	wږx:n-gv/4֍]԰q?ccu`!2ĪvlV3JRϮGlY݈oPĊh+Lۼy}Kv}Ft*@uůlj7){
OR|s~<w4Z=5^RO-\$li%CIMR%ჭ/mncӕc|k	lt0co>44>}蛭w|~eCeIe_J4/;fӁOξ>,}6#Ax{ yC|Rirvj_bnBqIQf^Bvj%G A #x=ksF+&ԒtHʎoSWt-ɱʖ܇\*C'!Y?by r<Ε~wc6YYQ[V4ΖǇlͦEd4$*VyKٯI~J#ܯ4Kb;~*0yfx<IDZ%U"ʽ)i{-np[O0^T	Rm@9~VGWz'LXC)%fpn ´|eE&akXYN"1d"}NMKTj쨔]e^W/C&Ҳ.
HtȦYA!x%!Cs^xa{{{p=v i
Q"%_̲"KV"Fh^Wwv"X	gb3ѱ9>^,՞2Ig "e,bx8#^y2;\B >oUL>3\ P&$=DVrXmTbs{1@ 54[H5bV͓#|*g	=f^8I7;/!7E2KR *X6sarURcy)
Q$j.ԩ+ 85	JZAQdb 1\ "BhhIE	Lƒ)S?a6}|o0j!f'a SKLװcv0!N2*scٚ\g,N|Ivj&	YV=9fzbyPOOۧ*J\yTde)U@ՐHwBc'u6۱4"p!0cY#;vbLz*.`G=LT:ZJF٥EP(ɠ80'2z!	g!)2fuiAo𡝶<q)8\;Nm~^/l.޳8ZK0g	4",ծ;,(JHǧoF탇J*5$DPX:bqnݺH,R~K)QrGi#
Xd΁']B5R]fpH.H%7F%5Nk	HJ>w=cUHlgK<Ŗ=ùֿ?>Q1*N}Z|hsQ/<0Mo$<qTCƉX&`{Tj䊰X,@/U o- U.?1<Y8~MRΛ,[NO1\l
K!||p2ONF^kEau;q݉h`C~dYD:桜bYfYRAwdW
i}Y
)'Ɍh7ua~d^:xY1PA^uNɃV1l'z)=	SBJKjyĈuAsBY=AeC^Os'b݀BI */<Nv" ڹ+4bǇ@X?TN &`Q\031 wUYRO4upxKIO^dW8:Vճd"Z٨U~8R[C`z@%&Vm<	`KÑ2glJe"ˮݤ&\	<h
σwӭ`9~`}ejf\:8iK(n6HlNLv
ߤ5d-'vٶӂFPSW@i55d/(.)h/Ivt8J{Yɧh
jm٘y%_
vd/ɵh,wv
oZO9qBP% }n}`@PnQQdEw^#F? .7ȝȬzܙ@H'H[bQTJ"PYFSy fB jߎSۤ[˼r59+f}!v9iWL2O: +>0{5ǭ	#[{|kys̾BFb>Av`Cӵ[ Xȑ tOJ7bh}@S U+wVŇ'k&AuA!&5R^Gru.< j%5w𑵅:Mk@C6dJ9h.r'<R+Q'977Җ:zS]-[)]u 7˹5$e'ciLlIFB]2zdI`5ȑí*5슊͢tu¼=LugP)kI7 ߃Qfqv<
As(k&0QV*UEp(T4B
tELSz-+H5M^صLZp[l?w !g# @@^Κ^
1ZEttR)K	ƒ!ӧ *Gxc"'rYނs^{$reyU?6Q[N>:PszsLh0_*P«5l:=G7sz"񉔳A<1%5}ц),"\/}>nzXr$13[]oL{}Xsk%}to8D'ըTKnSB\_8%y]j'mg1z~Ӆ<B^qA{́m̢IiRl'߯5}laH +ޖ~B>CO-K>~UF)i/`r%EK۷r4-Pgg`E[޵Jj'`?|w,6Cb"`9o?nu.3Q~>X*m	|(-CżrČgŌZ V=_Pĵs`j*G	ש~<I#BPjמp<GHw?^]^W^e8DS4.;BDmtS:\H:?;UBt1{_99L 	;>OTO:	i$hz<2@flBN
kE@7O|^B gI.O:^<iS=T09)8:s~eֹ!Ay7R\+,gBb*3ԬF}vtۅ
_գNT;2bԺ]\ڈd8%,[IQJR 
Y:niD%E"ʞ:&
lJ4?!ykǖR},ȅ쨀}zXlTN7TDdEԘx%+ʮ4q۸㘭L6NӥMB_bl날
sv/TFS5C&͇
;>Mb'1]gzJEuF %o@dJXf^b!
+hNkaellA<[nFKmѝnЊ9DYݭ>NB!k<1C13*Jp	:NWIT<zg}E@f{>%نگnRR2TV6=ghIK@?O$!ds0
6d.5&qp'Xm>niO8hjGdNj0f:ӑ5y@Kq6wr:/*TsSm{@.wE%X팩p6q-[BJ1bgH6k	0ZD60BM66(;7[q~)JپJH+ uV%c[e{;CCLg=>zځ;B>@Z֦T|(e&Thu=V X 2'ȹC9TBo),zKj夎[ˋ q	"Z{{8>5ԗvR !s@%0df!T{)_\S; Tgd6s[ƻjNhխ"0J~")
/x${Ko 	ƆI[ռІe/R81MCA] YqGi{3a
b>()CIrn3ݶQn7ڱVXa!wL?k_yj%eKwZH"~z&Xk;ADח`Pi}<SZћHvVZEVv:Uj5Jv3,/
?IN%S;<gj:miP5}ܨ	˒Km
[)R|ܤtO5CtFդ)Wb[7Ul܆=:]P	k>86fk ݡ%)A·NR4 MwmќMP]@Ӟmzԭ$-EQͩ-~U87,~M|NgQޱݖͨ`RBV}GGeAZ_yOw;Yvbh#6[iA[ib`-)eyW
)A$5"#uDX8q*?tLH(0	]GX3 ~S.?_AY5"-0c +?e#3k`TqB3d*{o
w>mTn$k;7W,vX$!n7&y/ՒtXYAc`I=3	d28U% p?$8IXy,8<fdz4n[4R	W~:W_-U9/w|kYn{ޙ)BLK?nCk6VwPm|:&^`Ãc*Xa~;SࡓJ akVX.lyo9dZJJ+~g;~wNX<瀃G>4lլE$$&:γGxMxᶌ0uoor1mnw}{6ai`AERΌ[}&pLߜF7M9ǼsN#j-
RIL/4a>m*iv7KNnDFq-$78ExjNp+<_G4.n,zYJǍiNv6ײU]ΖGa n)}UoŌ؋z-,Q֙p,Ǫ}`j-'m E_VAIyMߧ;Oj1ِC{@6F1IA$pxIs?@\@l*UtخTF}h]"HQ鶪NYKt[.`\%}sj"Oߏ׸}L6~(w6>e?}'a?5?/C𥇟dhj4#j""?Z־̘q;ł/m߹Ǉ{>w*v'J5WJQH6\EC"sm9,u%w{`5jEdB
s+5OpK'z*K}c.NL~*,QSlj
XF$~Cgr}zSJ~mO!W+Fspx6f_}90' B" pwj~gFM.C^<܌"[\e.l~"n~ЗLrI"Qw<wtlړ͆ޒk|@>N)bHj'C*fCI+cLu
BSN3rܫ&J1ilۣh+@:WungXqRr3F`S?kPxD{S7b~2=yB9P &ڊ͝Tj(&WD[@>c&l-gmxxf[ڏ=C?k?kc3P󤍷#{*wml9U4tuEfu+ϨX`s?3F0m6xT=M\6zwP,[0?X}xZmoF_T^\	j\ؾ~(
xEP$%RWKnfKJqzW3<3TeݰVbQnؖ-rFErtt dY+T
I%T(Ղ7>nIWY]YW\C\xJ[qZ=GV4MNe1E#)t	bt[DU*ٔ.(:зOեŪQLCe{ʮjm[uєӖ:nMXGz!Pm-^5AP*j=a˲ 8ּi\=>yNAG-*d<_lZ+F<g&P[|
lW"eOtST8MlPXYh7w8SX.6u~ZB%\	cQEM=yKff̱fh&5mYKY+3c1Q#եRl^Nʏ+_, ܂]ZX?ݫydQQjTX$=dotEV/ODX
H~5^j(CWU.bA$&`qX gW<gkK*56OVkg ;iaA6op2!n Q
>;ĐbaT_2u 7gkgGjxY~wRqr%TP Ec=.>8ـhBӀv9xkH{;c̋vm\ZU[o~~OG8@bZMQ#	^SL	 n-v4.m֠Cms!:Z1޵wrGGB]$9T;K7!֢6^$(0u)L臈l	adiĦjMW?;z ne^8bM"/oaUA<VeUk]hPIg(
/CD&|
:WJ8W?yAn>pu4OVZ,+ka3-zHĈ@]!փ5nyM,
x41*ˬ;0ЬwOSwK8)8U/-``a\!KA\S E`W{!ܩ%5n@y7 $)XUdoy#zO(.`uNI6x;~AU?iJrG
#~{̞I8q]+-{Ue΋q1zt^ӑsԻ
"cb2@ڴ :`,5QjXB,Dye3T˚ߚvprf jc&EAt 쪇 $RJ^͎A<7B~"i4&6g%)1EᎸ:9  >h[]GK>	LoDj];5t{"["ՁFdg}uC#nqosʻfj۹p#Qq)k8޳t1 #_e(`X@GؾpCv2We'Ћ0%.O,v1@%pY;rUp,41\a;#۰T^Jբ/,5\)!tLبqmkY],y$ZlR<A:&ƹ0BuqtPhQ	=גɹjWU>>Amatuj +,2PpUEjvqido!׾!߁.BT`jCreTM;G79Zҙ@D{VXaW)R  X7ul/0ptJiΆ<3*nQԚmR{zԚC>
&0_PG5=##q0\5cGQ"VUS룗wlfȉR	\K2Rk4<
(_N0؊ibޑE+hac}y̞վw-&SpƦd'h	7@/F"Zw`n>5R兎vs/sRw^iSnѢԉiժu`Dt;9vNvGVwMsq7G+NS獾X6~;=ȣ7;^KFkq72jg]R-lbkfR?U_= fp#RDQxĝT98ZAuuS@k]?aL뫔w!9>3f~5o߼ojk> F+p\6EM\\ndOUȵztutP rp	ANɫkseu H7&Z@۝&eEu%pIcdfJԏv|Bؕ9ʀʪuj뚝]Xb|PTaU<DE8^]	641=;I'^.Άz:Odg"p܀uЊHNC0mgjX0@;JUam(<1Y>8}6rC,!In
F#/ל{"9Ј-OcmSwR!BwSN/zFp=EUnN潦o|}i`Ee@mb	]LZ~K%zmbhsp"v7`gpz#gU{Vߥ'n좺n7 uJ.O@vV'qH:vt	|^]0K,3o!cDf/X7JDnec(Grwݗ`	yd7
nH2w=<òvyhQYhy`֪-&$~zE'<u;{.mߋRS*{A?RJ)2Mú~*v(J~}{$'SKݝތ,pk*	8K1F
LK=k䟰cOu).ez9^gky#.#/a't12ï3ьMmp|fEMca뿉{+F_dY_rPRΑ#/"ՙ_R `xBo@ ?C,ce[4~HNndzC}]^%IE!jpQѥDE?2\}5Mcnp7QKo_w*W|mCs'AE^b?,&q3T&-̾sVho BPw/oO5.\w4ӵYk~g7DOICAg!v?܄gdx߻|ڎ?"?*3|??6:򸅑K'xc Lpz!أ+b;_3P`ñ?\Byx{ iƚ;J3R2sR3RusJKR=3tRKR]+J<ҐʂLʓ1:0rMg|il.& iF&֤x340031QKI+)f(vIo3x}Np /x-/*QVHKLWUH+UPO+SJ@Sa 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쮔ܞ{fR3u>ꊓ3RsAҮ^پiQ u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;
};
xmAO0Qu-pġh@)k\j4UjMn=neAZ:pЯYk[tXG4~"²K]̕5j4c(ax]֔\o)>y}I 8pFsWmy|FO~_ǰ	a߶ p8Y%5s&#gc1YgA0JAuxi§n{eS+
JRRKst5 .mxxUn0}Wp@Ej҇mP`-6`զJr65KFYNYW Ab<!)^VR6B%G`ϡL(vYL).Pkc{=g
Ӹ6q^\$/><>wP\1åp:c"}SDr2#!aEe]^tKo0%
oK8v>:d`ut]tpIݧȲ")\܏Ҍ@*:VZ dleI*h`N$cHƗSc8JhXD	%ZwTL1mTZa\t"8Uxeg=k<۱tXk&t0;Xao:ldSoiwٲظ]vx??AE{PKc&VUj;~hcE jIceʨU2ݹ7Wߠ
OoqIqgd}zێ kx95[A	̘`တK"BSwG?%??lYHdB;Ӳ2ejkq($e:7WݒZ/mE#Z%[̨]дɐ)7`eBcj9	`K3Z\y+m+oSvL*6\"LE$o1|vkafC岧f3϶pO٫ &%]%\6mSjDt'xk?3fJҒ̜bԢ*'/g̝ldɔ4yS}Jf'g&((+*ds) jT
E
Iy
%`~f^Jj^I&)xII0	(^XaQXU)IVPC6p22OfՓW( 	2]S}6͋XKUQܐ_ 2L0N Qv
jx SD`${dir}/../../schema.sql`, // from dist/
    `${dir}/../schema.sql`,B+`${dir}/../schema.sql`,3`${dir}/schema.sql`,*`.${candidates.join(', ')})`N[8xXmO8ίVj8>njP)w:!TրEtmg@Ml'qܴkO3ϼ<ctp	 GL1)>m6X.8aBJ&0d>}T,ߐ1^I6#\^̢Ly UI,m(SY;GtnoIF$&BII"SQ+_zUg4߆(jm,֙~F23h^NGV(	[R҈r2"ZO.b=u΅L8=*9/ݟK*M*miN"B7Lm b	M&<blM8b~'hwڴ`F矷9E!}T]7nׇ' O0j<I`<UK3k8_抡;|s{ާE sZYߖXF:S&Qɢ +s-+7:5<TAv=1BCd`G5ګo~ԎXɁцg<EECc#qg>'|b>O:W{݀=#;h۰Qoҗ[WXzVДM(K.pByt+9x<!/NӺQFoҬ4[MD*P`2ɇ ,jwLg] $lIEN͋A&λ+mrK&ٖ+d	~o#1&
Iͱ1p	Xhn q"A{W' mؘhM)z3!25|X^-I}zְ" }<X,5Eؼm6t䗊P-/}ɻѩ+F4~6m=BD2DWwR5趪TO'z$J5b쾰BGjʽTM;5Y'TneVdňjۈ-*ȧEE^}p?aow!2ÅS2<g8zۘ",Ts=2<tQwUwElvir\3ǀv[8*kEeB8MAL?"X.K&+3;&JeZ(=ҼwYk{FT8GI,G}V&_o&^b)XĀ8C	$([<ZN$z#.Vglommj^p7+/<*t,Y|nۏf3MqosCd[늢=y`D+l/:?Q<Bs20av_Q܊Fx	PKxhZ)ZI};$΃3rxVKS0W8p`tt
=q4X+ɥd9"q מ,CB8V-R-\*]
Y\CeG0Z2ᡁ#mdf"
QftҺбox Q'$XeK?]'dDI[ *N!XH2`!pBcb];j+8F
*Hʦ:[.E*μSVt8,NfLv-7Q2sw(pƸ7R;/R+z;L3Of>:f[#$7G5ZN|%(,))]뽦8q-u#"(\rGP=Ӭ'%	a`e=Kk3&L֞9~($l hoEϩ|a&!)/I&oҘMHp|k̳oOޙ7oЋEQI۟WcשMW62'ONiOuPE{y4U?te07֝~0qؼ
 q[<0P|D;j!EJ";ΰYO͹[2kpO}xtxc-Sw^%ky3ۅѪ(lQEڱ/y6vFMGX##]vy8Źdo'P̔xq<ySkE*c^#3$cy3гXl20=ɸRf=2=EFBĎ(Cs[bb}t@Ũ<Ty;~bk/x'6%;-lM"xEQN@U'jZ
l(V
R$ c`zM{ܸT|z*zڦ尣7o̾}?>xlx8l@t[k"X\.)<И׎b.vMe> c\<,u&5e}Qi<;G/sҧGknu^}V鏺D3E:menzB^c;RϦoͯf~,jE?>@/nhBexU^*3djZ 9qe}ߕvw`KPkh!9'}u-@	-ńTv[liI (,}L1G+?)޾A,^*ݵ MLhj7CyN#`/ɫۣx31 Լ̒b"'X$䥬?{ebIe[d[W;jض 	x340031QH*MN-+)f[|tm.CxY%?!2sRur*Za3iڱ?Ub}Jԁ{φ]g>W||,TIniNI&̨7iٳLܛv7 >4x]N@y9BC%T襷
p6n4ANǞo4˲@ 68(c2ERAc4>ۘI"BfR 3/aFL_cr"s~
.K:+] n"2|31o5̙$US|O?2L|gZI;rsvӰzJb-Kr^'ZxuMo0D/q&Ů:K!K{^ڒ&m},;;%ɇ/?//ecstm,:'QJ_ڂڒZVFX:M	|F8Th"{}NK5m'n`6M6\B*@߂EUl+~zMoB613r{
+ad5M
ӹXHQx?%D1~' +n@4^,֤ 2P
4ܑfY(vR Ng>?/Ö,㼥rt%h@
(lT3 >X<"ğ6R h~wV\k$=3u^(KY4~E冧zʞb;Y"b'zq҄41`vu.4n@)B*joȓ'Gji[}ȏǄe+95n[%/ǚ;ݒ^҅ʕYE}zE]i)\ٖL(^֎<,iNj{og/B&PmkS6<Ԓ>lj
-i:GO=nzM@` s>m00MXxKo0D.MVlu'l}s!tՖ<Innh9inEHQQF=6TI AjrY"XGv-87m#Ovr
*t"NPID	7{TWp2!b.̬L{ݡEpcLG#2fػ #e(ΰIJN3o P4[Pb\X6[-+p/3ȵ> 8Lqڧ+d!	_m*;\ޱ:*O^.5&o1Go4IHW6{Lpx		K#5gD4S?[:Y
oi&vaiX^¡ѯC?sbZB*"a]ׅv?~Ӧ"GWoJeEhR~w7ZP[x_^|JGc ,W9]ɭZa,!鸶%}x(Npݨ(Dvf3f"RB/$"rE<qJMg,]qW􎛙瑺#&R[JI> wQ;\͢V-Y'1uM'}6nAt2ES:K-]T6$#GGpcE^%
֊ZХPp6-pk5/=Q|Eݑn۲טjAj퀎THPUr?ȋ4uMb{כ^ 懅§JJEd
k
0xxt.џݎv";K/VUxTMoA+,N*7z*P*jyw;*YBjzó3x c52²G#SxtmSY,l͇ to=4ΖZ=X6!k-I(gc5[cNp׭6 V7"[394?;K[ڃX6QG60Z7	#x2;a2
t*ڟ-&/X7ې;0Jp?nGP ilz0mTuXCTWTUEerD[Y_M\XA͢n1ty쵟ʡzP_,aǒ3C FKlNn^uiv:?]HikNUdА5;択v砽W~`mw|=HU;YQd9jwݢ`qs<eXnx̓sKJ_m"n/5CN!<-]bU9S( 5ׁ.9K}i¥x340031QH*MN--J-/,/+)fHhT˝i!9;至ɟ!2sRuеtV^$4c{Fւ::ݽs[>1OUҜL,Mu91_WԹ@Ԝ\ԢTrhx\̃c] n
aPxOo0svn
B􏐐BN)gXt~wN-DQf8g#^mjZ8lrYST:zezsAhkT{tr|Ё*ȱxQq nFavNTHE*)Dp(X]?0/g )2'dKNs[`Ic=Wiɜ
/⋶7<q'OQ)B𤜯\cy@$gYPﵭi,eb2)4Kv`B=EVMD칰184ǲ.@jIu1|aDrg CttZIyK}s=404'qP7VSM\0tZo̞B'^%⟐t
u0L	50h1UZ	?v?9Ch&qِƥO4>_:Ό.FIll?Cסݶ?xMk0sfM)M%9B+*KfFƄ8vzzO#­x(\x|geY&䪑䦗eQTukx޳	F0. 5J#4>d!#Nc)(M:xUxǠ"QtUf\~ji,MIU7*j`q5(M[J{ؒCf"')anIU/i3nB_ Hs1M͎yTƵXC۾IʅR"5F6YE6=+5iEmZ	=ϲ|M2J3|gor((kq]
Z./@`a>S]-X{9&bJ:2`S#aHrΊmsrB-/fc@1{m~O&=&~TmPTٲ nQbxWMoFWr	e"#ѯуa@+r)"wݥe]"e5q
"H73ofPYQjmKIO!~Bϔ]x"\&$Ń'':ݪX&XdJe%PNiZIJaU*!g?-=v `RKdk䣓*	bEJW5_)Y CS{J֙L-wz |-9ײ6sl;)Ed=`YY}}{IeZY+c oɖF
m-ߞ[O;;NGW6S|C4A".!؛w@r]åJ'4+#+fbReBxߐ@k1J*tJ%*%J.:)}BKgVk71;2,E#ıؕ-B4\-Z:~ZZ*[Ը=RƟe.?TW%9:Ih;Z .RAWg߷vH]zWG$qHtl'}Z}wopG`;}U伇 9)~jAMR*6Ad%GCY	T/>{+6[ՊQJ]V9["5m^{m`L!@8/Ysˑ@?;"ylup9eo&oCgQhU#HKQieQ!K1v(V2mJ[rQȁhC|l3Dn_mmtLc hDX`ߺ.JķH@3iS4-#[@**h:m.{q4uH+T`͸<@1/9:J_m:@iGbiuZ+~=5g lם?7g-q=iߴ-(9p3hOMN{rwm==NI:{Q_T"7(.dHT&d6}W6v s))eek-3023n;~RB/P]啳K!v.8}5](8R؅]"a[IW}ׂ]~(Ҧ`9I࿜hx9IMkr	ixVMoJW]ҪI^+ABsgkhΙ;I[x,3s?SVɶbS)/9)Ml6g+Fyٌ_F'\0+3/igOubL9/ ^H6|8("&zH3	71&])(s98MY3h0VEG|9-w
(ߏK0)ܳ\:eYjp}|Kn,žWxvw΅nq6#}/ԗwcaW}0zJztyCØLQ5X'X\bbuF4^s|a3(/ڳN8,#~A*pVޭ=>.@A *ZJ/^3Bغ(nȬ:(@ rԓ#?I4RwD[av\Y,;$n:(c{Sfo%Yz60(
0-8 ;;F3]4ODDT oq0&e+x/L^c|̏9}%U	Y5{Nsm]f}T\XRޫm)}n|1M{F0`,t (kc+ӄDCY[R/v)ĔD0cTw-)&Δu!uT4$' 6~Iq;V&$J]qƄ*;*q^ԧוJ`$$K r,Zμ݀v2ɸ"lͯ^{q
V|'H-Є_n㴂xuUMo0W,	tڵrPPl:bK>fH_| ֗t6Rm@@!K!7FT`6bʙsBd1; ǐn`7s0w.(&#X WG?2QUCAKx^[ءubM0"3ZXkGP9kH_tOXÇt[B!
Ug,5`0r-"n\	'{{07t!iil)yV:-|VyW7u˓p[{-,Mh?TEPOOPu"luDS|HDG+>q	z&oVιB*3B~'~.o/i?atC:'*J(ycP9{&~1eSȔtPOV֫V_ȍ7iA q~E?u{ a"qE/
4	0/cMΐ~
k m$][1^яE׫DZY,.'	\rHj^ s!453Qk)QF:Sy򟎕N"؈;M{K§<]I7GN|%~(('C0D,c'94Dƺ8α#:mR]|:Us҇bI:IZZZt7ᆗӧxB"Bv"PHq)?1>-$Usbtk"2eqJYIg5	I{EŚpp@؃ʠ*:YS;}9:^1roŐ`8E[U\x["0Y`+YZfNsFi^/Q %Z)eCDB*"}Xo0 xWr6燎;єb'|nCK$Dʩ/MJ$$`]{{{[ J{7]hPLyFY8oE舤gpZui:')e>}i,#("4/Ki^?|
9YeQE2LOXB6,sΪeJ#줟U ?q,$0W16ae(pjr9u1S\bFp&"p:[=b%N&G/ׄɆ%{bc¤,҇FMVshϸ[mIF	Iٻ\u)} x&A\?̞@|532{x1 b_wvYͱnIs֣IHtt4iPK2AĠǗ3pXNgklc	h~/V`\=۷T	׼u~]GOg	sO;(8@$>q?G/@E9Iq}S5M'
lZ?sD?!NXgtmXh!pd4M/양pCjf!GGOL5zb;%hj0&P_0^$u</qɌx͐[049`JylkN(ksch	Q4Bvpb ?OH8B1>}1"}rGi_F!G(2PK%椆Hv;[my(4
 (C^˭u ġƹb)3T+Ii7n´`;7E'OS&#&6jv.J|g#!ﲠdq.Pf-OˣG c~\x*p!#c-q<̵Ʀ6,#Z{*۶,h4=TQ+6`XIh&zD,TI3I	ú0pPOGg\B(g=1o0*3C\C0(`շL^Vr)
{)DǲhYGKGZF$,*Qodlken<Ҍ".~vADpB$[ݭUI/֫o<6J/_ԔΡ^amƓeY!|a392qV:btgi^ wbi-r&ҧ_tαysW]5{wG ؠxvM=,$41cDkSIhA<N939񩂮x!JvPMU0p!
	Dx5~cwAQ}=;)y^І/| |F©j'܎_eX0}r;p^uGx쌺&ڄtׂyw9k^Npy;F/!N_?q;'Bﾂ>FC[CȗiCucdLK/Z.IcID0j_0GzVm_֗n>kܳ7}'CGز"(-Uzț<~zA3zo_CVe6>N9H^0'4%(/]0g':Kj$O 7.vm]k2U+|uVHd+xʗtDP2,\4"jś);^x쓷ХU~2>mAK۶@T*:k9Odd""TyiZQ(!d'Im溴÷
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}S͊@&3(+t`]Ŀs^ڤ4&.y|Nң
޺~~y@摈C|]>X<R\**	6ewgCd3!H8^=#Nzs.lH1;KbxƊY*y8:FxEI,y*rlZhLR25dw[~]݋;R	jT ah {wz^	EOGT1N5SϽ`3$%zା8gܛ6xr	9Ai&<pF7/-{f"ԃ8D 0]ՕYovvXDin84 enlI_~&n=o,#n<o {V	lfйNeXǬVʊ̚"_jJC{l1t>Y߭\ީlX@-Hj0fe<O%x31 T__;z7x]a՝>R3KRS8lcӽ1xBT椦%2L{vK	i7?o3 Q0x340031QKI+)fP̱Cη/[O% xVKo6WI(C	H@(6n/EmDf,>qlq8\>H+kgIԄTMn-)9>_[([2)(UID՜s'NҜh}"4KKO|5(]|@i?Kj!l.4*JV๮j(keRml484z
i*P.,
YJҍmu<KC.
鄑T/|%Q3R^/$5FRam6JxnJU?ŶR%Zo+Uo"/΁ƨ{jB`^M]gYǹnaŻExҎ	S%s!70$dRKmI1_qHyOl 6O݁AQܳBOSz`LYA+l8ICHνH4K*h|I<ĕXs#);v^lL2!r;>Td+6 KDkBѭAE=mY>
E)4RtP1c]5ؗsiO`"^#08c]jΏآ"3ěF~WUq읏S :c!䀠Lr3{C<d"^۵̟#hOKMNS7ޓ'Tܛx'WRULi)Jh^M*8Yh͍6
{:CNw^R^f_Qxiٹg8(]C{&f<&.m Nee*[c72Ue[]w!PҎUaҘO58&H7;#b?p{渽-j~ ^vhY+Pxԗ{ZB?j7b;H6[7AHAA~N	kȽeJvѥ"Xày#[$DGZupBGe?>7%^~TaJnO0~D˂<WU[,t|q
ân-jmJ-cWh?, 2 ma;:LLBi;֗xM1KP$yjG)r6ش dQKuqp(83I^xy	ğP(88/].sSz291GdyJ'Z	 gP|Q@&Sq+~!fk!IPQ	X@ceubzO1ۂlnfL)JGcM<q6z{b$[!;`FUh^-j[G`hӫsϤ0oqiٙ3á)bөx31 ̪TlԈА+mez( 8$(3_~%Dz x340031QKI+)f0g{xVpV;!jr3ӋKRA8SzZ=qB箆*NHM)if+rM߭2> 
+@xk7bD;s&k0O.e '`R ;rx340031QH*MN--J-/,/+)fX>EA|ҦL~gQ&r4OI-H,BR$KVM6h9ǿD~ٌ7o%U[Sł'=",3.1z;? QqxVmO9_1']+4P:KqNbSېoyYJZhgyIx9UGKiUς)]8l`bSv'Uq4CUIl3i̴yTn?j=_#.7cm.)GV{gr2L%,h5:=+*˒tN{[1=Ѫ9+/\wwRѡ<֚A@o&RpoBR-؁7djxJ,|V.R@xjYE)4J2Eؾ=@%Ahpzf1\}WhF, $)~qQx~K>uՔ~!7隤Ө!)`+bZ:<la#NWMA\i>]0
dPh%i0M62R (\+H3N1O@6(bJA}0!`	eOU6hm$f.$4= Iᆽ G>vNNȌpmy瀞׿&()?x׽ׅL!]]X+jbi:^7F[hf+-3ԯo~IٲFYN=qi@VB|W݋g]oo& ҕ	&vNӚYWϢZpc$7e~oTF&dCqc^SCq|N\r(Ab|,<@h6bb*C0d<}W&FY 8#\TX%i߅3VOP[LVo4+#bJgAjRVG`ٟrj9E5UtuwlVMuzPOya3\Ծ!oO(Wl$7XMe]Y'#arMsoXʿ=ֿ-ϸxV]o:}WJDCvAuZZUUjIN)ewlФHfΌ9a[?#LB^_#zE|rH8-@]͊"E;C_pyLXҥbQٝ!x7	y]qt:u,Lp㕠R2 \0R1Q%sP
6!6qϋp4e#IEJc2<S
g$Tt 3Fʬ,Q;+4M`due,"NZᖬCȔH
)~ߗt#[%w:~+ey1YzS21wF4""MuOz
mb:cav}i~YDB|mAmsL&E#:$v262qiM\j|o%Qe>?@m5X_mHlE{E!T;4ׁ?	*v09n-eYSE涑ikD\d5\]}J<Vsf*S)	48DiY>JlrŅ Ib
o XBյ5g	INKO:W48RsZSOq_Qa*KŨ])TH^+YKldU+T=Hjm isI噳ԬfOM+:JD2?f^R<yɝaoY!8&.䀚U{ނ=1{rrk9iYzi_U/erWƏVD@r\8JV*lSAMڵk"&&k\_32!+iUr-'?ZG%Mq9wް62y.U|x*E.t=ut%Wۃ#Q]Gpm[6(XaUHyd&7g.iR;t{wyw?4-85QNK˯SK]?&v?x{!Dr7~cl² h4xXmSH_[E[Fd!8PWW@ci,KF38Dg4ze.W"~FbO>&I< _|?No[6LD%0)B:dGD,ejQi9_ҏs&PpJ|KdƭInmSLjZ=	WG6`%<0b\F|̓6|PuZ(%\@-x i4e;d8;ƄA_ݔ?a$ +HpFFm2E*'2],BX$|"gQqY2##rI)kpmG1< t۵?a(Cssjxss۹kv+nh3!4b3>i=+PGk(Y-1]!+܈#th9?GxFOw$\I$IIS-'1 Ulq29Gs,1mv1"US).UMOtcr)_P9լL.askYސYHܧ[RMxuDrs/
3,oЭYppY7e.sdh5׿nWq8&Bq`K+]1x%TiE6W=%T<ˆDnIPӶrzZxW]][C-V!#6e9mGZIXنh\Q(+j"Uqϸ)6[2^%<f(UR_a.)#Y8C"}h-Xt[m5I,4ReLO{E,ɼR`""|CKQc?9R8QNp!S蘛4
Yej0H|9V+S8(!1H2&Z¨gX4&(ڏq'"#`1>#U5>T8rJxF8MFl祈4LpH1Ӟ 2)uJ_w :uNQpni]I|()T7Y|F&$E#DSnH 3dB!&Ɠm_9]tJZ#RH+4v69nPX5-RuDZ/4R*!CdqLs,i>qȚj7c= Ps86i*fXC:c .<p[;m3ldW0%?y4Cn=wfW[z`k	L_)*53Na8[B*l}d\in}e'8{ļKB+\!84T_X}[g-d6XF0	@B>/1\.>\{%ΓAvpwt~3us_;Oq( Ϸc	yHWYC<F2QIYzvzZk9VW-/p(#T5u⺄ s,510V8YA][#'KBVn(Byƛ7Zғ0u<SSw]V"wB:To&US[j!M^l[&zXim]5	Kr9u3̞ǂ&TӘ3tMZ"BA^0nEՕٌ筸TYԉ,qr:iVv$a+3\O)=\;8(̓=Ҝ[px^+%٘'uSi+zP65]}>9tпIl~7yuq~z`TIq&xcy>ӣNеk&vu*|g7xV!5k(6	WA<sy_KP	bN{)ƣOo@*qbF%|}|s0ULMwx}JSmn`y.tyncv-n7i |#xal|/yO̜ԠԂ̒J`cHr~QĻul62>aaWW+ə.9Yl
0z쑌p,C&)ԜM\4xE'sLNmsBZ0je +K)M-iekL^+9U"kaijQz)%Ō?fVH*MN-LR(.)*QN,c7xĒ1y򌚓eebѿxXmo6_q
;Y9udqq9Ά(lb"ߑFn$t<=GEX(XnEA3א"j6X	Yk!vJ0ǜM-yI؂uc9
roG)Fe'7$9IC%SXn7	6:_75BA  L P1RWC* cb:Y̥,( C:EV*܇[U#hp[b^ς.}cT
L=U>o7R7{,/2'Ǫ>)DD;<#OH#<mx:Kx|ͦX{ybNBt' FqX!͒SWk#tjΧ IDsPmX9$N0˦D 	)Ljuh1 /0I@Ąމ6	 <(RC*mIGSH]hd}2A4e;t]ƳZLPڰw%*.V[pW`ER
+X&iGF DYcXjC3bQ>Y&g.Fohu|(֧jٲ*ۼwS˵7
Gn</'KTZIHFovg*v+G[P\vuO"Q~
ax9ǟ\//N=h2>^xf_Own
q~ln򹺃^^!bTPR2WiX f
ͅR/=ܝ0Ѥ5qRj#*տϳU7Wǣ~MA]GTݕVlKU_:jn2.=U}Wzc.#ޖN|>0Z3N,Qe<5,(og	'є
>oYHrpe3q`}8Ud|TBi^0vLT^-oMԴhr<I=YM[xќ>AcB+#v9<䯢/]I0Z-ˁAp3\TI#רf,)\Muef8V*ߎj>7u}<nFk{Sìec讽Z.[uȣ=Pb@ĽoUa[PI&r|tg)9	i0nW8}T2!)@pT`kGyl~og!s5;і/ 4%AWWtI_dOG9.)qSGlp6BA6?zֻ8{x>fodKefl4xͷq^l8˭鼮݃i;޵Dne:p7u&(x{qBcjIeABoiNIfAbQI  9)MtQ,X8y"浌˙JSK<SK2uS+ɍ,"g\f\ȥ;x{ͯ8'l)b ~0x340031QH/-+)f"}1ܾ\k/W}]mQQ[\_
R**E.ٜ7ۥz1TuIeAj1HЦiJ6g>>u[TMiAN~bnRbIrFjH'Ey|_^WmRiNmYz"9 7RxZ{oFߟb**);mW;vω@5LrU.ǹC'Or3⒔  >f=ٕ(Vf<狊aJ0hKEOeUvU19nDi$r~Re('e`e5h}Tsn^޴krX3LD&wՈ<;ee2+W43✆ܴKW*픭')W-ӏ|BT(/kx͉FlI\5yb^y
qK(
Es(DQ[Jp1xL+^9钕Bp,J
D	JPsPo5ψ7TE T/k/5}8^U}hP6r
ER%z=oRPb5d~Qy4S⩘/9-$jC *ԞUx>oP{9J~653Qpn8FiukcLDPG40%~;Z
rqVDܢc,"g^.RU5Z.͛PU2dg3)ᔕpTAݫ(H +β{@wrT=y
Tf%QjjR^$c'7<96p]ʚŲC_<0bUK58$;4=hRޖ!W-g}:SM8ʔ*?0{p^Wx^x>?:L`96="У:- ^[2h!oMH;65U7>3P)H!JD
ʚ<sTqlrmbXa z6NƑuQlRdpZs<-aޝMx>=Og~dvr:=~;ڏ߶s`|ݐ`d7LUJuU25eͪ72rk9m\hTKKbZ0qɇ&/&JV&UR_-2L&,xDAE"^}\\q&u@cX*8>k8:ӣ\ΎޝL.Ϧ9Fڷ-vT:eh;ӳ]& ]`,
_`U)HJSUl糣KyI^N{yJ!˝P%ۺ .=CZbkjPH}N:k+A9h]YI[QJ*"ʹ楖 2\Iӫ$_5x0\ڬSԞ?6[dahYM̵9KD_cHcJb<0t,H1_4dݪ_OO
G5]`욲ƎNö4g1̳L-~]FV!He(/thnZ4VأDλHY	Zt r/^[LCǢ!lEj|IkYEc="ۥPg;*[D3Cy]b~YI1e`GXƖ6힓lEuZ찦9>ghSCht2ۮF]r Qܡ蔪0^<	ұ؁N@We_Ҫ.yMWᮆ(tR8D?Qfj}BQ\
3kv83	umcT!cYϩ6cl@WlYYRֈ̶Kd~DpFMljUa|[=nW~g֋Qhz渤 op!#v-[TUM5ȕnC'>}ng-s"]F6>eu]9|ob*]~1z, GӆA.o!4j>2QV6<hDȋ>,ӴYYwX#K0bu aZH®tZ	د:%2VFePE+!lvQH[l)_7?Nq0.q=4\?Ykgtgt3.Ψ[Ϟ%W3<DDDuɦ?{^2Q|jt]X:׃zg
41kXc޳4nҮ=|?bsCLp.C]}t;]eZ6NM[V磯g4C=nj`y\uP4D,?VL1tTZ[ØXQ[Y|'0]:,TLCCAܳX!3zj~U+>r jx2as :C\ᬈM3Ybcɀ;VpKHVb]Nn֗81\>\}!;1w?"gI}óRxnZ.Vx!PstR%1^7>~$u% t>yM2{}?gS:QQx}Mv˗C$H7Q)C*OHl*_^Xj]zYOzA-©;hغhHk?DNttl' 2\>a"˂GeǭrU6?tšbN3:ԏ	U"m`XGM\zu1x`aΞuc{.Ee*$>e{LB^5(lnFV+~ēڄ@]t	 @JF7Ki`~YN$(ݝ(eK#z4m	F~m{_>eN!BǽUfN_SYز	&! ['}@%=@/p~7&ry@8jR4-J𭖸ә7fƾ7'%]OfEϔ|S(sQҷr2ws",&4OeP]C&YB*-2ZV܇`-2ls)1ةӛovJCdb0[K2uzvĈ.n|Ƣ%2YǗ]m;'hXڳ/_I_CXUtj=v`F(6¨#6Ld=\ǨtW!
~Ykl1]KLk^o+y8ilXbT{g:g$._,mMoLorʄ4: De{ؒ9VF4mMzVO]I¾}hK:nQ
E}Bw+8嫑=?b~ V W6 LC+,Ovbۘxξ 镈xP{?\6AvNZ&҇|dO1hMa
:6	PUO'6Df ?e/	<35;k]Z4⮝L{;&_Wn!KoKBᗁR r_5^@1GiJe1$~<"z-jF4go0M9v(QwyD<wkv ~h|{Hrz@?&xZnFOJr{J/'WعQ\I[S$KU]$73%eׇ	"3!嶮TXo~nٞTebGώ]*]ui~-2*yY<e hv d!۲s7\IW@ؓrZmk%UXWJ6}#nQ{@ד|$|ueS
"S$h<GȻOԥ(ZP72j,LYYU:MBv J-Fs-uQ,V<c4ZoSxFk0ny]Q
[m#qI+,|nxm1e%#L'WL(:\˼))*ۢbJtT"G)G◰gZ}(`lEspƗU0δ,אRm]T<9hcc	DpOMGoY?J#`Mla.bl6YQ"#o="D#49R$-fYM(	kKs+̱nkK*ٲSWעԓԏڗ}fk[D#lí%G9Z 7(1<"J[2sa7j<,/:t]#*uKk6xjm 'Z	+g
:8`# 41MEql$VwN.Ь*6ueg?xDtDI%:bPh
.:+|+MYFrc-cfjv%CCe r]mkM^\R(h0 nT2l\>;kʯW][$ec,1Y!MV2Xs$vQdʞ@LCAduQꗢlfעA^J./4xR􈋧I!0gFMggbh?Į/:x!AM/sy	MZ[<fr=W/)D|cWϓEf[*U=;	լ9`䍠ZPq|@rꔅ?j9q?sFO%I93o5<aOH
d*nvƣv>d=P\] *(v0[rNO_˺ƨY-qޕ Nt+1Cb-,j6 \^ݫӃ5RpvB.6jVzٮV@::gn#@ IXPa!Dwr,MmDZ.#׈|bn6CX[[
1!l7{M;F@bp*/Ɏ\|O 2NֳB8i-RaCu"	*Qyۛ4͢01OQ$)jh	bT#JyʇG|Qj`dhM2tz@ίp6c'ʧuS&x	OYL̉F$_\GBilОȩڵF8JU]ޟqi}Vb>jQ0>*j3cXb)(aLܽ,$dI2O;Cx[IDuB$Gá0|yYu'3] H#
3qV,3dd2(Ε	kǌ_(ƻ3)rR8eLR+y(B	WeK6H2=bqm-zD>HpJR%sp@_k
v܎}A8є:h-5|-htHrVsUvt!:[bcd-f3ZY8nZ4kMߥYIhyd<Ѕ`瞆ܮ(vpXjIag{@jm= VeӠ|xRy,ElN	InMnGz<>dsf1~7Fκn\o"u"~bc	zجA}ϬtǬA1 ͏1rM@rlBUPJ)63@^NNAz8	HLYd8Jy[Õ,Ja?6,I;2o#-Hvsb]cyR]2跌`;J?to]0*pDEFn՛gbt(t+=: ?r#PL-a|t(S3Mؐu{"Hì@.6F2dn!@X%J:KKY:m[xO'HF0۲j0nR{k|6miF #șap:z`xm}VN߻	>i&QDMPp<)0/|v{&,y[W{=ณ#ufhkT^	hm
-7x
xQUfp]V&?G ]2;C7TBtEs_<iu9PsTAZz2Yt&qYK*a.n]ankf:xXm(~Kl#oev$$/zD_y<v{-C֝5{CG#rGD=
8f/Ae<֐R"8.ކ-1LHONlQWU)`ї;"'ϙϼϰBe+^+y9Z?6={hf%eT.ͦB⢻=}ɀ'"ßλνI;B5c}[-}T"~BG$~$9慦9RD@䟭P8sP 4"k-	}5@R@kG@vSia_;MՂI82UqC#JzU*yum_ŭ}gRP;QK3	&'.o"~]eIw&'6;CE"?qXA
'}OV?b6MS1WjM+maW?'-.}`Uyd]+g3k~8%]^g4|O>:)#>ǈK9#>,šƔTUW+S\ӕ=~nuVh-K&=a3GUxMoI`/zN
N݁TΌiM
K	IKq̴X)2Nhwf=Pek-?w݊gn*`$abne6\:Ÿ𷖾-"|pлCR4fd9 9rN2_t0{_>2P; CD "x?I%&oJ_lYRx|f
xWn6)Ai&mv)rHtw{	5-6TIY7͡+:3$%Yo&g''Gpd!VN])35WM)TN栴Vl~)dn|e%b#qj)ϭ,-Ҟ_YT+wh|ᖂJ􂘡.y0Th3'՘R-Ҟr-J(Pl'a_
?Vs3r4L/YiwgkɱwY#Ksi$<)TDJ:'$CfcxoGTKKȒ%15 ln,TeaD++_@PYx>@s9~Q9â^Z+B$0x}E5y1"a0<J-ffH4uJ+?ᰬ_Š)X!@
z2	n0=Ѩ֤(fKQ=V3iS?R&V15Q86]bT.70@Ćĩ?a:`@9ma>أaK=5 |sŢU?6ؒ)֐Ah70?>!ΫQ^xwN񏮿+`ϖBo@"gn]q(7[&<vމin5.)>%ZTԓmb!96gpl,a|-V2Y	/mWe#+h%2
CQm2Qص<ϰ3ZAZ[?<<,03<CKw10@˵..Κrm/=^6bsgZDDD)"[dƆĊķ[kk-
f9A-TzIaN14ڧ[c Ffs	M߆br/)5'QxzE|OpJ$N; $I(7F
w/IS#^6.vo[+o_zoo:޽{풴-e+S6Dnňۄ+&GJXa#RU6	cPYj@`fq*Fw6nIDqt"1%hCc8NY81W01Ȟ؎ aZ鸛NѸ]
=%+C/P@psҿ$F majѶALay6蜟osXa;rrpۇ33>rvH=H]Ԃ; viW<9ߜs>[w)yZ]ʹsx8SlgagNN䐀h_W<C".3Y2O"B@5 6HYQ  "uomVIJ} LZHiYZflw|
r mZJ	S,Vcõd8WBFDz'سL_RցP]{S[|󜴽L(2?؜!xXn7xH+E"ڥ$_\ٮ-f.!"of(5{dI-O5|ٞ2gEZIwE)J2+|Wh죸eGs9\!)|GHITY !MkQdr@Kۣ3evHb-7(v3ES݈\my-ye\򫬦Fpv񧬀ŋ#]
S9㫲ь
YSe%OJZ0TL5\jdz+؊d+X͏}g+g=1w2T9Ȉʰ-+Ju<p
VLZSKxƶ\Ś'`c9.}#!9V?Aq.X+Qw]./߱Thh	[Fz-3Goغɲj3ĵIY
\D}Ʋ"%μ+o"<} ֹE`.pF/@su
b%wDqZnd3cGzN \vFN$IƕkpX$M̞e;ϦY-ulgyq	V(RlYS&,<$}-TļHb	ϲP&6Bg<'gXF/& |OD]Mq[wwjn cw/0V!R-2Zˍ@]y_.ANNN:ZhyiYeַ, P9dX_Wp];	5
hh-b]B&1o	x2@ۺA]6m8r85#ADEϙAKƈ((Zl'9#sMph@@1d:mX$j%3y/)-fc9U-w FeT_szQ[O^70xJh܃rΞY\O)
~1ZJcyFrϘ,H! 2"\/"tt*q1%MgFf)kl͠RMժFCwZ(ѴOr'Ƣzމ3co̷Y&v"?6UQAT
[ˁЈ`Ђ~4^EU!ב2Ve9"eWpBь_
nƊ"э/)ƎUdBwQM
ӝ~c4nwk$1r[4 ܙLF~gm==c^Ume)yCuv×»t&D
43w7y^=֬ ]1')b'zA%3{+mhU
m2(Z},=/ߓG0iOx-L۸l
/1#@k&! Ԯ4]iҕ9lx :@jsCrm{4ּ̡{߹EfN)jVk[Z믷5qD/4E[Xa(rExb;XP;5\gh`=@HV$P~amGaF8Ua^R=zk?[Et jd|Ӻf}thn%}IlҢEg1(%mFqfc=9S4
?fCܣZİț(!B⟉˴?è0o:,d[<4v`	y9:b>zn&8$Ta!y	@f'©kcN9fФWǆ=4"9ONV^xKlgˌ#hO5@ٿ@<wư(xHh2iCHf/9"BXLTaV1o!_VR͜QDvGV.kEA=˳ɰRCό
^{ppSMҮۥu숎tkllAd6/6T	iy@;nTl+2CTǎ%ޓa!Meֽho+ef\`J'B@.Ei%#;Wf\1pLn\<e^詓-$1o²Fu/L9EV}MeXU`B86e8ӂ6O{B%8Ugn~лK§>u#ʚRbH.\M |PT[(F`nEa.NJ|`$Gƃsרuw]ISj.0?YS󬑌FoAdۚ6іxVMs6W!3[r(Ǯ+g|pLioD($>. RT$;mgt}]r
+%tScGHvqG?~O] pC;Ȳ*AU9,QeR.ղ"'쳅εAEl5+R+ì+TBf" Xj[zys#Jlנ+!1+O{:xקdϟ`?]^^M~:Rnh(eM6)oJ+cP[FEg[x.zí;~d.hXJR`-')[ ,*MҶih&#/{LFT
ＳyA+H=Jue+f"}&:ᴀJ"˺`t%rt~oP| 9/
>A3r0HTK`a7bC~|<|{]Vw.(=SߨFBΪ<NNzU15&G<Fֱ
	638M`Zq}%s6ȴww_24\\|n
INo^
.&nU)+:j
-f7EzF_7]eH{V+R>΁o!!4ϏW	M7l=}dbe}5=6FUQ@;hC.w/GHvi]':v=նWM'$\Du2m>
t4B"+,ДZ2DrP>[&XDq8;i6ڌ[=$k/..?-qCoezGb=\pkfϦYb=C|̐/C16櫡I~9}uw@a@PIϫhE=Z\*iǾ|gYAub(	j~ϡ&2k:L?,=Oo.1MX؟ڛ(|n{BF? %xx31 ?-woM%%J雺<]i{"]l]"Չ|
> I $ x340031QHKI-+)fXoz[%+D݌!xYr۸f2#*l_n}z6G6cA$$aM\ %t'G9h'LDw.xUKe#TH^5Y(Y:<ԌKaؽI+REd0~abɏ,f\L0p' b=GwE Ut[WdOdyS]h^ȥXe6͉>YX(jr(6\&r6VH$e`wk H(rTYb퀨ݷN!`-hP-5]ڝ%3JI]BK7fs
N#zH2egyE.-'"(zhV1+F*N-(J2F(4lhKfEH5 p`GAHd6p$\wvLxdǚ~$Mo}&+>JY4PchPő@3^K6XZ`%BS/D4mͿ[1j/2D'. %DIve9ʿ,djo$@6\
# dI²e斯[DRfX)I),c5ourxk]#n{;OfR9C-`Ј]m 3)3+Y$*ĲzvqjJku[xX<ΊQTm :!YrIj_woыBbEJ^+iq7ADΜ[ ժJAuʄ~	i+SgokvBȎ!  R҂&:q	ꔷoW٪.[u3/y~^xv;oj8F'^XPx^x:@!ʚ|t}6S@z|3rȬFN^+yI
,Y}#`Ŭ3'7dk=DPzC5pS1>db1l6f4rlP6;h=~:G]-G{Sy.h 95r1׆W"P뻆Y] mAV:Tl'G/
Vv}1Nz6-6h<y˔g=YM.IY~ tC~1ő!3rʭ. mJf
9.{K$66D)6nb  J-br
U)̖B[mJڈ|D"778Xa?g jiwК[^͗wR,,kM p\bgS֦rQ"6#?@ܺ޿5ub;mXcdI!xDx,TVՃvvˇʰY#{P'A@Lv^w0+h*.͕a|JF6@ơ'_eBfi)!?pū*\H0*@A!>hSp&yn"\0Lcr<jyFf8v&d`PBzohCCZ,Y%7 0lzcupdBlmЫ,~HӳN[0hi;5 q	7&8]2;ds57>bFrwi¥>F6.%/
i7] q{K8{b϶ZM@#2QF7`",vǮ ~J@~ʚ0Y4l1i61w5D/uy=z YkILfZ0ˏ0u\!&N@ϟ$a;4`~;Wrڠ}%E<C;
2ΩwgIҊ[IAw3?8(BÀ3i/
dڳ{?kZxBGx^$PmYطIbp}hޫ}'auĥ](qP\R<XaFlHC019f\ķ5ѳѩ)aXՓx ís_\;Š,ӝ-Ywi Aưe1t.NguY.0
$_)PbGDr.<FQOHMc>uy3C^ۮQ`EI-=Izɩ^ا8of`5=n83(bL重0Z>&<0ǲf{s^ˉ$xܪ-Nᵹ9V]Na'gta6
z1ey3,I2AԶ0-CO$qWgO7%̼bw/ȱS/9dóQ0BxNWH	NLmܽ$L^0oY6\ݒN7,g.ܥyp=YRړw;Dx31 Ԣb/S.]N8iMr3SRrRR.ΐ*eMsr=VqEE%r=YƗE} -&x340031QH,-M+)I-+)f(**+yK^@H@R-{i9h6|xS~<:sQ&HM0{-ޑM'qL}~.ۿ^g4·[s6bZ/$72͕Y~317G*}Q%)kAՖ'`חrEBHF/_*/OMM,DӰQQ7F7|?|0;< /x{iwsOnͺI8˘eR䲋xWn7}WL^,;EDq ERԉ8]Jb"K{g8˽*)Eɹϙ3\~N*,SFO,RxY)7ީT6G;XZd6;ݖ*VN(ӅLOTF/
FVPBv)̯^Ka=7WS(:7\N
(Go,e'ʫSP&OL	VZkRe@?~Omvn_R)D	[k)
iMW[a
ZNZ%J("4ݬ#CwgB.EUz(^Qc?cN	[<ጆzP+mn4F-/JH~D]6oxh`@aÍIem+>
҅<;z_Lv -pZa娹 u5J{7V>S
C-%Ƴ{,S?P!H$nJF;ɝ	8PҌD)1X ri&.`dY@5Ɣ͆T -bM l]iD1cBWeyU(H*5g\d ܝ!B!r=;׈A%1%[MōP+(֎yUKH͒-}\JWXJe%
c4x`#& ^#Hcs?aA^S89{_w(]ZĈp!6DS@c\5&Uy1KÅ#Dplc9{(n?`ί)A(o"aCqqzf6,yy4JrSGE(+#c8ǫ>eip:٪s0zFuft*nOVUyT;N5HZ7Q_`Ľ!n68T߰bed&HIێ|Zwf׮+Ty/|1̱iYcX/5Y_4cӻ=G9 \K8ڶ{gLLb
QB3s훹;ti 1Nv]8 ӈwM6[~a;}SsbF&exJ	^Il&qN6wQt*Swc«L_9ãIAzC3ƪ?,|QVOkr73mnzfkҡu\
R,qm]9݉Fb
AJ%M4q¿^Og`2QBnGxY[s~ׯ@=rK'M^*$cLl$b`%+2{mJ4N݃~>|)$3Z^hxܐ-YJ!ZlT=ekZ^vg,{S/)-,RRe[VYRd;JVLW5ُ%`Dh6lӼ&d)Sc[y'dK)|ǔ敚	:]+X
}T#j5d!
+<UZH0F[}&DOYVnf`D7.+]Rl(gHbR(ğx1~,ڛf_ZZzM5uQ%SDV3Pؖi,-
Ag{Fܭ3R(<FYKZ@tdv##.s"&E	/YyXa>_Ax:ýmg;(Bg0Qu~~q?|#74#"&1h:Hni22%`C N&7AFHhBX7`mS1v䊪c)=43˼myЖ}G>:K!Euf%HɊ߲~ybBZTĂyXzCT%9>{ߪJ7O;:Nҹ)թ%HxaȆMo^Uڃ]qZw=f  PuA2H	*v @gP\1Èa^]{0רu>LW <kǘ/n
$V9/٢]eWA7u)r2r7Q뗥&D8	ߌc[mR}/hqbs=)]]	ZZQTkBc7JP1V{z Ku ab*봅`+[Kȇ0'Z'ҏniQ֍ft.kb;`P\Кk()]41Ca]qd	u#nA!Ԁ.Id]!ڪ$  D=l ]ͮSL_{;68Ρ!W`Ǐ> 8~[u<!|{\aI;"UऒX󋓚חg8{FSb\yتZ%&,f5Vp~V[Nm`_fKx0&@iP\+?*lx͔qQ*0ע8	!B0COO!]LB`Tkhʐ&s2K"q["1aGVmEeO:n{Ͽ
j(SH}Z.6Kwݧ
 ބZM=plkX+pkˇ#*SG9BrG~`7$vP_w7àBo?rǕatfܘ6SogDsNϼ0"#ӯ{Xm;pMwTnz0זFo޺^$;ѝru
qWXIUW±&|h=(ۖ$.Eֹ+9d_9nf$Qpk}qIǝ @
w|_SRx8%NH/{h	tBЇ(QiLn+2Dm<qX|ퟋ8\<<sp1Gp*c`&mqn&>lCh 3ӛ4RfZV{˔9kc={eΤn&g%cK۰2v]DxCaK~m*je;GW{}<N{'te+_ymNըsb6gnv^!4i] YI;k,|D~TP.=;y$GK.涱mot#4`T%=d4N7vM-^!h<G=>[ر=ޚ!Y
N~;-;<w:p#`;=8˄<.>@N~+l汊3:hK@XX\`\cyA|Mš.-/dkWܢ(n g!6
5z.[A7t'z3?xz࠺>w9TX061I$_YNBA+Zk8߽}"_b|yEaZ	7fffc2}Д~X<x{erBAÜ'W1Nx M) ?xRMo@WVJBBU?Zowaw] {Zrdɒ=3ofSXW]+kJ2^^
[Ƒ:A$i(y[I_YJdrSaHFNak:.yMyŚZcsbMo}tʫ¥+Ut00L rχ2'lk	j	/њW?byHcwa<24ǻvc#':c[Xr[Yn|텯=^`΃kd,	eA70ވ5$jAF^pVKyV6h[aEvb,)$Uټ+]9!Ńb|.y?J?vxD!d<lb֢&%Ԉ+#<jOݸ{L{e.b>" j$rFU58nGFw4ewlY)u[(59'?#\NuwI^P!x]Ak@PH"HOUCE5H{gS`PY^k3{BJڄ H{ =,JJsMl,}8/IxI~t/aRƕ=CLf(Q`zw#5\#dϖNVVh'r'BdExS9l2Drvgp	pے^T N:Z?\tvRD7_1o	Ӊx".qB5+aR;Yt@x7?9#3&5o4ӈy3	~$x=kWH9}rm $;&$$	 3{2 4%$4K>-ҙNTUuud42r⭈WqVŢ\V6,Mi7dqrz?_LX\8'QvXuHtaR~">Z<w$UʢOqPl\4E\lqq:u?7EW\#kg2,qahֽ"Լy<NBX؅Wkq<FPjmV^l v/EY~[Q5QأX,'y4Β(+wlX!I(ɠBcaf''"Q.E~YC	𝭋Hr+ϳ|GEtRIB_i7O''3"0a儂\-jv?1ࡺ&dLF_ӎH_$'z"hu=be1{+7߼rk۽NP6#Ճr*xWCq"re|0m5@  ]Qz"x`Qǉ76S񽻕̏r 3d$ϣ&ߎ/|̌yITy|k8b'TLzEE&hnB,o*6++D8ۛ}1BB^Xǅ`T H#d){+A.N +`qp~* xrx)% v܃wQo^ly׽b5VlHl(P(IH Br'//c)zp*.s@$etl]tѯ]9fdqawkσ`a[{"xsL}ccwQ)Iy h<γqHZӇ3ҦyU fY^A ,'ty~@c9l3H HnfSC
vaQTI
5m0+)[NCfܸ>'$SY?e'Wv?0}Bfq1XcXdW]bP&0JxH$Qth#K95ZKPӓ5{8X<nm>%~g/}˞uhӚ\ -G e⨒+0HŹT uԞ`
iO:WUR97)1hh%`P#D^g18[x?Y(^hb֓˴<]jm<MARfR$.Ipe)LZ/ko2&ōuUpPj]tYD-I>؈8<UaR}XoҀd]GmCD.|a>H`Y*=UA@fQ akX< $>'Vxm&{M6^M/} @&߶6; L nz$vO -NMryP_=
 W.(a!NF0vGᛛ,I	lK[!Bx``Y4$\8# <-pav.AT͂'9y0(L*dJV TDzRy[ߔ38f.
3!ZuY*p!xs'');'l+c^m6uc+RSX!b)rvJKkG<̀#%vx_;F,i%Ic;K+k	YT8Q5PG|h;Ƃ-i<4kdCXi.urC$<=q,TR^ *rk嗙$P7AMq	cy]Z~H~W Q@#ORmފ.@ߨL<d*^8X
#.?0y2C2_Nr
J$<iZHJ5lS?MI"MS'*~*DJˎnBR|prޯF^JV]ѿ}[lR_"jW(",U/)fX!jP$3_.֧UB)Նᢥ2̐x\Eytf?gAÇVRhLΞz:XڑӲ#Jܚ=<JPh$ݖV`+_- 6LC6҂רҖb/E"lpeE(oڝ-cwfgzIől\Y[EDՍPuVn#GJHlVVF_&'1zO;/Y8$QD`I&ݯ uvGОZwdN꒚m
-=DHj$O}oih.hdږe77/xeE_Y@ř}hfm͖}bkն1l
Wf{􂠵CYj`P(b>X	 yX|,OrUSghxȡ""\^pI5;}iomB(n*nنON3;{j\>L]hΉ _mz!,t{0$Zi=wߴ26/0rkg[aY3foM(E,JژnrK4I7/ޗI3tC;\3KxØy^?ة7?LMhY>*ٜk^3ƉLTkla7DM.MJf7f@`\	}_8W2W S>јt87OuJgf"u4`p۝9_&꾣ƚA{ofɷ-vWxrE/ɞ`y#M[{+:/q@f 6nyD R
9$E85ݡ
AvˀǷ~--5>U'uO^^" JS?RLW	ϟT `Υ2:{_v8<I:n{3DzO
-n=KT(ZԒl8G958",=s`Zn;喷IEג|p*
ڢxPڜ2=6."IcEyЧ1E3l#àY%fF >H< u˓B!iF#29A!~]	ruv.X-R俽˝A)Jk-ߨyKp^K7X0_z6~Qv'Ұkiװm=Z :I/SXfqL>Z{Xuq2Zv_na]h-i9{LVTnfoɰn
ώ
<2$Fp(3X
Nٜb
o1[, #=5:ۛNykH.!e|8o`z(~BB@0+dFt"*me0B9yv(Bn6X89Na7QK@EXQzqFsHR/IA/%(0$A\x 	N%T&>5|"X8`yle&$ڵ׻SKbYNXk0irȃ7=ۋjfpC[{G9Lǔ\4Cc)МSdT
Ca:^
ѽ#/tzElXͱZ8tXsťuԴ IX%tJǑR?Q$W#(j7$ ө#tKbLҥSt?-'WfԬ]*D[eTU&%,_::AZnL/΋? ~{s!05<9\{:q60?LLsWEBPdaMDJEbPJcD(A}!":K6V=RZp?:1V\`<?0*ʝ,%{l*%抖Q:qxuw&<kTw)0Amj=f~lp3
]|?z3a&
?F l;4za;z;9}P[iM`:׃.CNJe|lr1'$FH`FM31h1=a)_m[=3#^o!N&unZj_W,yv &t<]ګNtEɁ4w|K:b>8pԯkAw4:ji6|1jf	Vf,[>Xh MnLfz^~['"HsN|{x*7vqS,:22(ыlDTWKӤnOp#\K$JtQ3ԤW긹O;&)h~q\FNk7M	IsMYE5mjpGeč:piUrwyak1_-r,Q39gκ3|xԈ7?-U9ځ˫47rÃcOіU]}m囙3d;=δ)"K k|.UMz[Jzq@7e9.8}zY~BE? 	k8p~},3`:zaWdX9*_pfa7fnF3ULAG`\aO.uCho%a]$w?Nl@;ˣ$art\!;	7< QvFh!7@>!Rn19aB	wI`538dկRYtQ^joÎ@%h{ʯMxf?X&媛Q+`u wuuTʶ[lv_#^=p< $lVD9Jchc_teO1h7^S%ЪlC*>e廅f3A5CܥGqv8ܞ5֙_sk'Yu{\Ep>k\{kl JiٞZpq2hT6;Z&8ЭL&b]-Z"g6U, g'd	Tt~Rfv22RT}J_b՘`9tmTxWj"8*txM0v	K*JAi)SS%-R\w5
0{+b-x_M6PUN~WQ8N7"49 MiE٤e%OX79?վ?ծ_5b"}]!ssssRkPu>$eCzQ.YgNqb-uъ1ދRˍuexr4L`G+?/--S)w5bT
G`Qx~Ig9Er8:w=B٩Ik=H'b(#P7E;m@u(<o6X,'QF8wI'1f?YcwP+aatYH
r-T>ғ 0azQ wS+iUS?M
26m*E	URx
%(dbgʥU\ewEժNNKAxmō4D	4Fݙ?>HqUjl#$V(~?P\Ȝ3߳ԇQ_,Gcy6+?K&Ѿ<53%A=~g'B	/e-~mW>%6wt(<OhX#aӑ@RrይETeʟ.ƾ{o*$kvOoVZ-M8/Á]h3U2d6쮶ֿۭXXbDmEИGĀWKͯc.,0PGc0UUpp=,lŧUg$"yCUw7Si_Ӎoz1V c#~eMc/%o9߸^_x),&G$"BRV5,o+Fk J*%%R9xj*K(5oqrZx5]U-oF{aɎ2e5{IoPÆapX?\~,OqW3a-ssm:4+PfSBddM*[u-sD&,A0#V0j'PsTdaS$aP)R./-loOJVLN SCu,CVC1:Z"L^
E,4E<+9"`YH#htbubu8gHr9 sO+_ID󯷹7NwӕRSƇ'#0':"Kn6.fy9cKoq\4䑏 @1jcw7-fna7͊ܭT'.m4e;ŃevjUJ᳽T1bdlP
9SFuv%m9<9EUNjyi!5Vw\\k6A[ףy~:i9ioD3R\[r/9G9]"31)5CD<V_rX}yc6yBB](RzT6*\i,CHRPe(8i;-FK7U;S"t$w<`Z}fJqPіڶ +.I(-Xr\x[KcvoAlG"XŰPAao(wVǪr_T	=>&aSP뗳>D6`~Y~
Wr%%Y-t+<Q9 j:pۢ,#݀ o0L+V6b DL0MFQV\뫒TKʴTuȊOXNq5Q_Ҵ밣"kph{ysvRR1رG̷X^w+`|!k@H[t֢q^F༗XдM?Gyt.+xrt4D1&tIEɄr5ЅZBnK<]uCJHj5s(!aܑ'| CzWCٷ{Iu7.Jaag{gY'"ʆUZKzf/ۻb=G\髶rTAj;}mi{OIlN(dn/B.gM
xܸU>˟@#޻:kf1(n悚.Uų$:=r p$zx	Z[mOyWqݚ@٭xr\lGku{^Uc2Ƶl0RtZ9`Y-rD?m_YK>仡4u"r#0`M<GZg:/ퟫ⴬$ȷKLSw3H/ga,ojlѫ9vYq|}hgAex!.rA=:STS5<5Э6_l$;O2R	}a4RL53P%ebECŤ<^Eء8jq+afxIiY(Nz⥲+UaB/;HrS~\s)X8IO5`>ngcVѩ菍*{OnEkGr%M㇖z$hx9NV-YWЀLkϔ*ZW(JsUFմ# |wal]S~1gv/Ri#[bIA@ARO٠o=uuPh1c[*i]z,/&b.H8S19NȬ\.g|</CWɬoVy*Q.*c1Sɪk\׊EKQն[GK38K,Nnd҉~p@$.UQ;|ήKe0n3,ہc+S*2MM\j3kB)Dz	Bv>^C%sf)]?eH̲NSm*nU7eWM3If}b>eƙnUk-16;s|@kl,ckRE:ָ_2b+ȎˮؾͻWn:vkv3P%F!M/+;dJM+d\ȫ#95
ë]:DFq	jXс%ߤ@q4BF bφsVPDHͯ_}pοQ/jwIc_*K8tv{89صf+DKfBVON4EA\]|W)2HP?
@]8Eb`'0DN}g)%qԃBᡕ#C2yj>K'uśPeǴ-df֪O&ZW FiG?i0Zpo\ _Y'bT*@Opq'z57!vn~~-XeLW܌Iy`b/qF64?q	< x	{偢}'q`X{Q䉢+pBO!VF^-wD@.z'C@:a~xo3
)M#~;,d0uUlȈjWH,I/Ύ4σl4]reo\2\`.ayA$n|RL٣~kUA`zRѥ U/uT꩸(wҴ;{K7XGF~bX÷%%OQa
lBj	fMfl˦:DN<U"lS>!>x4w<6cukX"*k\Y$TI݃{O%oEmާªu3bT֏i?,4d~/!Y__+u/gI
_ZpN*Boʲsm|'OUt`a"nM3.#^{Ķ\[y:~+kFih{\bUYzDC[;ܵQIT}~S~-"Sp$UƼ̀o^2f-"*g:=
i"_\1Ģ2^t
aucvnʢ%!?A赱Ktyy[kQu>2JCY=j^m{]#k嚧|8:\ד'nQE&&4¯\LO<yI(~	c_⊿$dmJ)ES8B58K8ƷR+J|TR5?SC^	k]v>sZnK-2su4l8@$I%d?^qԒNש!aS-`=UEX1J[[\:lP^0t9tMpǉQ㔬{"~z%0C?%ĝʗz v.u\dV˿|90=*udBqVO;4M()KEB,DHvT3`?
G<1P;/K,![k A;j o׳)?1V_8muc+=*⁚a	`3+UkdSD+(dJ".ޝOm .ܮrgB,TXV+z7E6avFJII0ڪ7w#K&u4ghwlȌ>6V3c>JBE8\o{ۈI9MIzkE6fdјX0Yda}=4߸vcnkrJp><aU-ƁLD XجobmTn	dOK*;'EaX]f*XEQD*VlfwP~Q,;mMI%U5I^4Me^|SˈEYP):+FPkAp{)@͓w8?r]\@)лvv0&yl]T\?//S?{0	_;ë*1k?U9_CGCҟ;γyNal  ʻ	)ttw|sIԹ>Pk9 ^],gsD<q-'4UdJLSLH.E|LjB&lm2Oz.?t1IYiq ˭[9BO\u]- |Yc}~+FE5;.&RKVp=i=bp[$>X0BrI{"⓵*sSfeFժ8:U	+(	M3W-ql"īz-B<Seu>nx[s6tK2);+Iĉfv2iMF"QG; $q|f:cލ{@Yq)J-JT{&b\3ey"Gcmfpey4#8=pa%?7/ҩܾ|I^r2gi~z*85Jc4VOTU^!Nt<J*L'2"񋩌8_ӫ|VԕDQf.
~yF.2t&/t*eRԼB<buNiՀ%NT>*@_eay>ֲ{,pE@Th\FQ+qWF鬞h:e"Ti&N.+1E$Rrׂ)8yVxt^*8qT+i,Γ4;Q3{ߐL~&ˉޣ,Ө<.+7Dj %>\Wh՛'~ys-dX_@azvqCY@ygiK1fQ#Y+;|h]t}QKB1шXF& (')4X8!qgl\C%&V&zH*~^ `KqV0!?E)E@Bx$<ԄL2fa
\;mu6_<| UQPA<"	)M$9A?4`(ߍR3OaxE~ςTLIguW)-S)Й"
etnh&VIZr&Ps
dקٺA|d(=AN`P2c1̼l%**+=ҋ4DC'^T4Ќd c69_cuLk~|}`,y?ݽ.On=,S2M ?MBɞYJf
 E>qR~B8(/}-UwD˘T<@;XO$DC@86R`BiliZ-8038">	\xfqWW!P=*-hF`7!GO'吞wʧYHۮSAzf<;}g=k1is,BJR`A,egi;mGa$eeB"lRPI.q'}aOct/Xs0D^Ry>ҩ?p}Bvz2Kg,"_;n<$f=-&myBfcWA+DkpB6Ոt?lBkO1gJ̕8Ɯ} (DHRlORuئf^>YpCLVQ#0A	"ohfm;?v2yXlq詼GdxG쉘h
r2K0`	I	.~?hbθF|Qhdl V"Uқ8iy@x_[A&LRjxk`9v>v9" RYl39MJFALE_jVPyW~0?޴J+C}C*hFht&vOKPKz{P z΄w$~[>}֛.,߽㸩tFOAolH4uSqeVpt{
;(?<"$!r;EdXgf`#i
d0
4b#6T5Ղ/VhV}zt^%+HLsl͢]fC>(1߮68hT=!`o-b]cwJ"dJ#=tvD5V6lsuٸf[\nrkbx{V]fgn;Qn+KmАw
¢Vrhk8JeHO3)q1a$)@	ߛ/t=ya&3y(AT:j\]*NlpU_P`1;ݱv7JԘ>st%__"	Lrs #wwኙ	TuMK,">rc_+ꊮbWmU7PEd&hh;q6[I+gѢH/F)'Bƈ3Mump*\~	p*[:[%W$gj\Bz]1>l[\I~u/eE7mi}AS=Pfȥom`?3	Q6MicktJ<ſ ۓi;WlB7AKn\u1:NŌ!<qeKlL_V\FhcLǮBSM*Bۻ=!'5ĵ6;ucSn"R:lb9ch^Dou
GnЩDopŖ<?{vt>qB.S"ߢ~qHi6|f!a_SYҢR0In_[s]t̛4e`Y/kvE;axӥ'/R	A-q|fhfV<it-8k-gDpTNW6H5:7x31p5Gsi+*#E/cJRs; Va|wdWb:gad0pu^;ljit/xʬ:9QorYTcI[AwJ9-H00+ѱH%z8Tq&͏XE=QIFbͼw*?!f]2{1SskPKUq[sNxsyo(k۠4~^eЀW6BX15Tm$hW5pNFF.v@ K73dΣ.U=L7<V;xS70OSB"6ZUv+x`0o\`(~O11a>MEc67 vb3,ws0;D unaQv݃yϡ: `9#47@0C-^f-Gnj+Kh9/88a-bmq"T)_cY6nݵ<܋{U&W,TZ؏;ޅ6Y,-2ζA)%y\`cL_^؂1v[Tv|2bIkڟ_E_EW)BZ`^w^,,eH%=KUA񲕐5ִ573i?N˹EW9J|wn+TyQn̘.h8/ۇZ Df=KZ"bXݩ/:;ͿnT\^=wk7`ÔPw!K;k[gEZz]zuq~l
榜֒;t#d8܆+[ֺj.䔯0.Y[/]\PHS5rx]_X؛do|nJ/y{?L`5'A!xrb3	A()LȮ%˱H8,ID (C	$]`qܸMD}2X$qt>.JLx!zQ4=
4lFqWhJ v;A<_x2_6h|.@AH>83RUo&?K/0Bg\ ǩ89[]Oa& g36aJҍL.:L(+RyxÇ b&4e3:ȃ0#L!̋RL̒8$)yͳ]lA7VC\	L86A4M,O/\a+`--PE.E4H 09u\i>@s8[2WebQ!Y?iK,qL"+<<-B7f!3LTɝ8/m8ݼ ٱ恹	˗Ss3yIPy>rsG@H"@I{ٸ\)TRZ7 ~#tJ<LV;~/jId:GKP<Qt#7
UrGh)$"A`%^#ƅRL1UQgTd|쥲6
PmIΊy#ܘEs7Cr?llyFs	%.+7Mk4`Ԧ6Pl7ٓ>dм?;{#W8Q~)kXfrjOTKV}@D1υRXSa8B"W
XK0|fI!Ib7 ͥw$<Z00 fa`EWylYdE0^HiT9("@.:]wэ\*ہMm
	;hbfͲl>lGB5TX%;u4klmnV`4|;><M9W2\bwh$@D1x`bė|UI{B43/M^P%7 46U43yuC eC&rp;yp桎,sJ)M@|_$R児4m`QIoƟa;0a֋Tp'yn$ ޛF`~|6}֯̉6ZnX}Ҳ/-#w[)YժTKцA#L
Cl袩JFcx3sȡQey؄
Gzj: rQc4Ӫ|(Trk.04E@"l1@	wL7grlOzjp@(kWjc^Pb_(Ϡx˭ ~OF@f4,ZLamTm#Eb6n3,sĤ,E3_ZicdgiyiPF3'z/C>ARh, uh\Vw؊i1f,b;U-	k{c[s`!Ϡ9Yer	xtÂX]?93=z<jrtWPWE90B<5깼5&;å}yp,.pɞ@C+g<E N7C=x PW4tj\SѾmHZ)Pl{ʲEHFyq )iFgG2X<f.X	s!%T]p YЧ'H85q%Gyjl"k3ȴ|Vy&9JWHe:J;mj	v	ad'r<zI|*/,8`
;(h^TVT =q䋄ڙ~'+9>._]TkRKw<Wod>A>"yB3&< ke/ieswG=cѷ)~Tf>:-S}po!ymU30Cº`,s`Pblk&!(&.v/1kѮ
/ܞW5.lXUnc5sirB9xf?nE2Ӓ7M% jYCw+y:صڪ
l% ys7tԸk섁y_q%{ospY/K&_ޥ4},0K	fxi$F>4Pc58hpȔ:Y*$ƕ
'l v'g.9hʃ8݄z{̇Sz*	c]8\dՌ
"ftMGǙD}|ޖ6mfO5w7eҧd
.J7LQMI;qvjcX(9|zH2jm+9f]ca
fNe36%:IVSHvFix?w͜fSc?TƼFu1]aSmpɀϩ7wj'QjW|wʇnĆE`IߦWj+!}ʮ϶WGdi@sTɻ6ZTw#+ZkoukkhzK3]v0w*VO_E0S7Ae]S+S56V}-k6N[TekzeGWUՇOT#¹nd0&ݳ3~K7FSvU=zߺiѣVzT.VB?H'u,P/5nܤB:Gii	ڴ""tSSܩ\dYA=VkADm3a@9J$Bub&.;]q̀!DO)rϦn4'PA.ϯj܆wPWk_0z*k\sMVb55tԳZU.ydcq\cz@>ԆorQCAѱ1@̩o79*b5Vq]j^ۗ "4Ѹp?r
rϪ MLԇ?OiC/Qp'ӭyEH/.)ѾY?;ě{^ bNT4)?d}qDw{=6dd~Y_9_UOSR듆Բj.}N%	Xɚh(i<N_@<z:zFgv6`CH1RZ[-P:5m~}6SglbMeP+|Egjz(fݽB^hcyܖ>6@7}tnUnzQhW,Mo?EqÎڋ667g#⡨nEːbJu?WL`Db<]bq$4k*bZDo|ǩ"QkĉL)gK56p56gI3o}̌8Sj\#f?:dҵ)=(mx~(eCF-X2U
i\9Lxz.N3.oG>a^uTkk}B:~siÍ7cVlaa?#֣sw4͈H"Wcu_!t"rI:E. luTQ c6ܢMw7sK4j>ݼI)oTw"*Ye4VcpFt6w_'ҵ&bt
noyǷYu7?ZRp˗GqlltȮ;uA2MjіNk>.@F,	î^@iP|\KUY[CoN@1x;^ztC:&	1)Z  cx340031QH,-+)f-.
U*֭q!DIQbInNfnf	HyLncF9r K1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);
  };
};
OXxX͏Gxcl!XM X|$(XM%?:@tMwL5UMuN"8  9!A8"8  GOwOdz?#Lte^~^ܐ`W5Z愒<S2gCr0zq	 ^gLX#	-yLKH9_EË(\[KN8%RǜɄQF39գmv)m߯<]%3G)]d&s.w@|9L9]C"`{g΢4ѶHBD$X3_g$QD*Cj[M]섉!]8-_q)\8gfs	@B bt"x>s.yu۽oA%3c@#]#<PT옰=V9|8Or>(@B@O
.ys,xwP#Q|ba]:>Ozj<e9 Ie]z^:zf4٧9EI01h"ޅmאg,>F@ԀíXByk؜'LÖ~-j3?KX:3ZfTy w5P"9KЩ+}BO.lE&*χ8࢘*lA9tP[CA%K2)e(Zp׹)fwgðVٴ,$PLP48oGWh	7E`U0 988:7<R生`9F*)b$-L*4(Xfu[sN(epMhPw><J9+v-jH 
$S.rUKz26NkZEz-wV܉g+C.a+`J8`NKZ|uf>Vh@-e,%@vzǅM<-R..ɚZz̶/s9YE{ytXIaBFE"X!$ClK#hGܰг>&vKG
YXe hVvH`1 2`ޖ5_<Nnۈ;PqE_+h~{;t_?Lq{W3gpao]g*ʂtay c3П3;',;GU6PR&8Lxư3vlR4t|z]d@MRJƜM RF]r-.4砙ZdU\*tUaЊr<r`g336Cnch@hӰ֜'B6ls.S/jU-{MjHK;MOI"fYZvCI6)	kci@xjG%UđW?sf	?0LY<@љN';s$}+}"v?gܕ;)XU9sێT{'Um_c,rUM{vl4GN2"jTEŵ誰([sK z'<!ϥ_9	Ԙ{ÃeH>Ѫܱ3jv㹛yd Zqvu8x:gvzlJegy:O8k9qI7Ck23cyg2T,w'S%Dl['9}T9t]8A8{uțj9۷Ҥn%cuW/$|l-s{hǅo'5C*؋FB驦c8.Xj൧	2e.'uTS3N!]n]y1[C)L9(gQC6JVdۻȉ)rjGFG>uo-;+1jLȓDS<QT,_Q^t7V2ue [w,p@.[DRea38!f@G]]:{ˮDF_=$t&'iQ6EP2bm_ގ=j/vUꎫ wxkzm-v԰Ĝɏ&o`㛬Ʊ+7B1=V:7NMK)MQPH,ɰW<JJ
r*A̒T[
0?5(U}#AUi $)	xK(/*QRPHHMJ,I,&Ŝj`E%pĒb`yfI
iE
zzTZS_Sn 1x340031QKI+)f*;Վ&ҳn~K ټ)xXr6)p周vuLXqb%'	H(Ȋ>DOoR"c;Υ	Tݰ@%#9fs6jqn#P\7^R>IBgU_ݨdM<cLO\gYu|[+C䙌*uA$MB5m(<֯q2i-;Q72Xhc˅uԷ2'H*/D(m#	z,xE#{7lR;7z`ԿLcmʹݽvS6y*`hw'2kƏ̢S9vL]s|6,ﱡ5K8oAh]$
12Y	8R\mbB52!O*\	!6 S&d̎ߊj:1Vj<	[LiqLe&;dPX3o.;|nvsd94!Bkؤm[րsm国4_̗n?и>7|N)=Tp(6'IOPCnDP
[3I{2<M=c6dyLh
.aǈɢi
Cs*L8{YIKwEUK+dKVWPX5&`ˋSҬXG
.u!sr#n[/	lwQJYjX/*Z(MJI+N,22!=!Y]dbd[JXja`?7fʫ4-zށ*dar7ՔLI0FH"&E+kyqĝ*>φ?ȝ%dq }Y.*yZL%Y|prRrcySpi[Zk3,zUd!;~F=)WbVg;q
Mʚ^F=d`0_)_WM!V[ǘcVcP77z)JKCa,kIC:l篿YXσ\kdXM ݶbEh452-oWbIHE*떵b>E,Bq!uy:5<Yqڽ$(c䦿ǐG"~E~F.F6=DN+9*OQITG~wϐ<RQ噦_4>LhnTGAp84q%B"D1CXFǉuzTj1!s:O&W5Z.HQn1H6uhfIƯHH8ئ*+y^,dF4՟dXH~qp]{e#Tra%]4*s~V:u7oY5 tՇK <Gah,"9uhI]J/M3whkU|p#=lh'/;$"^!=-jёk|T>|O_{=:X9N{=vlڴ'v:mTDoun<:|9}ϒx[Wn4l8`߷y-?[z&w-Ks{sޮ;xstw_o$UNL?j?`:?~7Q~'߬7wb]|'T~?6WL.xRx2Nx340031QH,-+)fxu9K~>{«%)E USկ:eK}U\[\RR*蔙wsu<rL(|;(1/=J^'>󗇪),*)M/.)}׳i<z>/*a~a	TiEnHEnNǦ>YCPa ٫^Ux3 5{gH-D韉6pa'ґI4k?T$}&(7*hx}0D|.U;Z=P7X
I؈)b`@B{Y@愰@GߴK	=218CR'oPTUJ|sS[k5\Y;bGE;a
\K]⤘m?o
V4/Fr
xEM
09	MYv
 #"޽,N)
!xQ[QO2-:E<܎]rrn@hJt{ójǫjߎ1^c3ς͍ B4ۻxX[oH~ϯ8 DlpLZJIQ)"Q@M}@$',mcJZ;	s?߹|gB-,u:EnppId=:xlhGKsNTb*Y:Gп?2Q9e"=B]{у0+J	a	㳒%0{.QNXQƧN_]:=;p1<~4wm)'3ŜM>LƩ$(+v 'v6B
.k r5As^F7VYz,2rE%?2I;nPsMsDצ6CcJ-,9Gb$j3ezMOեN`ډ	KS)fm-QÄIGRVtV	+xW
ZӅ\:Q *#mdxHuеzJc/Q,p4	I{FܽTz+JXf~MR"Ss,;Q:cOY,D$V/pxTaR.CҮs^AXafrsE	JB_vX^lnPIu}qIluK>=xlEanry
0#'@ie 5u߭:crS-7udgfgXyj{65>#!a&F><)"6IxE;̓U΍0dJ'-D)锱B`GW#x-ml=$(l8P>N𰢕4%N ZH;QJnRWnD4Hy2G6EswHN}	A.Ev9"U5t3{#bse%T+:n1N3Ux@`ɖr*A&\t,6d<R4{/A5KeQakBk
貪c ٵ"hAfIhv$kT`zSo`+O~35%D7hgcN=̰%{۝R7KOGCM3ڥ^XZ/F $N9ӮD5nlF:Wu+9ڠTi>?LԕkmB9h@\M)3`֒E?ϣ}4v>oPfW~WXУ;RčkG[>pgs}4Ħr',K:ȏ?Md:	]پCaJmty$} J @UG2]5b4}ܐe9R2ըRk4~AS
΁vwY$pT$3LsawڍnamHA+^.C-Սpoawz	o?kƈWi;Z_Ϟt~%6/%ƄV=%VϢjƴP$ҢAo7zŭڤ:c-L'~;?W3ۼ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}`;
=DxK(/*QVH()JL.	6v*MN-q+/.QUH2􁨴$3XX,41G7Fݚ aеxuOk!)vKɶ@CƝӪ#:B^lA߼Ӈ+QS`dʑC,7`b$d`Od oe5_ۤ?b۷vOa`7eDK%U.ϋd}߼!Ս&/y1;F\HQc0lx'epc>gJ9;"׽fY<M%
ZO.4?j42hSyfX Qpx340031Q(.OLOO-+)f`{qgj~̢ 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x31 Ԣb+'eSt,,i;WI7+OOO-bȟ{HTAǙ:>NnQZR\sGAlAY/t&L0(-)fֻ+_yդW?%	 86¤x340031QKI+)f/p{m䭷O=uixAo05	akONDe>J΂dC"H~}$bQQ"bQ6:co؍eB-gw&Pv lx]%Dq)p\CХh1<itZU4t1!ߧy4CqpS4Y+w`+SM -Fޘ/kmX%9Q/KUj=5)
F[OFao1&CmPfni"}%c>yNqJ,\hݻgRT4}9w}	7XJOhpDlqoXǝ!
2}#iS1mM:sf?Uj;c	4~Skjޏ,3IɕsbCQhP|>u۔^m.ע=|Ga<pH3M\:"h]BFgOe+DA+`ݢ梌9'Sz^!8<E8%x340031QKI+)fhLjɛ+=hL  Ux-/*QOOO-RH+UPҒ̜b}5WjXeJjZbi\\ UZbN-Ԥ̼<jU
PV5 /x340031QKI+)fPh-jp=wۄ7?? xK(/*QVM-)L.vIM./QEQUH+UPҒ̜b}"uk. 
Wx340031QHI+)fx۩2cúϊ%ӧBdl;7KɆaރ"ʷCUT\\xUUS1-~WYc} 7xks6:;QJvri/'9ݸ7pX	J)%A?Ae3vO$bߋEɳB/J8a[ن,W\rt+$[D}dj n}.y$&eY22QY6,J(0Z7iv[Tb
Kqr^^1aWk^"q 7^∐t<%lUCE)8]!3YZJ!5h<'e1"as515%VXǇ{ۚwC,ͪxXMv+L
/X'a\$;^ "_e(81, q.e%,2MJz$U~pHx*̆}g?fqa:j(le&ь5)DDkzpyl>m]!ު͌ع<䒳PA/KIAVSJJQD<`"Z&q0 Rq(RG1Q0YhvW_DP^kgӸŝ*YY`e9VvQ'`!h\Q]jiX@gZm089Xcz	ob]D'o^Ӝ0s(R|foLאpȔ-(u0
2ig,6 Lf>iebEP Z4Ud<c~vvg .Di9xPҊP:3G*-ҭ2QL	XěFcʧ;}Tx}aԌ=_e!'g0A_Wap}`T!@i!2nDGN3)vbtk{V\
md07ZHe0HZkؠYjF<#}YoX;ZN0Chڼ`KpUDB-u[Gx/p1'ɸ}$ֻLNcV'$!gfPڏ9$ϧeƟ>ϧ2{݉5/GAPw`٭q(lͱ#6AX#:iT`h<69:[-vF5'2V9 c,!6Cg] ac#2Gd+ZVo,Ipi9hZɜ~Lq<nJ@`RxJcK7'IԆ*sr*Ʀk'`;&
*~ɛ5-~!J,-_fU>[D<)4}y6rɾD:	,;P:r'3h=>59\#!
blCjS%:%Gf휯 ]&0ͩ.~g\?2H̞0@!Ua<#4/Lua}ERG)sQG_xasFdT2,@PɚdH)D#s
Cl6BՇ07Pⴡ)˖XTZy`ZH
c{Bve'VР=7x͈{E8k^.음JN;y'T&d=*|jVE<]8|4M&U8~q)N{i&sݒ{MރV
 ;eECHw:idhLdaU-9 ?U/-Os3B8j
ܱk5ZQ"w<6P-d6}r!Z#q#uRԵ,D)Pܱ3:كii"OGQoxa9M/v.)jө+L9{O~fOyy7c4\õ%Q"V7=C=ڰ,D͈ feO,p4GTնq8@۫I@0Xxz[cWP{k3:ǣ <:|;4E:TM3rNS|'~Z-eβ6@Bkj!$L&iE3hARj9sc|۹O}1gRJu^(5qr(%䑈
#{3PjVR{[i0Si&
Yju<*3P>Jp8-VP Re2h)Bʴ
Ds%s1uTrݫcnz>CoUmd1(#unܥ
9/䉴CWT*nN<3A(Ɵfo>!j5e:W0Cl%7xT.(q=);fNG1w=6GIU[&JuGTj:W#,	2jĉq-!u5c+>9R5WZ}k7PnT:r&%US^3_ê.Pu_{gDuRk.TK(N-j#pPgʖ]F`s]FoJAOMxmJ߳0^!!2q]V߁L8`a1j/&bO"nuߕ"-̢4 )MH@C.45ʁj{2=Qͺ<17M̓ʴvwUO:m
\mZ8zwzkTBM\cxv j6Kwv٩{;X?\#ؼ*Y@\f18mV.TmDNoG=פk^9LMϴr=2AӦZF 3rG]CQ^^^rDW_E==9HU[%x%@0f -Dct1ff_N';44pATVk&'Pcr}<#V{fZq[>ij!1y b3P]K"ՃO mo8>S7
6wbq@
J6*N6(씂7e#`j#:z:zhMU|`-!e$uF'kf+8d`ڤi}`Բn[Kv|G&Ǜjc[Q'eAWH./m"/'ز}{^۩MaD.쓬4"b7>ť˼glܘUq{&1O~kcIY/P=ekHy)8[V^;v^y=7iE3*w܄s~A^Ip'ďAô^xZړ?BڽdO^>0ұaMΐ qQ3k.k5.%N1CUTWUL2mޗ`v
o{T'rHح!X$-xݒr|@iUV7/XxV|+n$.ph)vvƃhߜuTm/G{=y(T5zjQO5jUE[jլZ-_D^A-h/h_uUٶ":0xm_k0)ΞlZi2hH;B	0׉F,WJRw%Y`z{~#t-{lmm68A\t$,_uPYtU195eOΣ}CW"ڟ[Q_aikl%
{BYDU<bQ~%қB:w4zz*|\QT<sK"v
?0;7'(gp񓺖fӬA1lc7^yXY|1/#Kb%:,	vL4X=Li HAbhR	ِc1U4_΅y?!yr*]"mvY//wнh"ۘhN/jLxWnF}WLrRe@m<EB.-3{E8E
;;3gF"_BCK^@ZQ4ğJڋ[%S
TPp]<ؽPUɰ'Ev? H{y9VS´Z,xʪL(|3rN9yp+(Jt
	2QDE[O^BEWgHo8F?6&QX3Ae򇵒\C0gɝJӖ^jp7_iV&ެ8& Kq@JU04fYeV(ruZ(d)SUȹUVÊ<-
ܚ0@#:JI8{s2丷0X>Nof#֧J`l{gdXA^MNQlCyŜ];M.VL&>1oEdK#@q!V`l {ɶͭV|l$dU^y _|Etvy59Ξ.OůOH8F{[9='F2	|	Lm^H_"A53"PPA+e&)	[4Y
XjS=$lFmqv*Ǻz'7+ n+vE-!EV2PԺH^CYccgU<EV*{OfCYm
C*N0sܖ'jIlO$N{+T=xϲtSܰGơe`jZ!ء'op!19mԝJH%QT(vx(3qm:93*.)!@" A;=^^g(]9z`&4f%p= <nNVz`Zt4ނ[b"s-&jÖ$-oqinҶ>Zy)H/:9j|<ζɮ pezf	ƠUkC`Nl/@sY٩5ns=kcqfxJSh)gGj_MlzgD*Efg,>-dZZ9NI#wql=rB_5x֛F=;)>4~!ZGT"*Cvd=:}[V"qA<]s7|YmvzM912qi6*)[%bِf);;1JLXEEņ~[CrD-VZ	J匘7˱l($̹1P =:|'kvy7Bޫ;*7!9"R߆1_x_vWz AΈOL@q5P5)hVE֐i<mObF) X!xnFb(**dYNW7q$6I9ZuCw̐C>]$wߜxJ\\TJ܈y.EOS_Mo#v{L\dt>)sŏ J0̛V+6}&r٠j%󅿫I<ֱYݝimac#B0g)L<"R	kZ]_2W56%aȯVJ^UÇzClfҤȕ@hR-|!@Bjz[@oϟfyegHAay (!H9-} +7*̥Pa┍!:QqA4N}	MRΤOV\8_ ȯO=]`<LDPS,E:Ӆ*SD	pg't$%MLBiALfi`}oyW9;PI:/U&:E^цS:_v2ELE.9Y68f}hi&An>́S@2-()Rgɓ K@xL)9*asx+iZ
B.TTH󣏟+ٿU_ZNKgbggONNx24dFS `ggߜ60}u3ye* 	GK
_@j.]}IьVU.Y>}9esOGEy?{1Tlwwg2Wv_	'⹁l|dg!mG
Nq;+Fl;>YWY1l _W2l/2ЌÈ0%/K7|@P<',Qs6I3\	ψw9-1'j :{0=wJd3"<t>Z}B'|4wj* r8)lc@DOoFABiBP^Ksƅ וLtlTF!t 7ŽS!u$~0*(5{H;z&8ETڼqdpWJ[ྗotEւ@l;&XmrjyUZd&苃{6\"lsEYL=n00A@4C\]M [d Pvt&>vAN85T:x6Mw Q/`a)j CZ@kچɑ*LNkC+N}Q]P~FU0!Z7'@Otug+>ɠH8)N8wsgRdɹ + IS
71TޢLB@-"!aim鏍`&/C0D!3JĴNE;қdqm6t(%
=ԊX0bi?B߯ehw_+uR
°Yq3$J:wDݳ	Kn.M[Y!mDL%IjCg|Yq;*Q	Qk-~x]߅W}q@t)@%ALa+Xh,un`IDposgΥƴő2I"WH
HEex"$ԁh>JyYbJb!(]v@VrPFhC8<Ǘi$hNu=L^x+DsyU8$dh_,啘),r!]*)217"hxtrur^;"ףj(Ⱌ#k O>onޯѧ`[ZgAYo˯9<ĳGў!y
*kp C0Jᇧ v#VA<ld6`V+H:KC{(N(Ǻ>wm)CQ`@J%2V \;^o\[7\tL/mnr<EGF˝zo HCoڠ =c+%Dg!Գ#,-zX&)ĿŲnnntnj#FI]PՐC*N\ogsx.wC[_ebρ[CCߵlcX/4Bu؊MhM*VB"gKn6Ċ| tZ<71jpjNT0בxjOWY`X* +03t>U4FD70"#p0X|FPƠ
r%1#xbώLF		f 1D|E	>Y5(_|v(dP#3Z'j=Ƌ]][r,]Aп\UVw\3zt]^g犎Aiqku܎yqQ'PGàPXFFȶ\媀/Kfg󐟎]{yG`ˌ̛7qfC3.rIWB1rWX@7:=Aq AA:`YAu5py;lg	.	"hmX
v;`|9~<y<{2چdC@{%N-;5kFn*kUܳϕJ</b<4c}6n:R58m
*HUPrC\#R>j_׺-rK7/˕.v:8>-nǫh]_hkzo{2nx-}? :,f(S3;67ml-qw]6^A^1jۡ~סqvVEQ{樧'䨇>w--߽	SLnʝN\^x<0xz{y/Zj߈XJɡs	TesG,
j%j~6&' 0p^PC2\ejRjzKUcZ\v#(j)"I>Z'xbH{˥jE;W|N]&83	3C0L,*-OM3QUvmwV+:qɪM6bCanb\_^*_0LkJ:CDX38e[.<n}]d[yW1@?yZ;O:r43QMsˉFT7TlHwvӧW6"	_^b3IbJygdh$5H`&UWFaֲh:*8빺tZNckX=#Ls8ƫ6@n|>/i7 4o. (>E:uyklزYGGW8iapcLcGeӂ77鲳8oAPBXnOQ+Q_ĬLc4oF9㨄p1h2&͵[Ȃ:&"WܟO@Mm"7oWJ@޺y]5
pS]`Y.
9qH@#SQ *W/)D-ql O4"T?ڃT5YNcJLv-v+v<J]ʴٝ8;4u$tk,4+:lk0}O:22kk.C><GV^nt	a7QW-_V5P3UV6	$~lxO~4Qg6Yl c 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 bc5AM+:x;Ʈ	j@IjNjzQb.Ȍ|X/뜽+V?MY`ij)8<vtO~(:+ø_Ťs5{jKrSKqSܖ<UYnb㟼Q
U[	5Om=	϶r5- BxP ab &!x/NFU~˅$ӑu~72:(/}C\禆դ,R 5z\"u#_xͼyC$Hߞ/>Zx[˸-vOe bx,// 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zxmUIlUVn&oxM$gT%Jz{^QaX=@js [\@$hU$R8 (7-Mf/߿}ߜZa92]5Z-.6YtCպa:4y2QkE5M{Y7hK陔=nSy5˱كΆIY-uWaqJb{"{/m ⇨~	A H`3
&Fag04}RlrkъCtRZT-9$65S"m6Qvp~9gVZUs?E`4ZPWý`P揌#t(kU5}m:fn2&ƥLb7zqn87Ж87pdXb=RMCQ(x3Gw>	/{5T0luX/9Mp[0
7QxuD`9iXN_pIXLFl&YrwcE)׍iQ.ᓸ<$/qL &چCÉ9pQ\XvtFѽOQNx+A09
!#$\LfFRNrvЖY]̟Eqt:(mq> bS'J?OH9╍
1uEZCFQ4%~d4/sptCԍutu84%߰YdaOԩڨd~Lf"0/4U,̖	vQ6Ax!9P|d#"6o11eqS1+rΤAΜR(bZ~xT{%D+S\4]j{"PzVg/`wS28L\V$\-|-tL^32{0 (6u3uL-$"7*9Lq=5\	~`(U;]$؆kiwv
W$b*ʈ1:`S?*YfO~i^bD#]JotLB;+j+ Bp E:n|>S_&RQlGz17K.!VLI:zJ8x;dztCdm&Ō7'2%J  UW/;xuR=KP%
X_[Q*B
BAMk&/VEtpn A梳?@P܋`[VsϽ|(1p9°FyRi:đgdxG6!9ni)b*X#CF5tl1bǟj;Nh/"
7W\i*Rt)}) L"uB`E`?tNxC49''NCR#dwCp9$֠<L	<9@!z#¶mFT1^`/.fL!bLNvE>R+{X NcRąXUVV
1넉NX;{SJm'X:YL,Xi7ӝ(~JFLyu2vS%:	o3%Hx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`xkc[<A-5Lݚksc# 4Z4x@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"xXQo6~>ENى"C-0-6RI*Hʲ$CwUTqR/
4e'9~s3t2Y>9#Rw+u9v-kr{ټ\vq;?=Їـ(q'rU(֕Ff)R	3|N {[\kiT26JMZ~)4 Fv#lbN2]SEIaRF`?I7Ǔ;[|5(DUQhrc!qJ"7r=teMOF!r&/AGՅutAIs,7IuB$(W$of|PS{[V+~M?Ng[S݃S"^.`!L'Jq"gp 
J*C03u4@~CC4Ci0G;bLn-0/Rhѡx_bo1.!.ai.]4	 o/$㽼Chh9g$ !¶G?La'>Fr9ն6,l-9FU!'RȆCYՐ(Lچtm\%#޹3H0Dpg/|Y*4?*It`	L#"/ǵqcv}<|KcT'5>zy-50RG);fr6]*kҼ䉝a-,R#2S|DP2k2+?*[cbEh:/ЖYdE)8rkS֫^X2ݮ(F
ZHgA["rMR4hdvs\|eGTW	3/*ҐjFDaڎ9Scl8Ze=Ԓ,4_;LÀ's+a,ʒ%?q+邠{7~
#˭w2h5L?ж:>z}7l|`X`xL|*k;,0^ǏXCbWr2k*)
!N8&F%nʲ\b2O#*b|*BX_e(.w#?4EwubO$8yդU^A W%NFzi+d wwOw&/q1^XuD&8²:bYWt2u0f'aK95=x&a.͋y{OLu=#?`ާb}ʋYe2a5'ҏU#Fၸ?V0	@:ZflZAȇ]?R'dL7>ZD2¡vtåQ!5<}(NEJ\)drQ +VҗVS<inij@b/֝cN	HGQ^!pz@Ert 4lǍ{3%V0|)-C/?%<d@ۿ3V1#BZ'vF<JHv8/\\	kQnwT1[(읷<%1&lOj(Fpn} !;!._a{	xۡ4[p4sj^D-UUuk܂"\u=}(=Zr~i^B^inRj5BQjqjIHfn*BkFiLJy%D&3?ȸd!k̚,wu)S_	 3[x7  
/logger';
[oz$#
_x340031QH,-+)fP}T"Udi#CĔԢb7bXXRs9i⒢\\ov{߼ujSAjս~Ic,lxiz<y̢݌RoSqw\@V2"7jTqœN&E*+u\X TSx{QD!$Cp8_~m:&* '>x;[{6~%J\#i۩};>^\oŚ"du.$@RNҮX`ErGD<,iBVdgsYzv!2o{cyBpg2<KQA9f3Ba9b8㌓BV6Y<|r:傦Q1KG	dSDu3c9M)Dt2ǔ&iyTLf,j',3X
xDFuG$a
I. `%h1%-q9
&Ce\ jZy|93)u>0_2O8i0v6$U񑼞^RۛAfp"wDq]	ԇ%C
8P $~0}c/Á$h>+VHVݲ1+؟}S:ad_v2rIYbӤ		ְn[GT0wD,'AegY*m	j=Ix|,OQ%<ϙe=2c4b9zL<ȃ
A~)piv'HOxx)5:?=:<> Όя|9_:N$+vCL0"EEF(Ϋǔqk't	.v";$&IvsR0nkQoa@.@>!/fEr;.lh#߉<^Y6'hV4^NQ[Aa*H?ְ?,)@s/FyQp=	˦@گO%$.pt2A'ZgtJm::sv8	3-\5|V!zCcA˱HXŗ [78跁V)pki1l|e*(wl9]8@ZǡtE.h44Vx]/߲8=alN'ɴcW%-u%laU	Ua(n2@y_//7^K~~;9wxzfMV
LrC]7ۙ(L5*,jɗLqHu!hv)FW7_؃1lm		5xXgWUn+fDI0mm^5Z&.d6*qE tkTS_/;I ~#ߥ8>48\ULq'X`0;[_KW+z$l
b z UOQmfܪ2i`	\*[o^#OUYfǑ[ୂ{Ѳ즞5]EWx B
Qbs  ={+[8vi$*B!ŉtb q,UeW/&>xJp,:IF#ǈU-Cl4ZжgYO$M:Jm5ZF*p;*tj6fƣ ټw,rq)1I;d䔜6Uxɒ7|fHzrpmEd2Lw"]<![ϳkFL9T8Q8A(G쀎W&íl/X'()ȝSsJTTb,E&&" g\PX~!% =$נ)O@$&Y
PHM52>_``FQڎ؜/$	Y.IAkw䑙	=d3⹮1q[˚T-1	4J1ikvEm,Xa5KAl.l	8/'B	fD4NP~$}TYf]%;f^.:"p])q+c]ZgM9[$tίwZ@.29iњtiMT%xP 9Υ]DY?BX¾t`"M2qFMӷk 1$ˈAp &T0k]тd͹.^iq\OJuJr oH,
C0ZɢSǚ1lok/f,4 T"-rnHxjJSɁNZ^_>/,ߪ%	 (^g:Xi/d\]2. w(޴g^Y3:'u%LBM	;#]Cu_^5<p/֪}`>E+В_c!Pӻou'pLz "H-}lmG:\YxVwEͭ.8R7UJpވ͜=@gwt7<89eDTe^Z4^ZCb \cYv*ST7Ie>7ZbrFwV*ݯ/;E:hސ#[fDβ%eK5KDR̯lkv!85 \%)"mQG@,|82W,bAZMvdl~\y-%H2[q`x[D4"EYƘMh"-(VSw*ZEhO$K!C(Gss<PŁ*[I5JcWo i!tdǜDB捦L?ZSVC3sj"(ZJ᫬ȔyhHSa5JoC,c,f%6Gۃ>]J+ltR䒇-QXC4fZ]ٺ"U!+MZ@jP_%}I0egB`>(%,EИGSUb'VϢ	7f|YBhHG;8m:~@?fB,zɍUT.gSRl4-tIN\ZH=V1'k&U^{k>l@tDVak ==𹊌H7\:(0退ل.%.*<Nn0smtt"!zSM{ 8N:C\5cqKnR(l\vN)ܰv&ܕRjR2	GBKVQ-͕KLŦ37ȫRHEUhGPHtrўL"L7%e(΋jWJgDn
,Rd6y&T jL"p2xMWɟ%em2{ 
G?vf%u;t}nۇ qBǠX)|UF?.⼰e1p=k|%iNķͪ'jܒF=MV~E^?L^0bD\*?XVOAJ<1.OnaXJ4)B#cuOy8`Lc4&Hu@!Y7EOW9^aG'do=dekx I"&himcH
btF|lsXkҮS{5_QJnYq[cVd֫_1kq|+zjQe1WVO_k*C<(*KsR .:Gp瑯v⊌|EgSeVKlͧMk<4+6yC~dYCֆ-VaeAYOu:Uo* IW V^jo6\BF/fs=
0oSv8H֯,_x*vij'I"0s/G=KU(XnW>W2ov񝄳C.Qur3bɌij1}$VoLVp-dz}0Sz-5o4`0,6{CSx]'	Sʪ 1!K>{=/;)?űZmؐ2&؜zlwۇ\[ WӓO~VVb<Յ;Rڅ>$ٹpkMY::4kksr_Rm{5:{,muL8V[6+Oc_%>)JT4ۼuӫvh;ț)j!ȑ^Qk׾_nm}Z_txq^.؝+>]oϖLu?Zz(z؜Y}y߾Z(Ky1xMhe"&Ajzsn3Zz?\?׭>nޑ'ڏnW^u]T"EGM5eU(!Kpjpl0$c,'	Pad
{ M1%-DrJCzD|VP4S <JdRL6A폀zI33*+5ߛǪTU\R*I7? 0%ړɕ\C>mzU^y/уڿlz3I(ͧmNnW@PUMActAOVQyq.ʄF̕<v5mF\M$x;TomYrMHT(S7%59?%5E $X!3O$#U!91/?/391G(4K*)-ʛ<KPjEb1D|T܂ĢT$L@MI)V
%Ey
5
y99:@ԼD#S3ɳTPhBmGh=yx2L@%&'{V*(*;OW*,JM0
'l^X8YHEb4;'\/0y$d/uM6oPbLSH(HM.IM	LK<lb Hm?J<f̯k2`CcVO.8PSQ` ]9Ɏ
b?7o65],ݍ莓ۜb nxXMlYa]ȲɢXKf&xc80?aflvot={c{vcw !$ !X|ኸ+\#D;L^U?w~wnH d	V5y=4(2-OO]:WAw,	Dl3	݄H
LJ=T#;p6$H@!v$$$aqHx%~ȅIXmp"M8P岗 FC3N^ŐGiKqnO
18!)"ZUQ-@%a pз{A"јfiÃ:BHIj[yOy
q8MhhSHvnZ+sV O-Y}.,!jGr>xӝECr
TA(燀i_^YePcɼȝx2Eݏ ~>$OW~;fAy+Y#S~ .8LLj`tN<GG)ڜ<)6>aՂz<8Ԁa| `&bԓavtbwn}\[yxaDѫpK>NMPRXIE{h$TWo.w`\G0F.:>?Eͻb:[P-(Up5K	;І3Lz4(:\e}8`ǳFXYc+_h( Lӕj^Q;7LXRs3"a2O5,=d;|8">u|Mc264Pɦn|n.K.z{=RHɛ#S->*YusrªH:!CBZN78tV'0wwb:\u>-d1i@uy{X|o <{/e껸׉BW_/.B/y1N)vT<!8;u@`^27QFÕۙ`J}G_j~#<8]	XB)|d{Xd>܅}bs倻PZYUU_@r)	`5hmAoM5mo76:!uA7͓%},UA:PGS_)}zBn;^أ	"=`yfga uii`,<`}a0H*ZNJb/3ъ'ZP>?	p՛l;4	8$%yOz|0DK$	amlc,e+"|[5.o<YV/h& Cr_^x|W>3\	P.ςV"5q$Jځիk	af˄ԥq^^(9Š[mQj
YDR)T!C!g}0Gv˧ЦWpk`*\ZݬohVixZh7mjZ0(/5y<SXLX0J\ETRJ_Am&pg㍾ݿ2(M::j@Hl=U1.z2=cFx]N:i0h{~	ֱA@o)T̊l%7aDt|xoϦ F=$lƍ 6]^r$5lڵԶt)*)ZpOzCQs)Q[XQ|XJxk<I3/b'/XCU513~㷖Z8.6X?Gh8Mξ1czT('L:NdE4/kuytD/ba66c̨agU} 燎Ǎ7L:f}*w슽1SMOKP@=ln؇4k^٫n	[hR\2Ea8ܫVjީ=soqFl"\pZOr1O~EDwa)N/h&A_L޻3lDC<:|Yێ7 @"'Q.$u4bw	𗚈I21֯C W#:2fYqoHŨj%(FQ)ː41p!`b딚q?a}߫tOI,LP$h]OO6Lm~~"5wl0M 60<>s#ItbSCҧpG2I⊩H3&ȿsE2z;}f)'^I5n6:6ZW~[^|tM.l9d@eդ7	J1,_%cNfaE3~7߾5u;p	ĶOxSko0ίJI$VM	hER*MK..mZZ>$s|̒l\d"EBbi\ZJx5~ 2d|j{IM!(`"vhWGH6eqjo3_Y[t?14067;f@#Y{ ڒfЌ_Јiߛ8Fp8D&"'`<;ѭ_N'Y;rz>M&usv^
F\;I|gcc:T,r>G$] Y_i8
3LJT2e^䕮
RKZ3oj/u}Z(/ qNBׂ!bz\)CUv鑧MI?l+k~<ҧ*'Ϳ)v@t>WJ*PZx$E
Q΄L$f+xDL3EIʬw+eYEW6Ui]d_~xۭzLy$&rKr**Lc|[h2@>zͷ8RKSK<2Kz'OLSP,O+)IH,*TR ԒbĔԢbu@_ ,>xuak0W\!6eA\A7Fף}i)Hϒ&qONw{k)m[>Heh.]Hװ nh8?9y>2ڟ,6UF?232c ]ךK7ֿ6vL4EaW.Wi𷚳l|\&11NEj\6.93QqUDӎ>Q:^'?)gMk)r+JɑN)ӯ&Y}IR'ɶRbP'̟:j6!\-Z 2dG?7%4åʄeDyoR-:^n3 r9Ş'|fg'Ƀn˲uELF!`2b{h#ŋ;+|owN 0CxZ{oF_b!!ȤEkK2;ENGPv/4P\ڪ#>}>|Ə\; ٙyfw`$#$]M)MR$	q|yHNdA42	qOm;77Ael$¬>4CAKNAvr?GH3lzy?
i).߼0JP"TDyȽ*^3x<&Vْ0VDL5 ED$	Ls]QG^GX@3_Go0) ̈́d*^$N6Ppt;%!#rf{S^F[77H7lGWoMz7`v/M4ÌpWYonxswyש㳹;ebmյյn{$5L0yVniŶ|&ľ+D`"
`9rqHp¸O_&"pkq\9ᇡT5PʂȶWZFn&6+}/<^ae0En`A(ho\5^J}epK5l.g8&G(<~/(_d̛fˀJ$`NKp #Nepu	qG>OrXH^&H7 EڢB)y5*MߦP1nFA%^OeFGOS!-\Wq.#YUIJ|L,.+,epXSI^XcZl_cJX"^dd1^VO
6p
A?<%("9xcT1ˏco6郑e)b{t$%ތ<x`wp8L2^DpU1{V@Qv|΢wOє	VtT&΁~-Wp'6DTdu/hj9cF-xs>pˣ̴?_Wz7( 	ʧ릀h^6_. /@߀+Ct*]_W<jHw56wd7/#(-u̛~S퉿(C^5>w mqVaFN"GF5Bi~p#mh\8mDm4wBoo6
_f{][4X[|WP100ȵ*SC^1'MG;D$+vsT"TGWgSßN}rQ:CpO/WH{]D<b>GbU!zBUZ(2m(Y۳}'QEԤwUʬe.Rh.Sƕ
8-	{FMJn9oq-jV!S@2uDZČi*!1	1JXPVqH2[6BNu@7ҲRPWn̪+{nۗؽwC+Dx.)JTwuZbIzSǅmN2y]ôD 2#!l^%b:r	7rW|e>Zp/q\?U}h#LwwgwJ.߆	V<04۷<heu-j#I ,O
+trdmcζd4ī̚Ap% ^2):zX$ka|f^65{%dR%ӑANv*ZS"QlnP;&3Cr LqZ@bl5[Ԃa )d?;c.NxB(n%/?_+qƟ6ɧ<>%/j0whuP
^_+/1%3<c$QCvagGO3le#v|:}~^	8ƞOᄘSJF?%Mc!#"2I{DtήnlxO$Fy3\,O"T-[N -K417N5%Rͨd2g<=W*+AㅄJp72>ߘdk-~dA]F.Y؂Jf5q鳁K\R=ۦG4-,=6%"U)	.-yP7(Nu<f`L냲ki s_;4@"(J~)m;eH~4SOҍ~adxTMlEV)M6qH')וkc.(- MA40#ֳ2RqCѻ qBn@.(8 .ffNՎ|{w]4KE] -M:P{T(+NP7h@b'^';/K(h*T}Hꢦ')kq""K69T$/p{l>[ܣ%	+8hאdSY=rQ@(j<ԥ)QSO+vV'ak"M3*ƺ, Fl];QAc+pK1Yyc@͐0,߫;ԿN%vnˈ]SK@RI؝2uZJFF]!KUM7byԱ%*y6YdBf:Ws'Wg%~K?ਖ਼exN~3y?S1"EX7H(@j=f#KVҐ@ry[&JQ4cv/jBK0W&/u}}hX3Dn+0GZ$dn&]6E>]E0wb2>x>J\IǉY&RƓZ)ETQJ$O.ɋL(	XK-ˮ»E4uKk=-9BWxS!LY@`m$_WtײCV"{	p.Ev]-{*d'l. B`ʨ^L$={E&9=<rdXi&I0|ռAv#q
IHI*|=X^qD?LnafnsfU1d/ꍢU}+@~qFkqmdA'~8eFex{q0AjEA~QBIeABTfg^Ai[fNB5BIjnA@bIBqIQf^'fQPH+)KME(P`,5yd.q,V .άJu,I-R+MJ-	H,FUfG֛\89{}<'<g{L{Dy7wJXlvP;<9BKI$Լt`"\d L^Jx340031QH,--/-I-bӉ]U.-˴[5A"CrUⅮ;ҽ|PuI%%EpmwY/]a-+KKʼr6wt:oeʒ3JSStKSZ,WhMKȾGjIIm23}Ԃ2t2B<J-ɯMKLkNٳሬω^;{OxNjH++죵y	:oTYZfuLONsL_#kY.KT$u/8Ia3o^?̣P9Hǽ}y]oo~31 "Jao<e05H%f委&d!U9WI lUy#B:KR}2s3iej{6/0Tq.JZa b[OP6BiRirvj	z}l} ljeFckEOPp0BOJ>POnQjz&0UuquM?'[z7"H(BW~RVjr	0&</LYu=˟+y 2RHrysZCy)Eh(JCJ+E5M)T3f̗)p߅/˶̦T'mv8xc2=-Q*-IIM/BݯKsYbz64i|w-s]qz~j(fm3dNyo))f]*<5I7 |Ja瞴6E[2Ī%uo1ε:oif XxWo6_IH%^ﶅPrWv¦I8RFN Pm6U}~ǯ:MhD^DnD%ZgSd]'VT
Y,$p Aud_4$tPZJ8.o>AgtwW7Ҳ
-EҺ@jt~cбuxo{˩mC089;ljaj~vN/TDDx
$"nȦMS&qA^*,įq41񫣊{W{Eu`tPy׶f5+	9eɮH\L^t[㷃U@=(R{Iߘ3&ƒ	cW0jږV+TNF10"glLԘ?w@rTKGLUQ2V WrEVi]W!zg "x}mS*vHE}m[mK͈4W@C(	APɈSNH`j7	Q(RTFD,YS!d+NL9ue
NhPo!&ZdM=!zbyB,IKXrx	{^㚶@Gs{a3Z:yzP5Oi`Xq%kZB&9Q u#cJjfDcpL-t`bsխx8AL3V4?|W
|X1m 4쇤.$TC13i_"S٢uWxN)\dcaz9W7s	ȨvκQBc@O5񐶂p%,SsFCh]1%(HoJ6K[>$2;j5%,!|Űr-5ϒmLGV&f#]* P#a9`f@Yȶ=<xMxGu>;%x4UK9<D|_a_!vkv^[n*t{-zG'˃`)#yKgtYZwg5ȗ㢀+_iW[=<|x~Owξ7~;܃~Ơx1<%[{oԫie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xWn6+xTx'"IMMwdC4HBR񦮁~D_!%YYv@Ù7Ù!K%Yh*dYL`8Gq'			|-!}B,ɔ8%z=TJIY!d#Dx'8Y{pj1PHNYT}h[.јVWFT5*l]iňW	mڕr-`#kzXIWzFۭԿ6V3ZDY*|ƃjؠE"rƮpI)v9<(*<TO6Q"  xTټɈS&WԩF Xb՘ʻx[lH^,J	pRɴU0ܻ"5FډH<1iUs/E!>9!#Ya(;:nK4?/^O,2pZ`nF1`dkވNhER_Mk2	r:[K=+4q#P<ozt*e`H2.JnFnrc[mNi{,~@1$ӱ4oVs1eA8R2Ar^iIݗ=v>2̈4Kx,w(b]URpa*l=tC{\m<`"qNa&*t䤤[˞i}Ҝ&h}q<]F*I3dTJ*V`F;S@ ŗ*lZfr%S
K]hSI(іԑت@4batw
F_KaW)c۫.Rq]+vp|5:Sޠ[jU3o0|]@wcd,[jՌTHY%dr\تeFc]bV1	0ZR4$TF'թ͐]wWLyd#g^ɤؔl.IVNL-ˏqEǲamk`(w[vԽ=M{0ņۦLhM ,	=TpC	MXRƭ7N l\v aVgx}?Qbדët|7PR{M{
[6\ϟמ8ˁfTXgÈӧ,tqUk yl2!M瀯!lc-Yv?:!)92xJ2鷑:i=ɝEi|'%gAA26(6LO-ZT	~K|wnڰI3U}TGwxZmS7_owg K&$Ig0NI+t7CچN]hu@ㄧ"<$ݍ)O>=tBz$$=D'ed1dh)3ɘ/v:"!xD"2Kq|DBxFUisIFd%I\>~K+r&;I07ƣc?~D֓}Ï^H?c1:{}'{oǇ/kZo776
s5ۺvX)Kߗ\@ a/^x4T1p9q&ɍ<~kfA@ڇG,'OI-|tBd𩮇۝e+HfawM@y.fD+888kTv΄D3"z~TZxä G𦔅=pqvvv#6wyhTRPo̐ Hu]nBxjoqƂ9T_\4/%GWvK+LP@ Na!G!7/	)e3UbQٷQ5T0	nV>U+4$O݊v \SX+EβѱyEu<dZowW,"הMi 1ݜzQN5<m3Jcu,vU|,"y")Ih@ޯq)DE3ZDm4;!	* g0\	(H	w[ #] @^DpΕ٘4`iCe4'L6+ՙBY-aէ܌j4UU[ FXcURL׈/J|Vˮ}6Ҿm-7\+	]Am|2` -;	pJ]uĜgQAC+rNЄK	r E7|) Xw">(_e)\L˭"ɖJQ+Vbޛ`T6 /	$E%(|E=8LH+s_0[ rrm*5zNȃLucI\rpECp|Î1V!ipY-_2f\F|)`jKxrY',<fO-rE_uPnus{{;"hJbBSwJ[jk*MFS5i6m8匲$8[X(JsZV?{ڋ3Ccuy:lʲƴ"1\*-@#K
84|<ӘUqD4<OiSñ>UHn}
2J9k,zr77b[_а-9/?\=	VLX4m9BX>@4%Snlb|T٠aۋGPK2A6
`+/Tak}!komLA/*:mΌ?$dV1\}ҹߵ5\{ w"B ^@	Qg,h"5ŭ30ӻaڸy8Ȑ+lM #p1c<	KnI^ce֎3}]CKݎ^Uֶ0 LסLߏ`+PF-8\&vR-gozW$`\z5!/CITUz~)C.}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');
});
xVQoH~ϯpѝԢ$P}8U]wK:k'{AbovE)mpC-ahz HE!̍wu&PH,C|<9ho(WB+4i	-׃N~x7Up
%	hR"	i8wܮo{Ul:Bz@u	ύl?2grS'GFSRq$ُNWx,މTBrOͻg_?Y4x*dkԃ1K<",n}6ӯS'lr;WKioy}Dto):9-2sZ+^cn8|tNl2TqT=Yԭ&F4<Sv'V/'z.mȵ1bEb(h߉ 4h~VkGbEP<nHe{ؿmс	Yk
ׯqOH=QׁueƾRu]`G{Ŏ]y>VW:ZP$W@a<Kal-܏VmZЦt4h8sص%:7_Bzxxsav\;gwMIeEBɔЮW5"/o;u.r~ɤ*|)&egZahLw8^e?A4qFSb&'z`Ib_%C5;&j󯕇@7(I9;l &aܳ n~(`+ 9"AS7ޫl+d-zU,}Q[G"JȜS+c9]X~^[T`$$N^N1K=o~hP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
dXxSM0+fG	jT /lgE;NXJ(|yƺ '(pcPN8<4(=[x55y@$G̕"Bx-&e	̿/QSgIBP1ֶl5rw2Sfu5ĬtE#x`SiP97nz>2t=lB
md.Mc9cvq9>Bh2"8	]燷K1&1:G#G`,$Kq^^{1Q@&E}e[]CڠDh=|̀Zna_P~@<uh=<⮥3YgRfvcP{0z.+X~֜0ĚؾڵBӠ=fshT;ZklYg\ =<}<E笣{boo
nbͷ{heOxSW9aKkE(,Yܬ?/x:ٝ.qy+ըx340031Q(N-)-M++)fha_8Om&N( 8x]Rn0+6$ǎpQ$)=ɴHBRNЏK؝w|܃cHP@J
_![֔-vLi2&yS"pd9.	j;Ӱ*[%+0\lU-+(o
s)QSlyNz4F]X;ք6nՌ`h5d,5p5ZbQ>, {dRh#^L(7(P*ՆKNj2Lyz,My }F;+ª<_gnlJfk8G8:Fodч=*EN/(
,Hi߾{? MU~Y.:haLǞ?XUf:~sjV/HqʹTJSAl8neIC_Qi}j?s{~Jfw?g&xBPQܱ5%[nFʽY~̒YoޫΙMt09WI:K)	x<rFNjzHhŢu)ZbbmY#RdSDD(}} (9T>}iϞmg4znx.iV$d<׿B	C:?&#F m1[!cbV
="f˹F{@7dAvG(8v	so-xR"D=֧aX'0yݶǀY3@	u5XNAhm}l6[oΐ/qY{!TPsz{www3vtǌ.*rXkol8#BrOhh+vnX$NHx#b :0^nl6/9ܾsnt=ԽXt['V矉LQH	V}, Mi JFi`ẑz0pkskrcB^7,u%LIXЀ}H+7쉼e?^S>`FJw|{i6yhH~,}B-WӐ 
!kX&`'ǁK\X=,=5iv
dot>WO{Y-lD~4pǩF`D-y&u"눦>:	ei̮Y*o4'Pen5uu{݅M 8~2@5Vi\qmy/q^1?3&mΠ+ң}D/+7PEZ~
DDog@PD9it*h#%D^ۧDQaaHj뵜mN#`zPQa<}?j* nK]utH``qmXHώKR0't!
:v]}Q;(Ae!M!l9ܮ>޺QB$J4tWr>j<~(t;fTrF%ji{f7 :rX_+,>t,@fzJ0@MxyWE%BBVE=DF~9B6􃛷jOBp0p	K))?rSԨɞAN"Mƚ.Ƽc^bͷleh1kuJ5KIܼ*̻GǕqPL
jbAkI}g"tpo^8S{5e%
GSɱUQ ehzV75V,	YN,Kw.pcp%XoR>y39=1ݒ<xɁ<^ojrgM	lqqoD|q+Ѷb<FSB -$^ uoXcwpJ:%z+6@zrd98Ɯ2)8e
6s;TЋ'5Eo|CtĒ')B]ftN2HN7>aj`uQ0GPi|{QnBHGcG\̽~A:P:>TQ-_$;T*
^=3

trQcI k;>lQ$S[;]$Xg+P;`}2sz%lb2\N[xu<ձu=$JUnFT6TR#1R5xE
P'_uP}#SS(Ms/%+{D}"~ );r)W	dnl
 HD^b>@Ǎ"CiiH`։f;i|lBRۊPƉ|FE58c!t^9*ӈJX<4	+fVe5.uIHJ̣k(܋KL4Ȳ"wL&:ZPP;ɴMDRΥ^ZxDKUY6-#2Dr`Ky?1FݧKYIDhƴ`wB|T ^ܑXFFZy'bPb ѻ34('4}qDXFj9KDYbI!C3a#?<X`yJA|y=erMPq4L[M 񷽎p'ɚ觌<~DB[=^7`	/SUW^0:!5m	9s{@7Cc^P=z
BcvySbY$Z9{$j-x"%a!NXjF$Yēԙk
IHPVt.bK7b	<(K[Y	:#ID.=ʙ\y9]kK6s?vq")x;]E/W	/Pףg2qfYTMjHѽ!ƯeK˛zCw!Y)aCR$mD(VL,E/DCYMHjG8-0X!,ȗ`ĸ1(C1q<yzKLנâ+ȇNBrN# #_7G<fPվdܵQK<DJҧ=2lrDPGv40BZ$! "2|H`epgFC#EHkښUWވŀ| $qu\N}b~6'+Hu@G'c	2SN;RIu}'<bPT2^;=?&IkstȍinJd}8'VE9\r(CP<c,Idl)2Q<JW}2LӈwߌYT'߰ɡ7vWx w,>!9]	5aڬ=\|IeՑ8)bLtpV?ro(Bbl)CƂDsoA\1;*̮ݦ끫.dPƮ.Ѿ箍vhxvZ5o/zӰ5I"n4Y&ůmj*Kϟpd*oD]yĽqeOV	=q%+3Zƞ,L"4ߕ@`/9M9KJBPxݲO5+y0A<Q-̔CHi)c˞0^uZտpͻ.kOI>?hD, 2=VF%O)uYRk/ a
O6I2,_;Bֆw>/DܶY|R=8Ԙu>nava+a^HsSܺ[]eq?-\Z8dH.:Bǩ5 ;@fv
C+t;-8j|~v =7o
ӽ܁ԕ	4y?Z~pT>/%'ͣiGX|3@4ywC@-Ēj2hvp d]j$@H^ /=l4Gg^O6G:9ajuYs=!+g9뚓0KUv+i]r3/f* RoWfrd%Y^lC*e~^<;$DcUEHW\yEx~&.TN"~Ӹ֥Yވ1@묑<=W^مr0=ϤAXYx* OYZY[Uf8EPŘ ~V
gF4sѯ,}Wd·VWAlRw!忒ʮ}m>J][%9 Ɋb|yε]uYKerH,)J0MRnaƥ8qiJ;<ci:[=v&f؂sTQiw;<WGɱTW{Nuxys: rրo
X? qW|7䋽=)O?Z[8Z@Yo7/A.4nӲvNyNPyОvп"9sM({m^ GT>av\.Y~s^\Sn˧Ι~,~77{>MꔻZuE[Vj;׺/vvj%e
mv:;[.<٩t|y%OyD	T7jD'eQ*cWd'YиJ%z.IcH1_DATx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˰xVYo6~ϯd:[wk$	.(EjI*wHIXIz,\CLHN4]ƚhA!}$]&"tQxRݠGH"g&J;?eRDD)[4 gt82'`q9'Oysw4Ϳw~w WZg `"l%np?κ2ol[28x23N[n?N_&RdSʟd893=E"I,JE#h690+^i")%P58Cpomixo?c"T9JFA
Dnu~AƗjPJT8언%&2'PzdIp./
 t:%PY*](Wu|--F=1G5"祉w8oMAUo]uy|Ju2;{Iz_'D&,/h)͑HJ3M3,HhPeX"fƟ$cNn=8m4Qt
49As)UI)
(t EteƩɜGQCBcyCdq@l1g7ה ZĞB%9	Qtڢ2¸(RgPKSdNgV2W)
pWAIs]r_JVo+L6g<Lem_Wd:7LLXvJ%\?ƨ^yc.9"sAq!ڋ킙%ERBϵ!l3Mc)ZkӘaQ4%s58p?y\\gڪn%Tp۬mAMnL
ikB.]1)DԶ00%;R0FSi{*I4Xy2Fwcݶk+j`\r@3W{UBY3>V}c-+0	p/GN]n,V"^oԃ)ߔ[15JCP_qO°ԲXΆr=S!`ĀE:8,3}ʜ]Mơ5BȞm$p8'uT3ϐJ1}=E6/;i\+#$xRHTmdbflma$S3٭$EdmkՊ2LG9}Z_ $*#9}n[d<&<L\p2a9{B~f%HFقpBbzLdԟGɔi7ag'Z=$%dDc:9r<zygSJ&QL	SsjȒ 2R"pE4,omEeܐ ƴKuJ%L26'8OvQ,+,p)}.FlDj2|Hع9q퐽!}4
f$/,(=%cxA<
U$fq	u($r(霁m&=I>|mkn-Uo!~G>_$p'(="% %"cq>gaSrH}>ς>MgB'WOp7+`>9~c!9!	J$EE~8iS퓏hq|ifd&YIB #	۱)YUU<'WIzR!DSNsj*<58MĆ`U\Kp1-v6qJaO@*UoC!\:hH>Rlkd9`.qNGNW?Dzl܅(hEJ4,TʒMʃh]L$@,'n&
.RpBlT^'8X0&ׇށ$0B/@ܯX<.?A5{c_S)o-_nT4w;m"!g?O<5h:zWug!0[!թdRu uțRq*VKTr}`}ЪmjBqEӚ[rXrs?D"@6Iabt%fۥ$^+?[<AJJе[%4)%`ť<
b
!jv/hh^WuCCKw< xV5[bYkR(n%ާr^/[5ZLMQNUX*qd[i0 ČtQup**dYq7Eԛө?^]<ǾuǎB H9=h/`*8>>!*4q~-ۀ
W4r?<@	6( EA[^pjSw@<P"9oV
gѿ)lGm\rYwLJ0:ܘBXǏU6&_E+Hf84XZj+R2Z\I5s*t{f]2j!tMY;ͣƌMe]fѵY3.s&C>;]4 kE=\a:T̖d:XzTf8i$\EO3X˪I"~-{%P6>BGy
X3hSעf\~p_Q T-K=ޕg}7 ʷГ٩.{V]Ik7Tи'SXG!g44n
830VSU&HUl lyCӐګQKipܲ¥Z(aGq謀	G/47D>=.8<lfrƢD,=b6(gZ/¡dFFG֬fyK`XF_T_B|3@hj;vAXBspmٲ xfD۱D@#`tS'pw\0@~"N)"}{}YV)e+E5;lLkDb0Q)7c"(\W`Mj	(e̆4*#EU^=jjn¹?S
/
3\+-\Q	jEQ6Qe3 ԥdiUɄ@p0+t73ʃle/ۓ}&J4#ݾ6uMYV|AsR~wW}W~=R؋5Yhy>xӦ]ؾggd4}$p)D{/Wѡ+;5Dzf*KmkEz7n$ SL<[h
¡>Oa5akKq@Sߣ0IRbE׆*r}+yztpx˃藣Y-۷>\Iɋ1@
3TA2\]Jaek٤Jlhd+qQkYf§]xdfB9ǡ&g*n}ԅ*d$cPfTEN&4*(l8n5*)$ͧ|h&9GAI9	Uܟ=yk	S1zU}ew0OBUηC/niV`YBK.BFy@2YKT:ɡ6Xec_^~CqC堡 <'f=zP-mVff]q8àjeC5euQM0įj"⸸T@RFj%GviK&Lrs?ՙ+PxH[I07N1`veL}s~t1<~3<?==x3&3wa6="÷wvhoAC]Y4GEK9l\3>v-,-(w7MGRc<c3mX&e+eq1o77'J,)W"9>/P	`7SWM2Y=[jjfxns>7eR8~-w4Xj2hFXhe|kyWXMW%}&TZ.]I _Sה:O+V]ƙu
пۢV%vu%cIiF!9@xYr%՚s&ce߄0h3+IaIkQ]!O߬P;؄Ws:Ki!.F<&<F&(j35LUX)YR"yRkȍ!?PשּH<+4I 6SQ/گɵoxpnuS`uQGHw8j1PAATQ+My.C]&F;v5-B,)x2{ f1F6&2̇'{+N%$YVx#l.#$W(/-JNr&W6#0IJndfMRwx ƹ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();
    });
  });
});
6K@xT]kAEImۦO|FQTFXb%|)ݻٱ;af61RߋO(hه( @ُ{=3[U)=-hu.'Ayr@Vn{KQ&*Oc61l@Tg(-k:]P̤>LIg+兢˼/n0ʘ8E?4!	495Ys@[>@6Sc%MY2 TLk+]N-#i74ήtz|;4hn<qGqc.:>HRPz
XeX[I%|C[Z\l	)eȜw;-']$3V貓 s|~oԻjs*siJMO|zְjf71ɒai)K?^!cG?Ih2j-!Wbb!G$,{U{K9g܀?/P{Y;1>O=5Ӧ#G~V3]Ə	JR(PӦ,Y%0.	Hc~5"a9uɋȶaSs/~zr0?<c#;5gKI-񀣖w<Z*2T?
3UujuФ]ڍM'}:ۆX}Gx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`x1cBBf^ZQbqIQirIiQ~IjNjzQb~iAN~bn4OĢĜ̒̒6[2J3N.e2|)t.:L
&:
k[RsRStK2RJ($*$& R3L4Jsr*`VkN6gQ0ѴRP  qAF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6xͺuC<HjޜO~]IsKL x31 ĂĒ<큒.xF&CWL2LO8bmm:W(C$1l`=kףWCPMc0nWr47h240031QH++)f'߉!?گM|"#?7U/$72͕Y~317G2RR+@Ϛ1}#-QL_̼⒢ҢTxZ<YܙqOLaIjQZbrj1BF1*aiqqDQQ~i	Pxǎ= WB#DAqFbQj
މϝp;(&?Ҥo/bs 
JK2sl|#=g϶nKW Ǯx5 *~!~I58}<U>Np;uuBHVn<7x31 ?-woM%%g?^>G#vY2߿U?z[!|-2\| &x31 Ԣb)k&N7.<dQ&`U))9EA\g9+ٸqҒb_9ph,Kȅv -"Hx UT"./a	x{+;֓71*$[)LVWfM-ILי ?Y1prL/EOc6Y256Um@-@-$IVSVӚKAvuW:{qĮJ Udqx{QD!$CῧϦ|5uN1liu7ԙ| dPx=M/A̲q DbW_r$+i3e{U'#%.Htφ[U~ys\gqV11dTO"&K<C{  E҂ت-6RZ#pVHPZEi.,|f3m{j
}a 	41pр0˹_P!9ǏGrGYik՜{xC(ץy̷w[H
E^1]i} s:'rAN2Y˕i2n,
HXQY3X&nՎ7Z4o#fhrfS&#RxP^_۱0@RabFVMuts4r?wx[b;^桛'j M8x  _. Up9)ǓsHNx5 *k|+p)~E>NvD_~n4uDx[yCւ7K_:8};, xx31 ?-woM%%9/6^݋taӁv+!|-2\| &Ux31 Ԣb؞w\?ft)ZaVZX tulnP)k뱢O(*/-I-f`c	2\(k +6x{iw+LΏ4ZPYvVcdnD dlxʼ~=)4ĒTE[[u}uMTԢ"` @+umԒ C]ąi,&nғ'.$rq3kbW% ((nzx[:R"D< 0,xͺuC<౫uOk3Neir1 ։mx31 ?-woM%%\+X=]%.M](aW\ |-2\| |t%Zx31 Ԣb%MLU:vIcÿIZ3LII-O,Je:C675X'C嗖30ʱ^Cg_B. B-sx s\ێ	la UxͼyC$׷'K+
.i|iΡy,s yxͺuC<ܕ*o}6}SzRϊL tx[yCHDB=.>u#Ŕf Zx  Y|ٓ50PnmNVIx{QD!$C[Vt7L:dvvɮ̉ʇfwx;TbCɣ)
EũEe)
ź
a&
y9)
%E9y
9œۄc6|UN|d~a:oۗ *x  _Ie-PD·sH]vx5 *k|+p)~E>N< 
$8RЙb8
kDx[yCH!'߹ef&r--]2 5CxͼyC$WIv/7+=s 1%0x{QD!$CB޵r{;_voM| 7S-xͺuC<?8[)Ƚȣzr1 XxͼyC$i7	k}\(;ɹ~ eEx+ #100644 auth.ts Y藽9EG_#p*pux;TsbW'KkLF 
?jxͺuC<Ȳ8rHrb& ZxxͼyC$HPiת'nJ4m!\F? x{QD!$CkHRy1`]jUN_(c7Q0 BQ0x;TjnfnA~QBN~zzjBZQ~>g$Cd3RR+6XOn,(b/XTPZXZ\_ -/Qpw)VH*MN-Q/H-J,+RکfiB5PBnjIF~]Xa TXTZKM<Wt)iԔʜ+d^qNfr6ȷz:\[8M4AVnvWe|UN|d~a:ofxͺuC<7ߓt#Х4ʱfL #ex[yC5Nt_͜cP(E6 
exͼyC$H̚_ZF+!	Ѵs HRx{QD!$Ci\-t[Nz=Q0 6.7	x_V!]x\a&!VĒUg qsxͺuC<U죋n*UN\îL ލ=x5 *+*Z1-7(nH*>N٬B=,=r[W-Hx31 ?-woM%%u}}g5w\d>aDؘoEq6;} 4e#TGxə!^+6c[J& e
x340031QKI+)f8#Q%e^xlG Їlkx[ct\mg'Sl/xͺuC<YjgUNnzw6}< }yx vP1l&z
rdx'
6;!Go\9q/{GվڡpI40000 interfaces _QP>#k6\Uu5>NqNF8_nfk=	xL Pm?UTչ]Pxdw{CXlTzsfZ"fr>NK
LKaF9-b[x5 *Yt5dP:Y>N٬B=,=r[WkbR@x{\jDAt̼ԊɞL*RyiE%E%EɉRby%EiɩI%y)9E'2
k"IgM/)*)/6Ep'_dRTUH+UPGט4<肢ĒTݜuk܂jҒb<&@@=&ͬ	W0ّIy~YfQIibnF~1-9EHg >I]*ZRus/;棬7`|W É}Ax̜*ϱ}2~h R[xD '100644 bot-pool.ts zjnܵypy'?SVm7k<><rxv#`C#+zQjIQBbZIjf;MLqb)$'fL*d59QEuO!I	 N~x!6]hBGm%&˱-̴ 	x31 ?-woM%%OzZǣNNfo .6fۮhq\DewN{ '߬x31 Ԣb?_=}N+p&`U))9EA\g9+ٸqҒbm_yxR:4	 x*&x !4?DD5$Z˓a!rxMPkA&FEZ5d`[1
"bR0$;,3n
	ݕzXDQ"ɋ?&ۊsx|{})&~}$%.ûd5^!{2a}tEF#5U.Tqu
/~,b <._ȧT )BhKx4iJv7MAfc ScI $"<nnS-YOA#ͅ>{I)ҵfpoSzrKLvj-jN,Z X~0~cWYXNLRy?/M n=0TZ)ߚ~P0A[RRẙվlꏽ"vw<x9o(nyi:뷕׽Ka%Zx9 p4FKv{kȎl[ʸ26'K1dsH.]x{ioLS6 [ix31 ?-woM%%;|>-^kn, <	"]l]"Չ|
> d^$^x31 Ԣbt7Yf\S`w	XUnfJJNjybQ*pAiNǊv6>y($a[kW?m":&M Z)YxyBy|y%y%.
E6/*f՘lDK uFxͼyC$ȑ-̯vuo#;ɹ~ 3xC #100644 auth.ts ƓA^wgc`c,>#>!y<$Tuq{xM1
@E{/ G-"6VB*A<l܅͎NTZyxx+[{`aw><GL1t^!qBD	59,EJ.|H66ǘg'EZEʮA!lf`D$%q++%m7ƪSR,; ;tji^rҹWDU`xۭSqr&.IIM/J-ʙ;`-&$+L~-)f'g$(+楤e楦L.VaS*ǳ K!xT ۦp4FKv4%b\+bEZ/0{kȎl[ʸ26'K1dsH'hx{"Utf9ɌOp  3#rx:u_v?'m8ݰtigq< x@ 9WCxmvp(%100644 schema.ts '^X) W8g6ard?=	xa ($}a89O	V:%]A0	.$  // Parts are cascade-deleted by FK]	8V &.x\ 2Z٬ײ?b|x'֓}x Hx31 ?-woM%%'K>e7.'t1Ú_DNy^}@os# &x31 Ԣb(ōk,}Lr3SRrRR.ΐ*eMsr=VqEE%Z%m3t <1i *<x{iwՂoj{+kz6[dnD 0x}_HSa9sQmQI+>Sq
3ujXD.l9󰿞USh`d B$"̜Hta$uIfw=}tR5P)(GUSMl)Kka;,7SS*44gdٯR+O>qfRoJ'_]2%
9Q5iHe
rL0V,A)(@5dF$
J=݆hARRobѢ0շ|bUZdQ'^ߛd/ZHNfM!͎ɦ[5/P ,8du9{[}	CͰ΂:xpG%,xb,,pb`1@pa֠Zw!Ȣl4z2`/p%Jz;V4[21\g틙ɰn`'Dxx3&'7܍FrHCpp<OʄHy6RFCh]5yYZldкRp5BՅ)S'V̓$"uIQ/X>e>`B؇Uתh?F'R;ZXx>E^<kK[e t>ȅ\QpEVНWo%f4P3-!xiCdM6͙L;7il t*xùQɀYPOOO(43E^zr+znbnnfBA'B YphxoNXlb'%(f(*$Te%甦kJJSm!luM<7?Ud\ Q)dpx  YR˗XOì>mN	?x340031QH,-+)f0m|芼Pi:6)!J2RSRA_ͱ;uؗ*?)+5D(51Lo۟v~Y%dTmQb^z*HO6}/MO/USYTR_\RʔԸryUݪi_,UѸ"7"[7v'ccݬ!wOx0 .Y	x[0l"́k&s	MNt+J-)-SHIMOIt-K+(-B2d~~IDĜTĢT$7Z'K+H425R(.)KWQ+\(.DSj&2~̛[@%
)9)

剙%
%I):&NW:9^UY]z;&;5R'{Ns	ǌ7 kx{~mZfFF~q^qIbQIqxfIzBQjIiQHzmf *x:uSN݋ـ̒S BIjq	Æ8=
ɽ[uF_8I xq P1l&z
rd90辛OE `7	I40000 interfaces .0\#GгX>NenT
؉'\37@	*:01x̜*u:e_7KpyR9xD '100644 bot-pool.ts TGrdh1柦'?SVm7k<><bq^x:`Cj fj&KO$,9N7 x31 ?-woM%%Q쟩lrQ#Ybc51YyſF 'Wx31 ԢbYRw߬)<b+Ǫw7p7LII-O,Je:C675X'C嗖3lkǳXҁ@ǤI m*@xL H<˝̱94֍ZE\lx*JRq9'MN|qgܛ6(0x; a{ܤqwv2uRϕpF[[?\<"\ Ix;xPq"Z:lgl K5&PxR 		vDU5	5s/Y  'w^ 7j|M+h|rk#vD$KMxUT<ydf͖WxAR6x]]L[elP:N?(Z)<==c-L8Q:+u0*٥J%-n4*F%3]#&,B,^=o}vKmk5wuӃ.1AcbaGH`1ٔ>U^#?-F(ؔ0ILN'v0.XqL;RR!n`QG=/CDD#
{kB1bq]ǆbE6G8
M{J?OjB?'Z|CfUv`b<PO;5V@AF˹s%lSAũB笟}ӀCrXMcJ4xsbF7&]'IEVcdX`t85i§a%d1E?ikʨѐ_Ґ?HQe(/EX4wh=!l;`QoǕX
[q1c)(&O;y ʃ]8^8xe兗j4d-ܸcÞ&}!ƃqRxf[j(ٞDʎq1!.quFʳWfmNt"ᘚgjzzu7Sҙiqn8`&ƅl_e>ܡN-'zROޜ<.:JL#3iBŉ7or4>ʻa(<հ\^((lVCd^`ջOwMsB3&GgEWX+NLa<t՛gKYUt;%|ńM?8[z"Tϼ[-זR4&$p\n[t"HO*Ppxѡ1!}~
iEE)!%EV
ny%y
%n9yi©y9)	8"$ 9xY "KMٟGv#PP03˲1YJӪT܆2k40000 test KeKWg+;a3"$ɬ}겓q)Z.&hx9 q/;j`ZsOe ʠ<=Y DޓO)JGxeתk_:fYK	\2q Hx{"zW`OL'1+La~?y",X'O2y;lB@1+JJRK&{OwؿYs2\2 x31 ĂĒ<큒.xF&CWL2LO8bmm:W(C$16~>_9]KWq}.J&f1PF9wy4(3p(9dY_M(aBLsegL9lQ̼
As2xz}=;cd nK+J,.)*M.)-Je0xo1W_$_3|SXZZ̐ju@I˜<zQT_ZT>Dtڽg"JߩϘ	QPX°wbis'JO4ۋҒ̜bMo}/8#r麧; ox]i]Ә(?9X/5L5/LVA$D]^!:Va<O= ix31 ?-woM%%˗kmFS{ kb5>9y+yύ k'x31 Ԣb~t15wGMr3SRrRR.ΐ*eMsr=VqEE%Z%m3t <1i B*)x3 HoL"R8\ygX7uz)-ec{\Et {x{e9rC`zj[fNg^ZBIeA䣌|%9%9EYl>ɦ( :9"x;^tCmZ~QybQJH~pI~QbzBzj[fNg^ZBBZQ~>d뗤椦%n^̔(G.ssz0 A^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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5xxmTKlUUn&7v?UP$FDI+$.&{T{fOMR.@ HmlX XOK7l`UJbX,oƉ&ysWnZa9
]5ZWHZFHJZ3C
\z+5MY5hK5)
*tZsX] M:k&%gi{GTMMp}= }ev@x@>](crukհڊ3R@ԃUW^Cn)-AT_LNtDّù1Du[+ԪS(#d71xHܑ1~8
B^!ciz}*an&b%#LuZin(;Ж8}/D}(<Z$ifPTjU^ pǳk^kn;k2%38	kz4ֹ
KD5\HE,Zqn'×\~2<]^I~Dh&cIx>60><82-jۅ|e%F`49ɟςgb%ӵ9Da<?G`3~$| pp)	!JBGf0u\Ku!I27\鼏aOO<[by\J\W6k	<
B5Ń	C"#y*?X8$x)^:mt6YP_TY[gSu*yk8Q]ij5v0	<.K+emIT<$iIDd`TLBI|[mQEBWk9LL#{Hq%r=~KKmOJ/Y*HENEzI݄w3ɉ}F`RbFYCvxN*Gpoq{rVn?\lU{j%Me~?[$76CHöZ5Hp/]kS1U5Ɓ\C%ΈB؁m골_W\!MQ([,yOy܍8 y)uӋwOƚ1TmUj'"(D-B$*;D0ZLY04r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5xMJAE!QlaX# APvRjAw83lXN6\2|*K4 "2pSa0nyep1%\*Z)c?8J
\f	bJfD
Xdfp5SV)-$18f-h4m$~ R/sGJr˽8E,7鸹Ek!QNiKqkLߐ}3h.~ IoYxB 	%{ config } from '../env#0config.uploadConcurrencyQ~CxuTKlUUI_OIoGv*iS5A1&TayLO]6.Jز V,`	+H+{$b43{{|=daܥF@ʖQ85T)ii6y*'uPI(k:AzJjJ(JxVja9:ٽrYi[܉Bw:EugŲkضRKsNnT*j/zݍʆXjXw!:XJ''Oj&D$= ؎JFewlu%EKNn,5Xz/"QQ`1D7MPTryjAm֖Z^*ܺZXY[EJW7VVx<LW$N{{LR҉B]@j8"{@vX$8mR{chjso eW.OpPRRdµNhbQ9~Ga*8FkK[œLV$&inx&QEWn!e	Kcㄲg`C3WWU*V0B?r$pBzΧIotVZU3@xI	Od*G~ß1:Ûcg]fY6:0?,KS8Ϟ>t]8aIDYwk# V#Mјl\^ϊNPW=1xZ,ILOGXls(Zql(_M|χ(7z>>^Ƨ|[\hܿ~s,ۡQ?U{.UC<LO15G~߇vCHj̈́ZLi&#}&R;[Ʊy~G3M~ƱUt]Pa6=lxC1ԭp9<ZL"{zjvK14'#ah["kQaǦpp΍:u8j/dTr#*s}<wZ㕗\"ݘc1|'ą'D$cGg@Gwn.urdϋUx"!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[9x @		Uƛ	.dY!/K(^vݧ}XMƪ\g6r0h];uF40000 helpers EyPB%,u-V]R'!&ó4D@Eq!na=sM{j`\z 0|@`RisxUzy:&ҒbҒ͖Wxq
?lzx+zI`K5  hxk^5a1SJģxKKR3RsJ2&0l`f x340031Q(N-)-M++)f_zJ5WSNZ  [gxa ;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 o7/ftoken: token,[S	  getFileo9*
v '),8O):  bot_token: config.botToken,
      });
    });
  });
});
Y,S	+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	@x[yd%G,>W/E_VCC{IGL@!3$(-193:\xb!ڥ[=Q\WN|"76*e^q,GuOv r@^x̜*x[&Og.K/$ oxxD '100644 bot-pool.ts H& sd tm'?SVm7k<><uxvj)CH (1W859?/XAW47)H!?Mar%S
Db=Pa-CMMk=[yRKJ3K&oTd暜EVJjnѓKO~#5B~rTRեCkF)5yќPsel'78M>&\1}rdyɝBlEEEVdyt|^^|r`%vʓ*LWl!#<QYoFeɿ'kN,3a45ӁMՄE, t;i%EP4AN.X;<%L sEx31 ?-woM%%׽WV\RD3uϧ3@dj|&rWz; ,'x31 Ԣb݅3]	XUnfJJNjybQ*pAiNǊv6>y($5+޳$T -xL HoL"R8\Ci+9% ?MygX7uz)-ec{\E-$x_ +forwardToStorage, getFileInfo Atelegramd66<6H`Qy`'g5&x340031QKI+)f{2m䧜>˻ +!!xmKJA`hD1M|`b7"63Й{<+B7\p#X#
c$"'}u<hItjHXV)-!$k`u
&!"HF!e bcK|<JŲaW-y:b",Wb T+ex,zuOJ:	@4]^W7HĞ\MwF75nr#?5(am{mL{fZ$H7:cp0{g/Y&3~wޒkfr;~[՝j_Hm?3߶l2}c3x+w'ge$JrS&2/0@f	0=YHM|_*7;KyP6V& #J8x 2		Uƛ	.dY!/AT&f̯Xy
{xtoK(^vݧ}XMƪ\gr0h];uFۓmQ*Ì"+xV]R'!&ó4D@Eq!na=sM{j`\z 0|1]yx\zD&"I%YxKKRKrS&O^"ZZS</7T>-3'xNVO4LSFjbNIdg6M(7T4(-$6كnN'ɺ%1 k/l|x[.DfRf̼ԢbļԢ+}XsR&brKHMJ,I,ROTS()*MRPHO-.I,)FQQ]=v XG*[gx]]L[eAa--(c-NNT(ӵ])0TK3k\(f8Ƹcrф/4b19=|}suIC%-L!I=ֈK0nrj#=,1&'̤2W..Bnb&	+,lsW˹P(P2phR(YTR	=?V-38Y*p<i)rLtꡤ{wAU+3Dm׆!FT29M:lr8<0PxBqąsBgk3x7؁fmcI+[}:xlMX6n&ᡵG4kk>| ĦYJVq0<hAtn%V\,TxmE`>1Bm'w~ZcԥM"<5bH?ÝR2\v;aQ#wP\;=\?O'W7}GS~"/uXLGJ=qS83Z'48CՈظ׎o{C~k'_> ڱĎݐu>3cǗit<_/6KXSm ܷ⻀ƛ1j/XŐ-C8fxso?LUCB}>sՠ)N~h'˗?F^G:9&܋B`"jĵE}؍>3+~Rʖ+n{MyqzR՘Jq˱߰0[⭂q!>lY(VF$IXm jSX#m:,K:~5(^:5r4Ƅ$;_#|5J`(_pjlIEyp*(Flʹh.i!ǿ+(-U{b+TsYπtZ
3]}
2L|>Ofy:^)Y}x$ filedropfiledrop-F	;x c"ۦp4FKv^<%e<׭ym1kD100644 package.json KMٟGv#PP/03m"9bv*P40000 test R+<H"rj)E:Aޓ)kE,xX U H{&m*^H1iX3,ؤT(\Ԫ|40000 test 3^tdsy ukwiUO)'x31 ĂĒ</x[@O'\NEtWO u]ԞB$16~>_9]KWq}.J&f1l(>^J#\;̮@C3Լ2b%G,>W/Pz%9?_i[1'M?
UZ2(ͺ=gI\U5C3ҊKJKJRU41lI̿+ g0%Eiɩ_>2IR\iGQT_ZT>Dtڽg"JߩϘ	QPXqmz#E[5X((-)fbWJf$k7J z}x31 |0Mdy%rԒO&`TbzU$:ll|Yw̡ x340031QH,-H+LN,I+)fx.hzR+EBe怕e&pKgOEhY~)MKLOM*MN-]*=&Y*LmiNIfAbQniAN~b
H=juH8MU^l6{kY;WuH=3Ybܱ^[RZN^5 ;pbx#5K'?=33DJ-.+NsKK"ϼ|ciIFpjqqf~Ɖx [X g x3Eg \=xsqY	ͬ:
~sdRKR]+J<܂ҒT&ﵕ;ge'UΓuKS3K<S6i	
x敤L7fQ050|z@Qjr~^Jf	n,
&FK|?F Lx340031QKI+)fnl3{~s?g]f d\x35` .x31 Լ̒b"'X$䥬?{eb f?r85sh +xJ C5Ե,o8lmOҨs
100644 telegram-service.ts K|Um5R jx;!tBhhhAN~boiNIfAbQI oԑ l}Sx{~M}#'OFb^JNjhAN~b(N. 		&x}OJ1vQB.X쥸Nm7fg7dsLfU+| #`S6>]to߯ty%]]뺀h|k,q% fCm=1tgV,DyKh-AzC~X6l{W:<dc)H0rUr%
OHjRA39E\ 
Ank\U䂷xd{nPn%}^[}l׿V͆5l~x&Tj^X28!}x31 T?EwK?VASMEř%yɩ'Hןx0W\%DUIjNjzQb.C愇As-4iU? '*x340031QKI+)f8r[QWX̃fmJ q xMQN06<$TjH+!uhХS&EGQP<| Ӧ:sFapuCMR$huQ2.TsHNQ ^#D0!@Vyzc:]HA!RHC >e5CAʭz?(@LwUeaI6 C-Ћx [[u&Z-TTPAunljZ1.܀Qa	8ZeBkSҙ(uS[K|cO ۳򌝶wбيcن zeֽfH|[tmw=UD>=.1䰶j62qx31 ̪Ton^rs1:hOΪN4(J-/,/L-fЋsu*X.aڷc}Se ,Kdx340031QKI+)f0g{xVpV;!jr3ӋKRA$=m(0OΡ>h9wD뛟Nmf"'~ߴ:!c )Kx340031QH*MN--J-/,/+)fu]6S!Wg\Klo!2sRuеnVe.A:n9m:ӁU3K+,;_ۧisKsJ2Xb|kM>8w?^ 9tP"x/_r
'n CXx340031QH/-+)fZrhi|n1g]
!ʒ3JSStKSAzJݕ37W'۠MP% 5BfH)5\ۜQ9lE:P59)I%E eiZnJ,{֛ҨKSKvO/~0sז\6m b)O("xMjAUYՄ&&l&mF(_R[n#BI] z!h=+x;AEʉi33}su_5F-ZZgf`]f9*z|7?GG%QיCI5R̵V߳VRfv,CWͦnIP{U}:%RI+4!La&˞ki4W!4e+0(\
B!]P:˓Cm>^=//(0SPUF&~Z'3r!.1=>?8[\y/|~d_"˘^E3{co
7xkuXg73r"P`١R8H9q%sr95GZ/EҦ1wu_SN(hǱ%qj$HcV|	dtUM"s3߲kLxC ZZz'-#zG"TEzMNewFilePart, @6*#8xijBdk̟bi t:	x31 y7&+s|8/wU_ܰ_6e3JJ
8o]GY-\ &y/OQ䝻&s ?p%x340031QHKI-+)fѬͩo|כu gxajBd=FW'0.4eF* x31 Ԣbg:%'	qwިtL-Lr3SRrRR#.z9qȐfuMyQT_ZZ}bOK_H?z 7/x340031QH,-M+)I-+)fI2rᛰU-ߜ>}i9hg+mܹ!g,/U8-Ua_ݏ~ՐoasMAgKI۞;	Te0`!T2o&Ɯf6(TQ1J~sD\joݨJ҂4l{>|b;'r6B՗&&di_нA~rg%I[X ybxۤQqd}3RRRBS&1*pd(TOq*),HUp,-N-.ϛmُ1 	;x{eyrBJr~^ZfBBZQ~>A3RR+&zOax.nLS̜Tϼ|ʂGJK2sKRsRӋs72=dQ&֓bP\RPPټUqI61 7hx}Bĥ7e B5gx2e 	S=xT[LU,ܖ.^ =XdJh#0V[	Aeݙ,D>hRKmKD85ĪRB4QO>XcYBQ3];g;<+w&("5[nhQgv.[t#;-aH&p<ѥO$)Qq;Ć$$iة1lYJ.KaJ(|:	PAa$JkMȹvR~Qcw ?zzYu'|}'~.2XuV0FU;%vcnRM#G1'ӃݘQz,(j5$J(tR@[H-e, NTvOj"kTIj~x0\䠠o(denMпoIbkDm6ۑ߿:%cy%QAk/\w;>@ߕ.hh+9,A1~"up<R|`6Sh + ƌ2D!4r^*1 H;5Q":DnS2hNZǯfbkVLb1H03Bxfr76dRRNf_yj'y|cۏ{'8On&ؠ~;fW<0<T͔O3{?S$z&Fyٝڍʭ5&>Jƃ%%x	Hpvs {FW D'm>Kd|JDr^uL*5lmɴNYZx6r&?]~dۻ8$HCдf8y]pdO8`oޗ	ҧpgbKD2<^7㿉la9뀶OVxy=sBd}wMf
1tb>&O# otx;^ztBC,&0MabTld4y wr~nAiIGbqՌMmRBKSuSK2sR=jҊs!$3X$5'5(1wbD<rSK+x340031QH,-+)fhrcɌ䎶|$^z($U7'37oɻ:^77+X/`t qexK(/*QV(,J,I,QHIM+-8'&g3Rє+JO-$+**CPiIfN~L5 q0x340031QKI+)fHm*9&iKυ%  %2xmAKTQyR3E$`ph33)d	N` "^;.ܹy}Ң~V'hJh:pm畂wu9w:U>/`Hnb#oUgQ[gsl}`x0|6I$v}ؘiABO/?@	Jv$D`$КB<Ej4!=*C:5``ra٥#cRg0%:(q̾$12s'XXie$~~sܛ]~{-Vx>:;ᢛ$2TgdEeM.k,@~acХ	YrKMNALWZ|Fp.r&Q<wMV*=~=Mu->Yi5;׿@Z7:7[|\~>^ 4rxC #100644 auth.ts jI[iqŲm#pR=F
31"ɳx}10Fwm]J3M$^ w${)(~bNJ~ں?(r4=c%tde'/9^:SB>ri6+$$f;w!hrEd^xsw Xdx31 ԢbyLbݎ~ylr䧧1.`3{TܟГwUz!
rSK2z9[%o#~d%9oǧ,A?- 7Cx340031QKI+)fX! e87kE}? וjg!x;wT6 
x340031QKI+)f<}#aEpgsi qx-/*QOOO-RH+UPҒ̜b}5WjXeJjZbi\\nFbU-,H*C)+. /vx340031QKI+)fd5ߞs&>~  ׹xK(/*QVM-)L.vIM./QEQUH+UPҒ̜b}6uk Mx340031QHI+)fx۩2cúϊ%ӧBdl;7KɆaރ"ʷCU-dPב%axUS1%0.l_nfjU) c{9g"x{BaC  :x5 YLc$uq-mkjK1r>!'/{|BEx5 Y$,gWS4Zpdm^{B7	28'vZ=x{QD!$Car+g_FʇY9x{QD!$CRb_:n[??r/m~7Q0 I#5S}xTMo@RRą¡$h%NP+@Rя*Tq=WfwOܸ/p?9rdvR^͛Y(R	h0[,3`..h1T*/^JKzWD	K%W"0#*iCeH(Tp+ۿU?〻nǤ+bm$vBT
oHE܌>JڨD|*_Hvj!PؑgL=a<MdiD_Cɍϥ|H;H=G y'RUxYt-܇% %\/et-W&łX_ER{e8)A|jJm#ӲF8SR|h6KV)zgF8ߝ<cqI~FZL~z6s]ګث_X󙃚1~5֣
woV<z<t5zČ*jjxbgn::̃"
$E7;ʬcXOAQcIQF#zn97[}zsݡ崥bJ8;ܳ*pz
SQXRn?&|E$MzÕч}9ٵƿz(M}p/hxO>qr䃌"Ni9~V
%Ey\\\E%
%
Qy%E

\

&+YeJsR'1N,3YE~r#,6̪Tʒb2װLa9ͳ9'O^6!ɝ|;'o,$izQ^k$,x6'e\R\XTlvNj^zIBuV0r .[px 	Uƛ	.dY!/A;3* *T,zI100644 bot.test.ts 6LL?p!5đK(^vݧ}XMƪ\gr0h];uFۓmQ*Ì"+xV]R'!&ÓD@Eq!na=sM{j`\z 0|AlXx9zVZڙg5UrUW|.oh``fb_WZ$X⌚unm4>ɕg}LWaION--JM,.)k|X$lʴ +2ZH<xR͋@~ݬek: 4	~*z*G]dZM35.Re"sAIA$dS$~nWǗOUbt
&v<,v<IA	 Ȱ}iV.ϪTBpo<lȮҡߵٶ4Au#І}Ñ=S.$ȱt4ak{^K@;)M;mɍDB#(qxe-߆(皪UU @ᨴت,?#40F: KBIZzD|=>3\~HkkԂ[b~)Ox*W >AΗ}E;nޥ{xp
?޵Jr=uVte	0Ng[s~x	m/]>-̄V52=_%Zܼv,#eh%tQDxKiuOd@^+$LZ*.gbPy7ޯg7^JHmyssq>^ᓋyZ Ey%GȂx.RQ,?y6ZPxdqB[qFbQj֋,9jsRKrCRsRӋsS2SRBKS'fVRPqIJ-+Kq,,ML*VH.JM,ce2lv**T0T*\.&kmFbb b`	:Pm

06QkwKnA%V
Eũ6yyyvؤ`fC>Uld}"̼ԢbļԢKaҁNzjql(!NTE'Xl~(f^d}p~=h-/=F0m`.ͭ]&&K졷AJꛓE՘B-,5K&%L֕X80,Ɂ='o2)twO\|(kŝx  yDߤǲӵ Ok90HrUxl  CG^bżw\XǑ:XzO{Vj(ӓ>.4ly)"ADi"!40000 utils -UPk"2x31 y7&+s|8/wU_ܰ_6e3JJ
fVp=nL]Dؘ$?s7dи]z\ $ix31 Ԣbܽ3Һ׵zUf䤖'2G\~s!ۿͺ($ZZ둒,-1)x{iC3vݾ( \x;T;6Ҵ"['0C/(?W#<1D!-3'U/("iͥW\Xԕ[PZi@V\bSi^yQfIFIjnA@bIBD)iQO4	lR,PʒTԼϤ[)`9zs,&l7*(c1}b$ eOxkalaP RӚT&;305պ; y	j7xbbP&d !0د	Ky  BoxOޑ47ە7W9 EG=,xO~<QedYFe28e%Z)e[sZsqV(T*DeU**Ts)(`h+"dI.J66R+MJ-,;2d]ɍ, Ԋ3R*KRʠ&z$g L\*3Y[g7tc<{<'wͪK-W(,N)L(J-)KQ(JJM.Tʋ2KRS5S3A4)J-*/RQSkj&oOO,O,Q 1P[RR44PPK9hlZ4yh	PdLV &LbI"2Ҽl+Ҵ"1@vl^
F*S4&)II--i,'9ŐtOm'S0 2TQX
51ကY.myjg'Wk)$ 畜Լ05l~ 8Nhx=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FlxeSOC	g.df] JRK>
4vӼVۙjB4x[yȊ矙MM>]~󮤷hÇvI իx31 y7&+s|8/wU_ܰ_6e2Bn|O@X]sKcu1%%޹gb^WޜpS!&MҤ Sq:WS>ux -20g|x2 Sx31 ԢbϝLhLɉo.0LII-O,Je`!Cg/u53DQQ~iIj1Ãm\Woy '2|x{iwӜr^z}yT;6i24xRkAe"|zj1Ĉz1BDkf7?Bf'޾ U
zQ^QEp4x潷6oOGqr<ت$óCŒFIiu@jLHb4)S>Y}M5.3kc6mqkxfӂ8=n,<\j?5%?ݖ'5Ob@WG熻C#3Rf0@9uyN'`7Ȩ|zLlzjE}_IJR{Dw\dQB$+bjwƼZ"mppmp*sh	,nw,U;[z INeD\{>NR6>[ѦyPT;uyg
f	旰v0imH}s:	8Kκ c>'r=;>o#WX}vL'<>vE8hſ%wگ,C&+Ѥx340031QKI+)f`[ez.(͖՗   ux۬>EaC>dO%/J,I|I&#&<'PBgj}Fb^JNobeRjqP~~x:X)tqqq)S? /&ύ`!ͩB~ HRxХ+73%%'<(uL&-AW($U7g&̼ Y,x{qB)9E6?PJȎ [l^x±a>L[!cxeHQ$o;˿yL _x[yɴloEO|vd;$ {8x340031QKI+)f;)8Z/v% yx]Mn@9!E49@ݰdp'p#?qwfRQ%,{C,S8?W1dsđ!07Pm2-!,?<R<Xn*ecV|JEv[D_5C#HM}~qaog'nn틱{/;V0a=+O\e,x  %e<׭ym1kDæx& 40000 superpowers \w0cvfW>۫*Ҡx@ 40000 plans ^\8eP8q@040000 specs s88nQzd h5=//xeU]VݫftߧqnFd& 4eWx[y+Z5(d敤%&35oq޽Yͻ7ZMcL 	kx̜*w;z~!h2 dwx Age RЫ ӐU|	~x,j®YJK2s&2:3k x31 y7&+s|8/wU_ܰ_6e3JJ
vw$^DQ\w{9!&MҤ Sq:WS>ux s%x31 ԢbudZ=5ftHI	XUnfJJNjybQ*{E7';{ܬ)!KKR_"v\do#5{>7[  P%/`+x{iBHک>x]`)hʽ= Ox~}SJČ8Ғ̜bԢ7%Dx340031QKI+)fp;xyO[4aTg #}yxec_yKg޸uL ;!xu qe2a"^&A񑅀9su׼V0CQ⪒ѷ<40000 interfaces Ҷ6!ŉn몉>.*;Уbzj ڈ<x31 b f?r85sh x31 T%cݚ\wA˷ ffx340031QKI+)f=?i:Gk'Mп
 RxK(/*QVHIKwNLHQ S

iE
zz@TZSQ @x31 NXQwy^2f0tqoq_0OQ&|-vz.6f`E`e'D~ A$x340031QHKI-+)f8JU6ь]oڄbG   oxajJ&Ғ̜bԢ{j 
,x31 ̔ĢT:<y;Fu ރv; x340031Q(J,I,+)f[<gξJ81 'x340031QH,-+)f9gG7ſ!DIYfQIibnF~q	H酷AmV~ھSC  bx31 "Ss"'lq}l\#}7B	XAnjIQfr1P/_sDmՏl^|$3^U5CLvf`\!k~uw n#x340031QKI+)fXi973}} yx-/*QOOO-RH+UPҒ̜b}5WjXe5Lm5 xfxLxe=g|䗅&@PZ\{C(*MׅܛϤ	 exa "100644 bot.ts :5ze8GWG0S)>&|o^
>'utils -UPk}+.qxq Hinfrastructure 1cdӅ40000 interfaces R>	&u$	'shared |:}߁]4M1 ,4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)@%.x31 T70.7f]9 x340031QKI+)fvY\eZҪ> ,x31 tг_S7VxWܒXn]	XAFIIɬjԻBzUn ex31 ̔ĢTu~foސտ^% &x340031QH,-+)f`X\UĲR1~]!JKRus2s3K@
l.<4Ȣ.pv j>xkj1f x31 ԢbyLbݎ~ylr䧧1uLs%y
 $դx340031QKI+)f0{MډNY]{cc  ExMo0IN:w0lٰ!KPD'Zeѐd[>Iv~@s,#|mY7d4Tj`'l:M`
FI^:I X
C&']mkPxe}v \CLU@Xp7a#ɠTC4vNFj'@kZ!n
԰!"DMAIQdutk
Cbz5Q!06T4KYԅ97X:\.܃rqX=0+bWvg[}٩WӘ5ZWM:^ǞLp|Y˒0O`U}GWhc
1)٬8EjwJ7aS4.pc^Pۀ52|{C0$ok_a/H%ݟNi_#֟׋ρdlӌ5H'g}s8{brxeմ֫ey7
O{`5ـ j;x^ &"100644 bot.ts :5ze8GG0S)>&|o^
>.blI]
u: '
x31 Ғ̜bzU2ٙqE֍N3
Yx  V4Ϣq_DDԌZ0HRExtI 
r2K26b>zgzmS_m|nQM <x31 |0Mdy%rԒO&`Tb5.g]ef^_ "h+xX3!re !x  oA䡷;#3\Ó0H/xtI 
r2K2v&DΜ3vۨ 0>x31 |0Mdy%rԒO 0xeȟy*[޺[<4d& =i xŴi͉b 	/x c"ۦp4FKv^<%e<׭ym1kD100644 package.json KMٟGv#PP/037U!0 ISP40000 test R+<H"rj)E:Aޓ)Fh\x̴i.# W?xeHrL+kd5ipld& 
jx4Q~bY t>x9 U H{&m*^H1iX,~qY^qQ0Hi}x4a1 >\xew!%9$l>L 'x9 s60oLބ~vبXm"9bv*P0Hx31 ҂ԢԢbw0Ԉ^eR :x31 ļbAcR,K^t෸	X 5?Y{cCfd|) f;xeqw?|gg^-{ԃ}mM@$AŻɜVm]bBL 9x{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ikrx 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|x 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x& " q
+teleuploader#=g#xY }TeleUploader,TeleUploaderTy+#O });
    loadBucketsP ^Z"wdx 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Gx !>;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Jxo #K]v_E {Cv26ȩ1Ę<ܓmk6yF1[h1?zK7,100644 zip.ts ҈jL	B^b 2Ax en󋝛Ys8cQ8l8x NM",X*	hx 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^x/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 %});
?x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 kx #Tp9l}x `rlCx 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+`5xT 1s-ext.ts ҋ>cemPR{2w4vQfzZx.vǑ_:b%
/O7\'lqx &%(!)fx al/OA+x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Fx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Tn8x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9x' WOOL.y GoMx" +YT]ډFvb9oJJ@x3 a+Oπ_:khT<#u]en󋝛Ys8cQ8mTIx: !(X_	Oh	Mnew Response'=.body!d3x; [L,^Hv$%2Cl%{-ޜdi3ϊFR
~t
x _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?x ;&$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Xx  	(dGx! }|*
if (!bytes`*?bytes*b.Ex ~z8	"F8;L  +7x OO,o.sizeBytes?y	$ix  *hsͪbt:᱓>'x2  *˓_.[], prefixes: [].'});
^^x  ,}u@ J͓H?x ـݼ8XD`6 38x y"sdOUuKZZqho'0x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fx  Bg :fsOJHkx +tEΣNwS	8 ?ԿSx :^㥬;ʄLBʑN}|x; $#W)>= ${prefix} AND s3_key < ${`${prefix}￿y	k9x9 pZޱ&ł]C(wBg :fsOJHjx `E:F!x9 pWNO|i.+\Pw.&=5SD0YWaHRx- `&
&uploady\x Mjiir~VVa2aͅFcx  	('	${xQ =G@ѴwctHu^PQd텰xh6君Ftw2naE.AL݅:fwH!%g;x 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	 '^-Ax6 :a"Iw7^&n3ođNX%8Ó#zXS%e|Tx $"h
prefix}%`}	agx $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px	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!Lx" +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`3Nj8jlnx NM((yx ,!9~$gx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nx "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b9x-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fx6 :a"Iw7^&n3ođN޳u`	"%`x" $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Fx ƅ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yx" +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?px" +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*('vgx`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$Rx! -	
     	&'GET'});
	Smx? 3Z6v%dB=40000 test x?tE.)b`_x _{|F?ET(}2x" +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%g9x 	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7x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$$Yx+ 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\qx_ 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Ј 	Fx  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dx% $&KMPO2y(:;k[x 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^x 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[]ԔSx 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-4x {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 "x 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 KnVx Xa\.w"wxo =R]KP}ٱQ>+{ȢØdl^ɓ:2"W,8Q40000 src 1G[4nu6orHw20gwx yg:x 	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%M9lcx &&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	x 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>^:lIx ;;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>:Rx &%=m>|x@>{ 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.Ix 	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mx; AAtechG
techtech`)$Wz95x6 6,}+5s{jzcJ#.&1J~^0ݗэI8x% ::techZtechNtechD	%eEx tech`) -seHx  ӖvՑZ0Qԓ=x ==tech$KwC?x //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+lx !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:dx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>=fx#&"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cx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vx' 	>0Number.POSITIVE_INFINITYQ%d	x 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"nx*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Qx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>
Bx 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[mx 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>+\x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_x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3x J)!&		@xo 100644 index.ts vQfzZx.v100644 migrate.ts R7Aq2JfJP100644 schema.ts &[xĿB?|7+
sx 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$ kx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('}<'\	#'<'\*{+Pcx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Sx  

// @ts-nocheck
!anyS	jx
 #+`xm 
any)B['/api/upload']P*"
      'multipart/form-data',
    ['/api/upload']P?});
*!jx? 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{ښ#+)lWx !~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&Qx M<Fqx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	vx 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Gx !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sx )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!ox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vx? <L psql $DATABASE_URL -f schema.sqlOkՓYyOx" Bbun src/db/migrate.tsb1Bx- 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Dx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 
}0x,=!// @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fx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)
\nXx Xa1:mx  C,$t*4ѓ*kx 	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 x ?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~(.?0x) 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x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$px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BqtFK+&}ތ