From faf829437d86e2c0bfc968f071b1b8c59aa18bd4 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Fri, 28 Jun 2024 20:10:02 -1000 Subject: [PATCH] refactor: :recycle: Rewrite media management code --- bun.lockb | Bin 249860 -> 248252 bytes classes/media/drivers/disk.test.ts | 118 +++++++ classes/media/drivers/disk.ts | 91 +++++ classes/media/drivers/media-driver.ts | 43 +++ classes/media/drivers/s3.test.ts | 101 ++++++ classes/media/drivers/s3.ts | 93 +++++ classes/media/media-hasher.ts | 20 ++ classes/media/media-manager.test.ts | 124 +++++++ classes/media/media-manager.ts | 132 ++++++++ classes/media/preprocessors/blurhash.test.ts | 71 ++++ classes/media/preprocessors/blurhash.ts | 45 +++ .../preprocessors/image-conversion.test.ts | 156 +++++++++ .../media/preprocessors/image-conversion.ts | 122 +++++++ .../media/preprocessors/media-preprocessor.ts | 16 + cli/commands/emoji/add.ts | 9 +- cli/commands/emoji/delete.ts | 9 +- cli/commands/emoji/import.ts | 25 +- package.json | 1 - packages/database-interface/attachment.ts | 2 +- packages/media-manager/bun.lockb | Bin 2722 -> 0 bytes packages/media-manager/bunfig.toml | 2 - packages/media-manager/index.ts | 272 --------------- packages/media-manager/media-converter.ts | 117 ------- packages/media-manager/package.json | 9 - .../tests/media-backends.test.ts | 317 ------------------ .../media-manager/tests/media-manager.test.ts | 53 --- packages/media-manager/tests/megamind.jpg | Bin 5252 -> 0 bytes .../v1/accounts/update_credentials/index.ts | 7 +- server/api/api/v1/emojis/:id/index.ts | 18 +- server/api/api/v1/emojis/index.test.ts | 28 +- server/api/api/v1/emojis/index.ts | 9 +- server/api/api/v1/media/:id/index.ts | 7 +- server/api/api/v1/media/index.ts | 41 +-- server/api/api/v2/media/index.ts | 41 +-- 34 files changed, 1195 insertions(+), 904 deletions(-) create mode 100644 classes/media/drivers/disk.test.ts create mode 100644 classes/media/drivers/disk.ts create mode 100644 classes/media/drivers/media-driver.ts create mode 100644 classes/media/drivers/s3.test.ts create mode 100644 classes/media/drivers/s3.ts create mode 100644 classes/media/media-hasher.ts create mode 100644 classes/media/media-manager.test.ts create mode 100644 classes/media/media-manager.ts create mode 100644 classes/media/preprocessors/blurhash.test.ts create mode 100644 classes/media/preprocessors/blurhash.ts create mode 100644 classes/media/preprocessors/image-conversion.test.ts create mode 100644 classes/media/preprocessors/image-conversion.ts create mode 100644 classes/media/preprocessors/media-preprocessor.ts delete mode 100755 packages/media-manager/bun.lockb delete mode 100644 packages/media-manager/bunfig.toml delete mode 100644 packages/media-manager/index.ts delete mode 100644 packages/media-manager/media-converter.ts delete mode 100644 packages/media-manager/package.json delete mode 100644 packages/media-manager/tests/media-backends.test.ts delete mode 100644 packages/media-manager/tests/media-manager.test.ts delete mode 100644 packages/media-manager/tests/megamind.jpg diff --git a/bun.lockb b/bun.lockb index 180a5a1d76dcbbe62b05540600d58bb8e9613706..1ac7acc312659c6aa9c46c0a40095e0b773b22e2 100755 GIT binary patch delta 46543 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Ðk~~}pAi^rKMO9x;Bx!|?47Wma&oLX9BJPOt)r|Cfprg>x76we*lJ*P zb=J?+4?-X9O;HbY*>vQZKz#<74p_*IVUO_g4zTm|4uqyrv)zRjriXh6*@G%!#D>*^ zxIiZ;E%iBpLG~R;wU&*A2Dsc)T3+l1RJK_BWj@xyQ2kW1VEY!u70`1U2WVF->tW3u zb{`xyUNFRRs2gCwUtVi|^bgF91J37$~sbJ8BxDsrOptaS{_yyT7BE`AE(t`us zs+sZ5^%4(_9k;QJ%W{>cdf34341xp5?XuNNx3>tkB~@1o(zYL|)?Au)XPmce1`8xG zz}^NLhlVBY5nvw+P5KSX#kxzM5fZHZT0=h_;;{PY_Ljlcc)dN|-_mEa4Avgk)K9l` z*jv<+I|gb%x939RETVRJ;3726qD3$17wA&kV(A0Lm?GO?XkCmB?MI45>_(Bgb)0Ghnq2+eSwg;L+Khq${ha7Nbd%KHA{$4~wV=*+bS(Hef!Nk( zqR6ihu#NQ5Pe(cI>wV<9H2P$_2Q5gSgOXk-#vVB73oxj7XzZQTwnA%>tKEXeh_UH8 z-2&|Hjbu5v4bj;h8pn@A1KJ{JDsmeH*aP8;&PE>HHXR!4G~8`FNzGXQE`DZo(yA*o z&Y;{gmP4aI4aaLA`{}3qIP7cz6QY`I04)@nvE$j&poOqnZI8cxy06220|Z+|AU6%r z1_$V2{T%jH0Wzp8dZd4V_Dz6(8f0a}BS#6cQ-D1J8pq{g?1Sr}FQaUML135Tu7eoD9-u#yISDG|l>~?2D}xG;a>rmWos}J$y?u7g9!N5n9@< zX8P%Y4!e7E=}BW>*E%=X!v;CDh0XPu5SC#5G(_uQy-=(}n-HvrLA({L&y020zsIh~ zkz-k)HGjCWHTQh`7^HY?%f<5^w3g810x1|GJtFhMps_o~1lh;r=Al0OJJ8TuQ#;=jyS0sey1m1G0zCU6kDgYD>j-GlEeoKb)7%cwxB!<2&mfl=rgF<=dfEAS7;WUM=sFm7Kz65Y zdo#khS4BhPyvUBf4vi5fEx&+(2$*P7%RYF zIdkK}(b+UycGcKc?P<_h6XF0q-U^MLlZ*HVXk4}~+?;J*m>4;X!ANni#)e_v0gY}m z?!@*2on#q#Icf(@xy2*EmIbY`er9bm7e!+&vF7lb~^e zq)+!i<35LuV&A-}H0-y~Dn**jl=KgLN9u)=99r=%dRUUf-m;6F0XW+$ zKpWLXKb_>TodPL0TDMo^SkU`=0rq~-c;msy;N{n$(NpLS;^+#rrbegTy5?ISF5RIa zYBI&u0h;j%Lfh0;KRwK0{{k5}Sa5w>fIFm1H?zmQgtqLa&m8Ws>mb>2i*fvY0!_{g zx@vdsE(>5SLZ1V-9^sqFCOGuO&L++=b8DMzdz=UBZxeAT(gbT$$-1vIRDiBYoVnR=S zX1c?E7$k$jAngTuSuEIG>B!*$_CC-U6qrezB1@EpQzRh3UJaiVm{-ua5BG*9_dbll zE}(HlGE~nhOS#C4_c50T!?-gv>?=H{;JU@oI7XCP7U+^^I6S~st}jMv++zD7g)BKj z?ft%b*l35nSU>X+To4^#@2#|=#^$;Q8kbpNYMQ0L9yZ2dkLYj44?DaF8V7@e8VBpg z{q;g)QRe{Ze{9k90&Fdy;cDm^q-_|WpGNjM5Y12qyBt0OL`L%?K+%98+X|#&j62y= zq-0z$*!^OpQ!p3p0<_c^J#4(g_5sM2dieMtTjPNi%U~n594Q&F|3ZopfF5A&6dWXj zN8Vh*ps{je&DkbGL)73V^chn0JvIh3QzBNMnc=W^iOroJ9DH-3VHMR0vRyY)47NIh zn zhsOljHzLL51|DbSPtZ8iGA0_nDtD8jIzF}820`;RR=^UZTEj|q<03S63Y!@sr_2zU zhlPa>M?q_eJVY_H<krF6uw-aqh9%alc3_xZXu885GTiJ0N1FvrdV<;sXdJC^qS>5NkVk)M9a8kL84mkY z5HbYljQ5~n4_2{VCY8@JY@Jh$o0}Kr1u1q@9x^{cqmL2PSn+k!WIM+0Z_k8Ax8SbU zF2H^inzF=}`&i`}(dg1NUG~;E=oc=syFh2Gx)`^-GtgS-b3B4}isQ3FSOX9oyyR|7CY?kO_2)(?!atUpPK79ERa6X7+`9%fF_;ING&+c%%e6G8W(}l zto^n`bkUd@SWz! z#?c(k-eu6FTiD!9Xx(LVSd-o7nw@6edT45!n;+;h&+Id^x{$nA%#FtlDW50(d^sp_CrTxd3h+kt%)G;dhSXC|AV z#Tez-sY1HBHmJ3Q)-^Y8Ike7(#z6T2nhXY7Hdti(j(G>6seYpMDvM43aKh7}^^tYL zTfagZoU5fS$zRJkXmUC56s{?7ebI|XNbCBZxAulQ=SK}eFQGQfz>MNuM>fwunY$euOEU8BBB&6CI z+-anOjGC&ildBS|D>XoiU#HJ(>ae{AGLnY&+bY{=$lhqZ^s{XCFti@}oDD(tG8@e6 z0%t4g9VXHzqmO>+6lrV^obV~4b3 zoAlG$99o^tdZFzOP2a4CZFkte-7Mz;J24`y{+pObIpeq!PkmD_v;)ssKr(u8RcjVt zuel|6>&7R5p@|9d zRJYvJ%G}iT+?4k|({6Nb>UeId@P0G5D^eIQ>RXYU`X)D3_kfu@3@MCvZ!;&EK!L}( zso=MbOmLIUly>TE{q#YH{V9lEZ~#_1CcMW%J?s!Z@*R|)jN~JZPocGgiQV{g>Uu~o zbl9Oq9n!-NJM60u$t@T6q@)1*6=*@Q#OGLSB9{+aEIo5I|923nx!NpfopQB{&^qL5 zwT@tMJ1&~I+j8}e1zAL`uU}PX+6vR*s+1+4`{oY7XcLLyZBfq!78h?v2 zQZLfk!9L24k^}0e^gP*0G9=0YY04nKWJT!!|w^%pY3C~yi!bm}Lx#_L6r z4Sj0l{#mlY&lI0u()SktPWok)pI^FwyQWqH5^^NpE5rPf4PRG0CG{Ihr=)&U$sd&b zQOR47d{MHacc|c%E9Gj;Mr4Gc4@D+@{{Yv7F~Ybm`pNxUeTtb?}-kRgz?Yo++sO16Py zPxvKTMp-9FzVb;qtt6P&SsA=YvZ7azUleixBpZxT_J2n*j9-O4m(MUr79DOy`%I+p zO-WXqrt}v{PSR-PGZ-c+yZn;WB*ni-av)QYPm^iJw!(xWrmKQ~C22B4+2xm{W-30v zBsEL%luXW6dWMy#q=8h!r1|Sn)Ax zN-}v|8N3V0{1ZxlpP6`3GWmhh^Gi}E6;El?=bUymPT)@zn_sf_9K};I%CA6j3a%>t zuOv;rQFfH9?>i;GS3IO#ZX~!2ZYhI1%7D_?Qw7a=$A*za;fU@lUwi z*t2Jg_KT7yTVG(m4^RSh8tuJWsqNTqo}X= z{E{Q~R{Y1eCU|3yo2q=u!qD3&zOCXZY1dBa`6aj52$kPS ziW8mjap zl|M|$6eZJ?905uD(MpbkWIf|mKIU0oHZwu+Mac=A0?E*yrFcpfEGx%dz%BGM!LtCkQN+#PX*&dP|_|MWu$_z_?Bv@de$`}mE0`W>8qV%CkCPT8KVM~@Jh%Gz_J!^EU*c(0p!0R+0Yk|oQeyO z?9fF>z9`9mspJ*K=a>92{Hx;sLUR0n%kY26PPYH2o^oWrq35oUMd`QyO=;Yy{x=)I z062BU)Icby7gu_QnV@7w2_;KHGK$M9!~dI-vE?r7mHuxHf?x;Q0DMt$D%w%O>qV0G zp+>HhtTP-SAE9_kHkkj}5woZl;QHsYBfkDWd4|N^=RHjN=NXc*QEi1&IEDY6XGbtM z>{;%gXGs4%L;B|#l6;o*&od-rEBWUc(m&6Ta5DV!42kC?p3mc$)o+a^m*$8)4URVElh9v#)&oiWdo+1774C$X|NdG)T`sW$a|IKGdIAV=wQu&@C z?aH|RdL7|j%i2-I7q=D>X-HYq#j#q}9>T4JwWz37+uFf8QVg#R;v|W)Bu0yBbwG?N z31Uhe5MxCS36D}B{5(L67g-)4&Xc%CB18Dp1u?xeh{bh5WQnUJ0?UACQxC)>v7jD^ z8zgR51~El6sRCkYSrD&R0WnS7BoSH;M0iyYGsLp0ARdx9x2&@O9XcG|cik?kC+#qq7#0g<<3Swz}5JQ`S_&^*W z5!wJmr63TeL_!dVha}FB_)wH@24a&Zi1E!poDru;M0tVmY!2cRF}gVjmxdrNlgJTu z9U%6TnBxHPxwt?g-Wx>oU~32MtVPTWwsse8K45N>`NATawg7X|2McQU32TVxTijaM zdO;j3j*_E%VOOgJs=6eGmjL0>2*gE--?j3Aa0P@P!_~BahJqWe-Pcufw(T#lmmf(G^41nxq`SUB3(f|B(aCYkHYE( zVpAZ9ST_*2MK+13CLqd|2l11LE)T+`DTos!?uwEXKVUbN@Q!5Z<+khx4qT7IoYK_zh62(Qywjf;EfJko(qNF%RVn2ym z?Ld?k!`p#~ZwulqiL#EI;wlfG%5#1R?R3{K8NHi2BBSE-y z29X{K!bcn9%oFw7b6+{z})fL33 zS3q1N5hQ%Nf$-=GVsSSR&Bav`=Sj5b4kB1A=ni6fHxTzpgouzHAOgFC*w6z+D{+^^ z4HDg=K(rBSqChO|0m9Z3L^~1L6GUhfh&?3Q3u`YB4@t!K0?|Qali1V~MA_aT!bNm% z5K+BAoFLIjly zhFPOUNDPR;Xjp8BfyF>^m&6Sc-3Ee)6>A29SQ-PuHV8zVh#UkWbRdX5Boc%*7Q{mm zv9TbAh-?y@27xF$7{pK!Js3n(EQk{%l10fl5H5p3q{o36E{>7dPoh>lh*U8=9z=W` zh_fWpMYRMFZt) zO#-op#0+5_2I3)!*kK@MiEI*^l0lRm4q}do9u6XE7>E-j=82LiAY6unNKXN=KpZ2n zpG2)x5V{zi3L-uQ#90!HMYS{#ZmA%qq=673hr~$|e(4~ViL7)GqtZZJBe6pGi~!-0 z4r1{L5U-1?B+ip)GZMrqv0x;K=_5egC$UC^i~x;BFBIT9Svd+iOs?~7Q{mmv137O5!oa*jR8@19EfcqdK`$Tu^>*6*da=e z2jMafMEZCT+2R<9{UmBl0I^F9p8z6$JczR-_K0d3AlxQ^n34fvpU5F`l7wF-hyx-k z6U3+t5Z6c?6h2uXJTgHn&H`~*TqSXyM4O2qj)(;lK}^pAai7F75i$ux;6xA`CV_ZY z+$C{?M7PNxPKY&=K`fmF!ZroO2O@F`h|tL(_K-LwtW!ZeBoR9m#D^lA#HJ}A%1#4u zMnq2o5j7RW2@;=(lG8!BOaqZV9Yl^eMq)pSS~Ea=E{4wl5kDQoSrTVOwV5E?W`LM7 z6T}xHhr~$|ezQPa5LvT8jG77J8i`B7XEq3rSs)hA260(jC2^ian>ir95)0;lm_8fC zeG*qi$XpPCb3kmE3*uXGm&6Sc-R6O~Cf3XYv2-p7+k6n$MdW-Cq4PlOA#qb!7l3$3 zB6b0YA4N8aP4hvNT?pc~h+YUHY5|B7Bz_Vlbr3EKL8R*-?uuh1_LHc!2*f=xd=ZFv z9mH7@_eHhEAlw##n6ene1Cc}GBniJIARdXVB_KvE262tV@4`ob@K^$3vA})fsa1R< zaNjsjrp;2`K&)cnQrmjO(WfV2UU5c8b{RW6y zVhu%Y@ra_1hWQ8d^+h&C17Y6;;VGgiyu<;DhN9$V2yc-<;UkVw z_=@sxLNpS?DH@AY6n>)G76^Yanj%2tPy~v)TOpc=EQ+S$0!5JU*#^-}%%Es4u2MKe zlkE_}VgW@9ag!oMgzSK5DV9;R5_c(Di}pJq+K4q2ZN(#sb|NwxB2;XqXfLd9L4=8( z6dgo1MMq)Z1raWyDI&xHicX^BZivnzfg(~Iqv#^a?}2zl42KZ$yW#w^d*J+TqS{^% zZhJsX*$bkF$N^!E5_R`M^b}bXy~G8I-oj@;L?1DOqOZ70(N8ov0MTD8pcufPg!kYj zfg(nVf&1>hZQbZ(i!Y1s^z6I(zEx=Zj-IuivNevR(6E=w8`%U(#pRH#D1uFQ4S= zfcei(I}Jb?j_O-k(d;kKgSv65SB3|)*75sJe__4oVjHqB*Qs>pm$J{_p+uQ=_~sF- zX=qe~OIGwX>qc7)e(qS9^d|>R}#DE__p2aWQ>@;+??o0d8TBV$vwEz5l zid8>W3=cJtvNED{UKlE_~mPUxrRSG z;LFrmBEVqqty5*ghjW37tED*pxzVAx+KS_!7DE(QM{)cjBEN)&SBAwyk@FKsVnCSG33j$e&@O>qqk4qp!AH~jHq zRpZ4!0W!ZBum@m8K8h=j^jd%;_XWq%mH;{eO#3T4dXJC9`3g{6DWv(;OTGdXR~qSM z=J(*uubr0xISB@Q@vGZ8g8KUf(D?5Ihv6bTRPna2H`Q3WDon8x8 zTzRDVivzwgS}2k~ANmp`UCD}>UlHgG(3LF}=Z^F$WYU#vjCPd(EMw!Z1x*L;4MHbP?l~jZ_B?B0+ETQ(O(C4>1$3{)(%K z^kHQ;KykIe{S1yHk5(MNHk-vS2l5r8NE{3n{saGf4Ft!z_5hL;H(1%#1(&QieubXN zdO!jSaFp?itB*9l-CUJ_YLKs*Uk>+#@>EqgL>YR4$>gv4p3apjAQ!~c&Ee?fB{xB>hC z{0Q)8H{SwhfR6$GqG}E>7nlbu1ax2#uozeZ2!OlAGGIBd0^m-;pKnzFDgy3+6JP@h z0?zyyj|&p~MdD-N3Gftn2K)igaaQ0nBtHlK1)K%W0bc;;fm6U~;6vae;0*9Fa11yK z>;STXw}4&1ZeSI#8d%3KFt0~q5`a^l{~oU;8i)~#GnVv&;jTOgaZ)(zgqpk3I7ia z50Q8TJO+Mu68#diww1Uwo(H%cZU){2wg6j!ZQ@vhwjyI0rg=5623QNM2Q~tmfX%>G zU^lP_*bD3j-UbcV%rh(n zP=LQ#>kgs|@Cwis;O^TD;Eu~3HW+9DgaF(ixgl}`0 z2dV&70au_5Pzqd|obfbKvKAPVRS zbOHEVY$AXH7QH8G0np4rmXA0Udyj01rE!=KQu$W55Nl1N?r~ zL*Nnc7`Ox61-1ZNfptI)peE1>6L# z1K$HY%U%T%fuTSWz;lZ~>$nUo!N~br6#lNH3(yDp94?tzKqnv&;P;&h0X(062i^j9 z0kwb=Xykq12%rN3SPJ|Aj6?nefag^vkOdqC{s63~Z!hF-;78a_f}9LY0W$XCZ5l8Y z;FWL>4Auf2fi^%(@b3YjtkE(@^#x z><NVf%WEv#?c6=-bY9i;iom#=`Yfvdnbz_(69OV(Ou@Yv@GOt53efOixoqU>m- z#{gr26kr%YTmImQcm{nsFcC0*1ci zBfwGM81NqOE^q?)05}bt1Wr*p;jN>{PS>i~7$P4r*(&2>B+mdWeiZmr$sUkA6utn? z0sjI%2R;LMUpNa~01#vrE5He%&%Okn0bc`O0rcS&U>R^3pnop{{FDmY4CAFVI2ei`qbN`U;9fo&|zz(oy^&xpuc>z8Eca%)|TJ zkHRh(lAfZ69FWa{W&mB!Q3oNdypUlKM?spE9Y&fB#lk282nRX>9e{Q~dmt2GJkUcN zO&Bl;7yxtzIBMQPx&f~MU4TfSE5MF)2YLcgJex`M0Qv%bfL=gvpdZj5hykL3fj~L* zY!9Tlhwet&2J8ZM0vmx10A0NVFymt}(hGq3z!-o7;=vKX`RBbU9*6^m1H%9w>O9;N zfgwNu>|TX@J}!oW8>RA7Ax8pfzz85+r73x0O$X)yS-@;yB9Ji)Z!>{RD!_Pv$J#hx z0x$z$!D+x0U@|ZXU?r?zDliwA11th`V4+GgK-U9nfVIFwVSOEy2 zHbzM!N*YlvS292M+DmNBvTX2qd$YcEDi4jr2L68o3C>|dfb+EplJmp#4#;gx18)MG zfh_=oYCFINgelgOWcgX?FDaM)C(Z2bF)o#`@1+x#><;!W#-3WWMP_ zQ)lGUhD(ziEB+NQ>tcj6&B4&K3{KNqwEqqG065P1XF;=9oJUqn(2z!~DF6JD=eo~7 zMdZxJndbcG@01xE%*$UcEXFddydY`Ge?zwx}I-@dhUpkHV1@3W*E-^ z`7E#$z-NQS0DewlAuctJ#9T6_SIC(iXj zMo|WxWZ?dtpyjl(4o(vfJ9>oi@FCYfBiTGG%!bV9ewmw1Pvu@D(7@k~`uz8VjQ=!n z{$1QWe<$Ey&iny4pdwHnsG#8wb5)RV2Py-V#GFZ5hq5)0tPWHIssg-;1&G^|v`*dm z#FmeV_(YD+vc@B818@}h2d1AIKp$HY;3K?y%?$KJH6 zwPDc6%g4vd&-fTw0y!6dKiO+vhh8I*kEJMV=saiNjlMOrT2=O7!7r~r;9$v8#53IU0NB%Ry}v*&o1e zzsP=DE7!%x+c zA{hqwj^B=5usaC5!mxYt==kmdqsttCoevGYE$@rNuaULpqatHw zZX>4xa;|0LCaf?aZ*iSQagy^v$PfXV$DAJm>E>(29-rlIRF=uv;h@Oo;2obqMDMrxG=`viut+7q|IydI(Dz9_<5e@=4VIr(id5uXmwthq>azBsDvEG-A_@M7Z(fL zQfAezrdCpy7^EA{JRWuHwxAOBsR!2mHF2KX}bEQ$oNVLp5%d-fMyS7Uqm;H7u) z6+imwFPtg8?f3EsDG#4;19;oEX1&v?joC&3G+_V)iOZaTSQv0wJD2HSX6((DT-Iv8 z+9ygb#F#uqeTa&qG3(q0gZuq5;&H20TU8|Camhr{pLVlE)*{#~g&pf_GrHySgU8ww zHSBy4j;5WbXsE-yskq8gt;FvO;W0^8+=(rY>(*`6*kyAzb@f$^ptq_KRamxVJub3Y zRSoeW#fwaY?;@=501?ivy}Wi+pCQVK9g8&EUl>mmpDZ#KM1jQ+yG6iaY_vxz$~F4t z-Y1=QIO;!j;zGm!`1p4f4Djvc+l$eM6YkPI?LT$5_ZfR`f@Ynd7Ubum+7j#-U%QKD zOSHii`&Tg@;bmGTzO(6M+m!d=ipFx~CWyl@te6f1I(o~jHB%=ReJ}_H+!f%fN#ZUH zupJc^uv-f|Zd*z3_UiaxbM4Wv;{>B;LIlGATMxFx^6oF#qI`I!dRy*R5p#v+rVPbV z0hdk^mss{qabIYSv?tX?y`@@5zl}BJHkTFTeEx0!RS{^58<9UAn;-{IBs1s#wqf<# zl?qHWdh3s)$l@=yEY-r@$JLTkzrNm;S?TTTh8abT;W*V1pRPgk2gS`b*s^+ZcO$LF)^Tdm5eb<%T*GIo$txS$7|5|L(#d zzp0 zzIg&Uyy9SHS9pmCxXgDrTA{_bf_+yspS>Xo7MM`R$P3%Ykt$ZN)7-_x6B-a`e*<{p%+eTu;nX24V^LgcB<8NyI%+>Q5?`W5_s6j0!goLS& z!`Mh?2g zO)4F=RZYdm8{v`7O{F)hRvuKY{iH>6v0eG{Qj5T7Dyr^STcLj;gn-HLfg2bzvFo$0?+qZv{=5NK8ZTYFA(8QNoS#hsa6>c{G3tXMO~ z>mqV+&&c#DetX)v@4gddtd8b58nA6$Yc3pbVwUg1f{~S;u>JFPZQA~lSN0ilI78Jt zyZ-w09j#wpP7#Ngjk4|)9CFbf{%B8Imt}2x=ULQsh@-5%2`m_8y?44q#U@NnEW^0P z-4xeCTXvmwgqL=V8L5!iSYj7ZkLrod9}|_Icpy#c{`n~ zFf=b`r9<>VS@*rL;N_})am&KawX=t-+ToLr9b!IfzYYst@9VaHw?)Yln;y|Z?qWf~ z(wpl(TsnHytDhxc9s<3vsd#gyuCVr(&y<==QEw}jvr=$i_1}t%T;&!rp67lV;n%X( zpp9DY1#O?$w)Mp)mD)Q#7gu4xlj_M<#DPZ(QEnUdiFV6y9Cq6`c9~Yds?}{NGM6J$ zZX#~tt>s#R$XS8?`*5p$RbSIItM;VTzM8LV?pF5)ZR8NoMYP@cvv=|G!B0xqG;s3FhlPJ(Sa5sz(B)?Bu!z$i8H>TW;avQ*c`CaPb3c@d+1|cfdw{Y%olD zpbyz$oc~5Am+jE1xUY_oYoq6pJGR%Z+~y}M^%3HJgxLBPs@&5_+(A`Za%bVP6IGAt zESl}q2D|r&G`_5xIcw0O>_Zn%aj$1d1ZP~NcxNX{MAaAPSz=_QD3T5NQ+?4Q8~l?< z(JLDV^na~^74RfJ+(k~>H0Qc2!`Hoq7&I?`88Y%SBjxN=h!?VEm) zckT?!bJGh(#)}trYc(?W8Qo;0pPDnSn5%Ynl=@(Y_Tl2MG4_fts6%d-JXvBq?=|{7c*(yJ@if?w|CM7?L z`bYJYwT{dAw%(!79@Rh@^)Mh2Io!31HJbX?cTKJ9kb}VX^7Xc~=p`afVtGXLl6&Lo zuybM6UT<~>7WnkX&JhMTeM>=L`3%X1K7e|@G;T{w*q)QNc?^PGyHL^ zJf62yi(315${#h2t{S`SrC8zpHkwZuEMwruB2~*5o>+^Y;8d{0#mNuZyLK*_I8O|n zZshni!K7xzi3uoc&HsR`yr##AkH{L&wMvUCA7}-&b#Y4$qLDqQ$`xbmqxtTBtK`P{ zYK(~YkC0OvIb#-fi76X;@0@Dhr-|j4I1zCWetQ}xCLTmAxWtQ3saK5`PKTg-#fvV7 zu)15v%h~wwWar1fh0c^aB|ZpwTe`)I)rZh}e^~IWUbN=tPuk9N9)dR1lf|A1(mnN_ zh1E)4SLzkya1A2{1}BI+DC^E&JMr4zaB8x1KydkGuz-K*;qeKg`eF2ER)XkGDH6m? zO2pXE$V;M$g^u!HZ*XC%X;Wq0Uv5z~Nt8H( z+E?LD$;It)_sUPHu0QWX9sY9kdXnr}^tr2_6&!apGOzYkNunRiZh-~weH|Or-nFiF zHHHoE@VMpdM@}*1oTxDDi~El9d~&9G_I{Gsin8wKV8Om;t@v8}Qt!&|u%Op5?(dVt z*R1^kEK0&+K&ff9wl5qrS=Ej+%q3Yqtt~oyZmSC4&VGm-_8Da>CJV2lC_54stlhi7 zoU{dr(R^B|&W1|EWX}#h*-|pN@%lLAsLw36;bI)hy1(2bRfmh6M-go=s6!nljfab8 zs6$g8a(}rW#-@ml$I#l4G#Oiw-$rJ%w(oz8{_vuM=u1lznXu6IriAQukU*C8x;=b}1>g+pG+(Mo1 zgJHoacs%0H{C4ZdH^?iSI#N`9S6i;t86^(At7U3CMhVAz+Q+IFgO%MOsuwgXiSB zdw@Er|Kb2HmMK2vU7~iT+@@2OopP*RxU05i^^*s$rA4L~_6#%LEmKrJ1vxZRB>oJU znJL~OKPOWh{Sh^6M-4ULk-#IB?syCohcs&=FTW;u+-=Fp6su9v{a09Um%bac{L^N( zx}WD+RLBxlAE2%#S>g`c7?353`~f*8OBA~Wxh6|YxQZGMW?_Y*hMbJ%+hiG^zN`!N}HMhAc5GjQGi#Buab) zi-oY@r^mP&mnJT#bG2li#ha5B_d_?{nzz*i7-J2u^+=eVRSsY~(wI|C8 zUKx2lXh*%FdYm#@y!HgttjVIp_mHb6i}_#4QSX^727C?b zIH*UE|C%hSehPVgve| zogxNM&Y2=AKZRU9MPzCiQ8whq*d7Z_l|A3qduVigq~M1SwZ)<@J1=U!OFqGJR%z|d zRMGhpto!cM#Lnw@?DEa1Q<=47Ux6`*z#L z4O{p?jBZ2Vnsu9X$^gl0OGaN=8*XEc$xF_n)qJBdeT@l^IGeBe+LJ|}uV89c{(MKx zpfkJ}Ja@@YT3Hc(4iC1>e&3iYR-gMXp2!=YS+Ut`vm-K=%ZdYEpfc0Q%`KD~!AO8E?f92(vT~Vvv z2tdOdIg3T-FY~#+*AlTZzkXtgxRs}y>&n-?jF2a1qma{$1~2sb15K%;20L=nWf>jV zaD2H#LpR3oGkJ`u<`qaR^r$QOmd*1$$rp-pFDz@r-NuZyd~Sa6#NESBSaSq%Vy|)mEma+Vq_n zkyQ{m=`-YKq3QEGZ(neHwoBfG{V7&dZ`|*#kUOeha>WTgXFGms9Ebiqob!$A`QAwP zm;s57xoK)?h?U>w8*08P%u)Yo3Au@j*D!d~Gv;mMIo$G}rWZXrqhE1El6h}iw?^E$i9vs` zMod5*+VwS}DksA1b;CL0Bl6}18N0V}A{aqznwirdN+y4adwXs)mqYyh@(;)kE` z8S39p-b+3w{4;s~$&L9hChy;GGwv<$!Ha*yx|e!$$&T2Yi}H@#N$bVVU$GYd-J$HJ ztz9pk!IF1%9>%-^7}tPLHi*posNtIp@`M?*u(|f-)4td8wh?p9nft)M*(Uy+?cWW3 z=f|MNBR*>|_k-Y`C3t5sK3D$x5H$CVfA)+2;6vJ9gk0_k@RBK2hmQNp51s$v(V~u! z+U%Z;@pa4m8`;xZu$5`PD-od`a5d zvTuvH#r97ri(}UoY%1;A?m?c#r7fbW6KcN=i_)mwyXEr!`wAA<@+|m`Z|1DsJmJ+l zF10?(%PGB8B(ZF5W#LlNQGdU6(MNd}O^{OtWluVH7Hw|~J)V~nzEvDTS@&pVaolpJ zUG1A%i#&_;t>S0aK0{g5D}{e0d)BvW@+@9gIWgs)*o#*w^IKj{_EzDrq3j8>{ogXH zj9cGL&$GCI93FEkD|)~E`OSg7@^bEM6|+&+-D#WrwrEk;);CvfAMrt+MTKqRC@s8| zMdE<9f1F!zASBPC?KbhNtw8IVKkkyx+Z*hbGj!$3k=M8Q9&Tb-;=45eXGXxdn;ydk zG;2FFhMrF=`Lxjf+irx4ldGps0ID#%4OL<0b7tGZNn5HADzL^K*mQ<9)9-=w3Ilx$ zio*_;H5u`OR=`nwUN56ETjfO^G% zz6!Ftpe-XSxE!IEW$knW4xs5$&=^|nmKj@QuRaH%0&CoXO$WwZ9Z)Y5a82P14rbt5 z1?Xxg^fgPMr5-?N0vwy&JB<@)nf_i#>*J!?W~cqwwEY|lPD*N|Cxx={ttgIc5o= x8Cx?}HMz|Hz5*(F;>7e-=a|E$bDU=u;0B*i12jSBB=hwBsZ6TVJSu^O=65$Y^dpNOiefQN$Jz+Iucg45!nQgyh8CU zgt2@R2>mf^_+)(yfgv7|lx!@O5a=9H@(A<_uP^j(v>KVkFenBzV@xcUm*o+H!{%;- zjqWN=9^nr05hVu z!0aGv^BNU9{-UzY0iA(e3i;#W;)lke*7&s847PgSOGYl=Th?QiKL&pebRU=t;kkh5 zeVts(W;c|xJ1hy)JJw>+UoGyIdD2G~=n7^;IhfvV1z29f(dGKRSBM(6~s14Z+Ux^OXah4Cd6A#Y|ugW@i0{Lyddq z8!#h(4a^48hk9c!Qd$ba`j3v{Ob*hyta2U*ncY;}9N1(-m^_-Otn^QKl zjpUQ4kR#j&X2jl94U9}m;|y6gs{G|())$|Y8apgL&9cTJH^CAx>z_r=L}DBg?0FoR zp&1sNn39gFhQ-FDb9;_fheH(d%Obxs*p>e2X`{v?e}U>iT5Q6w*wolqBOD2_<0IS4 zNF~HZ4`s(L1j`N$OB$U%CO#ppatFkpp|m5xkrcpzd>G6TZUAG)XT>EY8f$!-8j(jQ zIf584N4QbhkpfboxohAtyRZTyUTLpl2V7U^CMt068*r8Aj*0TPlz7T zQ~BNKA*a@_r_ArH^mx}auW@OJ*&}4IBdxpZ&i4A5`y!;dyO(S~AIzyA9-THO1p`Tq zO;3v-KHk!{w>(y!g1M%;^pWl4DgR;dsm6A$6e;W5hI-l0Mrfb)#g3F~Yh?5&VwAJW&?3ia2xcobXCi8#>}WWc9q0&V zKwE-2(%N9w=L%+n1*+j);PSASfvbbBptH{0m1EMpl2g-hT}ksAos^m!cWbaLcpl6O zvxdkH>9E;QLeelS7>i|OT2dm;I7<$6c4U0?XzmHim1x<)>=;=Ou13Z^L~LqmQmR*M zhIuQ{8+s;t_!#kIL%ne&t_|)2HrA@TMw+2F*P0pfzq%*;5FOwYc;HMSmjUx!dkxHb z4Blw09riO?WqX-n zaO7>915DxV3>kV{y1j-*r^O~F8Dxn=xSf%|3HfaJK5TYq6>K)FgIRu-D*v*wW5J9- z3Yg{gPn7ixkbaq2EmXs)v13wEr^N+!eK>kdmKBc!v!PL7R#fI?+0bBRo2&g3=x)e2 z?@JzGhu|JH>$IBUdRcO+ZbIkw`~+NJyZqh>G3!j5GB~PpxeM)WZ$U~eGF!Yj({1l&0u!? z(h^z!K`=X-0-YydIf3}sL1MX(jwSMB!TD2V#gVW%_kT47p{S@K@*9BJA$Ks>$bFRO zF8UbEf$RWtAS=Kec>WT;xMlp_d-14G>u%$`;Tb7W88#}VuUHvq3u z^`g1_7#J9G@phMiC!M_Ol-^e+o$y*Jm&HFgZl{B_`3;LNE?=z$8^ zKt6T@E1qwa+brTW8L|^#hPJ}%a!R%$qrUaNS%;t7gJfH~qmih~tmZnvN;7qy8RXZu9nF7jR2(X>cv@ zAco2n36A(F_cr(^urv58FdeN2~2UAfrKfVoz7gE^UrNeg{muUhF{eMnW$X2U9MDfLdbj!(U(?yK9c_Vvn@4%WX^u9R0z?ba8M`t6$d z^y1G8hV6az=7Xw727j^G(`V9-oT_E5`k~4hZr-0{-Ol)}eXGm;y8P02{)L&&U4JOw zxoL7wJ)m+<*&q9F4a}?j!JrlW7kGcyXh-QC8E&_&JM!FGp;}*7x+VhC%ihO5j-v#$?kY8v0v9 z^^?$EgZ3h{V*0sgf4ilm#o~bFWLOSZ^e;}!DHm2dSR#6twb`N&yOZ)QuDdqyw|h8Q zEbWo28Fk0$vl<6k*Xt+o{;h6r5@hY6hv9vyJ`3;r^pj13oa~sEo~-Y|K&{!L22R#~ zdYD&`b+tarD@eQStaoYX&?=YGoxL6Q#n^?oqg9=}2P?{GD%Z>39`b_4(gl{4-LNjy z?M;JhTQJkzbeE=qw#P_yH&P*GES3o z)!4moTD)YIw$FwYZCbYPV9BAh!r9Wx7)qW#t7VY=6l~5KV&5U4I973U{dB`XOmLWQ zkaej(3-7n|lX$PA+x>#91NAVxpVVjh1!<+-^a4MJEd^VkrGC0epnU^UoJXr3;pwk^ zjciYctsZtnYyGq*qQsPu&1f&v!vccrkD+xpv|L|*dn!&`&W=?_9Gwbab%SM2vb_>6 zIn9i0tU+4{te*P3#)0VO$yPzu$GW|BkhQfQ);h?Zh<)A?o>*Va{OxOD1;Em1*(%^l z*hP0~7HA)YRCB|N)3y*+dp$fL(CH(jIzl%acB)ie3C8km;AHO)!4Do7DR#p`eO8+w z`x&Jb({o$;YsG8m&TSp`J~b>BZ#X*9(e??fcKST8K$~YxcAlNcKq?3x&Rm1GU9dv* zdA@=6-;v_PlrXkaU@f`ejg@Mj1B)HEv5eCJm1k@&yEjP+AJEI3H3TvwkR?6CIHPX-5B z=jitKLDutnSowCLwNo9c%KI@$JE zaCnLbC#@}DhT6yDcA+U85ZPdV`(aoNA66$i@;xjLTSH-gd#whtsuFr`V}Dx&tk$|q z>p=S)r06AA;eiJF>~0S2ZUeobtHV~;(_(3%pKcYX_4U-dggcz(K?sGjoa)wgC)ld6 zm%mo7p+0+%!`=h;R#H6I&|h2BP*Z2S{;sVlut`o$57~mOL99IBESF>(0>*`z+{e)mS=q*CukK7-y|ExQSi>mo*UE z!Nphy_8YL+oIG-?cojWzv1a>0V26vd!*-#pl8mM8DJ*RKCV@`P5N@_=_Qoj&LP!z8 zz7s-w2=dhb85SEYrn|QGw>QPqaf!)3kAlSwFFiNF;-ZoZ`ZBD-&f3dkO)w9hh;Udl zy&%?Mp9rxFM1-lWzjnNt-etH$`xSYe9a=vh-8s~u<@(UFedNQ*pNAvp1@xU;S2o!b z7OO=>uwWL#V&`xSpsS~pr!jx_S}kN-$ioF`6fA~HuK&%jI9;f-Wq=b5R#MWa#0NT? zGp>sEmtk?dh-tLHZ8xj{J=`nMPvpYHL5tuN}nQ@)A&q9);D$PjP z?!fB6)$P=@mBoTTg)2ylYo!;2IJB#+bmz_vdmBXPMPyreOxhR0VkL6oFSOR3v00l3 z>Rmu7f%@!lhju1VF9>(oY9Z?F^?A$N6h|UZ564b_9Vy1UxE_HOeG3-nLRt;m%H=Pu zcvw7kY^;$v8HAJcM064By>Hu1Obgw+!kLe)0F2_bA}6yxl6qGcT5 z(~)AVaqgoR=U{O_a#kv%0484@5?$Nr1rZMWWQf?R3?o|H42vg_aa`FRzzSxyTJvDN zpqIlQ8!TJJOt$vdz6{p8^mf=iu>Tlhi|*>@uMKXm7eHJNkz>Ts+S%X!0W1y+cViex ztqyYLWFPy%La;DMZi^+b7&l|-J6%+kx&Q6ekSBwji;HAmSbg>I-htZIj=J-U4*N9- zDty?vo}KjBFFLf*o%90GTb*?0z7FljPI?zmV2D1uufskG(P4d7hRgO2tY$_Szd}lO zikIx#o#kMReXI@ctQSN%>Q+Xjho-+~1)+2-CZO z>UGg)_jlO);S}Mz#W8>(Y=+g5_YL;DNb!J?hlPJvd9KL=VLU8GMdlrV#n>8?WWW0? z53%*Zyd zwy|Gq1f+2;55hyZrnq5-pM$ml3=Of<7n6fi}5!W zl>I&|E?2pwJ7TeO&_;mmIk2AXlOwR?Qs(^pq4Kb32K(DvMaa;}%ics-IGeIc85`ml zEFLQ|kdI)=DdhfYgcFMvwk51Vuw;AZa4&cS7K399Uwhh1FGz4`J$viUqaF6Cz12CX z=gtkV_SR>QcG#Tzm>aSmQXCUTfjf@Xu&^gNLWKMpEXG3Kjns&gEy{lPgC&=;ae;)z zeTj1l*Y|HC_1Q@dyBkg}ABfI+1iBaot1v*?)ED(G$qxG`5ap!uey40-*`9H!w}--F z0c^ZjfBO_zj0{GIZTL1U#vT`zX8zjazIs85!`>*$oK{vg2^Qkcb&QET1&hUT<;8?N zg@v<&dA|Ny=YG0#n!{;IKXrG*`&PYOzTF7|HaLQTdnSAB{&Iz3{YClP`@v!jCG`j# zDhK=PUB)=SfR#k z^J=6}zZ{`faqQ8BGvRsVg?-4BSyHd2Y;32y6c$_pt%q7IQpNGYP z;!MXGTqasCfJ={P8GhW1;?6!B7A~V+f!bHm`s@h~d*v88WZatJGshxWUG&p_fwn71 z#Td6Lj-lq#;Bp@ei$TFS2m5Pp57nI~Ic!eDc+GQ}6lhCFYB*E&%Sg%2a1m6DE$js* zv@@)ZsDzg}fBPI*0kDjnXFCe3r*YXVKiphYY${^7-sNS7U5BWq2*>hSSh%`12(){S zkgGzjxj0zuQKd1F_8qX;6?y2~g~hc6Pn@#N;*h7i3=Y)B#OYnK9QHl&@^iSnRVqKS zusKAp2du7oc;7(#ZKTxI5Es*Cqs+#+Ba>nATyfEJ5wH!glqdDv%|%w@1R1gvOSmto;Z;7ut`p#GyR7R)ublsEEV;ShoaPpw;`?mW$5Uy^7Z zm<;h{Sllym54tCrn}wB)gcXQ9XW3m?O<`ftp}Q%`7E5PXG8iXdanSM*E}J6n^yPhW z0<32P+XjnQEMxy_zozH~GaU9Qsb(iw^$}RI0Id>faJQS9U1oE`GX}WWc!~Q-* zt~_}p+Qyh?4A*+(7(Pm|$5f(dDc*k)N0@tmRafeWP9GvxVTuQqmMcr76+Kh2}!5oLv zafmqjo|UeaA%}y_8Xn*TgR5Fj=&!H>3a#ej0NY(eP)(CqOoAH zPflg&1-e6X&eok5JM6ZZa!VkXnE0WvWc<#(8sG$jS2J~bz>-1XvhAE>=F!>;3->p& zRr?cIyd=r_88FM-r1X3PmdvB|V__bTk0G-QpH^acr(r#-(|wNISH{_8kA=lULH2A9 zEVZFfuGU<9H&FHk)+|`vU}2ia1vtTA{N(3~Ve{mLNLq(rsj~!YrQCdcmXn^iRxX6a zO^xPohF^ju!@^l;w4kulC?{a4P9yIYtY;nTu+Z!@%L!P|YWW)0kiwpI&MmA3b#8|x zd(Dv97vU=zg*_V$izhDDKyN%ogVo>2qvsP?vQAn9b+bL(JlOZZY6ee!{0wk{5hIJC zV_g@U+l1Ym2}?$r)@4|-(_Ac#mzXO8_tj1?dKr~4>j^B`OSU~^X<>|Dy#s5YY!0LI z6}WB|T5rQLYYA}5E8G;g;fl@EXTR#OuZ74RVO+ND*I;o`VtVmZpyo2!Jy^J;C&TKl z&l}ms35itM1jf+pmBP`Y`9-h-j0&(dYz45w^m(ra+R87-lUpN|h*Y$ZI*U{fBUOEc z#S+VuT_6=|)Qw@=K8F=aUF)_|@3P)uTe%VshK(Xmkb22T^;>1JL>Q^pkm_rstgG?W zA0w5Flu?E4c%jzvRkIcysnPVams=yx4|)623)Tpu{2n89`t>%&*W!`4?y@-07O~D^ zNi#B!A=TN?O0DN-bXGJJDeg$@tQ3E3^Lo8YYlrPB#0Waty*J2g8SgQ$xBz6UPhsJ+ z=K4Te$NDVbK%Vw#yaYBNdR<`m6 ztVs4n(_YhOZ*gdGujvJ#PhQiV-*7n9eO=B#;mv9SgfL@Dc%%LP>w3W(4tv8ba@pX+ zLK}a3EG!3Hj8BW&)-C$%tq$9&t;Y0j4Yb`wDqNq3RO>g*)J!84zN1a?LaEj^-W1U- z0V%8-q;?jjo))G$Z#Q!n7N#y1rfR=s=8h^%y^RzihWdUhOts%(YO@McA0pMs@T>T? znd)DdT5YE6H<6O76!%1$?@oR8+YVdiPGh`p2indch4CWg{*IZ7Gg9Gu+7vI8v|Yw{ z;TDb*#*5U_!qn%5DbL+zZc1TlZ(*v~9y7OFVQL9d7%%F(Qkbf<*VIN9rnV!6@xt#> zVJdK+sbv+Wj+-gXdA~k;ufy)xFHc5%7HHtFwQ?C{q9q373g5=OzczcRf`ose~lMe1F+!6>i6^J>$UVg@3nM;WjB7Y)2X!&r(c3aHK{n z#skaTc>O2L+71GmUhQb*cosXtH@wKqJxT*FGVNoEkAvCT`-)G3`6|lH{{Ucc&HygJ zWkAzUAFZ58^*X@Ub%1kq6X5H=VJGla-ZCX3|Y@6~$E*yE79nGDB2N>DAGPqv!+kYA7EvefX^ohP#2%|BRXCsq%|5 z%QsSbQRdF^fzD;%tMb8eOBNB3Egk6NryR*l`YXFAQwmV|tyKP>F?UjXl}~1}gH^VV z1S~`NcV<>cmH&6l^%Vv`x^}@EBhgKjE6R*OPv~4>y}1tQ(?>aynbB9-WLDHq*<@Dm z5}48;#e-?!MW%nWvdK)wD4WdWP`uGEGgb-1sp3Uuas=MEhqA$RoT~I`iuuhTb~Fdf zxm^I}>(7{33swGeoM~h*FIPE`nOvmoKVw(quR}gNuu=JuncSo}U+H8fH!FTk>5|cP zCan5PCn{%h&2LxPf5x2QcaYC`>{h&&rD5;mn_}i4Q2M({FUph-D!mBHwMc<^hw#Rk zKBnyBU{?G-GZiy=Lgk-S`5!3z6f^N6GkIFsMVZnWrJvzm;0QlfLQ!VJ=apWRseh() zGA{u)!JL?{mHu~3m)pwkb|w<6@LR=qR7O!Yt^z9mCzVfTq#i5#@0bJpUHOq&&dNsd z$}|&X`e-VH%w%!Av4E4(i!w)C3OdWZpz@0{br+RiTIG|OEGz9yBSA-3Q&h#&aH`Z5N*_kDgV9!f|S-?ec88DYlB`_o7 zq4erXuczz=VEi$DP08|2l>WjnxlWm=eE27jhWZU^SgM3{6in8|K< zBlo}?8;VeNA20_HrIl+NlA4`nlSTOzL4IA^HfCLNhm&o{INl|v1;&d=8 z8mnyn&>DX%6Y<6=nX2^ZV2*e;m<`NVya>z)=7AZ})nE=VA8gzpZB`C%f?2`aVEnP{ z!W#>`3uc8!lzv?C38kL`vm>7<{WGOs2DAJZU~ad2VAlU5*c0rIN;BzD8wrlE4qH)7 zhpw>UYv~TAU!?MTQE^|T4+3+9!@%{yv%zd=0hkRh2Xn;h!T4jz#~b~(^4CR8khlba z4P6CuDn19ZL)XE4k*R;7_@>f}GCvsqs`USyG5>u1=LPDtM8($iisg z^=HhH{0}@&GU|U3@5cWB-+!KDv~L*#vUA&vrTx$IBu2qv`R93(4ja)_XKhKj8jH1t#c)#$^^Q3>CC;jt02^#?~K3C#_ z@z3)l^D)vt&y)Uno+S6gKhKl?d7kvo^Q3>CC;jt0>DParC;jg~PkLJBDeugT*N)O( z&2JzMoVEswH+ES|iQ6vL7sQiu%Cx47HO;JjMP6yEi?I1vyIRMI2p6Shma|(%^^6IgAm&sLbljWVK;?}Eg(!4 z(Jdgvm4|Sg!gS%<5`tR=2e48we{!L>mZE9uRg?SS_q=Avjfo5Ze~Q z8WCUK+Sj^P944(3RVpAmt~#qbgQq(}`fd$bEcytqI##!CHx7TerP#Xy!4^Qy!fg*k*RKw2=B4 zA3v2}+mYSdA#zf3b?hKDW)!%h)g8bV%O2pdX6*d-oN2(1SpvJ8YhVoezck15#7 zLf9uF%0kGm4`C;T1H$SGA*um{SXT%K#dZo#o)9XQgK$_xmxHjI!f^^mg)637#5IHv zUIoH&v5dROtr3KmxM|)Oo82Ltpy1*G;iTy20U@I?gaZ^l5cX;iJexomT@Au%v4_G1 z3e~DZI4k0-L&))haGJt7QKbfi0B;DBYe4u|oTPA@f>%ul=f(J%5b~NrxJuzO(XbYT z&}KL%=GL-yvtASzDLiflkG8epaaqi&4I$qL!d(hiMXNdxqMAckSqH-B;u{K1Eg*!~ zg>XYGs|#T_h2JTBDZ=VOh-(Sqjd~DniC-wV`9gT9K7_Bu=K2s$P;hAg;TzGf0fY=c z2nQ&9C+waOJpCbz_JnX(?4fXhLbZkvz8CQgA>;%=I8EV!sL}{RKr0B78$tL{oTPA@ zf>&b*55@S#5b|0>xJuz?(Xa`G&_D?Dnn3tPT%_=rLR&8gzlm915c1nVxJ%)8(aIY_ zR9gruy&?P|zMWJ+W;<`Yn z7zUx9hz^6`#!(%o&_K9$fpCICdKU-{#bF8=-5}KK3Zbz`=?cL!9Kv}DUZPev2p1?! z?*^f%I7cC;I|Scw2tFb^96~@32)8J-5I)@@+@`RkI|N^GgF;?U2%UOB@E5r~AcRIh zcu1j@2<{2tF@+61Aq0vC6!LpPh>U>HR;-DD5Y-!ktrvtK5zz~RQy&OBDFh2^ZwR|7 z#P){JL2RcG7YU(a9|)a9bRP(AFG4s@p|fy}gm8jFdL)D}ahO6zUkLSHgwR!_ya>TF z3c`5`;i6Vw2p1?!?+c-aI7cC;9|Yeh2oWMX3PM1C2)8Ko7C!wT+@`RkAB0G8gF@Z_ z2%Y*v=qqyj<1KU`gohORiQoYc9#hyb0Kx$AfI|LD5F!UccuA}o2q9_^1lvmx28)Q7 zAUF-SmKN{4WbI;&7S=%!b`OR}>>zjy727Gq4S`T`FoakUJs5&pG=$?6MhMp-5Kd4? z9|9p>9Hx*F1EF3tgi#_T8iMCg2gLC6#laS)tFLfA;F$>FJcW6p)@TS9C`=y>VSzYDAtwof zZz6ZsG`dA2?#bF8=84&7?gYddY83)00JcRQU-Vn7iAY7m@ zJp;m<;v9vX2@rh8L)b2|$3qC12;mlm9l~b&4BQk$ejTpGzY>%3KvE2ObCxDY?uk*vUorte-?zu90*s%nj8pGvmw}KLHJxm z%!1%F2f|JYH-vRIgxwTkXG8c>Y^M-67ed835N?U+IS}0DK{!s~YvDQ58YkWEA{MIpC5 z2*)Xu6s`io2@2@~f?Z&pAtK8l)XRfVN~GjL@O%Zrc?vF~)-nheC`?}lp^P|3A!j)R z-&Y{GitJY)1gwB?i$ZzfvmC;03QLwls3>kw$Xf}a(+UWcMeYg+p{pP~q)QxA~)evfkh}HP;Ra0yx)e_cMLA6CcQXR3KR9D#7 zfa-~8Qhl+9)Ihkd1$l~iQbTc=)JRlW2Wl)*NKM2^l9#Bp9^@^?lbVWiq-LVw29S@) zCN&oqNiBrWMo>#Ji{vYAko-ieO(1`fO9~L*kXni0d{Aq#j1(vyklKi_&7iho4ap&X zAq9zu*Ff#WW>T=Qz7A?H`jI+_?WB&vz6I1tM3X|q9#UuF`UWUe#FN6rVNw@SWhTQ?QA&8diQ814!_Yn`g?O1^EqsV!Eeko@}{D{~w;0_JNy_442wyzuEcOrDJ40o5r@yVn4 zvA6?DydY5hI;uCltZlgTg<&S>)kADu7{cg%it@DM?7uHR-bz3khs%MR+KKufng<6s^1p@CWq=P9(|v-hSiP+si3Fq(tJw-sZ>pqDg<@kP3)f5;d02FT`P zv2rx;}=lc0em%58o$ET!B8xg#!BNCP4LW5evz+< z68UXKca`>1%x|XDMw%~grSV$^^_A9CX_&vlFU>cD#%zA+dX;*}*IfDWn~iIg)`DLo zW}4s7-wE*5QaP4JdKbWoe3e!PX-t;!tw(+pwbvo zJnA-HZIo7?a7EkYfQmq6zzwJZR0TYM zYCv_MhGr2HhiWcibzsy5>H$0~c#`qt;tyx;0{4LXzypB4w)qC&uX{cL`12|qSPU!? z=Z0#PGgl(Xvt%{!DzFCNIl`ZA^#=w30|EXRt`iUfbOu5JenYA?&<1D=IDjCa9S{sO z0vZEN058BBXbLm~Y5}#a`0i^RB}!1PliFQ|B%~SD+gZ2806q{pwlZL*N|n5%4i^9{3da47dPX1gfD+ z*}x#c6KDwJ0JDJEz#L#MFwcsGINvH#W3`ZKE|~1nKpCJc;0lxjcy?YB=VP_ynV%qe z9{3da47dPX1bAw01U3Qrz-z!8z*gW*;4NS;un*V|ybBxxjsVMnRe%6^(oP3XqHp|0 z!XN+#lW`8=+{rW!8Xhb>NVoxbPVijlfCBtk+CZeEfPO%KfJZTp-VQ)Vpc4=RbOv}b z@+9m6@cIz|Gz1y}yfX4a$cr2=VEn<>FZ`zQuSom`JOOxS{|H_Z% zIlmXk2W9{>fgE5KkOgD|V}U53AJ88d0Q3P~KpieXX`l>H7H|d10p)?4@VNzi4crDY zzr))VpgJ-a05>rA{GmY)fESWpKsBHyz@JQf3ETv30bc=M1K$AO0^b2!fVsdEgg6r1 z9tZ{alN|mk<{R)8Fn^wLx)|pFEE4?j2!E;456FYVTwp%109XjT3}gd5-w&aayddoY zUWDBj2nTuqJ%I?I7tj?50$KsQZzv6v0r&&Fr@$Y8g*PZaA@MW7A78!&YzBq`!+>a@ z3(yrf4*!F|R$w8p2v`iv1?BGNl zR$%1(!3lpn5(UJ-*3sKsfImxf0KC&F3-BUl1$Lw2JwPn*J}Nr_90ryE%YavaD#&{Z zwt%gG1{4F{18k@xvn1a3!`KT{r3jP&N&*J}JKzMk!;iOX>w)e-XJ88S0-zo8ynru| z{u1bgyba(3;61==!0W&k;0>S$z#A9blQcDMLgu0HVH7wDbVs@yzDY@D_1S& zv=6`=gkRy?8)=4kBFQ;PMkSm(PA)t02>1rz zCg=XX4DdR07B~Z(22KIIe<%PB0lc*F68AQ+W7qZst+$rB4QbZL>lymTb!1rsybNRj zuL4tmY+xdg1xx_2u`J^O9#s7JL*H=#%h8WMlNHl%60izb3D9>nFo)G-AwlP)2z&cj8BDo}@f_c@3xx)CN3&*HDg{JHU0c9;ksd@9ua7re95<7El-P1e)0Z3;1IcqrnNlC?Fje1Ec||Kr)a7&@U1E z{JKbiHc91=15X4pfC<2Ol_pQN2FY7{9maHEAut120L%xb0aJl&fK!zPOabNrbAdU+ znvVU>BC~--KrWC62wG`jA&CE~>pM zwakBWHVd=C=L?t>Zc=${oIbCCIcqHd&d?SxCxq!8;O)RRU@Pzj@Fu`z^pSzsUV zF>nO90$c_za>brOq5wDn90CpidjSS7a~<;*_`HuK3Gv1y-1`cx6y)J#2cN>5V2y0 zk|AWHEWoHxHw!bwY|LzcVg2YipHE<${?9YZvQ6rlEX>Ny-d})iRz|&O=PoH<>Sh^s zl94b6z_Of5=3fJwgP_d@&G4&EW&WF3bdJ74fm?t%0yFfi>~<0Tn_Ru%Bh=c zgMNQku4o-a{Wv}R^uqEOTxMY<=BSF!*Pk^~bi{1L9H|>B<@3Nw0G|!=86iIwu?}uE z4#eCsW>ijd|C>GK=cl3vDL+f`5aq#X9-2>3$!`EFEqY>_rzH;|o{l^jKL?7gnLj-h zIsbohEEb-M=5{ip^8@_N=`iP-ANDvRe(*B~V15|+tts`}g~V|9a)ibu;|=8-jmZ`On`I z@Zl04lkr(Up79qx@2`V&O`tYV3*h54K34Sy{KQL{TGxua_hNBAb>q`FK85Qc4rFRQ zLJlB{PaXIv;b&kdvSNT}UaH&PG#-(S)*=WZ~V7XJ@7Rp^kI~^OutF+v%_zn%kqYOPkhk7vXO z_{|p$r)c5sS5#kD*UM_sczSFM{CvHAy_@4Pg~+AfF7Z04KwRDlx-342<;%k!pTV+x zpN!xALdLMyHR~XMbkh%iw~3H(Xa!%*?b_w-ho5U!9;;QL)D>P+(J@YODVP;c*1FaUqJYgNQvI3S2V==wC_GfiuOFX_cjLq*G#-v^aPd>^W3o`7F-%mzldQJkjVo5DIB z?d}uqAor7SXW()|ohMzXl2G30pAY{xt&c#zV29OG_(UT8+dx z_Rd$hya@^twi#NsdskG%_+Q>JX~DVkt?#i9SU{*~pja~leu-k=3~jkJLj)x z#g}E~%*6OQ6qn2ZgEJA=H8z~HsC(TzQ!l%_eH~_WvL)lS zO}ONshP`m$%>15U-IiRt<0Ul;L?}@N!$H$Zh=Dnpn|n?PIn{TD^^d&T=1@mC2B0l8 zrHch{L@()YoggN@2AVFe(vo@ZWt`+fk4dT9wq2<*0Vs?}F_?oy<5}oN70eR*S>;5o z{p$2>T!UC3oG4E*7!LS8(D+%He+|9hZtQPeaXIAY)yeSllNAjWH|RG}{5A{A;HYRi z8^b??p;dEarbJYOC5T3*TM-jpLd7YhpG1PKoe2S{1)L@T&m7dpS8* zoI0mWdDcAtLs_0R6Ys>_e_`t8wa;=Yl@=@JAl^;j!Lr$BwFbSXYLlONv=e7hxo>|s zxWU20{g*knT9oe-lgvt3upOU)N3Dx{j~ZPW|{MWwU&dR zq4QZKD-5nM@tgK?tnOdP&S94Hui{BNj^hSVXgcDy~FT%PsK|>U0mmalk1%?{Vl#@3)Vw zH`?~$dH-jn#I*%lRrmfV!&zKgI&V+6lo&5U0aY@#aGG{4??8LM`0-h49Hm&%YO4bG2~aOIWy!;IW)f&h-E3!z!Bf zWo|{p_O{9i`_RKaaN?&gBS%I3kvNSSv|`opl}>HAb&BY*2o=s1Wj1p$)sPjwK4;C0 zDK0+`L(f!&8^ucax$lJoFA~}Wmv?5@E}ISq?pd^WT%2P?d&PGor%@H+pl&Vlu z2D8tbtIstld;PWw3`$8qk)R`d8&Nkyp0e?(Wo&~gafX+1cAm!9MYR@MjXL5}UF+si zP*-*$JFw)1y?(2^!;uFJ)l zvQYQ;{o(Ymp(92-8`JLk;>kKKTez;n3GcT|Ybh2j)vAg!d0JIX=D7)NndUAcHfk-z zw)K#+8}2%_Uc;AO4>jEN;Remas_knm?rqfE{Eju2+sE_4weQm^-`|C1cvR!GJ%b!h zMbDQL_Fvh)V+(S4smCfpoZ4*C+&nJAgZt+Lr*G0WRO9%?pkH5SSAcmNNs#0^VV z-Lcgi5)2PtIh{`%i}jndZrXV-@dG*ETeQp9x`|(xYAv+_bhnqKnX)Yh8WB z%K}a(eZ(o~9y74yx#yi%-ncZaee!rfg&zzLW9quXixV`d#7oQD&fSoR94rzpK{UUDW&LnN z$8nqgt2R7vurx=Flaa$qlllK_Yt~dKjN*PjafmDDnz)FSqt)~mo=Y&YM*d>p60Mux z*#KE(YWM8}w_n>DiYhTizAhuj6%{NyFOnaI^j(A;RLNIc+9#Ew-Qa3 z!amhXge=vv{3^AUo!Jz7H@D-(U;7yYY0e3(ha9fB{?@nmhaFuMXbixQ;n@)=5|-hL zGPI4HIL9Du|0mx&;cm`6RZoi5%di0@d31Kj-d(KH!p9l^>!RUj1Ukwg?k&Td#W_Tq zS1@Pe;K{w>@#&Mx{(X1SlbZ@*o#hb8@bFs#4~Esf*5zNqt4A#{JdD7vK@NBBiNso^ z&wc#PPUP@TPL_T56-@YsV0o3bmR&KY!Ioe8!UKz#E{B7~3(L`d0X#UZGfzf6sZ*!T zk47Cv?H7VYyXD$&zsUA-(d_7%_;rPE*IhR1zG#)7{9h~Z`)iNC&XJ8*6o*%6)ih~z(~dP3-YXFik4`e0HLDM+6gGA7 z{NjvRE6#CDCsDR|F*lEm%EO-2=DlwtUfG8;9QXfzxCYe^5!+T`B{dBZ1uHR!y*lr5 zTc!C~t&>EL)woyb7%Df<#5*HS=al=db_HuAxg;&&$l-GLE^~M0r#Ekk3RXv3pBA|4 zHj4@st5#!{hrxqKLVEnxk2`k?`QcgF6y$J*YWJ-C)9>$UgP!GNhKf5V>oE@=++GJx z?F?~R7Bb+O$BIx<^HtQo1s+^w1GYIu#l}xdsK9lLn{+%qMh@3dr%s`BCsimO$DUz{ zAnT)0k;t-N!=oZR%7zaX4|=T%d{+Atm9zG7Vzc5Ws*Zk^;~FORqpU~6FnMVXD`Q#I zv;OuJRXd^*9479w_WtnTr=f-&kG69?p8pFy_2Cn4?*WiXWw~JiQ^FQqF+rHkgOLuZ_7MIOGxrnQi z?vV(F125tO*J2$k?;^&o#kFK!zE;KKuRjHp?~bpIV>4XJ$JMq*cexUrW_2j}`MR}j zv1eF0X2Q4^2-g~Q7lStA*lOKfECPFUMgfK)!z2E~8##k`BJ+SijC!Kp%?RONx7-h9 zxpX=m9Xj-MgCA2-7RM%EBf1Ok*RBabtl` zCY6dq-z!eOhMR&LJ;c-35CgxS;{F?$3*%en?B?xti)%dnQC@qDinc|FZm(lN`yxcv z>o`yHddVyJoMDT%@4s}CXDBa^7*M{-IoM?RrNBrx-Zx;NJehIj^VotqcJ~rJ!5$|O zF9s!Y$ellaT^Rkyr~_fa-x&24tF~Zo=JgVHwxC~6d&~Vkvt+|n;p^VQY@<#e?^gIy zM;}oWJ=ZElii91Qo?dTgaT;Q}pToJ)LvE>l2k+TdT)XS3iuvQyj2y6E!HaSkot!tR zbY<;)hWgxy*!EO0t)YD}DB|8de!$|@9i3A_^O#UlE`sh&kF2dyaI^wEzww z3Au&($g3^uF9yDefxXdRo&=>^%y{c&YwJ1`L%rV3n_034h=uUb77P&A$gTs$7A#xS z_NzTmmh9en#!r>2m+qxXqQMxPm^|HboA#eqe`niP>+ha-noHD0jX@<#0?N0Fm$prZzgt*lDjWvS1~T55Ok1hV;= z`Cpc+4e?T&UwdwJV%v7DrAKS^+5gkhtrE*s?8=Y!epqmrshq(}-@+==2aE1+VLHwa zmTPwL6t{i97kFVhUd8~qG;a+SE8xM^{oz~KjDIru+~0^&))2X*4%U3M<^If|W@>0? z>|nI0juq(lVYFPU*WP)(%+OvNUWEs*5%9Qz9NxIa`3~OLw8>rG1E?EDxq6#xy<^0y zJJ8c%G4f-0;Ow z%?f?`_J+EXkfVbCc&KReHb&WHm`HwGbMyUc&)&oEWD<3J)pJ6z@W^GnarXCa-qPD2 z2g-rBaZdjG$~GFo%^TMK4`}4io^)WtatMP&5FWbumBHT}^IG|Ot*EuXPXD9svwnIY zhxbZ%9>-iAT;8cAa@4+gZG@P=6Ftw5laHKsY+E{Iff$_wj~3p(t#ASzK~6c8eKh~G zhToo9@zBWeYlhuX^jTje8;*`o;zZeZu;^NgGDk6_o2q1XyEbom4Br1CN^%+DM0*q` z2Eoz8CEj?>pEY4oub7IVKYnV|jTO|&(js0gdIynp#EbXd!HNru7ZrBFj*b_hyI?29 zi>0vrj9=NHcORVS`Rk+5**uUiXFL*?qHfNbf8dd&J!1d3iX6@w=JVBf@pKnv{FRY1 zUX7l1sh6~_TyJ>rkcY=6VHso&%x`rx!V5K)9M?`p4TapRXB)Uveu}%%C? zSbrB7eyeQrIVj1K6|2m01|BHs_dPu7z$4&bwR@gJ#r|g=<)+A-Ek4~d-@ouR9-NtF zeW!@0Y<0jCQT=PQG69|qP{*SY4a-glnT&(b^jtGV^!ybb`;^D=m|yywEbB7lna3AX zMAmWi;|V<6QOCK=wp-^+X*u?pN9Cz<^xcoyzw7&THGE(;Yi~MLoIzPFe5zQ*RubS@ z8+DBMVCAtA*WSWkIhdaFr;6mu@LVx<>5rftQ$^4Bz#mT)_b>qM{!~%(H*oQ3Vik4w zX=2N_sIb8_{B;)xcC6>(u>cvOc+o%aTIh%UB%)H9C-(?rep z;jtMW{50kM?!JhQSAv%s9>y*^GEJ;tZ$6$T65*+RHw{0@Lj4gzI^_VX1v#t@-#jBUJiZxIu6V7gMzO=l@ z{o`d0zxJ#;F2W`-+*tR@K4}J)5W%H;9@hxp%dVWGsHdU9-cGs z*PW>G!`>CPj%hLktI9l{LuZK6C*c`8L$rAao;gF5MIg0}Gw?_VynlvR_&fLvn0_}< z^Lg+OGsLyuv7x%o6x~n3M$1DAwC3W>MeJ#1XiamLB2R27fF^RppsO&h7vP?80YZ!i z&RKniRr=v!la^DA{um*~$eMjnSYP1?R8=1BXUVxKHGbj`9nbD={cIqzW2ytPVb$4Z zos{9KSOa12G+R!{k0ny{g-+3DR7J=!J1^NyduO)z_7n~=Q~xh2DeS%&^ch0%`y6ra zbm7>Vs~X)jIjhE@Z8qXHPi%Xv8B)kR@$Q*_D`l2$?ok}EV}?jBj*4Q_hj>_I)+877 zzl`o*hhi>MW0YA7#QjgOJZfHkwthSsE|kmUeXqSY-pzhb-pd=Ga?M7b=RY5(*-yDG zDvIAfLO+YRn)yp|MbD2B>Y|;IEBfz(<|c}Nf=|6g>MIf=bI$)Q)P=36Eml#~IIrRV zEjM=bzpMqbcWQ+f2Kdi)JXte#UE$(CxI|q06f=5hi9BnYr@pZ}{I~g^Jli_5qUPpl z!NuBAD6h|CkE z??cCtzVJ)f@b7sYEN$aR8F(Ld(;N|Z5sZ^!#l<2U?zx_5YKdy{=GDNem8oyR#gJgX!r?BW%+H9l93>(?c+j2KOyL=Uii*fnFX#b`9|Fquc z+j+j6xk4>##Gx<#Pdis16U7n5xg9|yRX$aOQ>v&n)q?|iD^pbG z5@uRv;)pqi;3Bp$I22`{fD1^;!2}9ulAXT{8IsB)D00@NS|>)GbP2Zb%Cr`;^HPTo zjUc4JbcBd@^hj2TMupL1PF37E_%tI#hpMPhV3D1m#BVHa6aMqjnLJT=l%@<=77h;% zXk~(_Cwah%9a`! z=)*budq$vexdu}1;`cZz(7|rf`qavE_(r3bpJtiWja!M(TIgafS~7^r>>tfKsO7kt zy-%%)YCJu}7O)lRV;k_7S7;-RAGIY{>1uSpBmCpuO z6pH(Xglh^(i3c8Sf<&r^?VnH1{CnnbgoMZc$F&WgfJhPI>tNmhhCnOp+;~LL@T>ah_MDrZ zv%cbpR3_Oq#-5$-yP$|mvK-KcTx~HT0+8D}_BlwpWnj zT;9ke-|g6x6j9OH%q3?WdzT}%ivcnw-5=*2641*5x0qzl!W*GEyZ7zl5)bFz{a7Iq z$CUkBTqgnF|c4I(yiML2azH6G4=gaVY^io+k1i| zhE**Ua_n4-ho~tDWjw%MrusPHKI<6G|y}E92&FtbXuK+3|SJeFG(Q ziF>}vXKKrwo)uhzb#504iBxJ){!l7Ji%{?nvsvq!{Cj>A zq+x-QxC;^VSG;dSMMzA?L5?_p5}N)!X-|&Tu8lsxCC8e;PK%q`5kd2_vAUz4Ii1sf z=7{bl_yG|Do*COehNNhU01Gascx8JL{AsMs@PZt7vKT&cC;Fg!X|g%m8Et6O6ipcT zmVrKKgMN2%X?niVwAq+tE)2JtGc87))tHA@1w$@e7d*)7St)t>)&+4ng$3am1;$Ks zp4Di|+>&R^%}h@(jMC*=Y-XJ)*J94IJqiZOe2EssWD*J%k<0{JGAV;nk!*%kkxYd7 zGe|J(HV}Uh4P-6c6^ZB?vy^BF!2=szQ&PwZch`#%q?3RqolG^1azo*Iw7z1PEvs0% zwL%--vSo`z)v*hNVC^GroghdyxVi#USQN)+ylgWUN)&&IB3*||rWui-(UR$|#Cc?r zJDf0(eJ=fc^7c5_mvO{8)|I`Itl2h>9@v8)pR*9uN@ffA?)bGY$di7`+c+=aVLtSe z+)gzY47$_gGM+1`_bO_a1qa(HF(ybMyxWjn_j { + let diskDriver: DiskMediaDriver; + let mockConfig: Config; + let mockMediaHasher: MediaHasher; + let bunWriteSpy: Mock; + + beforeEach(() => { + mockConfig = { + media: { + local_uploads_folder: "/test/uploads", + }, + } as Config; + + mockMediaHasher = mock(() => ({ + getMediaHash: mock(() => Promise.resolve("testhash")), + }))(); + + diskDriver = new DiskMediaDriver(mockConfig); + // @ts-ignore: Replacing private property for testing + diskDriver.mediaHasher = mockMediaHasher; + + // Mock fs.promises methods + mock.module("node:fs/promises", () => ({ + writeFile: mock(() => Promise.resolve()), + rm: mock(() => { + return Promise.resolve(); + }), + })); + + spyOn(Bun, "file").mockImplementation( + mock(() => ({ + exists: mock(() => Promise.resolve(true)), + arrayBuffer: mock(() => Promise.resolve(new ArrayBuffer(8))), + type: "image/webp", + lastModified: Date.now(), + })) as unknown as typeof Bun.file, + ); + + bunWriteSpy = spyOn(Bun, "write").mockImplementation( + mock(() => Promise.resolve(0)), + ); + }); + + it("should add a file", async () => { + const file = new File(["test"], "test.webp", { type: "image/webp" }); + const result = await diskDriver.addFile(file); + + expect(mockMediaHasher.getMediaHash).toHaveBeenCalledWith(file); + expect(bunWriteSpy).toHaveBeenCalledWith( + path.join("/test/uploads", "testhash", "test.webp"), + expect.any(ArrayBuffer), + ); + expect(result).toEqual({ + uploadedFile: file, + path: path.join("testhash", "test.webp"), + hash: "testhash", + }); + }); + + it("should get a file by hash", async () => { + const hash = "testhash"; + const databaseHashFetcher = mock(() => Promise.resolve("test.webp")); + const result = await diskDriver.getFileByHash( + hash, + databaseHashFetcher, + ); + + expect(databaseHashFetcher).toHaveBeenCalledWith(hash); + expect(Bun.file).toHaveBeenCalledWith( + path.join("/test/uploads", "test.webp"), + ); + expect(result).toBeInstanceOf(File); + expect(result?.name).toBe("test.webp"); + expect(result?.type).toBe("image/webp"); + }); + + it("should get a file by filename", async () => { + const filename = "test.webp"; + const result = await diskDriver.getFile(filename); + + expect(Bun.file).toHaveBeenCalledWith( + path.join("/test/uploads", filename), + ); + expect(result).toBeInstanceOf(File); + expect(result?.name).toBe(filename); + expect(result?.type).toBe("image/webp"); + }); + + it("should delete a file by URL", async () => { + const url = "http://localhost:3000/uploads/testhash/test.webp"; + await diskDriver.deleteFileByUrl(url); + + expect(fs.rm).toHaveBeenCalledWith( + path.join("/test/uploads", "testhash"), + { recursive: true }, + ); + }); +}); diff --git a/classes/media/drivers/disk.ts b/classes/media/drivers/disk.ts new file mode 100644 index 00000000..fdf42a92 --- /dev/null +++ b/classes/media/drivers/disk.ts @@ -0,0 +1,91 @@ +/** + * @packageDocumentation + * @module MediaManager/Drivers + */ + +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import type { Config } from "config-manager"; +import { MediaHasher } from "../media-hasher"; +import type { UploadedFileMetadata } from "../media-manager"; +import type { MediaDriver } from "./media-driver"; + +/** + * Implements the MediaDriver interface for disk storage. + */ +export class DiskMediaDriver implements MediaDriver { + private mediaHasher: MediaHasher; + + /** + * Creates a new DiskMediaDriver instance. + * @param config - The configuration object. + */ + constructor(private config: Config) { + this.mediaHasher = new MediaHasher(); + } + + /** + * @inheritdoc + */ + public async addFile( + file: File, + ): Promise> { + const hash = await this.mediaHasher.getMediaHash(file); + const path = join(hash, file.name); + const fullPath = join(this.config.media.local_uploads_folder, path); + + await Bun.write(fullPath, await file.arrayBuffer()); + + return { + uploadedFile: file, + path, + hash, + }; + } + + /** + * @inheritdoc + */ + public async getFileByHash( + hash: string, + databaseHashFetcher: (sha256: string) => Promise, + ): Promise { + const filename = await databaseHashFetcher(hash); + if (!filename) { + return null; + } + return this.getFile(filename); + } + + /** + * @inheritdoc + */ + public async getFile(filename: string): Promise { + const fullPath = join(this.config.media.local_uploads_folder, filename); + try { + const file = Bun.file(fullPath); + if (await file.exists()) { + return new File([await file.arrayBuffer()], filename, { + type: file.type, + lastModified: file.lastModified, + }); + } + } catch { + // File doesn't exist or can't be read + } + return null; + } + + /** + * @inheritdoc + */ + public async deleteFileByUrl(url: string): Promise { + const urlObj = new URL(url); + const hash = urlObj.pathname.split("/").at(-2); + if (!hash) { + throw new Error("Invalid URL"); + } + const dirPath = join(this.config.media.local_uploads_folder, hash); + await rm(dirPath, { recursive: true }); + } +} diff --git a/classes/media/drivers/media-driver.ts b/classes/media/drivers/media-driver.ts new file mode 100644 index 00000000..55e12537 --- /dev/null +++ b/classes/media/drivers/media-driver.ts @@ -0,0 +1,43 @@ +/** + * @packageDocumentation + * @module MediaManager/Drivers + */ + +import type { UploadedFileMetadata } from "../media-manager"; + +/** + * Represents a media storage driver. + */ +export interface MediaDriver { + /** + * Adds a file to the media storage. + * @param file - The file to add. + * @returns A promise that resolves to the metadata of the uploaded file. + */ + addFile(file: File): Promise>; + + /** + * Retrieves a file from the media storage by its hash. + * @param hash - The hash of the file to retrieve. + * @param databaseHashFetcher - A function to fetch the filename from the database. + * @returns A promise that resolves to the file or null if not found. + */ + getFileByHash( + hash: string, + databaseHashFetcher: (sha256: string) => Promise, + ): Promise; + + /** + * Retrieves a file from the media storage by its filename. + * @param filename - The name of the file to retrieve. + * @returns A promise that resolves to the file or null if not found. + */ + getFile(filename: string): Promise; + + /** + * Deletes a file from the media storage by its URL. + * @param url - The URL of the file to delete. + * @returns A promise that resolves when the file is deleted. + */ + deleteFileByUrl(url: string): Promise; +} diff --git a/classes/media/drivers/s3.test.ts b/classes/media/drivers/s3.test.ts new file mode 100644 index 00000000..a28bbb4f --- /dev/null +++ b/classes/media/drivers/s3.test.ts @@ -0,0 +1,101 @@ +/** + * @packageDocumentation + * @module Tests/S3MediaDriver + */ + +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import type { S3Client } from "@jsr/bradenmacdonald__s3-lite-client"; +import type { Config } from "config-manager"; +import type { MediaHasher } from "../media-hasher"; +import { S3MediaDriver } from "./s3"; + +describe("S3MediaDriver", () => { + let s3Driver: S3MediaDriver; + let mockConfig: Config; + let mockS3Client: S3Client; + let mockMediaHasher: MediaHasher; + + beforeEach(() => { + mockConfig = { + s3: { + endpoint: "s3.amazonaws.com", + region: "us-west-2", + bucket_name: "test-bucket", + access_key: "test-key", + secret_access_key: "test-secret", + }, + } as Config; + + mockS3Client = mock(() => ({ + putObject: mock(() => Promise.resolve()), + getObject: mock(() => + Promise.resolve({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + headers: new Headers({ "Content-Type": "image/webp" }), + }), + ), + statObject: mock(() => Promise.resolve()), + deleteObject: mock(() => Promise.resolve()), + }))() as unknown as S3Client; + + mockMediaHasher = mock(() => ({ + getMediaHash: mock(() => Promise.resolve("testhash")), + }))(); + + s3Driver = new S3MediaDriver(mockConfig); + // @ts-ignore: Replacing private property for testing + s3Driver.s3Client = mockS3Client; + // @ts-ignore: Replacing private property for testing + s3Driver.mediaHasher = mockMediaHasher; + }); + + it("should add a file", async () => { + const file = new File(["test"], "test.webp", { type: "image/webp" }); + const result = await s3Driver.addFile(file); + + expect(mockMediaHasher.getMediaHash).toHaveBeenCalledWith(file); + expect(mockS3Client.putObject).toHaveBeenCalledWith( + "testhash/test.webp", + expect.any(ReadableStream), + { size: file.size }, + ); + expect(result).toEqual({ + uploadedFile: file, + path: "testhash/test.webp", + hash: "testhash", + }); + }); + + it("should get a file by hash", async () => { + const hash = "testhash"; + const databaseHashFetcher = mock(() => Promise.resolve("test.webp")); + const result = await s3Driver.getFileByHash(hash, databaseHashFetcher); + + expect(databaseHashFetcher).toHaveBeenCalledWith(hash); + expect(mockS3Client.statObject).toHaveBeenCalledWith("test.webp"); + expect(mockS3Client.getObject).toHaveBeenCalledWith("test.webp"); + expect(result).toBeInstanceOf(File); + expect(result?.name).toBe("test.webp"); + expect(result?.type).toBe("image/webp"); + }); + + it("should get a file by filename", async () => { + const filename = "test.webp"; + const result = await s3Driver.getFile(filename); + + expect(mockS3Client.statObject).toHaveBeenCalledWith(filename); + expect(mockS3Client.getObject).toHaveBeenCalledWith(filename); + expect(result).toBeInstanceOf(File); + expect(result?.name).toBe(filename); + expect(result?.type).toBe("image/webp"); + }); + + it("should delete a file by URL", async () => { + const url = "https://test-bucket.s3.amazonaws.com/test/test.webp"; + await s3Driver.deleteFileByUrl(url); + + expect(mockS3Client.deleteObject).toHaveBeenCalledWith( + "test/test.webp", + ); + }); +}); diff --git a/classes/media/drivers/s3.ts b/classes/media/drivers/s3.ts new file mode 100644 index 00000000..f4081306 --- /dev/null +++ b/classes/media/drivers/s3.ts @@ -0,0 +1,93 @@ +/** + * @packageDocumentation + * @module MediaManager/Drivers + */ + +import { S3Client } from "@jsr/bradenmacdonald__s3-lite-client"; +import type { Config } from "config-manager"; +import { MediaHasher } from "../media-hasher"; +import type { UploadedFileMetadata } from "../media-manager"; +import type { MediaDriver } from "./media-driver"; + +/** + * Implements the MediaDriver interface for S3 storage. + */ +export class S3MediaDriver implements MediaDriver { + private s3Client: S3Client; + private mediaHasher: MediaHasher; + + /** + * Creates a new S3MediaDriver instance. + * @param config - The configuration object. + */ + constructor(config: Config) { + this.s3Client = new S3Client({ + endPoint: config.s3.endpoint, + useSSL: true, + region: config.s3.region || "auto", + bucket: config.s3.bucket_name, + accessKey: config.s3.access_key, + secretKey: config.s3.secret_access_key, + }); + this.mediaHasher = new MediaHasher(); + } + + /** + * @inheritdoc + */ + public async addFile( + file: File, + ): Promise> { + const hash = await this.mediaHasher.getMediaHash(file); + const path = `${hash}/${file.name}`; + + await this.s3Client.putObject(path, file.stream(), { + size: file.size, + }); + + return { + uploadedFile: file, + path, + hash, + }; + } + + /** + * @inheritdoc + */ + public async getFileByHash( + hash: string, + databaseHashFetcher: (sha256: string) => Promise, + ): Promise { + const filename = await databaseHashFetcher(hash); + if (!filename) { + return null; + } + return this.getFile(filename); + } + + /** + * @inheritdoc + */ + public async getFile(filename: string): Promise { + try { + await this.s3Client.statObject(filename); + const file = await this.s3Client.getObject(filename); + const arrayBuffer = await file.arrayBuffer(); + return new File([arrayBuffer], filename, { + type: file.headers.get("Content-Type") || undefined, + }); + } catch { + return null; + } + } + + /** + * @inheritdoc + */ + public async deleteFileByUrl(url: string): Promise { + const urlObj = new URL(url); + const path = urlObj.pathname.slice(1); // Remove leading slash + await this.s3Client.deleteObject(path); + } +} diff --git a/classes/media/media-hasher.ts b/classes/media/media-hasher.ts new file mode 100644 index 00000000..eb70c2ed --- /dev/null +++ b/classes/media/media-hasher.ts @@ -0,0 +1,20 @@ +/** + * @packageDocumentation + * @module MediaManager/Utils + */ + +/** + * Utility class for hashing media files. + */ +export class MediaHasher { + /** + * Generates a SHA-256 hash for a given file. + * @param file - The file to hash. + * @returns A promise that resolves to the SHA-256 hash of the file in hex format. + */ + public async getMediaHash(file: File): Promise { + const arrayBuffer = await file.arrayBuffer(); + const hash = new Bun.SHA256().update(arrayBuffer).digest("hex"); + return hash; + } +} diff --git a/classes/media/media-manager.test.ts b/classes/media/media-manager.test.ts new file mode 100644 index 00000000..d8e762c7 --- /dev/null +++ b/classes/media/media-manager.test.ts @@ -0,0 +1,124 @@ +/** + * @packageDocumentation + * @module Tests/MediaManager + */ + +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import type { Config } from "config-manager"; +import { MediaBackendType } from "~/packages/config-manager/config.type"; +import { DiskMediaDriver } from "./drivers/disk"; +import { S3MediaDriver } from "./drivers/s3"; +import { MediaManager } from "./media-manager"; +import type { ImageConversionPreprocessor } from "./preprocessors/image-conversion"; + +describe("MediaManager", () => { + let mediaManager: MediaManager; + let mockConfig: Config; + let mockS3Driver: S3MediaDriver; + let mockImagePreprocessor: ImageConversionPreprocessor; + + beforeEach(() => { + mockConfig = { + media: { + backend: "s3", + conversion: { + convert_images: true, + convert_to: "image/webp", + }, + }, + s3: { + endpoint: "s3.amazonaws.com", + region: "us-west-2", + bucket_name: "test-bucket", + access_key: "test-key", + secret_access_key: "test-secret", + }, + } as Config; + + mockS3Driver = mock(() => ({ + addFile: mock(() => + Promise.resolve({ + uploadedFile: new File(["hey"], "test.webp"), + path: "test/test.webp", + hash: "testhash", + }), + ), + getFileByHash: mock(() => { + return Promise.resolve(new File(["hey"], "test.webp")); + }), + getFile: mock(() => + Promise.resolve(new File(["hey"], "test.webp")), + ), + deleteFileByUrl: mock(() => Promise.resolve()), + }))() as unknown as S3MediaDriver; + + mockImagePreprocessor = mock(() => ({ + process: mock((_: File) => + Promise.resolve(new File(["hey"], "test.webp")), + ), + }))() as unknown as ImageConversionPreprocessor; + + mediaManager = new MediaManager(mockConfig); + // @ts-expect-error: Accessing private property for testing + mediaManager.driver = mockS3Driver; + // @ts-expect-error: Accessing private property for testing + mediaManager.preprocessors = [mockImagePreprocessor]; + }); + + it("should initialize with the correct driver based on config", () => { + const s3Manager = new MediaManager(mockConfig); + // @ts-expect-error: Accessing private property for testing + expect(s3Manager.driver).toBeInstanceOf(S3MediaDriver); + + mockConfig.media.backend = MediaBackendType.Local; + const diskManager = new MediaManager(mockConfig); + // @ts-expect-error: Accessing private property for testing + expect(diskManager.driver).toBeInstanceOf(DiskMediaDriver); + }); + + it("should add a file with preprocessing", async () => { + const file = new File(["test"], "test.jpg", { type: "image/jpeg" }); + const result = await mediaManager.addFile(file); + + expect(mockImagePreprocessor.process).toHaveBeenCalledWith(file); + expect(mockS3Driver.addFile).toHaveBeenCalled(); + expect(result).toEqual({ + uploadedFile: new File(["hey"], "test.webp"), + path: "test/test.webp", + hash: "testhash", + blurhash: null, + }); + }); + + it("should get a file by hash", async () => { + const hash = "testhash"; + const databaseHashFetcher = mock(() => Promise.resolve("test.webp")); + const result = await mediaManager.getFileByHash( + hash, + databaseHashFetcher, + ); + + expect(mockS3Driver.getFileByHash).toHaveBeenCalledWith( + hash, + databaseHashFetcher, + ); + expect(result).toBeInstanceOf(File); + expect(result?.name).toBe("test.webp"); + }); + + it("should get a file by filename", async () => { + const filename = "test.webp"; + const result = await mediaManager.getFile(filename); + + expect(mockS3Driver.getFile).toHaveBeenCalledWith(filename); + expect(result).toBeInstanceOf(File); + expect(result?.name).toBe("test.webp"); + }); + + it("should delete a file by URL", async () => { + const url = "https://test-bucket.s3.amazonaws.com/test/test.webp"; + await mediaManager.deleteFileByUrl(url); + + expect(mockS3Driver.deleteFileByUrl).toHaveBeenCalledWith(url); + }); +}); diff --git a/classes/media/media-manager.ts b/classes/media/media-manager.ts new file mode 100644 index 00000000..c2eaf370 --- /dev/null +++ b/classes/media/media-manager.ts @@ -0,0 +1,132 @@ +/** + * @packageDocumentation + * @module MediaManager + */ + +import type { Config } from "config-manager"; +import { DiskMediaDriver } from "./drivers/disk"; +import type { MediaDriver } from "./drivers/media-driver"; +import { S3MediaDriver } from "./drivers/s3"; +import { BlurhashPreprocessor } from "./preprocessors/blurhash"; +import { ImageConversionPreprocessor } from "./preprocessors/image-conversion"; +import type { MediaPreprocessor } from "./preprocessors/media-preprocessor"; + +/** + * Manages media operations with support for different storage drivers and preprocessing plugins. + * @example + * const mediaManager = new MediaManager(config); + * + * const file = new File(["hello"], "hello.txt"); + * + * const { path, hash, blurhash } = await mediaManager.addFile(file); + * + * const retrievedFile = await mediaManager.getFileByHash(hash, fetchHashFromDatabase); + * + * await mediaManager.deleteFileByUrl(path); + */ +export class MediaManager { + private driver: MediaDriver; + private preprocessors: MediaPreprocessor[] = []; + + /** + * Creates a new MediaManager instance. + * @param config - The configuration object. + */ + constructor(private config: Config) { + this.driver = this.initializeDriver(); + this.initializePreprocessors(); + } + + /** + * Initializes the appropriate media driver based on the configuration. + * @returns An instance of MediaDriver. + */ + private initializeDriver(): MediaDriver { + switch (this.config.media.backend) { + case "s3": + return new S3MediaDriver(this.config); + case "local": + return new DiskMediaDriver(this.config); + default: + throw new Error( + `Unsupported media backend: ${this.config.media.backend}`, + ); + } + } + + /** + * Initializes the preprocessors based on the configuration. + */ + private initializePreprocessors(): void { + if (this.config.media.conversion.convert_images) { + this.preprocessors.push( + new ImageConversionPreprocessor(this.config), + ); + } + this.preprocessors.push(new BlurhashPreprocessor()); + // Add other preprocessors here as needed + } + + /** + * Adds a file to the media storage. + * @param file - The file to add. + * @returns A promise that resolves to the metadata of the uploaded file. + */ + public async addFile(file: File): Promise { + let processedFile = file; + let blurhash: string | null = null; + + for (const preprocessor of this.preprocessors) { + const result = await preprocessor.process(processedFile); + if ("blurhash" in result) { + blurhash = result.blurhash as string; + processedFile = result.file; + } else { + processedFile = result.file; + } + } + + const uploadResult = await this.driver.addFile(processedFile); + return { ...uploadResult, blurhash }; + } + /** + * Retrieves a file from the media storage by its hash. + * @param hash - The hash of the file to retrieve. + * @param databaseHashFetcher - A function to fetch the filename from the database. + * @returns A promise that resolves to the file or null if not found. + */ + public getFileByHash( + hash: string, + databaseHashFetcher: (sha256: string) => Promise, + ): Promise { + return this.driver.getFileByHash(hash, databaseHashFetcher); + } + + /** + * Retrieves a file from the media storage by its filename. + * @param filename - The name of the file to retrieve. + * @returns A promise that resolves to the file or null if not found. + */ + public getFile(filename: string): Promise { + return this.driver.getFile(filename); + } + + /** + * Deletes a file from the media storage by its URL. + * @param url - The URL of the file to delete. + * @returns A promise that resolves when the file is deleted. + */ + public deleteFileByUrl(url: string): Promise { + return this.driver.deleteFileByUrl(url); + } +} + +/** + * Represents the metadata of an uploaded file. + */ +export interface UploadedFileMetadata { + uploadedFile: File; + path: string; + hash: string; + blurhash: string | null; +} diff --git a/classes/media/preprocessors/blurhash.test.ts b/classes/media/preprocessors/blurhash.test.ts new file mode 100644 index 00000000..5214a464 --- /dev/null +++ b/classes/media/preprocessors/blurhash.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import sharp from "sharp"; +import { BlurhashPreprocessor } from "./blurhash"; + +describe("BlurhashPreprocessor", () => { + let preprocessor: BlurhashPreprocessor; + + beforeEach(() => { + preprocessor = new BlurhashPreprocessor(); + }); + + it("should calculate blurhash for a valid image", async () => { + const inputBuffer = await sharp({ + create: { + width: 100, + height: 100, + channels: 3, + background: { r: 255, g: 0, b: 0 }, + }, + }) + .png() + .toBuffer(); + + const inputFile = new File([inputBuffer], "test.png", { + type: "image/png", + }); + const result = await preprocessor.process(inputFile); + + expect(result.file).toBe(inputFile); + expect(result.blurhash).toBeTypeOf("string"); + expect(result.blurhash).not.toBe(""); + }); + + it("should return null blurhash for an invalid image", async () => { + const invalidFile = new File(["invalid image data"], "invalid.png", { + type: "image/png", + }); + const result = await preprocessor.process(invalidFile); + + expect(result.file).toBe(invalidFile); + expect(result.blurhash).toBeNull(); + }); + + it("should handle errors during blurhash calculation", async () => { + const inputBuffer = await sharp({ + create: { + width: 100, + height: 100, + channels: 3, + background: { r: 255, g: 0, b: 0 }, + }, + }) + .png() + .toBuffer(); + + const inputFile = new File([inputBuffer], "test.png", { + type: "image/png", + }); + + mock.module("blurhash", () => ({ + encode: () => { + throw new Error("Test error"); + }, + })); + + const result = await preprocessor.process(inputFile); + + expect(result.file).toBe(inputFile); + expect(result.blurhash).toBeNull(); + }); +}); diff --git a/classes/media/preprocessors/blurhash.ts b/classes/media/preprocessors/blurhash.ts new file mode 100644 index 00000000..188697f8 --- /dev/null +++ b/classes/media/preprocessors/blurhash.ts @@ -0,0 +1,45 @@ +import { encode } from "blurhash"; +import sharp from "sharp"; +import type { MediaPreprocessor } from "./media-preprocessor"; + +export class BlurhashPreprocessor implements MediaPreprocessor { + public async process( + file: File, + ): Promise<{ file: File; blurhash: string | null }> { + try { + const arrayBuffer = await file.arrayBuffer(); + const metadata = await sharp(arrayBuffer).metadata(); + + const blurhash = await new Promise((resolve) => { + (async () => + sharp(arrayBuffer) + .raw() + .ensureAlpha() + .toBuffer((err, buffer) => { + if (err) { + resolve(null); + return; + } + + try { + resolve( + encode( + new Uint8ClampedArray(buffer), + metadata?.width ?? 0, + metadata?.height ?? 0, + 4, + 4, + ) as string, + ); + } catch { + resolve(null); + } + }))(); + }); + + return { file, blurhash }; + } catch { + return { file, blurhash: null }; + } + } +} diff --git a/classes/media/preprocessors/image-conversion.test.ts b/classes/media/preprocessors/image-conversion.test.ts new file mode 100644 index 00000000..51b3cfc8 --- /dev/null +++ b/classes/media/preprocessors/image-conversion.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import type { Config } from "config-manager"; +import sharp from "sharp"; +import { ImageConversionPreprocessor } from "./image-conversion"; + +describe("ImageConversionPreprocessor", () => { + let preprocessor: ImageConversionPreprocessor; + let mockConfig: Config; + + beforeEach(() => { + mockConfig = { + media: { + conversion: { + convert_images: true, + convert_to: "image/webp", + convert_vector: false, + }, + }, + } as Config; + + preprocessor = new ImageConversionPreprocessor(mockConfig); + }); + + it("should convert a JPEG image to WebP", async () => { + const inputBuffer = await sharp({ + create: { + width: 100, + height: 100, + channels: 3, + background: { r: 255, g: 0, b: 0 }, + }, + }) + .jpeg() + .toBuffer(); + + const inputFile = new File([inputBuffer], "test.jpg", { + type: "image/jpeg", + }); + const result = await preprocessor.process(inputFile); + + expect(result.file.type).toBe("image/webp"); + expect(result.file.name).toBe("test.webp"); + + const resultBuffer = await result.file.arrayBuffer(); + const metadata = await sharp(resultBuffer).metadata(); + expect(metadata.format).toBe("webp"); + }); + + it("should not convert SVG when convert_vector is false", async () => { + const svgContent = + ''; + const inputFile = new File([svgContent], "test.svg", { + type: "image/svg+xml", + }); + const result = await preprocessor.process(inputFile); + + expect(result.file).toBe(inputFile); + }); + + it("should convert SVG when convert_vector is true", async () => { + mockConfig.media.conversion.convert_vector = true; + preprocessor = new ImageConversionPreprocessor(mockConfig); + + const svgContent = + ''; + const inputFile = new File([svgContent], "test.svg", { + type: "image/svg+xml", + }); + const result = await preprocessor.process(inputFile); + + expect(result.file.type).toBe("image/webp"); + expect(result.file.name).toBe("test.webp"); + }); + + it("should not convert unsupported file types", async () => { + const inputFile = new File(["test content"], "test.txt", { + type: "text/plain", + }); + const result = await preprocessor.process(inputFile); + + expect(result.file).toBe(inputFile); + }); + + it("should throw an error for unsupported output format", async () => { + mockConfig.media.conversion.convert_to = "image/bmp"; + preprocessor = new ImageConversionPreprocessor(mockConfig); + + const inputBuffer = await sharp({ + create: { + width: 100, + height: 100, + channels: 3, + background: { r: 255, g: 0, b: 0 }, + }, + }) + .png() + .toBuffer(); + + const inputFile = new File([inputBuffer], "test.png", { + type: "image/png", + }); + + await expect(preprocessor.process(inputFile)).rejects.toThrow( + "Unsupported output format: image/bmp", + ); + }); + + it("should convert animated GIF to WebP while preserving animation", async () => { + // Create a simple animated GIF + const inputBuffer = await sharp({ + create: { + width: 100, + height: 100, + channels: 4, + background: { r: 255, g: 0, b: 0, alpha: 1 }, + }, + }) + .gif() + .toBuffer(); + + const inputFile = new File([inputBuffer], "animated.gif", { + type: "image/gif", + }); + const result = await preprocessor.process(inputFile); + + expect(result.file.type).toBe("image/webp"); + expect(result.file.name).toBe("animated.webp"); + + const resultBuffer = await result.file.arrayBuffer(); + const metadata = await sharp(resultBuffer).metadata(); + expect(metadata.format).toBe("webp"); + }); + + it("should handle files with spaces in the name", async () => { + const inputBuffer = await sharp({ + create: { + width: 100, + height: 100, + channels: 3, + background: { r: 255, g: 0, b: 0 }, + }, + }) + .png() + .toBuffer(); + + const inputFile = new File( + [inputBuffer], + "test image with spaces.png", + { type: "image/png" }, + ); + const result = await preprocessor.process(inputFile); + + expect(result.file.type).toBe("image/webp"); + expect(result.file.name).toBe("test image with spaces.webp"); + }); +}); diff --git a/classes/media/preprocessors/image-conversion.ts b/classes/media/preprocessors/image-conversion.ts new file mode 100644 index 00000000..768bfec3 --- /dev/null +++ b/classes/media/preprocessors/image-conversion.ts @@ -0,0 +1,122 @@ +/** + * @packageDocumentation + * @module MediaManager/Preprocessors + */ + +import type { Config } from "config-manager"; +import sharp from "sharp"; +import type { MediaPreprocessor } from "./media-preprocessor"; + +/** + * Supported input media formats. + */ +const supportedInputFormats = [ + "image/png", + "image/jpeg", + "image/webp", + "image/avif", + "image/svg+xml", + "image/gif", + "image/tiff", +]; + +/** + * Supported output media formats. + */ +const supportedOutputFormats = [ + "image/jpeg", + "image/png", + "image/webp", + "image/avif", + "image/gif", + "image/tiff", +]; + +/** + * Implements the MediaPreprocessor interface for image conversion. + */ +export class ImageConversionPreprocessor implements MediaPreprocessor { + /** + * Creates a new ImageConversionPreprocessor instance. + * @param config - The configuration object. + */ + constructor(private config: Config) {} + + /** + * @inheritdoc + */ + public async process(file: File): Promise<{ file: File }> { + if (!this.isConvertible(file)) { + return { file }; + } + + const targetFormat = this.config.media.conversion.convert_to; + if (!supportedOutputFormats.includes(targetFormat)) { + throw new Error(`Unsupported output format: ${targetFormat}`); + } + + const sharpCommand = sharp(await file.arrayBuffer(), { + animated: true, + }); + const commandName = targetFormat.split("/")[1] as + | "jpeg" + | "png" + | "webp" + | "avif" + | "gif" + | "tiff"; + const convertedBuffer = await sharpCommand[commandName]().toBuffer(); + + return { + file: new File( + [convertedBuffer], + this.getReplacedFileName(file.name, commandName), + { + type: targetFormat, + lastModified: Date.now(), + }, + ), + }; + } + + /** + * Checks if a file is convertible. + * @param file - The file to check. + * @returns True if the file is convertible, false otherwise. + */ + private isConvertible(file: File): boolean { + if ( + file.type === "image/svg+xml" && + !this.config.media.conversion.convert_vector + ) { + return false; + } + return supportedInputFormats.includes(file.type); + } + + /** + * Replaces the file extension in the filename. + * @param fileName - The original filename. + * @param newExtension - The new extension. + * @returns The filename with the new extension. + */ + private getReplacedFileName( + fileName: string, + newExtension: string, + ): string { + return this.extractFilenameFromPath(fileName).replace( + /\.[^/.]+$/, + `.${newExtension}`, + ); + } + + /** + * Extracts the filename from a path. + * @param path - The path to extract the filename from. + * @returns The extracted filename. + */ + private extractFilenameFromPath(path: string): string { + const pathParts = path.split(/(?>; +} diff --git a/cli/commands/emoji/add.ts b/cli/commands/emoji/add.ts index 37257746..6134b925 100644 --- a/cli/commands/emoji/add.ts +++ b/cli/commands/emoji/add.ts @@ -2,12 +2,12 @@ import { Args } from "@oclif/core"; import chalk from "chalk"; import { and, eq, isNull } from "drizzle-orm"; import ora from "ora"; +import { MediaManager } from "~/classes/media/media-manager"; import { BaseCommand } from "~/cli/base"; import { Emojis } from "~/drizzle/schema"; import { config } from "~/packages/config-manager"; import { Attachment } from "~/packages/database-interface/attachment"; import { Emoji } from "~/packages/database-interface/emoji"; -import { MediaBackend } from "~/packages/media-manager"; export default class EmojiAdd extends BaseCommand { static override args = { @@ -97,14 +97,11 @@ export default class EmojiAdd extends BaseCommand { ); } - const media = await MediaBackend.fromBackendType( - config.media.backend, - config, - ); + const mediaManager = new MediaManager(config); const spinner = ora("Uploading emoji").start(); - const uploaded = await media.addFile(file).catch((e: Error) => { + const uploaded = await mediaManager.addFile(file).catch((e: Error) => { spinner.fail(); this.log(`${chalk.red("✗")} Error: ${chalk.red(e.message)}`); return null; diff --git a/cli/commands/emoji/delete.ts b/cli/commands/emoji/delete.ts index 9567e70d..487afdb8 100644 --- a/cli/commands/emoji/delete.ts +++ b/cli/commands/emoji/delete.ts @@ -3,12 +3,12 @@ import { Flags } from "@oclif/core"; import chalk from "chalk"; import { eq } from "drizzle-orm"; import ora from "ora"; +import { MediaManager } from "~/classes/media/media-manager"; import { EmojiFinderCommand } from "~/cli/classes"; import { formatArray } from "~/cli/utils/format"; import { db } from "~/drizzle/db"; import { Emojis } from "~/drizzle/schema"; import { config } from "~/packages/config-manager"; -import { MediaBackend } from "~/packages/media-manager"; export default class EmojiDelete extends EmojiFinderCommand< typeof EmojiDelete @@ -84,12 +84,9 @@ export default class EmojiDelete extends EmojiFinderCommand< emojis.findIndex((e) => e.id === emoji.id) + 1 }/${emojis.length})`; - const mediaBackend = await MediaBackend.fromBackendType( - config.media.backend, - config, - ); + const mediaManager = new MediaManager(config); - await mediaBackend.deleteFileByUrl(emoji.url); + await mediaManager.deleteFileByUrl(emoji.url); await db.delete(Emojis).where(eq(Emojis.id, emoji.id)); } diff --git a/cli/commands/emoji/import.ts b/cli/commands/emoji/import.ts index 161ccaec..9b7792e4 100644 --- a/cli/commands/emoji/import.ts +++ b/cli/commands/emoji/import.ts @@ -4,12 +4,12 @@ import { and, inArray, isNull } from "drizzle-orm"; import { lookup } from "mime-types"; import ora from "ora"; import { unzip } from "unzipit"; +import { MediaManager } from "~/classes/media/media-manager"; import { BaseCommand } from "~/cli/base"; import { Emojis } from "~/drizzle/schema"; import { config } from "~/packages/config-manager"; import { Attachment } from "~/packages/database-interface/attachment"; import { Emoji } from "~/packages/database-interface/emoji"; -import { MediaBackend } from "~/packages/media-manager"; type MetaType = { emojis: { @@ -169,10 +169,7 @@ export default class EmojiImport extends BaseCommand { const importSpinner = ora("Importing emojis").start(); - const media = await MediaBackend.fromBackendType( - config.media.backend, - config, - ); + const mediaManager = new MediaManager(config); const successfullyImported: MetaType["emojis"] = []; @@ -200,14 +197,16 @@ export default class EmojiImport extends BaseCommand { type: contentType, }); - const uploaded = await media.addFile(newFile).catch((e: Error) => { - this.log( - `${chalk.red("✗")} Error uploading ${chalk.red( - emoji.emoji.name, - )}: ${chalk.red(e.message)}`, - ); - return null; - }); + const uploaded = await mediaManager + .addFile(newFile) + .catch((e: Error) => { + this.log( + `${chalk.red("✗")} Error uploading ${chalk.red( + emoji.emoji.name, + )}: ${chalk.red(e.message)}`, + ); + return null; + }); if (!uploaded) { continue; diff --git a/package.json b/package.json index e8d89858..c296887e 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,6 @@ "markdown-it-anchor": "^9.0.1", "markdown-it-container": "^4.0.0", "markdown-it-toc-done-right": "^4.2.0", - "media-manager": "workspace:*", "meilisearch": "^0.40.0", "mime-types": "^2.1.35", "oauth4webapi": "^2.11.1", diff --git a/packages/database-interface/attachment.ts b/packages/database-interface/attachment.ts index 663f8fca..00601696 100644 --- a/packages/database-interface/attachment.ts +++ b/packages/database-interface/attachment.ts @@ -1,6 +1,7 @@ import { proxyUrl } from "@/response"; import type { ContentFormat } from "@lysand-org/federation/types"; import { config } from "config-manager"; +import { MediaBackendType } from "config-manager/config.type"; import { type InferInsertModel, type InferSelectModel, @@ -9,7 +10,6 @@ import { eq, inArray, } from "drizzle-orm"; -import { MediaBackendType } from "media-manager"; import { db } from "~/drizzle/db"; import { Attachments } from "~/drizzle/schema"; import type { AsyncAttachment as APIAsyncAttachment } from "~/types/mastodon/async_attachment"; diff --git a/packages/media-manager/bun.lockb b/packages/media-manager/bun.lockb deleted file mode 100755 index 44680ce8a227603bbfe18daf3013ba4707e9db1e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2722 zcmY#Z)GsYA(of3F(@)JSQ%EY!;{sycoc!eMw9K4T-L(9o+{6;yG6OCq1_p+%$C(-z zx&Itb=RC~C`LFgyNYzL7N0C1d#D)qo@5$O;Cd36)1O#jlih%=-Zh-RhU<$x|UIvB+ zQy@zPNY4Y(JV5#-kmd){H-WSOkd9?!U;wFGUo*jQwUpRCy(K4^$n*UWuW0O1WE_8l&Mwt>DZ2>}stW&!uj9mIQPo%v_MaV1T8PW#e5Q zCcig+8A3T>?mtSdHe6&>%DYq+b7Ysafri;ntE`o;EmACFtxINz27ic{vt@=yL;Iw! zb6FgFXRYgMItMfeW-fBNWOM8`Yyf?huI3j9L)=Ba%BxL-x{&8{dOW&Kp!cKr$B=Um!QLtbW)ZD8O_wq9o?b zO7{ZCErMRsn+_jdceXRMeSTxmmrXr9ht^38X1%^}ZKv+OYkW6s4>s-DwBLKtRK1%# zZo8KkAeoCC?<`R(#CiW5dN)DK@x{&9s#OYe*FLe*vU@rEo86}=yPldv3A=9KySaJS zly>Ffvdm&a6t!Pt#E_QAo_mOwY?t1J^JA{zCxBJ)rdS0csix zs9**HE}K$gBRilb?ApQQ4JiMC@(L(j8$k8B5TPGdCV}*U^n>iLfa?1N*B@u1XaGxCSPclP*_asP4D^f)Ef^SJbs?;dgURR_!D>rb zEr^hT)tj*T5Fz6Nt(;*rYFrej=9Z-v>A6;vq!#5R<^-1%W#*;ZDHtL=WC8b30n{We zXzdRSf1uwDv3fl>H6=4qH#adaF&$`dL9UfU7O44Al$es5mz$WJk`J^jB|g5`ST`rL zBvm&#Co?s#gcvm$B_##LR{HutGxdN*>SgBZ*Xf(<6Qfn%K+jyySg$0#3aq-g1n7Xw ze0&B%l>jvYBSy~%Rcm5#acU97&0uv&l_jag1Wg00LN<_~4v0O-S`f}p%Pk?OAE*Xd KGcfrLN&o Promise, - ): Promise { - return Promise.reject( - new Error("Do not call MediaBackend directly: use a subclass"), - ); - } - - public deleteFileByUrl(_url: string): Promise { - return Promise.reject( - new Error("Do not call MediaBackend directly: use a subclass"), - ); - } - - /** - * Fetches file from backend from filename - * @param filename File name - * @returns The file as a File object - */ - public getFile(_filename: string): Promise { - return Promise.reject( - new Error("Do not call MediaBackend directly: use a subclass"), - ); - } - - /** - * Adds file to backend - * @param file File to add - * @returns Metadata about the uploaded file - */ - public addFile(_file: File): Promise { - return Promise.reject( - new Error("Do not call MediaBackend directly: use a subclass"), - ); - } -} - -export class LocalMediaBackend extends MediaBackend { - constructor(config: Config) { - super(config, MediaBackendType.Local); - } - - public async addFile(file: File) { - let convertedFile = file; - if (this.shouldConvertImages(this.config)) { - const mediaConverter = new MediaConverter(); - convertedFile = await mediaConverter.convert( - file, - this.config.media.conversion.convert_to, - ); - } - - const hash = await new MediaHasher().getMediaHash(convertedFile); - - const newFile = Bun.file( - `${this.config.media.local_uploads_folder}/${hash}/${convertedFile.name}`, - ); - - if (await newFile.exists()) { - // Already exists, we don't need to upload it again - return { - uploadedFile: convertedFile, - path: `${hash}/${convertedFile.name}`, - hash: hash, - }; - } - - await Bun.write(newFile, convertedFile); - - return { - uploadedFile: convertedFile, - path: `${hash}/${convertedFile.name}`, - hash: hash, - }; - } - - public async deleteFileByUrl(url: string) { - // url is of format https://base-url/media/SHA256HASH/FILENAME - const urlO = new URL(url); - - const hash = urlO.pathname.split("/")[1]; - - const dirPath = `${this.config.media.local_uploads_folder}/${hash}`; - - try { - await rm(dirPath, { recursive: true }); - } catch (e) { - console.error(`Failed to delete directory at ${dirPath}`); - console.error(e); - } - - return; - } - - public async getFileByHash( - hash: string, - databaseHashFetcher: (sha256: string) => Promise, - ): Promise { - const filename = await databaseHashFetcher(hash); - - if (!filename) { - return null; - } - - return this.getFile(filename); - } - - public async getFile(filename: string): Promise { - const file = Bun.file( - `${this.config.media.local_uploads_folder}/${filename}`, - ); - - if (!(await file.exists())) { - return null; - } - - return new File([await file.arrayBuffer()], filename, { - type: file.type, - lastModified: file.lastModified, - }); - } -} - -export class S3MediaBackend extends MediaBackend { - constructor( - config: Config, - private s3Client = new S3Client({ - endPoint: config.s3.endpoint, - useSSL: true, - region: config.s3.region || "auto", - bucket: config.s3.bucket_name, - accessKey: config.s3.access_key, - secretKey: config.s3.secret_access_key, - }), - ) { - super(config, MediaBackendType.S3); - } - - public async deleteFileByUrl(url: string) { - // url is of format https://s3-base-url/SHA256HASH/FILENAME - const urlO = new URL(url); - - const hash = urlO.pathname.split("/")[1]; - const filename = urlO.pathname.split("/")[2]; - - await this.s3Client.deleteObject(`${hash}/${filename}`); - - return; - } - - public async addFile(file: File) { - let convertedFile = file; - if (this.shouldConvertImages(this.config)) { - const mediaConverter = new MediaConverter(); - convertedFile = await mediaConverter.convert( - file, - this.config.media.conversion.convert_to, - ); - } - - const hash = await new MediaHasher().getMediaHash(convertedFile); - - await this.s3Client.putObject( - `${hash}/${convertedFile.name}`, - convertedFile.stream(), - { - size: convertedFile.size, - }, - ); - - return { - uploadedFile: convertedFile, - path: `${hash}/${convertedFile.name}`, - hash: hash, - }; - } - - public async getFileByHash( - hash: string, - databaseHashFetcher: (sha256: string) => Promise, - ): Promise { - const filename = await databaseHashFetcher(hash); - - if (!filename) { - return null; - } - - return this.getFile(filename); - } - - public async getFile(filename: string): Promise { - try { - await this.s3Client.statObject(filename); - } catch { - return null; - } - - const file = await this.s3Client.getObject(filename); - - return new File([await file.arrayBuffer()], filename, { - type: file.headers.get("Content-Type") || "undefined", - }); - } -} - -export { MediaConverter }; diff --git a/packages/media-manager/media-converter.ts b/packages/media-manager/media-converter.ts deleted file mode 100644 index 4ea793df..00000000 --- a/packages/media-manager/media-converter.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * @packageDocumentation - * @module MediaManager - * @description Handles media conversion between formats - */ -import { config } from "config-manager"; -import sharp from "sharp"; - -export const supportedMediaFormats = [ - "image/png", - "image/jpeg", - "image/webp", - "image/avif", - "image/svg+xml", - "image/gif", - "image/tiff", -]; - -export const supportedOutputFormats = [ - "image/jpeg", - "image/png", - "image/webp", - "image/avif", - "image/gif", - "image/tiff", -]; - -/** - * Handles media conversion between formats - */ -export class MediaConverter { - /** - * Returns whether the media is convertable - * @returns Whether the media is convertable - */ - public isConvertable(file: File) { - if ( - file.type === "image/svg+xml" && - !config.media.conversion.convert_vector - ) { - return false; - } - - return supportedMediaFormats.includes(file.type); - } - - /** - * Returns the file name with the extension replaced - * @param fileName File name to replace - * @returns File name with extension replaced - */ - private getReplacedFileName(fileName: string, newExtension: string) { - return this.extractFilenameFromPath(fileName).replace( - /\.[^/.]+$/, - `.${newExtension}`, - ); - } - - /** - * Extracts the filename from a path - * @param path Path to extract filename from - * @returns Extracted filename - */ - private extractFilenameFromPath(path: string) { - // Don't count escaped slashes as path separators - const pathParts = path.split(/(? = { - [P in keyof T]?: DeepPartial; -}; - -describe("MediaBackend", () => { - let mediaBackend: MediaBackend; - let mockConfig: Config; - - beforeEach(() => { - mockConfig = { - media: { - conversion: { - convert_images: true, - }, - }, - } as Config; - mediaBackend = new MediaBackend(mockConfig, MediaBackendType.S3); - }); - - it("should initialize with correct backend type", () => { - expect(mediaBackend.getBackendType()).toEqual(MediaBackendType.S3); - }); - - describe("fromBackendType", () => { - it("should return a LocalMediaBackend instance for LOCAL backend type", async () => { - const backend = await MediaBackend.fromBackendType( - MediaBackendType.Local, - mockConfig, - ); - expect(backend).toBeInstanceOf(LocalMediaBackend); - }); - - it("should return a S3MediaBackend instance for S3 backend type", async () => { - const backend = await MediaBackend.fromBackendType( - MediaBackendType.S3, - { - s3: { - endpoint: "localhost:4566", - region: "us-east-1", - bucket_name: "test-bucket", - access_key: "test-access", - public_url: "test", - secret_access_key: "test-secret", - }, - } as Config, - ); - expect(backend).toBeInstanceOf(S3MediaBackend); - }); - - it("should throw an error for unknown backend type", () => { - expect( - // @ts-expect-error This is a test - () => MediaBackend.fromBackendType("unknown", mockConfig), - ).toThrow("Unknown backend type: unknown"); - }); - }); - - it("should check if images should be converted", () => { - expect(mediaBackend.shouldConvertImages(mockConfig)).toBe(true); - mockConfig.media.conversion.convert_images = false; - expect(mediaBackend.shouldConvertImages(mockConfig)).toBe(false); - }); - - it("should throw error when calling getFileByHash", () => { - const mockHash = "test-hash"; - const databaseHashFetcher = jest.fn().mockResolvedValue("test.jpg"); - - expect( - mediaBackend.getFileByHash(mockHash, databaseHashFetcher), - ).rejects.toThrow(Error); - }); - - it("should throw error when calling getFile", () => { - const mockFilename = "test.jpg"; - - expect(mediaBackend.getFile(mockFilename)).rejects.toThrow(Error); - }); - - it("should throw error when calling addFile", () => { - const mockFile = new File([""], "test.jpg"); - - expect(mediaBackend.addFile(mockFile)).rejects.toThrow(); - }); -}); - -describe("S3MediaBackend", () => { - let s3MediaBackend: S3MediaBackend; - let mockS3Client: Partial; - let mockConfig: DeepPartial; - let mockFile: File; - let mockMediaHasher: MediaHasher; - - beforeEach(() => { - mockConfig = { - s3: { - endpoint: "http://localhost:4566", - region: "us-east-1", - bucket_name: "test-bucket", - access_key: "test-access-key", - secret_access_key: "test-secret-access-key", - public_url: "test", - }, - media: { - conversion: { - convert_to: "image/png", - }, - }, - }; - mockFile = new File([new TextEncoder().encode("test")], "test.jpg"); - mockMediaHasher = new MediaHasher(); - mockS3Client = { - putObject: jest.fn().mockResolvedValue({}), - statObject: jest.fn().mockResolvedValue({}), - getObject: jest.fn().mockResolvedValue({ - blob: jest.fn().mockResolvedValue(new Blob()), - headers: new Headers({ "Content-Type": "image/jpeg" }), - }), - deleteObject: jest.fn().mockResolvedValue({}), - } as Partial; - s3MediaBackend = new S3MediaBackend( - mockConfig as Config, - mockS3Client as S3Client, - ); - }); - - it("should initialize with correct type", () => { - expect(s3MediaBackend.getBackendType()).toEqual(MediaBackendType.S3); - }); - - it("should add file", async () => { - const mockHash = "test-hash"; - spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash); - - const result = await s3MediaBackend.addFile(mockFile); - - expect(result.uploadedFile).toEqual(mockFile); - expect(result.hash).toHaveLength(64); - expect(mockS3Client.putObject).toHaveBeenCalledWith( - expect.stringContaining(mockFile.name), - expect.any(ReadableStream), - { size: mockFile.size }, - ); - }); - - it("should get file by hash", async () => { - const mockHash = "test-hash"; - const mockFilename = "test.jpg"; - const databaseHashFetcher = jest.fn().mockResolvedValue(mockFilename); - mockS3Client.statObject = jest.fn().mockResolvedValue({}); - mockS3Client.getObject = jest.fn().mockResolvedValue({ - arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)), - headers: new Headers({ "Content-Type": "image/jpeg" }), - }); - - const file = await s3MediaBackend.getFileByHash( - mockHash, - databaseHashFetcher, - ); - - expect(file).not.toBeNull(); - expect(file?.name).toEqual(mockFilename); - expect(file?.type).toEqual("image/jpeg"); - }); - - it("should get file", async () => { - const mockFilename = "test.jpg"; - mockS3Client.statObject = jest.fn().mockResolvedValue({}); - mockS3Client.getObject = jest.fn().mockResolvedValue({ - arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)), - headers: new Headers({ "Content-Type": "image/jpeg" }), - }); - - const file = await s3MediaBackend.getFile(mockFilename); - - expect(file).not.toBeNull(); - expect(file?.name).toEqual(mockFilename); - expect(file?.type).toEqual("image/jpeg"); - }); - - it("should delete file", async () => { - // deleteFileByUrl - // Upload file first - const mockHash = "test-hash"; - spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash); - const result = await s3MediaBackend.addFile(mockFile); - const url = result.path; - - await s3MediaBackend.deleteFileByUrl(`http://localhost:4566/${url}`); - - expect(mockS3Client.deleteObject).toHaveBeenCalledWith( - expect.stringContaining(url), - ); - }); -}); - -describe("LocalMediaBackend", () => { - let localMediaBackend: LocalMediaBackend; - let mockConfig: Config; - let mockFile: File; - let mockMediaHasher: MediaHasher; - - beforeEach(() => { - mockConfig = { - media: { - conversion: { - convert_images: true, - convert_to: "image/png", - }, - local_uploads_folder: "./uploads", - }, - } as Config; - mockFile = Bun.file(`${__dirname}/megamind.jpg`) as unknown as File; - mockMediaHasher = new MediaHasher(); - localMediaBackend = new LocalMediaBackend(mockConfig); - }); - - it("should initialize with correct type", () => { - expect(localMediaBackend.getBackendType()).toEqual( - MediaBackendType.Local, - ); - }); - - it("should add file", async () => { - const mockHash = "test-hash"; - spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash); - const mockMediaConverter = new MediaConverter(); - spyOn(mockMediaConverter, "convert").mockResolvedValue(mockFile); - // @ts-expect-error This is a mock - spyOn(Bun, "file").mockImplementationOnce(() => ({ - exists: () => Promise.resolve(false), - })); - spyOn(Bun, "write").mockImplementationOnce(() => - Promise.resolve(mockFile.size), - ); - - const result = await localMediaBackend.addFile(mockFile); - - expect(result.uploadedFile).toEqual(mockFile); - expect(result.path).toEqual(expect.stringContaining("megamind.png")); - expect(result.hash).toHaveLength(64); - }); - - it("should get file by hash", async () => { - const mockHash = "test-hash"; - const mockFilename = "test.jpg"; - const databaseHashFetcher = jest.fn().mockResolvedValue(mockFilename); - // @ts-expect-error This is a mock - spyOn(Bun, "file").mockImplementationOnce(() => ({ - exists: () => Promise.resolve(true), - arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), - type: "image/jpeg", - lastModified: 123456789, - })); - - const file = await localMediaBackend.getFileByHash( - mockHash, - databaseHashFetcher, - ); - - expect(file).not.toBeNull(); - expect(file?.name).toEqual(mockFilename); - expect(file?.type).toEqual("image/jpeg"); - }); - - it("should get file", async () => { - const mockFilename = "test.jpg"; - // @ts-expect-error This is a mock - spyOn(Bun, "file").mockImplementationOnce(() => ({ - exists: () => Promise.resolve(true), - arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), - type: "image/jpeg", - lastModified: 123456789, - })); - - const file = await localMediaBackend.getFile(mockFilename); - - expect(file).not.toBeNull(); - expect(file?.name).toEqual(mockFilename); - expect(file?.type).toEqual("image/jpeg"); - }); - - it("should delete file", async () => { - // deleteByUrl - const mockHash = "test-hash"; - spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash); - await localMediaBackend.addFile(mockFile); - const rmMock = jest.fn().mockResolvedValue(Promise.resolve()); - - // Spy on fs/promises rm - mock.module("fs/promises", () => { - return { - rm: rmMock, - }; - }); - - await localMediaBackend.deleteFileByUrl( - "http://localhost:4566/test-hash", - ); - - expect(rmMock).toHaveBeenCalledWith( - `${mockConfig.media.local_uploads_folder}/${mockHash}`, - { recursive: true }, - ); - }); -}); diff --git a/packages/media-manager/tests/media-manager.test.ts b/packages/media-manager/tests/media-manager.test.ts deleted file mode 100644 index bf19e92d..00000000 --- a/packages/media-manager/tests/media-manager.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -// FILEPATH: /home/jessew/Dev/lysand/packages/media-manager/media-converter.test.ts -import { beforeEach, describe, expect, it } from "bun:test"; -import { MediaConverter } from "../media-converter"; - -describe("MediaConverter", () => { - let mediaConverter: MediaConverter; - - beforeEach(() => { - mediaConverter = new MediaConverter(); - }); - - it("should replace file name extension", () => { - const fileName = "test.jpg"; - const expectedFileName = "test.png"; - // Written like this because it's a private function - // @ts-ignore - expect(mediaConverter.getReplacedFileName(fileName, "png")).toEqual( - expectedFileName, - ); - }); - - describe("Filename extractor", () => { - it("should extract filename from path", () => { - const path = "path/to/test.jpg"; - const expectedFileName = "test.jpg"; - // @ts-ignore - expect(mediaConverter.extractFilenameFromPath(path)).toEqual( - expectedFileName, - ); - }); - - it("should handle escaped slashes", () => { - const path = "path/to/test\\/test.jpg"; - const expectedFileName = "test\\/test.jpg"; - // @ts-ignore - expect(mediaConverter.extractFilenameFromPath(path)).toEqual( - expectedFileName, - ); - }); - }); - - it("should convert media", async () => { - const file = Bun.file(`${__dirname}/megamind.jpg`); - - const convertedFile = await mediaConverter.convert( - file as unknown as File, - "image/png", - ); - - expect(convertedFile.name).toEqual("megamind.png"); - expect(convertedFile.type).toEqual("image/png"); - }); -}); diff --git a/packages/media-manager/tests/megamind.jpg b/packages/media-manager/tests/megamind.jpg deleted file mode 100644 index 0f8f035aac906b24ba9385613cd96466caa463a4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5252 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 display_name ?? "", ); - const mediaManager = await MediaBackend.fromBackendType( - config.media.backend, - config, - ); + const mediaManager = new MediaManager(config); if (display_name) { self.displayName = sanitizedDisplayName; diff --git a/server/api/api/v1/emojis/:id/index.ts b/server/api/api/v1/emojis/:id/index.ts index 0515d63c..69726ca9 100644 --- a/server/api/api/v1/emojis/:id/index.ts +++ b/server/api/api/v1/emojis/:id/index.ts @@ -11,12 +11,12 @@ import { zValidator } from "@hono/zod-validator"; import { eq } from "drizzle-orm"; import type { Hono } from "hono"; import { z } from "zod"; +import { MediaManager } from "~/classes/media/media-manager"; import { db } from "~/drizzle/db"; import { Emojis, RolePermissions } from "~/drizzle/schema"; import { config } from "~/packages/config-manager"; import { Attachment } from "~/packages/database-interface/attachment"; import { Emoji } from "~/packages/database-interface/emoji"; -import { MediaBackend } from "~/packages/media-manager"; export const meta = applyConfig({ allowedMethods: ["DELETE", "GET", "PATCH"], @@ -102,14 +102,11 @@ export default (app: Hono) => ); } + const mediaManager = new MediaManager(config); + switch (context.req.method) { case "DELETE": { - const mediaBackend = await MediaBackend.fromBackendType( - config.media.backend, - config, - ); - - await mediaBackend.deleteFileByUrl(emoji.data.url); + await mediaManager.deleteFileByUrl(emoji.data.url); await db.delete(Emojis).where(eq(Emojis.id, id)); @@ -172,13 +169,10 @@ export default (app: Hono) => let url = ""; if (form.element instanceof File) { - const media = await MediaBackend.fromBackendType( - config.media.backend, - config, + const uploaded = await mediaManager.addFile( + form.element, ); - const uploaded = await media.addFile(form.element); - url = uploaded.path; contentType = uploaded.uploadedFile.type; } else { diff --git a/server/api/api/v1/emojis/index.test.ts b/server/api/api/v1/emojis/index.test.ts index ced30ee1..27c569a5 100644 --- a/server/api/api/v1/emojis/index.test.ts +++ b/server/api/api/v1/emojis/index.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { config } from "config-manager"; import { inArray } from "drizzle-orm"; +import sharp from "sharp"; import { db } from "~/drizzle/db"; import { Emojis } from "~/drizzle/schema"; import { getTestUsers, sendTestRequest } from "~/tests/utils"; @@ -21,6 +22,23 @@ afterAll(async () => { .where(inArray(Emojis.shortcode, ["test1", "test2", "test3", "test4"])); }); +const createImage = async (name: string): Promise => { + const inputBuffer = await sharp({ + create: { + width: 100, + height: 100, + channels: 3, + background: { r: 255, g: 0, b: 0 }, + }, + }) + .png() + .toBuffer(); + + return new File([inputBuffer], name, { + type: "image/png", + }); +}; + describe(meta.route, () => { test("should return 401 if not authenticated", async () => { const response = await sendTestRequest( @@ -43,7 +61,7 @@ describe(meta.route, () => { test("should upload a file and create an emoji", async () => { const formData = new FormData(); formData.append("shortcode", "test1"); - formData.append("element", Bun.file("tests/test-image.webp")); + formData.append("element", await createImage("test.png")); formData.append("global", "true"); const response = await sendTestRequest( @@ -104,7 +122,7 @@ describe(meta.route, () => { test("should fail when uploading an already existing emoji", async () => { const formData = new FormData(); formData.append("shortcode", "test1"); - formData.append("element", Bun.file("tests/test-image.webp")); + formData.append("element", await createImage("test-image.png")); const response = await sendTestRequest( new Request(new URL(meta.route, config.http.base_url), { @@ -124,7 +142,7 @@ describe(meta.route, () => { test("should upload a file and create an emoji", async () => { const formData = new FormData(); formData.append("shortcode", "test4"); - formData.append("element", Bun.file("tests/test-image.webp")); + formData.append("element", await createImage("test-image.png")); const response = await sendTestRequest( new Request(new URL(meta.route, config.http.base_url), { @@ -145,7 +163,7 @@ describe(meta.route, () => { test("should fail when uploading an already existing global emoji", async () => { const formData = new FormData(); formData.append("shortcode", "test1"); - formData.append("element", Bun.file("tests/test-image.webp")); + formData.append("element", await createImage("test-image.png")); const response = await sendTestRequest( new Request(new URL(meta.route, config.http.base_url), { @@ -163,7 +181,7 @@ describe(meta.route, () => { test("should create an emoji as another user with the same shortcode", async () => { const formData = new FormData(); formData.append("shortcode", "test4"); - formData.append("element", Bun.file("tests/test-image.webp")); + formData.append("element", await createImage("test-image.png")); const response = await sendTestRequest( new Request(new URL(meta.route, config.http.base_url), { diff --git a/server/api/api/v1/emojis/index.ts b/server/api/api/v1/emojis/index.ts index 4a32c329..255daf00 100644 --- a/server/api/api/v1/emojis/index.ts +++ b/server/api/api/v1/emojis/index.ts @@ -11,11 +11,11 @@ import { zValidator } from "@hono/zod-validator"; import { and, eq, isNull, or } from "drizzle-orm"; import type { Hono } from "hono"; import { z } from "zod"; +import { MediaManager } from "~/classes/media/media-manager"; import { Emojis, RolePermissions } from "~/drizzle/schema"; import { config } from "~/packages/config-manager"; import { Attachment } from "~/packages/database-interface/attachment"; import { Emoji } from "~/packages/database-interface/emoji"; -import { MediaBackend } from "~/packages/media-manager"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -115,12 +115,9 @@ export default (app: Hono) => } if (element instanceof File) { - const media = await MediaBackend.fromBackendType( - config.media.backend, - config, - ); + const mediaManager = new MediaManager(config); - const uploaded = await media.addFile(element); + const uploaded = await mediaManager.addFile(element); url = uploaded.path; contentType = uploaded.uploadedFile.type; diff --git a/server/api/api/v1/media/:id/index.ts b/server/api/api/v1/media/:id/index.ts index 102a10ce..9f5323c6 100644 --- a/server/api/api/v1/media/:id/index.ts +++ b/server/api/api/v1/media/:id/index.ts @@ -3,8 +3,8 @@ import { errorResponse, jsonResponse, response } from "@/response"; import { zValidator } from "@hono/zod-validator"; import { config } from "config-manager"; import type { Hono } from "hono"; -import { MediaBackend } from "media-manager"; import { z } from "zod"; +import { MediaManager } from "~/classes/media/media-manager"; import { RolePermissions } from "~/drizzle/schema"; import { Attachment } from "~/packages/database-interface/attachment"; @@ -71,10 +71,7 @@ export default (app: Hono) => let thumbnailUrl = attachment.data.thumbnailUrl; - const mediaManager = await MediaBackend.fromBackendType( - config.media.backend, - config, - ); + const mediaManager = new MediaManager(config); if (thumbnail) { const { path } = await mediaManager.addFile(thumbnail); diff --git a/server/api/api/v1/media/index.ts b/server/api/api/v1/media/index.ts index 14666163..279c46d2 100644 --- a/server/api/api/v1/media/index.ts +++ b/server/api/api/v1/media/index.ts @@ -1,12 +1,11 @@ import { applyConfig, auth, handleZodError } from "@/api"; import { errorResponse, jsonResponse } from "@/response"; import { zValidator } from "@hono/zod-validator"; -import { encode } from "blurhash"; import { config } from "config-manager"; import type { Hono } from "hono"; -import { MediaBackend } from "media-manager"; import sharp from "sharp"; import { z } from "zod"; +import { MediaManager } from "~/classes/media/media-manager"; import { RolePermissions } from "~/drizzle/schema"; import { Attachment } from "~/packages/database-interface/attachment"; @@ -69,43 +68,11 @@ export default (app: Hono) => ? await sharp(await file.arrayBuffer()).metadata() : null; - const blurhash = await new Promise((resolve) => { - (async () => - sharp(await file.arrayBuffer()) - .raw() - .ensureAlpha() - .toBuffer((err, buffer) => { - if (err) { - resolve(null); - return; - } + const mediaManager = new MediaManager(config); - try { - resolve( - encode( - new Uint8ClampedArray(buffer), - metadata?.width ?? 0, - metadata?.height ?? 0, - 4, - 4, - ) as string, - ); - } catch { - resolve(null); - } - }))(); - }); + const { path, blurhash } = await mediaManager.addFile(file); - let url = ""; - - const mediaManager = await MediaBackend.fromBackendType( - config.media.backend, - config, - ); - - const { path } = await mediaManager.addFile(file); - - url = Attachment.getUrl(path); + const url = Attachment.getUrl(path); let thumbnailUrl = ""; diff --git a/server/api/api/v2/media/index.ts b/server/api/api/v2/media/index.ts index 3a17c4ea..e4f2db6f 100644 --- a/server/api/api/v2/media/index.ts +++ b/server/api/api/v2/media/index.ts @@ -1,12 +1,11 @@ import { applyConfig, auth, handleZodError } from "@/api"; import { errorResponse, jsonResponse } from "@/response"; import { zValidator } from "@hono/zod-validator"; -import { encode } from "blurhash"; import { config } from "config-manager"; import type { Hono } from "hono"; -import { MediaBackend } from "media-manager"; import sharp from "sharp"; import { z } from "zod"; +import { MediaManager } from "~/classes/media/media-manager"; import { RolePermissions } from "~/drizzle/schema"; import { Attachment } from "~/packages/database-interface/attachment"; @@ -69,43 +68,11 @@ export default (app: Hono) => ? await sharp(await file.arrayBuffer()).metadata() : null; - const blurhash = await new Promise((resolve) => { - (async () => - sharp(await file.arrayBuffer()) - .raw() - .ensureAlpha() - .toBuffer((err, buffer) => { - if (err) { - resolve(null); - return; - } + const mediaManager = new MediaManager(config); - try { - resolve( - encode( - new Uint8ClampedArray(buffer), - metadata?.width ?? 0, - metadata?.height ?? 0, - 4, - 4, - ) as string, - ); - } catch { - resolve(null); - } - }))(); - }); + const { path, blurhash } = await mediaManager.addFile(file); - let url = ""; - - const mediaManager = await MediaBackend.fromBackendType( - config.media.backend, - config, - ); - - const { path } = await mediaManager.addFile(file); - - url = Attachment.getUrl(path); + const url = Attachment.getUrl(path); let thumbnailUrl = "";