From 15ee0ad993779f764835fd05308ed09659c3f7a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 20 Jul 2026 20:27:05 +0200 Subject: [PATCH] multidb --- .env.example | 34 ++++- .gitignore | 4 +- Cargo.toml | 4 +- Dockerfile | 1 + README.md | 24 +++ docker-compose.yml | 38 ++++- migrations/0002_workspaces.sql | 43 ------ migrations/0003_authors.sql | 4 - migrations/0004_pad_owners.sql | 2 - migrations/0005_published_pages.sql | 8 - migrations/0006_file_tokens.sql | 5 - migrations/mysql/0001_init.sql | 26 ++++ migrations/postgres/0001_init.sql | 29 ++++ migrations/{ => sqlite}/0001_init.sql | 0 rustpad.zip | Bin 61282 -> 0 bytes src/database.rs | 43 ++++++ src/db.rs | 209 +++++++++++++------------- src/main.rs | 20 ++- src/queries.rs | 55 +++++++ src/state.rs | 6 +- static/home.html | 1 + static/styles.css | 9 ++ 22 files changed, 377 insertions(+), 188 deletions(-) delete mode 100644 migrations/0002_workspaces.sql delete mode 100644 migrations/0003_authors.sql delete mode 100644 migrations/0004_pad_owners.sql delete mode 100644 migrations/0005_published_pages.sql delete mode 100644 migrations/0006_file_tokens.sql create mode 100644 migrations/mysql/0001_init.sql create mode 100644 migrations/postgres/0001_init.sql rename migrations/{ => sqlite}/0001_init.sql (100%) delete mode 100644 rustpad.zip create mode 100644 src/database.rs create mode 100644 src/queries.rs diff --git a/.env.example b/.env.example index 8979e7d..c03a131 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,40 @@ -# Serwer deweloperski +# Application APP_HOST=0.0.0.0 APP_PORT=3000 -# SQLite +# Port exposed by Docker Compose +RUSTPAD_PORT=8200 + +# Database + +# SQLite — default DATABASE_URL=sqlite:///data/db/rustpad.db?mode=rwc + +# PostgreSQL +# DATABASE_URL=postgres://rustpad:rustpad@postgres:5432/rustpad + +# MySQL +# DATABASE_URL=mysql://rustpad:rustpad@mysql:3306/rustpad + DATABASE_MAX_CONNECTIONS=8 -# Assety i logowanie +# Static assets STATIC_DIR=static ASSET_VERSION=0.0.1 + +# Logging RUST_LOG=rustpad=debug,tower_http=info -UPLOAD_MAX_SIZE_MB=20 \ No newline at end of file +# Maximum upload size +UPLOAD_MAX_SIZE_MB=20 + +# Optional PostgreSQL container configuration +POSTGRES_DB=rustpad +POSTGRES_USER=rustpad +POSTGRES_PASSWORD=rustpad + +# Optional MySQL container configuration +MYSQL_DATABASE=rustpad +MYSQL_USER=rustpad +MYSQL_PASSWORD=rustpad +MYSQL_ROOT_PASSWORD=rustpad_root \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6caa88d..2befdee 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ rustpad.db-wal .env.docker *.log data/db/.db* -data/files/* \ No newline at end of file +data/db/*/* +data/files/* +*.zip \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index e3c58e3..68825ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "rustpad" version = "0.0.1-dev" edition = "2024" rust-version = "1.85" -description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite" +description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" license = "MIT" [dependencies] @@ -17,7 +17,7 @@ rand_core = { version = "0.6", features = ["getrandom"] } serde = { version = "1", features = ["derive"] } serde_json = "1" slug = "0.1" -sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono", "migrate"] } +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "any", "sqlite", "postgres", "mysql", "chrono", "migrate"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "signal"] } tower-http = { version = "0.6", features = ["fs", "trace"] } tracing = "0.1" diff --git a/Dockerfile b/Dockerfile index 283f553..8b49134 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,7 @@ RUN apt-get update \ WORKDIR /app COPY --from=builder /app/target/release/rustpad /usr/local/bin/rustpad COPY --from=builder /app/static ./static +COPY --from=builder /app/migrations ./migrations USER rustpad ENV APP_HOST=0.0.0.0 \ diff --git a/README.md b/README.md index dd5b4b0..a01fe3f 100644 --- a/README.md +++ b/README.md @@ -36,3 +36,27 @@ Use the **Page** button in the editor. RustPad creates a permanent public `/s/MIrm1OyyJhVh-==LN)<|C~M#v`;ZJb}?o&Ght+~uyO$c z01i+PO8?*c-nMso&rk@=|M}qa0R+r{-UqlbI9vKE>)Wpjq51R~Hn>`1B@>0JrO;?9 zWr$$XP{Hx4r(mC+=?;8*ndw1Is@&_-boFADWa_v|5dE zFiT{Z!O$bfkg)u?wpk?M}-4T0T43xFs;eg4`OHP&3 zY}fmIQe931`96QHA05_c!IlMMVxGmVRkED+RmvxZkp(k~R;dI(AnRf7YlH~)F z{P+FNhiw}nd!4N!u4s!qg&c#qj+BO}$ke8LUo6Vo16z5)VMu-y)<5Z;qnsM&%9Ogm z$%1%%*Q+t;;2(~t-zEtI6Mua>G0e{iRxu4(z+*~1fOEe#x$uwpVY>ER7-xU(Ze}IC z&??&}tF~zE2I!pVMv_lJyWX)kMWWZo0S=N3Ull5i43tJ`4vhXQ2~C=3dLWy2j~cLoX`_ z1_#d=HQBgDCbZs1O{BxbLbl0BL>+1=xbMNiN~qaX>VGKq&6{fpV{+U!zI_mK&j8b) zca!+z?9F>Kt|zfIYxb)MGicc)EoXXL52piBVgRThWKMUYI&5n!D+2cOT4UWpXT;$L zh?u*UQo_5Fmb-hChH@pyw$jxkMj3A|DOK{sM+WGTk?;L~OPlluP6*Fth``fdAb!oa ztcwanB{>2U6>w)H&c>s#44~U>c_t=RQ{q;WR-YzX1@a1N`l{`T|0XFDS)p^DB!ufOzY=}5%FQO+MMS}jkj(Bd34A`D#abUeCw`w1(*YH>- z1ZvgDXoIwdyYAVY7)M`J+P>9hrs@g>m?DXC#XuhFb(BY`$+ zJ#*UCG4DzvnonsIZkW<58mHVl0$zt_$9w_cV#WV$kPcXXU?coP_#N9iS0(A;I>=4} z#0k7QRL=Z3^z2Aaz^fk_7$7xu(I94yfKSRiy~@pYA7=^mj!tZmNa~RF`iJ@CwHkK@ z%bq7?%A#HNLxCh@fe26HXe`r*?Y)sfV9W3M`Tga5lw1lHn|&CunfatPKA}gNFm|(} ze5TBNVEOplB0dT=6DUq#R*g%ci#=B^%Ku+nFR63+t&M6ZScAXJ&;}5{L7Lyye^$hfQCbapw z@3<`GhO+`*erUdFo%ax*T*BeW{xRr%Gw!N*otGuJ{WQmj z?(~kFOB|IyF-3d|(f`86X6RsSW@ztj2l(IcsHEhm|No$4DfmC&;*d=LuMtJq^iJ=A ze;I4$KVTpsAV~ig7JmbMf%_KUG@c@T@G-1x^mbj|;9agN=Yzech&IIUcqQIl zQhttJyXI|^X?mCBR=1I+f}}1?ZeoEOq5ic$FB5Ossw=bpp&ELBEuksA6e)9-kb{oRo9?DC zr||$!FQV;iEmaJY1Aj~By!4tr9FKZcLOL1!{x5atn%(Jri8j9r{Zs?Pr#guLmpV8M z&8=(zhA#F%fZhM7B4wr@VSowk>zsF=N$fK+|FHS6(56Xa|7PGEoVXRV_tovN9ukpQ z-QZcTvc{P3(g=oK*)B>6d*@at|306Av(BMx?hmXITQ&9ELvnw6&Ky$m;NOR6aG~tt z>GbW`N^AyvBfAL()|Ii|45AvBmNEOW1j~Wf2gtC%4dNV@e^4}Q#0!}JrGCU`j?kT; z@%8O9{y#%QKwy4$!P(`L_@<23&W!(KOH&4G=Mc3GJA6(w-xbaJ=z#cNQ!U$HY(ch( zrIwJ;@Fp+Ra5Rww(#`FI^xU+P+Gp+u{DvB5;thX(kKr>OA)y{kMblQHMX<|h#JUouPDYzk^5^5aYhga%`EGFD8U|ofE5ze{mhI6)RcR z9RZ&nuDtrCG4RQVt>ahaLbI@bpiGK8na4+l!gl@XO#f1djN0w5PvVT+@1T=>JiXW>)po6W(_r} z-79y{Ov7JK7|;2%Bm$oD>G8ozj7=ve9m$?ANkGYuo4_VfD9YMgGoJsCXOJ!W2?1C`!my=i6E5WUwwC)%u$7mt;}$#-~W8*81%a=t89;AB2x z)+0i@jx(>1bCmeb3z%T4_Ujp&r{=oo+K(3=oMz(E>VM2{9OxP>#D^Gpyqj(~u=-GW zem1|dX=XMw4wLpXCn-Om{_8FApWY(THPhel>Fn~K88ya#z2*P7ilvLKjjO6`>=F|i z_>nq##jJX(p*{#L6B;9Mp3MYbeS@7)k=M;kb73NO(E<8_ASCI>#@{%+hDiPUljSPS zSRd2CQh*p}ac4bdiumw1F_z@4Gemzg{>YzSU*#UrFcbGMd)O}jvF{oS4O>K&F^lcV z!73h0Unj9I(FQdwuO?6Gp3-YO$S>?D*?t($_lK;Kf%kuL%E9b4n2aY5K3J{uW_htq z!HPV*V0-6c@CPImeqmYC_BTV%*^BrdRv}3?^Bm3?i6{k69g*odN8OXBzT&%jPSoeR zO`PYR*X#GUbDia{rKHIk&2X3zMOb!8c^zGybhLJYpT`?&99BvRSW4M8<;CcGDPeK3 zM~$5Xm(h6!GtT7#yB0*WG?&FAdUprX$VSw+dXh&><*TX8v&(iiTK$KxJZX%R9ps^s z3d=1&r2PRFiAX$}fk>}tS9_zk95Kv0w*0cDF+bh)+O$ZI}-vMR!@)SAl!6>cHEOoto!>t~cDnFf`m{u{Ofvok#@YN1Z2WnJ2k z2Ino4Qo0Q~!CAxq8koZLT2E++gTv(0umPWf{3#4(_NG99la+;?y%Rt_k{RK1DEs>2 z8!7Y%8>C)3j%s%5gP!0b(Mk625vI(sX$na;5t(eQk?kBQN`G}uaPT3Wik|asU;Xkq z3VP_=8<|{xdvmwEd{+K?waD_$Yjv|hnF z@#nus6ZAh944cjur4UHCqQ=boXV^&BgXo~gXX(JgWw&XaJ13>P)=C-?4rsnMhM!jH zCcH3uaiX&OD`yy6X#gvEmXe$ahX^ZXH(*(qqJ2WB&uiY}J z7rQq7ZQR1yO-hKF+!`DnFk+U-ivjaHMc@iD_zV=cVumC9S3_Vykg|nKUqN%+0_mB* zB&72;i_+{&cRW)aWiRFQEp|&y@W0)6^SRUW)9Qkf`0Tdvry%^N`=4d*|1rd3` zIEa2;#h>U~V$f9yI*WyV+hUA8Hf~(S07Og8IMrk}WZwIhXrG~gv-ZAtkY^i=lmTmb za6JWu{6EvRO`d(k|F(wbeW&Md{uOl10|D{!@c*_ZYV2fT&tUUu{+^ymyBz5_dajSW zfiYG-a%^1mXd^ZyAt?S6&shZMp#G@OGC@UJH>2!*#Blm&~WSW zqWA5Ru?u|u_HkeL@%-fUcwp?ilvVfP_2|&o)t-1d`%(E+{z%Md&%jVO{qeLRco%hg z`G{(GtC+4W-j7g&L?0ASFLT+XUVw1M5!IqDqj~NM0}uNJYb3Tv{rC2}v0v7R!D-31 z|GzfWkM}s#KHvMg=Yxwj|EE#^?1rn86A6I3v-{7sw1bqGs|Fvs_J)LmD-OCWgiVt3~9v2T0g?JbYf-R)ZZqOY47t1D|#6G~ZNKyuaNM`8W+ z-w9g1iL;U-1eqO6WgY5Zs6oKPR;t{u%E`2sUn6j8|NaQ+3HtP`g2xXo-sMkw4Nivh zZo0M!JMtPEQlx|?$0~oi5e-Y?Qh_r~IVGR~c)yEiv)n_qp-V0QE^3MGZqE3uf-QXn zJba$pTTCOqvwO++8A!fiKbX;xvbTqgUlrutn z1mZ>3%&W#yh4{%66WPL$j&iv4tD}|R1H+~W86&q~%y&$v1)dUHOTd%J;JtnM;pO6b z)V`}s{yV3;p&y)WLJ|HGld?rh_9+rZ~$qh|Z<-``RE zv@ZO$k1KVz=Yt!^TfsazV!s!!r~7N_J^{V+-1niH?Fz$7hKE@<&$p-R;mOmejjpT@ z7qGsb{smE=%cc8aUM{`w>%HKmeXZfE7i||~o*QT0>&3<4#qcTY$hJSa&+G0+i{JCD zf?c0(bJx?`&Ecpbc30Tx>?3dA`#V}!mSCRXQ`xq@-guj4-}VcbIPLjvjdxx$YUcdq zsDk(I+IK$%wmPV&FeU_Gg7l~?t^8u7#7-PEF6J{qH&@_tF>Hps&m5H_&|^SXUp9Ql z$KBVPp1U&ims>D5@8h~$&^93mt=#6KLPEPm3GpmMshuU#=o-eLMH~J%cTfwa1KkR{ zt;yVvFs-PTylb^t%2S}?WY#FOcp${6L-r0+v5R&QG~=Dt?r>0;wD4JpkvJ$sCwnpx z=g{HdiP7DWJkLcAkiB+51JHc6JS zo&PEHKM0wrsUP(?`au>l?e2Q|RlFMg6pN-x5eTYIWtNh}V{A{)PE!Et*`$h~As7Vl zzZM6$J*{23+vBlV{aOWyCe_MRe?bG70I4Vpb={mS;`FGyd@mH9jIGmtKn zz9JlFbHUKZ!&zG78r<*9PmT(3__>T4Yw(OSg+~%CW$x4LC=DZxome-o4=n@r;Xtq) zjU<&+_jf7e40Y#wr*Fhw4P6}_UVOWkeR@xVv-aO&X0g*8jV>Ryj6eweRboal>*f8{ z(-x;hDJ)}*Y!g~3HJ|~osne(i4~DrloIdVN^bIyF{E_lMy1QGm1TPtD8S4D~O$+U7?{_a4NFP4DuEJIlfS#}` z3u+KYXpakv&P(%n9jf%Z1zXlXn-|o=OfF4lEI~Z`aLqiD{M_bV?#}*wdc7C>bDVaE zUe3Os@xP-vXqcH(aI%Fh(=|G=>X#&xHOJu?)3BzTopZfZj+VJg8WSb^L1Z&e>W;JQ z%i#>;-r?@LnY*WRk0Ey0%4SW@0!|C3K@%>QHX~Y(xG@gtksaGr>@E}<0%V+A`&_sL z4+YC@$Knp)Wn~B@yeSGL$vUo>uR}0243NA{S$84; zN=;7t%}xWq;~rm${&~u-p|dJ!WUckW2LrKSmWMp8Wv8HTmTb%mj8I5(cE2s0#RR)Y z8cu`a?n&(kv02mx@n}`WM!NZOHWpY4u+M#W^_U$X9t(OUPBnsgXTRy+_C5&S`N9(O zx!?m%r_4s#w2V|JGNYJkLl(;0t;eNGzjB;~@`W*8)C9T}|K*0uWY~!&+_rPq-hO(% zKYT7%sPDKnJzzmZ!==&rRhd)*Xv%D?45HJF5Abx?)gKjkJpKjd8DrGf5_oNWaXVc> zHJqKz9ar!pTSmE9Z+}WnqEHAhqfm#f4@`H~-8E2F*8r->>o7qj>Qc|2mW!uqLh!r$ z5E>Zl?Eeeb73@M4uMIiX!nCZ>3jT#6+R{7~m<64Z-hy>Qml&?hcDh`i`Io0>I+rwT zl%+rsvILh5noZAk=|=JZj_F^$Ac@D=n%l%!vK~Zgpq3bI z%`YUhT^5KEhC7y7wK{`CuJ;2=4nyx`U%j__H%)u_ql35e6rU!1egyqi6f$MDnBplg z_ZO&WEex_R!nVS~IRs5&^=v$S7(~iU~?0m%gk@ga~JFo>(Fd^~1^1wg{s!tLRT_TDj&Ta=(3M zMD1iNoR7Jz&gJNTpu8c?zD8p(AXmRcQUt9yC2~lftKbhv{KI1}uB0FsEqX_KareZkjIa zc87f2J3NKyJ>47E5^%T|@`bV~9|gGfFHkM`w9{hKs1{5K1;r+D?_=9EGm-&g98|)c zPcJVns2G(6_YS2&6^#;mvQBY#c)==N_qdzhIb1aK0|N7@(sg+J=A4#*1LMz#JUJ6L zH1O8;f_l|pA^!53e7@xFM=43`bQ=wr&cgdv8`wCa#eo>3ArTCy2eTG)}CR`cm< z^ORelxJ^GKK|k%bF>)#bYF0pP&40S@{ml#xPFN6QVeP^MTBmPwV_okVDuqi(B%mR6 zYmD$I4p}mUG)>uY;Dz=gasMQ=3@;tnY~Jz+--X|_zr4k)Uu5iZO^FhPUg)MBSw%30 zFymp2e8osETz9yshCu8Y)d837XmOowQlbQJJXpYZ;jh}2vGL;z;$xil0BRq2+n^U6VrV+$ zJc0ibdA6~FeoSmUp_$FlS%IDqx1@?q%T>ZW($#Zup(^%E(|tp7)zDf8uaE)(#a)|# zqzk)#3A!4^nQ*__$d0fz34Q3doG(A;cr0R;ktvPp5F?oHn#A;MAQeog4PF5UQpDv`L{*)~x8EQ&A|VV#=oFg>N#H#I`5_E?}{s*Wd!&IzmaTW zX(C!rH=$F;u-Uw#M}ZizNP1N1W*!}5Eiy^D>uH9GC=J?xX*_Etd#^U5D7(DoP zKs+jyH4TNB^{rWmeRA$+K5z0=g$#V&>Y=qdZ(BWxMOLiJg|=NOU+&}@IGFIbsOaSV zbmQ{o;@b2QlF;t?L0(WROS6WS1@NfoAFLWOY0Ik!2jD-<;IIY3K5=A?EJlLGT`fT8NQhQ57YqwP2jnSo` zzblG$ESvQ(wAPW)N`!MNAhz&#lCEuybylZ~*r`5M>O~bT zU)ZYT_%*&ygR(&+C-DmsM`(Hi%e3k<>)~zBs-zEoN8p%pK6R&ifCg~D8cA)s;5!=? zi^LKJomq)1TNR&|a=tdMRM&mqpmMsknWqtWHHEE$@UYYZqAj=K0pEt|S;=7>l zR&MUl5&XZa=BpV+_2=40lr-6pLx?2cI(pR&iB3(6xPd0Dv0YSkph!8=K867h z$a=f`$AXgjBM*ve<*2e>sWuvpyoF0q|lDIc*Z0krQPndVllUq?5-rWGMl)}TlL9>|4m0YF5kFKr0a{oT(OYB%J1`~VJV^vzIeDc| z8h&2e%hx>6AP&WVd+r}_-G%o`1RA0f<$zLEINWK+2bU-T_;RTjqKh$0SufhVB#o3Z zq7qIz9~(_>o579s>k6!0D$FYHvSYNu)Y>mp{oA-fEnnZ@lMyn4HNsSEKrF^q^Lgn{ zOTTVeSrzq7TQ5BnHZek71CTm0fqCC^4v#5{ih>hxxEthvg;n=}@}x?+x`gpH`OY}M zo}2oQtsFi7hbTsd(I3g@nr}k^DtcNxnkL!H7;>TYN8WG#e#-UEsj zmYl=J?c8bIAH%3Hg3sS-Ys%Nm_k)#V+GP(L%MvsgBjt<^$a&QOq7dtnC)YeD!}(qlz6rJK-fI(74r`%|PvozAVfdm?r(fw=4o%ca?;{T^p2 zK9BR1G|@8Z@fHG}S(x>6$AiV}@rOMkKwl=v$9$2J+Am6{cs0|Dz7TcmiKsj>s0h&h zE$|L~6n!6!dE1(AMP;gRh6g7ns4JSjnMWK~aU)BjM;aWk_Ab_J1+}7IuyaMLFT4Lp8IVhrlCIvt%gozdsXZbNZ4olA%d8ZQXp%1x6hio%;X_gxL zBus7-qr6ZNHB8$#%Xfp(J#PBAbTV|0d}y3-Rq|n7BXqJU=nCx2$OC2~0w?g6=+?aE ztc?syC|bj1^2+wbeMtxUmckUhoLvtx_q~^PJ33Z?>zek?LT-L$KO5pf?A@B?`f5tD z?1#PXJ~QgVx~1(1GPb8k;rq~PpP3xSKxU4V4woKvD19#09<&bVQsX0~U)udv zod~@6IWM*Gb}>)>=IP$ytnDrKhY@A^ej2RV(fN8Aw0OfW5n4#=yt_P#ko$WCdQuCz zykp1O8%JpbocoPe&3cVUIg3t@?dUH@bx=#6J6+w`b8`II(F&^1;-aj-o_W2e%lg~! zXNjM;<&6{8UQsEm;Xa7lQ#YcTpkZZtEu$y>kETVWD!!l3bqvv+>OA4XLY{l@G)cq8 zfP$u8q#*bfVuhw>qk3ypNgeVeB%k-1YOR8p5BPU6fHyJb@Hy@K;N+WsH(KoXvAE@U zL?d_iC+vsC-8|n*M>&KtmMjWa>7VBq)&ydWSYav@+x%X_(P!0}m6>y+nJx5$qBB2# z7{&~F*IAaXMNzaPMiw1OGphq{3sibyD-C|jWtyMP*+>)Ae;NEfEtc~UImy?8WY9Hv zdo*dN1?fhAnLAL9=%JKcNuuO=lTF{5fnoIEYayAZemb15-2fl7wyqV6*aH9#R@9iT z+(^sWxt9^_4l?{(JlNN@Pk(=fMY-0|txKn&5#W`x{-Kh9oQB~{N)ux8%}8evxmny0 zh9f2w?@=gVU7;>LX#eU@gyYOVpCP^k)YgfIoM4dG<65~mg{v6}4@YS3D(_jt4S_nH zr{lBL;xmJH~_p zML)66{$I2VCT%K5C5GjtT&yyM=usrtTTnFPh6BdUkXnG|>K5cHI8MTZ?htIcVj#0W zgWOH6nWGXGEIHLbDU%a5Q4N}BH^sm8i>*C6amx8cY4dhfrK`Mhvez_>6j)IdM>oBK z5iz|ZlW)6bl1P<67^1?iKa=hgySjVg1nvM6fWA0-ujI34z?H6Od?EA_W5yj=25A2a z*Tx9fcj9fR`Q2>`U3~)9En}s=xKxv_gMe+*Y&5ic+H|{}m2)GLJ_$1;_bBpkcI~+M zpk=hVPi|WiAlLSp(IWLuZKTsb)m!@rPbx>{Y&{wODpLyrKCV0J1h7vZ5m9;yyj<`^-F<(|}OishGIw4~;KoU(u8C4=BFom5ZK zM=oUqmFqN^UK1T549sfH4&n_R`U;VI@0P~3a-9yQSfUlmw@R(o!SHFee3hxF?gdRP zgTvI(?=z?eRA-d!?cQ#KHrMc3U4#(o8CUFmp94BAuD?1W_B-dkEpdFcKrD*kt77u0ryKm(n=;3JT~W@$@n?EDG%E6 z-;~swd3k?=`qH$eetg{bH62&`|b^ygk~8*pcBo}pq6zOTz!+bUsjeeTd$U=rMI=p3gR z%ub9nk!srun#DRc9MTjF`pFl-WH|c1feB@Ol_*4hV?d&|6h<~ix87JGy;y4dG~wl{ z-x41NNpEEBfI9{9$&)3@UUnKQ_QvlqOp2Hpk@Vurjx;h~fy*&M;;h z=f|Sc!hG?3*cJ5kyLuUn{$>+<9#v2S?M*BW$rAB}An~^sLpklnG9HH{p(({^S+xMZ zE;7E{*D}#n3%r?P!gycDi%k;V;~ySz9OVcgX;_#_&Uk1{+6qz>{F>?vXJ%3g8Bytn zADk1apzP9CRx#}`wBaA|;Q@fD6cjeo8_sX*R^t>xld&$V?=I8EFknf(a zmdys5a%%v2qzc(IQIH8+R^!D;I_=S?#T)IcXJEV8QPr?rEf!1}YBNfAw=xJC8eB4* z-7t0TPR9{lMtr1g>2esWJygHC0#(pS){nn&Rp<;N#!{KGB$*>XInrxcxJ^YxJuQCw z!;G6TQnEL2RCc%Y{&)%B1vSwz&MT+ZUpk%uZS*-K>4~Y8Sjf-N)Zt^(L_83`Y%BlDq5JK{@ z(j=p_grnTjAzeg#JCtF|q%P7#)PK{P92l?73R=Pj$Xp)#Jh3Y*uHcbfg0GcyQ7xOU z6SbSwk3eOG-o!1G!%{5)zW}@SNQUJuaFWL)nd9MklQn-Ly7DfOR0LWLNy@#XmE5G- zTRo#Es}yo**;tria`0Ow01moUu4s;#HLZAvG+L**@W$i08^QYTY7U3h5wL7H!XTUCd?_DE=rATX$o?o>vt3GfF*v)ubZza=TW_rYq?N43ZMT@l z;WHP%*+P^SQa`gb&g+3&&YcT64DCtn#H!9lzXsf0x+a-#?1QP|6~G zr|73~MY4lOcAro~Hm1~PhwoF>6_FCHG>!g&#}qx-&Nas6jOZaj_BJ$dl>Dl!c?U}C z`t!4zJmZSJt3qR`v~iW4l`FC|vqV}+17I76fj>#{lxo6XcP zDddD%PuR5h zD&w+FQ4HP#CObvaIlx9GGF|q2U4&(PdgWq!SYDxyo&CqEcXxG8Z`&`zBw2)(_LS9a zSiU9(Y2!}5=0Hylc(cigLM&g}{6q2yk-Wi1;+^OaFXxxg!y#F}a8f=|>q;9WODRVz zDW_9->i3Yj%u+k~QS=I(Fs@)uEgwy3HY!ygEz9YC`FdcKJj8w!h zA{eJGbjHG|ad$FxDe8pD6f$Ae)unb2Rv*mn=;R<&n%PRpt%G}E`WJPK{Z!9Lo(&)S zv7aVsWOpICaz_^0%sj4YQ{`G(GJdCC_>d}|a@fOJtWkpi6x|yyx|=f1m6q@3ctyv$ zTEAlZBR}}LGeR$Rjn6it$F_%}OnZSg4T-2GKMvBHxrn@9gT}g{B$i=d)nPgWfRneD zBN!Xe$G2aGJj=YfO0P}@P|gROTKv_%XKSkM;f?x{p~G=s7G?`gl@Wo-jM^0o zl>}79w!A)@;|F|iGKfrB&QT2-Z+Fm=1B*IRe+CbQu? zG^hI~)BTBRnB(}N9AYYsc|yLlom`dN_X#i5d%A}C#Cbf8D)9F|Ckl=fGH9@5EloN5 zLw&qcJp2QhZElSVJc^f|@OHTJ%(?>}%92ORz{mp2J*r8WwM>~3d4q6z9*tUyMhL~R zF6@p~OuI+%yZd?zy-j}jTB3S6GOm+XwQXYJzAT^sU8ypZHJXK+kdL-yvyDD_zuNxh zBeN&jNi#z9@G7B-ku^QvexpIvSH2?G*vS4Pner}T^~KT6)l;m+@$QR*qthOWg-NXG zD3?pdB`xQn*wqx-w^a^FSE?2<6cGwuBZ>g7M1ZS|W)%U43G{{QSc7Zym-&GN*ya=$ zeag{SNmpAzv2E>A;+W3%x3?RTh_wsD-hX9veS!*%?|+2-ryoiJ=kaH$OQe^^A$;3S zW5ug-bdtF>s2O?(s$!Qlg|nf9gAW6{W%9;_Un6rAi(Jy1d|)$*V%H}&X|-WYzI^cU zc%oy_BAYbt$}z(<+@(~56i8`-EhG7Ucyo6TG9G_(ws1l>_-0WKEEMt_KKCos5_8W# zsO)(gK2g!OKSC>t;7~GiGS-_~v^_Q1Z5N!~FAo-FJbt*|&nD%3%!cO@4~)$U8g2?! zd-rtd*|V!12=T@AH^dG+tcqAeNn4Uhmr(_k_-F>$C2~^8uj}QI>MpUP3qU_Cu>V%; z-dS@QX$yPwf8dtTT`*&Fj%;88avy^*vxdo9+Sf?b^{dw-in$?+U%l;3v%Md%Ax=a$ z!G~(!k#%FbL(m4MazhrPbmxDRbu39{RsWLx1|J#2PM%1wuH*EYO)BiE*CV-a*xDN= zXPh?;5S!*m({ZV>@y(-KD*##3L{rx+OIU?(c_aX9vlfQ76P3A>b2$5CS^hS89}cH& zZJ&io+h*O_(hMoEvZb_;tf5h?EQe81R*J|la7KJpN%SnT2@u3={P@P3Cvg9sipL(V z@|))7x3auUfDwhXp4q^^krTGeSrJdstP~6sQu9QgIA>XzL+sdpRLXf$nK2#ZkXdc5 z>*f5e(lk*WRUN|R*wjU0)TK!gchVK2J3jeg~@TZ8>*SWKYX!=9)N1L!9@I$l&6tWBzhG@YVAZeP|; zmPNE?DW-grg$HF8p8>wrUK-u!bm*V0^=#{Ev*MMj+2+W~hcMM^7%kAvfO2H93wE0v zeiNwi%*+WSp}IWq6n=aedaBN#_VZg^Y2bcN5T&bNP9Gy(KB6b|aro+X$G7X?nAdV5 zHzW}mAh(!Yte6?&d_3wKx^uEOIX!)ey`6UxKIDAswrXBQxs6K_DE6JvoQ6$jza{Vy z?RBo80*NI-Y9O;!z*qe=vTPDR&f1c(QSPYpl$+8CP7C}RC{RAlL#aIOAj5?jl6{aF z3c{T2et7NS&yRoNphzk>q?O9~Vpr5n)%hub4@;Kedpixr5+`3Z*$RQR` zEy~iKf$_*u$|FqT=;frY$quRzKGLwsr!9Jtt_65KLLIzQo?5Z zV%tZn(l1F}o*>18SYQIzF3lnZWN5+b0aH#?632l}F86R~02RqXzK5Yom>KNNmcDjU zuabnVve+W_XcPALko||*F`szZD-^}M4<|FDoGYOYqS#tI-0r!pGUGP+Ku zW=q~9XIbKhEHy0wv4mv*eew>V5d>~1!#LQ?cJn-PV^A3UV%AkI>OF~H({LR-gl53B z#tkiTv*%#XjFJIr%)m+Yd=V2Q!I@o4k7#h2$%$!b835;oy{8dE0 z6yzg^v(=~`GT(2yO5oN>kgfP0Wb68|@s8*HvG-{IpHQ{GvXS)yKDz7&zo$sbgtti^ zC6lc4y3~6TYlrQ1+W(Fd_+~@}>RfFsU8lv$9>ixmS_ZFSqW=8DkEmx)7u3@snEAu- z58(cyqWZqt^+UAzM*7wLV;^Wd(<7A0|8g}hxAU&w?eR#^+KSV=KA4QKMtGW=$HpL( zWg6Fq%#?_EN8+S6Bb`#6fDJGR-tYhQ~IGZ9hkU`mQwKc~|m3Ljzkul%uAozuH?b_SKbxq5t~aNcxU$Ja2q2 zdpw%SWP2?>WX)xCIFgvqhJ%F$*xMnpOs95&18VDo`B8=EPAZ=6Kzp7IbVMm_A2+Xp zz@vA*YHtG?*fVQJ%^_(Bp5OA3vRKfq1uZ>a1n%I7M%p`L5dnxBJFyc|>-Z zhoOk?P?~Vx8+dG_eoT=FSjW-mBX`G^1fg5z0`0sSrnbA9tcn?=TX>?FYq6viNr zG{saX&5wj5t_G+zS!o|2oo6W*RKi}SQ`23o;-c=05nu`8d}ReDkDi z8$Ds#a1Rj)Z0h3iENnde6{tRMHeRSLUGI^loV*(E(`mU2si7IgA|p6j;r4OP9|9DkNzN(u!hct5UjTrusn@IR;YZuwkZ+9fh?gH=W9dms>+UzaHOx z2=0HIUHDm*%@?H4$51}CTS!#J-zo*l&Cw0JC>*UzhMi;9=AKPX4kINtX0CIh_*Ghn znJes3BuB6Y=1alW#^o*FOGXgMf8kYkQq~lXUEs#n`i9)nYz@P5autQ%>aFry>?N6u z8h5vGm&TnMKou7Mh+_I_9H%04E>o18oPpIpbO-51qs<-(!BEp;+_X_Qckh3DQ@2N(~{rB3Uh*npPRjCx2i^x#9|gdyw<$?k+F)w##1k z>CIWPc{Ho6rRoHuCOn~MtDj$`th)v@NfN0}&*eD+y!^fn30-gcC0!;P(C#7EPM{QE zzs&(UN~`TF+GMH} zgw2Y_ULzsIVC8KrZ*OM;H`JB9CO^kOX7d+w?pWooV;xKfeRhdF`|PP~b~-xqAk1oP zM809@&>4+QivX4cg_W*YMzA918MbQ#w#2&`pF%iP?75~m27Ea!6ur>V9G=F_O?pR6 z^q<2MFw%=uzubO^s_2MyH7%gwqw?PjVgx7%xrBI;m>^Y~1qJ-D4S2m~G+B zj!Hy}>J1&%M9GVqdf*~=hq(b$H%q6X@+t9sZ(2DUEQ<=;Dw(=K#f$f1Xt~Nod{YDO zX7@RzNxt-u&m#W+o=P+myCiS&XP25_ky$A*Z3NYaeI6jQ6mItOxJ-oBc`nu^7xY0RN z+|7j4=5qMg-~Gol(iC4yWI2NudC$46e4L&HTx~y>2i-e}dhG`<8T(?C1g{SUe^zG6 zer<)v6UlYrEh;%Xx156~FCvYGELUZPk`#>~v=q}H$dux1wJqvTD{?V=dVp@^Rx?=- zCYsC+pK_{^W6@;+vm<5IPMp!N4`C4z$j7Kq|7$*|JC*VuTOLfZ+*FhkNL^WdUq77@ z3rKLHgi%27=0>>YPm+czSG6WPQ2JS%`y+R~SuF(5_K%&PDd%S~Usv;pO&=RZWczY4 z%pNGwYSe<|KRNVmRzU~pdvj>^)TWNqDBD@S#)uvyY1OR;>!WXE=113WsrC0r@i#Hs zYo(ceh$4SA1)r2do@xd{q`OmSSHGPPMFY0ZFC>7bjcN?w$y}l>Lzf&uY!&ZT>mx%WjtuJG(w_gyh8Ux+!R(rwE_k*WV)*SM- ze&UAwRKp5s?lrW!Wg~E~?phd8R-w&ny5G&SN@b*5vA;A|PRq?u*!ChycF!<%LpiGq z9JNeD&v!a1Zkg%x*^)6Liu{OcF<+U7R1*1)1S$I#j*?ysV?q4UA?7aT-E!X&kL8+B z!;>K6h8!J{b0n2pmtrpr8HKZ^S7ozy*Z51dVPmqnN$PoJ`Ezb;;m}3_tUH`(k*b3r znjk}F;4XCCCmFqPY@XS}R3C6Ab*uZ)cZYL9TCGS&FIzWxmZeMbH9Lz~A`;mPk{BJBl1L&@u}8W~*iN1V zFK&rDAg4<8d)+1~dgA1CQ~rUQ!|xJdEKym%6vmC(401yH#}fM`7m(tIn-RJlU>%Qc zc`+cVqFh~iynAuBPm_(0b~|r>3s-SClD=U(AEs}fc%+t7wGFIpQ^8F^i@>A>^4m!G zsGu``%pD2-*|L6BveM=}_Eh3c6+YzNza{G_s%ze&!4l_ zsj9u}RMqnb9U7b)S-fAh}VNi)9j@9#NO z=_PPI-(>3f+0K3xP3RbdEDE{dim51;78fYhK#^U{$5e@=r+zDZ&g}Ey9}&F+DctHI z#odC;;ZQVHRF;6iJ3t3r={E3h7!phC@R>yczyQqR)|v&WGK9nED4(j{#_m6(!L`q~ zo|rkHePtLa3ZkHN8Ks_fX)IQ8zewXmB#}AfsSWjlWM+ZQ9^~z_g1<6%YB){L35UO% zlGX?O#uSL05hCexOO?b>L?tkC7rM+hv5zB0Ho>=#8W2s16ae3UGF*9L4+ zZh8uIW#mcVBDhi|hW$))F?Y5=G z^oo4FH|lA1y0%`mM-@+R`G(F$pj~~w?H=DUV#HosP{w9`z3Bd2|KTk@8^RCLQcPqN zFSm<2NVeSQTe?w3{r!iyY1U$uvoSuJ<*&aN3In;LxzfHbes0-8 z+Py@B$s8>*DW#tXPFb;VitCSOSrc5%;I;mj4>@+0lcp$eFJDn&7wz^wM-gfbw~@gd z{JoI8Wzis(8O|w|$2}m}909Z1cH^%3BW_)`)zoc2TK_yT2c4f;lK}(KN?9Yp0mF(M zJQ4+(F+t_Sv-|tQV{`n)dcRXh##`v9x20lRbQjI)0zK24|E#-$rP3#M+c#S;)U!eNDA%WTtSTD2^_lrD`j4OfGA)GBSYxw2Tn-w@sL0xj zSYXrfi-`5)O>49oQiq1$nkcq8gd#PajjO3P-hq9iGZpYq7QrjRc99Q5jA@Nrbb6p* zl*&fs=ULQSf-bBwN5q%fP&ua(HP~zt`H*iQLI@m7@%lJSEmvs7iM5w zuIF2^u6HJtRTx83UflB+E-)uJN#m>J>;D)K3Rkx;%`qr&ga0LZZ3CO4$LuseCynF$ zoLeuMUWQXu_4WF!ONimUd;Egqe*7{%&feyI-)f`%N_a0B`F^`}o#tMdC)N3W?P0cj zPwHyIJ0c^uZak)tV19xOmz!DBt2J5lFqB`lG|GQwUKPz7nmo=d_!?68T+1*uy*&9u zZE$(&`dBwDxj)gYDZY<#t(oGwJwN>*Hg#l!zwSYoG$6Ntj4i@&yo*g<8<{tF)VDC4 zQp2_sovr|IoGqs$wk3(Zwf#o&+|o~5BB(kpg7t!E6|l5Kkl({s;MO)>dEjPtL9F$N zXSq{imK)VOLA@Lk_x>IeM<@!X0~gUAS_;RQX>f!$Wx6p?6ezfc{0yd`nHDUBrS@(r z^4(5#O+_EoPpLy8S@#?dl7~V6g`BX;v8df(EdwqU#6c}`c0H~EmN?1HOMmo^qvYL21(SiAwb-=x`5?$&AXaIo3X ztnP)e!X77dA@bGgm3)EhIw`9rqWbkZswzY#wQuHK`*ly}MppRZFJe7qWOVJa&uGH=!?Bvv|$jLzDuQN9FDg?a#CAX1sH9Gi<4`{_P7Bo0p`5g-7 zr=NrIxS}`jvQWwsSgFW+7DXPIqxvW(6G<6W5Y?HNx#^V!2!x;+3W%bfM{?iQ&Hk6!S%By6^ zPbwPov`AP4&{kC8-;kw&@UgOi-*6x#c7HFlPNJM)o98n9$!<3;xchygymU}5=e4Wh zT*=};L3Y-F=9H0`FYh~_?3#+YhrSt@@w5cpI2%9bL=*x17){gFkyoyHIwrQEAYzX{ zTZqn>u{^zU2MlXZhwFXcDX{~;6;aH54YotY=*iknJk@sx1%Q2j0Let4NXi@pXh6ti z3@^KG=h}9U6k65oeXSJ8raxxZYO>hWveqEp6{f1rrwpYEBM%!=xa5owvLs?^K2~Z! z_s5pC2ba;*`tt(VgrE^5-ltBo!YFKl0$Men)evxb%?&L`Y6ZN@_<T%a!hM& zP{?y>t*ke(kBiA{EiE|VI_BH($=BexO?-Ppi*}0bDa0A7UDcqXh&Z?REi=iB+GwzQ zT!5@135o>DusT@*Edz0o>pDa*aCNEF|1V!G9)pUkoQhBje_4ed-C{f$vF5m)8XGLt z1(3*W1B;+>rU}p6*Fz7}2rci+76opol4SU5%NA)lh)`ZyGU;ZjrYbt_e}rN}Gbx^v$E6u# z(bp7!J}jE=6ZlmPw$%2RU-V3825IZO-9k}PaV3pk!IfoH(eLo)?Pf(S_truU21Y3y zXm#sK1=)^$#bBQG2gq2uUaJxBU!fnsW7C)894)+!CksY1>(rixRb$!O85;=7A-|DGfdn_zslm8)CuEGG( zgAe7hJx;|HG0vE3DhB z)BXB^dceD%4A2Qr`5OoW7~WvHLZ%P;T{=;>6U15*BT#5&3$aqq#Nc{~H$a1Ni*XUb z!HH{&?jlNA+cRM3XwNB~mlUvoy3rRlDYelZc%p4e9V5``C7BOlk6H3z@ zWt$A-Z%aTI)yJ=h;y_9RDqRKiJPIaHW6;4@oXa=Ho#Jmo4nCF!e_G5Z`!lC;MCX8I z3X*6dV)y$Y7kZW^ckh+G#cjL@P@_rk+7y`TcyyY6l^<6!cb44K!NA<1UGMJrZY@l) zow42S{n7p0Jh_PCYVgl*IH{Cb6EiAy*ok(rdzeUH^R6nzIs+QZE z6{ZumU=W*g3NphZ?0GRF;F3h6^{FJDj3lzjx{ixG4E^XdMc}xfdF0}Hu*kLLabS_+ zCc(Wme6xZFPo~r8%C_Ww^i$jAxHFs9zxBSU$VCBi{XJhg(+YK$wT1R-+@XXwp9iXe z8&9Q5TFkBmb{`;JXVkiNg$mdWtR06`=Wq=KkjH{@O!2ri(ztdH;&^uLG4KVZgS>X|P4&%r_XgBOGRIXo1E`Gur~ zX{?Qc5~XdiSbqgy-%~=onEahW16>n5!?4>!f!Gn=Y!wODqKY2O(pMoYds~URKL@rj zx#S>=UB3!NeC_rcWqgrS=oSP<-K5V$VbAVnjXuO%=~Rs<%cIR6+7tDSxzIRC+oK!nY1 zG!rMcr3l9OWFq%?c(@%+O}yAIw~`LxP5?3MOOmyYfgB;(>t1$)Jv{4rV^7fX0VX4d zg%e8+o2(H=RaSiGA&bzM6Cevh+-sufSSmb%J9PPYT>hK!QrC%P5{w{k( zgp|FJY74cE)_gRK40r$uZB4(f0tUj*U@x%OB+#RLT7nO9 zsQ@tBUaLcMGo|&LsQ}JQ!FTNF?3Ur3yRAaUk9wdY6>8L#KORW(iP2<{P|7y@^xROKJ@@ploMg5C*2fIsSKgN{s>g($`oKVlW~v>a$MOEJ^6kfx8Oadc z7&l_FZdW@+d29U}cc6hdr>D3g!8ksEaid%|K?EgQ=1Pcd@nR-Oay3#EI!H`5g?TQb z^0w*eYrDdGM>L74El9gN_q6vbcgakV#(d@iqGn zfgSqvB4VByhSrNh4i16gJm6I*1w1sFzP)*aSQ>hXVG03H)xPR^SprUO!MW<2=A`nb zcS`C_OF@B$v_>^m-KYoKU9#F`CJP+po`(ZQK@HWGsH? z1K4s<)b?-a*UMw*rc_rAkj$MD=#}ovopxBIP0IXCN6lk^{c@|-V#}xbXcg^ynE%FC z!Ve=uR{O5A+|2gFU0crk&E1@>DwYIZ$G^TO4wz5{(whW+y_u1A(kibpRXih;O zBdn@Mstm6)ydW-2w_#wW33HKq>VeyjX+B4U*xvNsKD_aj7%#?7XmBZaM!5p&urkz2&81L%-eO^utD!0saGKMQrKCDeW8y-V zoJwtYDGuk*%X?7))oB*`aFGM&+0R)S9>yp_$1kA&8U|7CR?AFW4GH+iFrhz&LHjYx z{}(AouWN2&?(~l*R}{Bt5x@r@bnPA979p^Or@H=#mVB08O-Q5~;Z%g+AD-SEqq`@O z0R{xqmE}0~;YA1o5}?LagqIXMl~$_?in^d`Q%HwLV1Y$Gxzax~RVtMV%)QT-f1qV< z9?O64;SybPk4gW;*~F^-$=<>H@FM9Q z&h5+9Gaj?FDtj67^Ct8cBAOZG_t%ZW1oMzsA09arxq%j=9LMliF#kT!B`bKxM$$Ph z%S7vAkW8e2NXE|pa9I99OOKrGEZ%-TT+`2u@&D)DG3dJ5I#@c|=^OrsF4vVP9g{%^ zAAJ20-bO5DwIC=P<&jGlR_QMu6<1Gs0Co*V9D(pevhJ&^e+Sqhp0O#xZP&|qKlPW< zkT#V!#@kIUiVC;Fi_q8n1qw2YL#B7;7-Hia1wR&jCJ>lLd|Yv*!lb~mE*@hT7JyG3 zAP?(!RIg9$qQUbe`gzBc3!h03jqou0abf*fbf@(|CB{S^1f7_d;o@&GlO}tkYp{k{ zm#TDJO^}Xe$d%Wkn7&aQ^t}Z0U|NnamVYFDA=fd{aN_%IeuQUrgzGT-RZgDLxYwT% zNaL*t^yV*=Rb90zC1WG!s=C<|--Po$6G%aZ5|qK#?Plm-rlYZ7E4o$omf~cAX!xQK zA51p`1L;nbjB>YBq|eFZhfy0z?MjR9BgO+YGG_!Yz6IR5E~7jECd*e06+F-af?C}M z@+h=zFw?SlnsGArqs^=j@bkS{Tlt7Os zkS>hogL_NHj>jfY=`0~FG}X9Y$W_n8E#QwY{k+YWaw*KYNnrEH8DJrS8A>26&yU7- zusLmFmp3VGU-sO(0H4fTWx^4_uy{~l8IuR)A_Qc!D4B7xlT^Mh+dW6DLMA)P{yt$U zk3%#d6lfybR5toHt#OqA>c-;!R*u+9*G+jN;&S%+KfqjhlV06u2mkWnm-Y7LA!Z{`hx^3)`U>3lAGH_$;s|@#i99gxci4NsM;cD3ds`8F z<7K|$b?^7^oIv#ymXVXGhcLlP$Ta#r@<0nv^}QIdw>SR0%bK;(vw}3;|I0Czj87y5 z(FwNo{p*8i_rM!NAGWucjWDj*;?=;GR!VJZwxdATf4;zVVCVAY;XMM^OX=W&)*tNT zRh9N3Her6};^gDwc=n~0y%Nj$LGroOR5!z(w4`sk;5O#iRJ10f?{)oon^na3-N5!b zVw)g-7IP0LVPbFGAo%%RM?)(=(^PnLR^1+b-mBa_f`~Aj>S>mJnylmOlA0A5C3}}r zAz*|&Cd>~ara#cW-|bN!KNTA^wMJ_z-T~Qf8NDy_BhzTvJzS81#21C=7z#4jlo_l0 zMfxNPGY*o*;OdhmyO?d-Hi7DHmh6y8pY||uK$>lqudp9BFI2f7SI>=f$P{XoqKp&o z0uU#_+za5z#eklKUA7(rDuae>a}s9KydMbTHPq*Ql0<>#(6gUJyu?Z~k#`bNMnHxD zSDQqkYZAg$UTA|;wZbRaxPnoobipc~+Ll3v$j!($S}X%!-h+mei&{ z_XlGG5dwOmUQhXoz!b-HtugbdCjh=-?=vmHq;Fv(XK)mrMj+Hi5a>>a>UcK?r4vHp z(N5kSgM{VY{qj=3>5237#Px89ucn1?pHdCE(iLs4Faq0!!y$1>HxYj!N6$v0FML^gW0tuaXJ^GQX*751(JbP}v6Bn>_MY(&uk}Y>lo^~*KqIA5p z6Eq@U%edk+s%+Hx57@~}eh3h|xk3gK2__JaBvBaKpp;q{u@Xd|hjf`g9+YS*tRm&X zJNZU0{x^$L75C4pA&SPc_2P*cmn+Yj=$OPI*QeB$_03Y`mk|S#Dq92xF$j7=b4buX zBbb6_VgXhJT8xvA@=_4$M8(;Q+2IC(HT#KQc%oE7Qy8*A5)=M5flb<-(E#k_Q}a1v zF5HoOa_DkA?ZvbFA^x(aXW8zo>Q=%R=uG;`zgvs`)e8oe$_D|>mKB$|uYPtFF=k?9 z?amom38pWiftQRj(N7dDKB_(4N|$F{F&Ci`e*0%A)j6p0aKtzQoBSENfF}`w*s;Mn8iJM~I9T1peNhq% zH0>bgTubWEfow367)u**1#v4jG6BMGk${R`ge{yQ{y4e>Md!*n(0(S=0;DBN2<{TN z`?6c44MVeu!%LcZP&LmQZJG6sTNxQXyOgo}J3(83TCU%$pM{4mNp?Grk~mLLp;=p%Wt<$zxgxH*z#@fu#vh?Rsux1p z)a!`w4^raXdNHl44Stb3Q?KLqpV04syMrHLV~ks7$L+@M?w)Cw&A2_uIxr-cdbo!h zxP_M|Bj^+mEg7Nc44sY+=ihVvj%QF*sVxtBvaEu;n+?3N!yp1O#=`89ui*?80{+|< z{8Ay*_w-)wuQYALgoBaj{v&hzGF+P{T!eYczx$)`-9f-ET8>^8oU-lVl#UxKngvKZ z3PT2BVxp91GHY>@yqBWL&=nNCxYnA!YaF8{RHqv#qlH%jPNb|#?YZFBJ&Z1AUAmjJ3#?^*O0cu&r#m5h34pZ2(1w02W& zaDw-0llQcGs^3oxl9@qLy9qXOa8;{S93saW!G^>%P8_-<7z)EcWg@PB)VxPKc zTtakq8W|^E#+U&$UL_92OJ(<>QI$T=*YQ`Ihq%sERZppHS^~`FL3byrRUhFsfml?T*vdBLL{AH%gZ=+ZA z9URSrl$$A!D7w&N9ZIX)R#6pD5QPA0?if9bpIw|C@O6fQgbL+Mooz_{%Hxxc9L<&6 zN$tALR>5^;Ipb`+dNB>!BiT&lfC0~Ne1>?pC6Mmnmp)3KyY(V5(4gI@crqt3Q_Zd= z2!ck`ItGfhP2Z9Ekai=3%An9b26&NQgoNo7<2JaB=O<)T-tTsJHtLxKj(P4MzdblU zS>u*YjdoQ*i-PRfQ}i54Z;VWE)r{tUcn?T6gridz!*hK;1j0;8E{|W#NOYOyLCyDm zdUVuG9nQw+uXJd~;9koRx84=>jBkHD8SIuG&T%7-MnF8|*O4N5fQ2FAaUSqq^>!%| za;go%axbK6mjz$u{rII>lFYAAVAd2U|cUwTS$;-sF6z2eA(U(Ezlm{Nv__}Qg) z0ksO6lFil<))E!xM?#18P;S32=GtaBpllhd7(o#@v)WmhUR07ib4UR%;Lq51qnUTs zX<*c(Ba5Jr+m;h3ZYznJy*}y^jprK#P1)X7NE<+)Vz?3(ogrXFarfEeG$W{nW^JYd z(ZZ_SF$VXp_ow_CTZ&xlK@R4Bm0o65{T5vuR*|HfIMempU}a0Fe~e2kMs|LvnH#|o z0R*#Jd{@7|39Da?e&86&H7kO%-M^Y*>GkhWMm5+F3om%$0q;DqaqL~+wBiMvd%~1W z?Nae1ObUq)S}S?6xTkolmK|M?5wBT5s(?3SDq`xN!V-FFNy6iC8!+b`%Z6Cuknjz`8@LoA1E|>*z%>2l ztU4%HFi^rmIC4RW7j9a>*Z@5WzH*{&sCL@kPbjZsy+7e(!eH zY~_6PiDIF$D{Mm6bpC_gxzUW-fk5lJY_;i*x2lipEDWtoWUlEVmxRWv1H5>KSv^2B znc=WTvQekCX0g?HZwiCcbT_ucLHGR(?UJ$Ec(81s|190r#PPSd!79UiUP84w;aM_~ zXSet+oQEM+r#a4bh0f+&CK>ZRt0ipOu5psY%+)hjgICQ4(20}{^ulEUSjK)q7`ZAR7ten(k#9D%#|&yCLT9;_)RR&!3BO z8>p6%g;yqSG%^bCWKFWs#qi@09p|ab#Nk0EDB%jEP8V^(oH$OL(BtYS4aQ6`qKOUR zT0U&&Y9x{hwOmcIek{)H=knr!d{~y)O9T0NM{df!(!1(&^)u~RUUu(4Sd&;K#o8*w zGw{pD2hp~I;%(u_UG+&y&BA3ta5p{C&@<4}luQEwxiYQih4fu6Yhz2fG}njNXpo{_ zYk?$&%;b3xjOog`~Nw@a$?X6O(`xFkgC$OjuXajd}O8HKb6 zmvG4-iA$stvoa9~j8sC0xe=7lrpm)c&IY!>d&pBFBXKB~QIO%1;_@ir0>L9pib1?j zNLZG4q~=DN~LPKt+DVF$1sF#XTs41;p^1U{ZXUU@uk# z0De1&KH{nw+xB*Y)BUep}Di>+=3q{k^5Jh6M+fjHx zv(7wv^u7sP@(3Y85E$4z0Q6N>U=5M*f^a$|5`ZX`*>NhN^Q_{Z)CL{NamM3ef=fM8;LQmk?!km<8W{7>g1yHkFn`#sE%2SsiT z->ZphzR3V}Kv0Wuhj?{Xl>^OfUv7>rv}_#^)ShgZs44aBjPlx`ewbK*w$AZ`^UDpr zSq?ZyjCNR=`P5*vbO>3{I9&+>?9C4?Xz!>;+8=&89Y+TndRF$cO67EX=ImG@*EdJI z%cH{Eu+lvBX@%L^*jTF>JCm}n?V_HV@9UNY5nM<|CzD||Q{ zRLp(s9Kguw?*;=A28{7@>wbi|k1))q8UAko>Y2WjJ=VZocBm+IED&(B(^cL6Lah4U z0vAgeE+pyjZpHTv*BLBv1EqxZN_>8E47P5Ps|MSidX%IV;HreHwK*pSVi}kTB~fh1 zlq8b)1{*y)3>t=oO50GwXlN5r7Qpgi>haB(6N7j>Vh%YtFZ{{)% z&bPDOP|17S{oXQ})!(Gc5+J)X1Xr9C&3T*3>G zSLbJiZ%iJ(9$HP|9q~MXw1{>vyenz6E_ZEfK(A$Ewsb$}FmgVoc_wu9l^dX6VnrIY zEPa1P2TNl^Xy}HkAFpGug(J>xhh=wG)Pb5DhUN7jaeDjM1OJ-n?yL5i9XlPe^YVCL zVZ)8~#fT2rb>qUYJxyvv^-dseNVyA4m$=-O5}R)cr@R+35;^0`K${VP@hamqR_&$@ z=C%JO{(6c9miDo?72&E~mjmdtX~bFPG9u1hNY!u@Z#@?W23bIpoG0UQ8e7w>=TCw4#elTCGN+x=FAuPfa? z<+?B>v&&28_q=hNKUPbZV538RS?l?HfPTrtEc)U~;uO)-qrS9k3mwEGW5g#-Zv>={Eda*)Vbp z@Ji;EX5(6jQ`!Vgmf5VD@__hnr|~d`Bs9nm{uOqpg&?EV=~2v zwc?8qO7d{$!FB6(`*LiN#bS)!hVXHx9DPI8eed_&A+4;yMIV?4(XB0w+KeE_b#Boz zVI4Tu#>g~zj;zRGI$96RN?tJHv%I66E#V*a%i6*_e?QUtrmtDt!zWXiW^LH1xSgjz+$~O2=q*|F86vorS zVj@a9e7Xru-1C+P zoUpGBK9%d_FmRsQyDs(3wr)VB*nv9&*CS>wjQZu(+xIpku3y_EJsVz@N1>0W`z0!C zYOGp-;;jUshb)gaLxsgaknB51e&8g?yZ)7>M2>#~1L6Jr?N(55n!vm7(L!m?s0Egx z77$xJu%Pw#F?KAJ_-9hSR(hvVjND9nMmi+7&Rg3CHRMqE#eS1=z&#!dAkKx7uI0}- zOks?x`&!Zd7;xhQ6>iN0`<^kJ#a$as|m z(3#X8agn@c}4Hko^E%73_@VdS@kl#>Ne z^~+8^0?;}Rrdb^P6G?VEE4wq4Z;|WcuH9YXJ$(_7T(`r1BQgabDeM5RH3q~DZZrnO zL;bEoSwpYM*PVAh>?;8M#tG%50!%ufk#z#nsnf{fSO}-xP@%xO?n|)Zni|a@egR%h zSstbRpitnO@{w3$_uE_3yMBkD_)Poxdv|Hd$h)X7J+Kqy@!KTMM>n_=JkwUWVlw3| zg2(8+_ZEasBe&M(JzeqK0kjX}T@E7{sBnR7G(p~;=%mI-D}w_Kqp7q3>=7KbrySLD zY-@*Ugp5`@z9OUJ_Tgx6EhnmkcUaMvD@&%B+4K7+SU0^s9tk3k-dbsPR}#UwBH)RP zU?I>TLJnAf%Hi?{lV^yHA>$}%lp4La7_ilHXk;JrP39`xEscOKTi8ANU5Atw&_eko z+O2*S`41^seM$*w5ByLa0%j0$zrgra1@4MkN`$xX9MnH~5MxE!?Q6Zh4OlR@UwMb; zR~HY4pQ%$a|NJt8Du!ZZtknb++3d;m!n~e1f8-!@admdx#1A?5kh?j)wQ{+AhMnaC z;_h#mugIY-{(kUH)47gVB6tjZc^q241q4M?92s+!!Y*M`JVc;=6{zJgo+Ey%Tw1D+ zW;6=hLx#PFO0&cLhFK?dMrN;bn~d976F?p`4-6?is(cvzThmuQV~0ks?JXEGomA1Y zIUnD`^+ZCZ+`4zr{rez~T`0vTlU#F}mcO*T6utadTSvV@jIoRdqPGW5iN8*p(^R;t4DblYStXB77i8#KS$ZR6 z_jIgGvJwfS#8g|{`we|8?yNYQ(0Ns`DfaC;@_FXlIsBLJ%e^l1r~S?U+`LUcOCC~& z&VjOjnuJt8{jPr-C;oLV3~g;p%uRnTU*|+?+n@H<$o4&XwN$#^ID~kKb-px3dOeLs zh7f*M@lVZSceK)f(kh|Z@@|tffhA_pHZtb@+pGJ&EfGS2IQu!$Y6~EE{QfN{hseO) z1kKimnCt|riZXIS9oPfi)a;ujoB^y%h+TPRpQ?z_jRgRp@e?j(JOH_%t{Fv(3~qdB zB#Jyt>9;7NI5`8H05Ms{;>ToxD8pgou9EyzA^4%W=RS#Y;YlKq5*C3%dS2zXG75T3 zd^q(fFVSs+K-5x1lqwN11%oI)xhdx89G#htQHZE4dZGU@$m&M@t~cs1*@RS9NNN#G z>p{%%7?NfYX3$RmJrK8Z2kkFz1|?;vJmAqIBOJ(y2T{U1XEd3=V5wD4q$AE+CB|zq z2#{gP-uBG}OJ!TCF~R#1-e>Js^1F43wgc54VZNs!fNhVyv0HX@5FQC%cJLN2#{~zU zPRF;B)h)BG1sQq1%6 zG2iCYd||JtwY#~?t7p!#J7KdPwI{AVdH(KfzKGz;eNC1M)T~-r@H2*GXg!WBUL{TN_*6J9wbOoP;CUq}=c(wt&lzSp z%T;10;Y>Zi$z0RpyQisBw|oL_`DI@S%KFHeJzG z2iULR!v0;Ws>HCAU<~Tl_*+mp2%4$XPyjMXy`C4Zr&CHgQ7xZ*!7l{5>#s*Lt3l;N zN1D8PKe8;y@aED5IV_)f10Eb^Y)kCk=1=p;m{{-Zt(I5-Zd-&1BSsfBE>)GhDA?%H zu0wPC_Ml;iDu4%E? zA1{q=!OGU}Kr|e~!Op7ntg1vpco}r!h36W$JU&4#K^!*ZJanU2A$~FRyU%l)>xs)Z zBL_Zo1|&8Egu72b1GM26N(R}U!IW;_&+J+CoGEU)7eqrpkHp~?PEAeTEaA{y=mV^L zd5+s&eksViKWUdfTM}~!JN`v^0KN(?Q%Gn8dUj%>0HQp-HM;Jvz&IsHxM*B=B|s5q z%D5?UAe%yZ5?g2Loi68B?i-ZfI`aPJkSx#uz*{&QIu&701(G z<$b*#NqPwI(U9ndiJ%-F*&vJR;F_T|ZzBDh+w^E&MsQ9DvxoPpRvAjZvQ2-L51<)) ziYR@aNQSQ{1GHWDz;yO&JYg}5el>w*?N9Z=?}%L=n*+HS3*J!CaY@?lc=x>5?Vy4C z`kXv)THx10Z#6ODRloUq28thy;ydz+5M+WWg#jCa{z3P13>5~Y^9F%th;jjCrS^@^ z>Sk@E!7;i!9jUi<{!(TqWzg`M?_;}f5a1Kq{?Cd&l0{fD!AYi53Ek-8SJ| zaJ4A#6mS>s0ic~vhg_Q9?aHain;pt#k(&qBiY-V3B*kVxpyw1#I6|F}kSn4S4cQty zdsnV!R4qa3dk|n9A^cdVn20HlfoU+A3sKU$I)!RZt5?f0q190M;V7fj3#nDx0S+P= zr}_)#Kkq2IJC>txJ5tcCPl6gIER?dr0 z;4od(P(R|U0@xYW4i?a8z-$uVhCO#}Rg1Y=zC-tV(7pkV9eGCwgw&1)pMOa$+CVDi zi47$W#jqAg;Q0Ll+k*sMbJis3@9v~NA7|fclV#1=u6cj%G`D3VaUFz?MtO4cNbc1M zi3y<1FZ~j@ua_9>@Q|!NKT>H~orRU`F0rvSy-tDOa{7wd=4J1;VxAYJa!86fSRe1C zArt=AFmu~v5Wdi$O8xa!2#_|03)+NS7B871L6yJJRD0_X>8_Dbdw2_2(D59n?A-i- zOcZX5D&s^ck&699-RE@V%<+j?PcyOV;%_NtvD%8o=Qc%#Clvq(i==MIqSgtS`UdSC zgJ@3X&9k0hpF^+%9&#D~PEqzXKjzs`FSkL%%DjJ0jnSducwhVqe#iAR7Dh&ptQf28 zcwyVtx9S(UIpueQ00lEixxJ_PqJD0J)vFO;fuJM(MT@GO}AzQK7%-*2M@; zB&HRiEZ2vDdh}YR=XyR|O3z3X1U>k-<&q^A3Ei9T#BEt4ma;ST8)>BM=5kXXfR_DiLc|i%xv;r{etC?nPUNkpGBov z!8KxzVX0B<%JRrVJVRwlC)#Pf_ks9YFsT>fZz~Hiu+L%Wl_JZ zF~kI9`{_+1g&^+B#DWQ%#L}_Zt>(?JSi8%p2fQR}F51|9@!#)1B&sCKqC{DK&uk*v z>ctr&oc4Cl02 zzmvBq)ACC|L>a38K`(W zd1ZH8O%|;IYX9a?{X=R9lm!PB0;xDm2bGl3w`c#9#Ysf}ZmPC{woG=cti=rp5)9|f zj1lPsmL%q;^y03}!o>tUsdZwR&MKYQYR|4E!h0e>mVdp8%V&jlzN-zj86rP zKOvbs!(TI$0N;X?0)Onaaw->fF%*_wE`D18Z?I0X35RvD-JLJrz zC?cOG53b2%QTUe-vrdSJ@A9HEa@Uo;F*@3iQ*;X=9oZ_eAXp(b6(Yv?{9z_RQF@iF zaxwh*p4#hefi}2*G~UGQ<$N!RI=Fi_{AHvgW@u(7+)e%fY)i$_jBFu7I%E$(_SW1k zt@l>~6733!`yx)R0~v&5nHG8D)!oLAHm@=vJ1;V`7QvH|jNH3!fGIOVSzX<*HvIUb z@iXB*VASdmcZ$wsha_1*{)kihR!ceyuXAr`rYYDrHo@RHX@iDoUAa6-1h;aNJJ@Ic zrU9YDRoSBc9lfvx8;>3R;)y-y2B%Cb_kKKGzo?2|r}K7Ax$9(>Wep0Q{QitIIe|G( zR!6u_lp;MisvoI$HF@T;Z;0f{y(2h4T{-XRyWCw)K_X-;By5FTc8|CuO&GaaqeDOy^tOl!}swaik9 zFXWtgkr2hhv8q?d*v}%Ja4)6^-lXDM;ue|$4)IFi3i7fA`QX%6W=YcYxpu@R(Jzhe z`ff{ZW`SrTnYGhD7l_*Igcc&Zh;M?%qj!6HYO?m(0V>mwc;R0Nntq9Rb?aL?v)i>9 zZQ)Q-GAYVvPbXa|{U7#X_yA+qg}Y9yBP3eP{0?tTau~#f6N$G(jV5ph54LbtB#{dz z?PaUQhnp?s*>_8&%D3L&(KC}t;`z7GUikH%`=-;wx0klYM7=b(_^v&Fc)vcDmq(6$ zpI>5tu~ka$V^7%Ui|Z0Kx@IgDmXkF?0AMiCzX3LG_L|@GU9j_q!>d<IuCW%~1H|M9b0_uu-)f3f~=y{_?3us11fTmDF= ze4cgf&vRFH+zqX~<>vTC!LY5g(^^94gXJn63HHRQovJh1zP3a5am5oFYeP=^@HRJH zx1X~P`tjM~BbCF(7^K9|FVQ*>ZC-^B@LQu~&@$EvaomLyJTo>KQMOIOZ-!GVxrrMT zg6mk?(lrT<2(W@~sy?jZmAUUa2hBw3I?{D?eLw!1&S#un%s=P+f-3pK)9PP;MkSo~ ze3i^sc0KX!b+3caPezaGv!$gy+8ITG>}$gRh?C@pNCF3#8AL3AWTp=tqv(PN`7^|M zm^iB?4tBP;NuyvzPlQTZF2pZsi0(Cs$vbTARv(d}P;h7&cP())co(BCd(Txol6jcf zH#ORIG=+c5*$8uOXj3*mV_$Y*UpmT2NTeI#xz##;j480JzF|Tvna6Ar?VNz_8gNAv zO!qgs4y{RubL*8mO{$q=kz-m0Qi9ox>JwO7_JloeU;PWPGo3F|eT~^lr2~|m*Q_4= z3&hOr0K^dHc`Dik2}I7;vq^VW>srJ}bzIT#6^iw%ZGZ|!KPW?IJo%zQ<*H1M+(-;Q zgZa%S1Fe^sDOFyUp`NS|S1A8?cs^*nZ+E{m0J2(PzlGU(exW;!58W9WYlWW6Lkco9*m1Pgipdr1*Vu{oW+$$W#f8QU^taW?Ncl+B`SRjS6Av^;Np^fZ zw0r46&&*_y(W1CSQ_JV=42fuHN`Jl;?isO^HN(~RrP0CX{~^X9W#392{b}+D5C8zc z|341tYHZ;6PZZ|#^o>p&FnPGlI12x=LUlHe%qiac)qbwb=@spDUXGoFE zi}JU@#WDR>)cj1addU~BoGZsojMIdkI$mGB$cUvB_W}8+LR(x-^-A}?pQQ?7UC)D< zd=vt7GAh&u57S6a6Cho|+Y6#hO_XCsI0XDmbwF4n3@gJFg!n98;6g|zmj*7A?@i)7 zWGXxn%JrbTQq^><)d3T~F}6E^H=E(839yus4^GfFM@Yv)Xk7uL;De02^+#2bnl{o> zt8R7%y@R-`E2J!&p-k~qsR1#ZbOEdEB3jO`EsiP+<%YYyE8zB!KqiPcmRArmBN%#A{e^1b@kxEMm@%AcqTw%PAso648lvv36;bd z!(i*LpmBQ3P#q-yK8pKPw)8zZ2a4ZRB@+cC-LErTngY_PLxk^rB`ixdA#$O@c_79g zJ7)P1<|Q%8J6HtL*V+X%^)sfZOq8o?NhzFh!^62SuIsO2_~ zl|1$PloS(omW*D6)9ubS9z;jdA#$qOTP2#2hjx-*tyi==t{70WfudVbDe)nzl;ml& zM21&%6L@}fY80B3MC>A{C7&U-@wy+c9ftq(+P=Cr#8U=BV0I3gv6b{IdknM?I}P8k zkhIXPle={Qy1j~clNo01O&X-0BB$IjiV%Znb~y(7hpSweTXrJ;PoiiWoN}IR_hrS%Sfnb4~4{< zfl!V0tNK@6RPp4VRKy)D6VdNT^iG&E#do`RH%$#eU)VtcM*pN4uH z(Wd)Jxo=R;clq5h0zZjE>2g};7ZNTq5Mu>gS5;j01%Z|+r+f%&qd|V9ZBi=7aUqo| z6{1%xhEtF~poqdyObpdXNIPP2B#u9$2cphRMY7OK1Ua|wGZjHGN#110ky^y>fYH}S zA|ZnzS&(SY0q*YucRZN5KiAYij-CCMP$ds#8O>$SrSlHmJMX!DkJ2O@#;^A>GO+KL z-LA0eMZni`eb=bl?$94To^f*)(4n*m{~l8qIYQsZLH{+{!#!T2>Jfq5thwYBjUGxW zap_geB%$8Z9wMXPA0B=kmeXzH+T2yzx6RY7B_8O~Jk^F=n)JYWunh?oTR5(kMT0<# z7Tu@eaWHOCA_BE>Fz(1J1GMW{F9>Q1vW;=k%^Yb6#ccU;$yis`*E@IxFP&m{>a6*^AR@E|*zh1ZIar?~_rDFm`% z3RJI813W#Ua<1CWg=;p*TTx{Bw>?aS!vtK`SO2+Ro|=HA5HTq)iRN|Xc**?*_j;H? zxSO#Chh^^Qx`}U2U3M_I>dx@~KI1|HJ_BSTZX#5od_B+3wVinCL0W4(n${QfEqwRa1tjG}-k*BGR(gA&0@LWQN&KFqO!~-swfXtXWNb(<w?46zt&GxVA_dVf< zn%l>x>MVgrVJR-q)AM7ah*O?kS+lpljb2CRC*S|3q5m{g?MmZHbpA9{Y5id5PlpV^ zKPjk%5xt3{qn#t&|E#Q+iJfOa=)A6G;Pn??I*Sg}SqO$uIh#|_kKp5yimq#M1B(>8*#Z@V^NNRcS58EyA$v=W zu*TZh15FHW@bH(ZjY*=HCv4NdT?QZ#!zqXtKK;N0H7KHIBV1i zjh#G0omG0DiY>fXA@Neu;+Gj;hr!fLb{y+j_-NfowfyF(QF%08t@-R2EMv%uKdkK4%`EjrP^> zp>0Vk;u*k>d~|`j_=j7OC6W1)(o?TWwVqzhdFk@|8a~wJ&VqM{1P9T^QA=D$;X2Gv zpUxfKJ}O4g>QjF5+H6tovr_XRB8wiazTI)QITZRX-s&Q5c0gb6dX~?5FjfI(h^t#*d*BHeU^>!&DCE00quDSK1+(xtI+ppIlM z`;H$w=l$@;zZu^D*xKCA#^hg9530%hG(#bDzN_W@3N5-!gd<^2lZNA2m>l@~RH6Y$ zjl{-_p--v#-32W8fpF=p9LvlR9;w6Ku?dQov&Vi4f~#c=i>af7dK+p{<1#vX_sfr< zoO%c<5JZ|~2Uu~GYt0Osmx~62=2gI8n^P_m(hQRJPlsAUy5gPyOtQKNq~J?8a**tuiE{cNsPX*Hm~E z6Rg-WqL!Cg?bR=1G$$c}vA@#_$sJsv!U4^TJWmyFUz805H6t18qpPafJV1oNPn}A8 z;TmEinPv=f`ki?pX|joytZ<0CZ6oW*!5;&;ZIVsOq46;oHT7%M9QPUuw!L|##Us6` zf_k9Xf=%n{EGFk7*3yqvZEh&c9bDLU<#IF>T2{t$;Zr$Kqkzi&^xct&tfbo;_D8p##oUr$xE#CEob!k9Z_@ zwaa&@b~cZi@DX-qn@^?M#ZL`t-5P#!kJpUsOR@2$%yYGl%=0Dq{>S6OKVBUhd%hy# z$E$z-JTUynO}BLVx9!o=$wYBHW`O~rV_I!N)Z9d>7Y0WI6!I3~$JO>ck>MJ$8Hu^Z z3X)~`in%7t0-%r6x$!7le(}5z;#5Yg(cB}+3L|0MB`%ljJ0Q z={ojaz389UT+ZoP^q3 zhzXxLOzr*;a_|98%CHPWO=jS@22u?Hw$P8ewNPD#!4M-CRZ*e1n=*df`$iaiw%Vt$!*bgB)*5ru1D?@Xxw!pd!odGqOuYBPg+U$QAL!LPwd?*ESm;D7Aq z>?q}i_hUDJAG`h3vH!nl{9`vGYYTfrI|D~!I!h;;co|DzMwsAdj^Hy9`y80f6dFl$ z`WapR+CQQTMwroL%tE7^k+|j|bAM?&<$Pn0h%Ej36M!>^aH2?s;_AF+thdaYaJ}pR zUO;&FgZXBK+v8erKw0}-p^=X%l!p7cf)!2)USj}vf~;@g*JQ>-pV_8ms*LlG-nwi7 z0uFlpoG@XZ5J;nVOwI$4usX@g_Pp;95v7B4D=5D}h|M(zBl`;=Mgh%DqZbUvTb8Ezasthwi_eBDxID(UK^B{^sim z0#HiYb`s3Nfe$=KCpwMBJo$?B=xwMaTihEjxGNea0n*4FWf8xhLoz7>BYFMFz-yl>_S^ccukd&{t=R$ zOB-8ihp2-Gm2~ot7|%1afJ5O<>3b7~S(BbQR%G1y+IJG6DWm)+onNyfzL=WSfr#G@ zmbAb;saVf?SW`Y&OIjh+Er>Ni8NPD1N#dt}W36&V1Lq_1%0}GgZpGvM&n^Z~P8M@* z2+H^sPAFRnrRMWH2+m;THkq$}<)u%dN=vt{h!G(&F$1 zA_`O&LAau!4i?eG7T(`2R=`XuCytfZ*a$t=vDH z!$h9vhH>!ZzP^ha7dY^pm&}|SnARP7@M(abBJIMo)phhDs~Tj3uTYz8h)z=SYgMpc zI+V(HNK`V~%~%FtyZAX7O|+$G0)dlkG%R7L3ATUUFk5tR(ErmR{-aRCiEJQ(beYgv4uEA%QtF1yauHf3pVtg5 zFix@%n(PG}W@H@S*F#pYtQLi)SO4wYZt-G$)q3wM%h;~i1H^dxD@KNQf=q7PXJ00< z_Hy&U)^Uw+=gM}(%waAnLDwOzYstO87OsVc+A~d5z6skltwi$46SJGALp86UcE=w< zs4St3-{SNg>hTB)luY%M*R}-%U=whdPza1TPVVqm!MHO>$yky`5JmvP-nDnvl(K{r z?bezTDeYU>_iB$5mN2XA;=<|pZz3BncnM2@#=noLbug$h%EiU}Lyjq(n5N8@F?R^y{Zr}-GH z7Nj)c)6?KooiC|bP(2~oL}(9hxI=whwPdm^33Z(COxG_YGF-L(qWe_$xy_}9%r?GK zngn|Xgc{YiwIrHeot4d{&YkzId}2%A0e#owOzsV4J_tkvO(rAD!ufC&54yM@7NxfU z((@c*kCUvJ`u9aC5Vl|iy;aB!EsK`0q;V6~9wyf*w>j+QnhkQDm*p#cb*j+G9(Us(k;ke3W zj8IMLPmBq!Mv8833cy=ue5wsgcIlB;QTE9W=QlOFd%o|N6|jci^~)Zl>kxxD;97Ef z8+`4EW~}r9HMkN0+<`gAc`gv1FF9~w2HPeZ`B*Zf8?fq=EiTy;el2o`4aTK}h}OCM z?W(6ZBj;*rcH$t^e{7F#?ib5b7StyTv$HnSa<5yA!ZBGws69;nm~tovKFt{-(zuvd zkIuEe|FvC@D#^f_?0~P0g1ef*RdPxzONaWyz-~mz9CFO04rf3`o6gMcVWyyQe3bnl zbRTBwc7G2|`K-V3E%kXdlU=lj`-R-l0qhGVa^0o;jjaJs`MvxjpAO_p9B_O4$|L%E zM>GHPf&Sltpf%D7+4(03H2h4dL;bHnU}NBDWo+kW`x65yf2yqeYzW@dY7`Jqv2DiM$EkL+z9ijqo4 z|A6QgjQW2YKLzlT<{iM4K?6+6l#+f}I@2{*8dkjukWd!e+823ME_(+@)L}O^_E})}l#3;vGzu3Kn zDU=x-IrT#eNzr1QPjNs2 zRpnQz3ek-+Hfl#`-#M9z@;GYv!3zYfgR<8!dm4M987wq&dcin&ql2-ihbpj`7@#O7 zL8Ea*ZPEAjL5{cx2eP)t6v3Fd;xkDDeli6Lr(%McfswHXFi$`^av6g24j6cNdV0R! zALEn%G2uqnww{2WxmEjc-}c?idb4+Vp1sErF)chWm}RjE$w&LJp7ZGD(87llpEwdbVC&sH^IKGU=Ef?z6_H8?+oYUBV} z-m<7b_zYnb93lEjj-S~+=}h+ZX6E%7Vr@wOHIGuX z%k7(hJO;L0y|>xS1|3i6r!Er#aZ~VQr4=LX&QL}I7xaX7TX{w-=UJxOzv_@loi(GZ zsr^%v6<{O{Y3Q`nFvqoRCvPSUPL~J;xPR5}cF_e$eCL1BL)>EZ z67dmqfem_4WoK)f8E*C6z>;Rt>AiRP5YYhoXlhag0PVDMPoVr3=G*tuW2NkKjd_RV zzo)d{#5qg`Kb$<%le^8%;BB6|XK?#|jb*~J?LhYZA0xZ|d846_<2lxb0{|GM{l60x zTf3jyu%*+c=C$L-FURkz;v5Ifj4%!y3mvxJe7aQMR+0?@@C4jm@VV}W1b*NHdocqOQ{6&%| zn2}>RcG3`s$xPueIDzDbc3!qaMq(tm&p{7Ua)Vz;;|GZa>ZZ7P5@BMmun@W6*uvc! z0pyRZ1>e~r`9XA*H>Yjt0@*j;_x9-WBZ$Y{@B5CDzAK_s~Dox~Ix>sU(xM-#$AY!rSG(&5^1R1p^m{C?-0zk#%; zoE6BhvBAgISa@DUCI{7%;es=t^%N3Vp07aAK}M`lzDQU_d&C5lsVM4i+I$nAl0#$` zu1&+sPYGM#q-q`(kgioJ#r}ZAG0FufS)B5hX=jwzHSFm@L`OGpZqtCQv(%v}=mN~K zymc@*K7y7&*Y7papNXVwsmSoPU^;ZB*h2EaOE!W0UembbT7V3?)F+8*AbK8b+9nN; z+^JNM+y-sL!;c;xZv-=UoJIRpNyDVez*FaT;5$e>diu`#r@|c2FT1i$Bp2C7-wfe=1700}XZR(y?|% z>Y<0YZR^qUQw0k~2ct#?L&2#te_2ja^Gtq>?AO;z>QnH7{tcKF?v{PLnCx1oV4i2` zBSlJxeDf67LTMWI_XkJHQHg-4S-szRyr=FYSx|-PsFAjVjbO9ec4AYNqv?)H7WK?q zrj8IIUL4xyLW$}i*!-$O{UMlICnK!1$vEm3>HIShblahK6rc)IR_J9VR9c{HXEzqq zZ5f3iQi?55E@XD79Ic18pL!~(OL`K9%XJzn$ApGXYIt^o3*Fjk$WgOe{@v%GW|TZCCih525P6-Sw7Q1s^Z0aJ|8yK`gSD3uYJ zRvY`Rb{RX3RtVAGh*W6Uv5v9s8knHh{o&?g!q$*hWqVj?I`M~(2B?a>LXf0{2x(vA z4nJ-NCJK^qU|;Op)0TAJVit|Zg~82bK8@oo9rQM+zisk}+jV(YhC6y7x#4a`S9%rD zSpx1js}h%mq;68W{3j9{JEwkq-DQ@Y)}yJAV&o6Ixh@0n@If;JpjoNVfKYDPoNT1! zeK$@67My?o9(}MeY@oy?IDoz0FUNXMj4 zy}LM_L`cVCeeD^$a8A8mI*abpCqyCypTV4I9zFB8?R_|jCv%6+_UwzwHS;|6Og;>B z6-Ga2ql-G!LfBbUxfW|A@iRrN2R7_y+fkws zn~3QRLFePxM>z(1!@rB$$mcOpPo~+tP*0K8&?}3YOOYkmHWcat1pDD$Z3b3|y?krC zI<_gUf-6+O{9O)5E$QK-N@))|jFdtT_@ z221DtrILb?j7)!3BY2KtANj(C;sur7Tn=S)R1qFI{o`P!{%Ys5Sk8s5VB zoIs>xB@h3?ONQpGv6^eG%PbxKPyu3aScQ=HIMunzhkiPo^yzASGKGwY4vNi; z+pr0-omBPZq$4GrJqu90U8e`}oiQ;xds#?9x8WIRtw%7t`M1~Ye0z(b z!Ci#&uE*b=>Nb(+c~-hXa56N6E)&!6QepGKm=8{ymB_Ybg1gBSXC-SZtn>0STxYUi zzlop*hn@qPB~evEnT=`X9vyh?&`E#`VOq{;O?{L`M)o>zD@&f)Q|G_0mJ70!3_
FU$3M#!`DAXO@^_Q)Gdtq!Cf@*j=(TCS-nYL4r8PCbGei|T$|jNgb%tOL5}E$y zQNivS(@-Ng8_`e~gxv7!*S=Oy;;>~JV(ynuKP?KloynpaeNLi9UNs)iH^(s9Ugk$t zYI@UYh-l&d9j{n&-;YPB{ycNt*6TIo3)64j8N**k%!$%*bYAk*1^s@^_7@=&gZ)w5 zIzyRUc=3OwVPau>#N^Bq8D5`4gv}O1Be6XiQj1!MG$8f@%I;4OV{HC7})NpDNSsydetR52>UT zrj>>LYO_XV2063+_G->hV2^RuaoabMay!!t`M!%0-OP8B#SM7t3ujiTkkWAwV2O;@ z6?|%Vjn40a$r@$W8Akj?I1=xw7LGF3(A@ViSnYyud+mD+mTIT1<4{5zev|pb->@jR$8BrooY5(gl4w ztN84g6!Q6bOHMymgp?dsEdU+DDwm(4Q&dGUb5Kj{GDNMqEXC26ovi?dbS=LhRCraj zl(R;hEOr=U2DpK4dzp}UVwHT$HXaXXZr}$dwStaGE!Uf|kP#lN<~M#r5Sd&TsZsZq zgO)~bJvNeoMKvg>1@f>%Mi)`WC_}&AC6iS}-;4m&=lycuXkw?vCuVJlT^-&kz4#n8 z`bC^kcxw^5vHbHQ_<7f?DM@kYszW6PS)7eBc{uUWsnE=e@4nvBFxb=Ui(%ev@VbU8!c7r_thQ-O5QkCsPW zhjeE&7LxRnnX==a`EDz1Zh=d=3UWYc4do*+miE^-b$q0+;Rqo!Z4Ze9Bqm znZmz_*Jx~$7l=7^1!kpY`AhkT2jm_?BIGr2evouowCw#^8pjg%cbP)4@#otoZ{PVE z24&Zj(38kY1?4^^VmgetYZ7z{_q0G<*h<8-KUkp28q{f8vkbNk9v?sfQL482ywvDB zZ9yH=vvGtCU2%Jp)q+*Gfem)|$xOE*!A-*V9e+*ju`T}Ad1fWJwVlN3R>F1zT1Qz= z0S`dl+Sz!`ud5BM^CaCS9^!%2&fCOoy!lY@J23clAL==seCp1Ev1NTcyb*E-;+oFg zb~PQV&m_wJdZ4uot3&d6B^}0Ej*nMn)4w@zk6jx`D=(b5#PG2rHJwe9urU3FQ3-~O z1E#bY^R>R7mhO=Ax~LL^X+U}+XPR0h1c%z+a5SyjD#G}-s63O^EU>ueohHlVk-bn&sTLX+(Be6 zx~(jJJ~l@XxNh2KT5Fwft-f{b;6+(oJ@GEdztoBr&o$Jp@jXD*k8heeu;!b^>StdW zMeA6%f2ElHs?WA=nJkiPAD+Da#Tn>$VwT;q?&hGr)tLR|^6|$p;x_U_Wx)brn%p(< zq{j>DjXvi_We2@Z9;AL4F2?#-+v?IYU?>!&Iu}P!UCl4Xy9hT5ZoG&no935eFfou} za6_YUS7L5F>7+9#zWA32``Gu@ag!-%jMfG$zPH^~8F6@=JwGqL7Pm&TlL^@3LImt0 zw6G_4z_+`Xv1I();TnsO@N5z8Ff={pe!+;X+$aqd{~lYnj5e3FwD&DT{va<2<5u!- zG5(n=r)JjNl365mI7vCI1ub%X`c)vsOpJ_oD8u#edEX;bOr&?)jbJTj{!(1744zJI z%L|hC)_E28g^KvSh$*uA{%ftC!rm3xoWYn8Uv|7TEN|kwFh73Jr)tM4Y8K0Kd_vtK z&pQZPoz$0jbcA0jcu8c+_*PGGrp3Zl=#nG5lJM%yh#FyWvKcN~HCFo~2P!J4E7|qg zbuAB@c(pVHP2ECVn>>8nE%;X9aI|`nq{tqCtpW5`C0{AV3Ox-dwvRq$W=;Cd)V!z{YCBWLYp=QZ*8EWvhhu#GWym|a4cFj+gF9Z zQp$gjv&H0+ILtUSZG<1 z8njQlA~is+EDQ*g#@B}LwvPc=!M?b~e@imfsWKvdsFCoVJPlkj2M?2nX7gVY^&)466$djsh}^RLEJS^*SIe8S%hGABUHh z1U?Nw#mdV$T3VT-brP1nll7ZBH8G=czmL_aTZ}0gw~}vbzbI&Col=e4fv1w$iJuUBE@O@T&b;C8kT`p4D#^$E9zT?|Wt z1v1u4dv3H__TG(*x}_j8h2h3sJ_=Zn#4YA>U4X@Bh!t`@H~c8=^2gfNAFx2I#70P= z^eM0cC`k`!p0ksSY&kg#J#kntR`v6nkVtOFIXo1ke920&R+gYPr>$I>$<4Rp=~fnU zSSHSFaP8QQ``^5T#yoO(`w?cyZSRjf7UxuEV7f=GK$Y7yRup~2bE-7>i)AZ&9DEk8 zdE0HxMgFDMjiM?{-t=`jT${!4@}&D7D^w~YIL>GNtMVZ!B8Uks&e#w#FGOCJqx(Pu za}_1b1p_6X(-orzy=Q&WA4M)>R~YR6QZR%Xm`bOk?wN2qxM&Lbt*0Drcy^$uVgyn&REA)bH&Ywjsydl z=(@PU{X-W}DQbNR(k5QK&4+9e=;n06slp9yA{y2&3 zJgf1S_h&}*=ifF4#ghe-gqLJgmz1|JDhL@b6sPY0g}r@bES`LnYG4DJh0@AQT^O3$>IQs3FG$hB_DcMcvRE^i z0lU+YKs=kjNLVYDh-k912omQGc z;@G?p-&oL$Vt#H#!%OT;k;}m*#s9J=_95)9$x3tWRwW*QU_Ma)s%ixhuB!K}=I_V~ z@jBF)1XdKRrx82kA;8`D(8t41Q3rbCN+t+v61gRC4}D3KwA$9+Tx{^%^RM%qE7rdf zL=mpIhh`{rLms@Q(_?}5yU);#&fv2$tANK{vS5rPwyS95{~=EzNBE=;k#@cF290@B z=)x$|+G)Q@s(11D@2}{Mi8^E4$j$pPeXdC+{P>v>ca- z&niFQ=_dOFo>HSr%z_#Am8vJMjSHfFCAaCC^TzlsD+&#P{b862VIyD*%@uVDgY-qG zJ%6%ztitD%7er>sy!E0Dj%-dF7b}8aq|+dTyY7z~M-pkxZIxjqz6Tbd4IP5z%ze9F zUUjq?3%&P#kC%mlJ9>z;J#_S5pI^!ETSrUX`iGMS*`;X^CP7rL#Pb!)cq%+&33q)a z*hbpu80D93iI^!uj7W=PtEFTPuZ>U@gukajwX7>Ib!Ln2Gjt_wK_6yLs~}>)Daz_TqHz z%Gjq5>5Zo*iDiX8oyGGB6nysQU&J(;Xk)!>VgB4a%MV?)2+WE;e!Ke#&6!3iHrln? zv2yTfQ)Sf5>fPq}2?`+5>d;=D zJ2*3B%2#u&{aQdOSCLMztjNH>i;awVGK^jy+&!T@D}sosyu6|N^xwb$sYu$o=w`PG z{<0zvD}os4wnnfONGu0(x(iDQoB(7PB<5Rbx~OGaYfR)b-hBuDnK>A3&gnhpSsR1O z+Khu}7p2{i&iEChWgcWrCiKvMl0goP>6Af))Iy{GVp|XD(k_YNbaV6aAz9}^QS_a3`ciZR;uw`c>J26U3E(n z^>X==Xw^uUu-ud8*zT#VK`nDqL{)o&o9?tBKf~Tieb&%Q5_uVj)UKsPz!7c*bX}P9 z`8RUBHJR5!$7a~^IZOvT^PP)FL#_32++1K+aBJ;9{MhREK8SwE(De^LmMcB3WOKx( zBTYyiA>Vx+N5+!$buiTW;c+<_j-o#s%n!v?-GATsow&%fk5#A!2Tx`5c0m3Wcc_dg zvGS4}OwpckFVv5>E^Sd2DV}_`hFlRnFI~VU2f`(Z9yvY_2**Z3By^aa($(xV+BpMROR5JzW9TMN3nkn05zCI5{$I4|Q&eewd(<`WDp!LiCQaLl{ z+W9{p1Z=+Dk)L7|YB5JPjX4=u&9Fx{O>B6h7tTI+4{{7C#M=13H~0Oqy>h*QV7XL~ z66H3PXc}^UiyU&+0rW)4#-|kdK}d$))zb(o;>Ax-vKZKG?!^+pIv?m4gk2Y#C=@+3 zh<76EX3|?QNEt8^Pfl6ttYZRkVe9!F@z^yco8X6u^{#HVSUmtm!~ul0hd4K1CvG`+$+WX5ID#3p%VHLmQ=FgZE`A36fN!KIk@3QiKxup;#eA_!Usvtd*llc8k<%8Y zdc(pOAsB~+^29xBTBjTJd@V6{Li2>@13thc65E3(eJQRK2)A6jh0r*$J=}&$#+6>R z3Jn+ge(`8keP^N#2J}^Li-Mk=W3|2Gb{dOK#Unhfmrv_AUDeUUN4#ZW#>TbLx{MHa zf9v1v_E?#0*VuKTGPPDk+#9UYt$H)O|d&IgL(eLd9Qc~cnfytQ|RGHOvfmY|b7%$+N(&NvjW!@!(f%9Xd3^)$A__} zubW|etN{7RB?|SjPQ#F36x_~#@sZIKx{0&n0PGZ)=s=2SoagmXB^A}9+i0hB7V2Mm zWD#&fptf8U?o30W)|Og1vdiLbZM zHjVkcD;CkT8*$%XhQ?-(jU&WBKsxi?W35#)e3fNF3z438hx0`}-{C*_SdNqQQl@wE zIMy+Rx6u)SV0HZo=XQ7S4e;NBiBV(Oi^(6MgzS$N5#_%G6HX>hP8N3m=p};Vb)W+H ze@WT8591vQ;XoR173KX6mSoJv z!Ci8Pl#79?QjXx-zlX3SK#@axI?$kU5q}Fd)`d^5mKa{8-{tI&7mum)o+1~)NNcR; zawu@rKA@oPWy*X)7ntWq@S5MJ>};7{r#grE?Zd*>;|n`Lzk)Qw5Q7+0$hFsvfgEh( ztf{VI__7r5P|CC)5^%-!MttP|{`~*xh_rN?{G(+z&ww(#{Y)9v2(A?|ciSVCUm?gqf?$mddh3U9pEquyjj&|>7?%S+;*wu+@*dX+6xHFSk4}#wEV!60Qv9!d|FoNC0VC_>6!^ zB@qu6DQ%ff3E?SntOsR873^hc_)(SI1&L`*Q}MT~$3ns1gQc8!vS=k@P^$cXAM%(} zAVxPM&RnVEA!q3nTWNMff6`a5V;PRsRi9O!YSH}9I`%dOxboS#=qIU}Ig*>l`*(J- z@o2D3(K=N?-OH9$)KE3E^E6nI1RR}!q1Z7(^O~r192QuNSoqLqoRc8$)GVA(xXL*v z)^h|h$E4|trtFWV*cS|_xsq2MgeGi-DlHqNIvVTAJM$G}bLg-T$20Gte9O%dXWxF~IXIBR%S2njwRN|n(&ROiT4m-ZLxg2E|N2#V17-ub8tjZN z^tWCp-7d~wI|?q%31r973DF(%Zi0nz2rQceZ=`hktatog#CN1Y?c{yhb= zEAqyry*@}D(%s)VctDmzVAtfipz+$TFWya_X1k-Ovy9zl^X$A9GaQxPUlYQ-R~>2{ ze~xDkDrDYknr%hq)0cQxIxXQtzJrwFH=d;Iv-){LJfWTuwv2B8dTl(YbZv9L0x|GH zmhHU~&)G*iL?0d9_LlIj$lmyXKYiV^u6G*FF+QUZz4ZjQ1&@;_Vu6lAkMHyhUsvT{ zC33Yljqf-UydUI%56%EBKK^6J=RX?SGvM&Vf*%d-+mC?qzg5XDj@GpQ)3p9`8xp21 zHy99xu6-f|9`plmf!#G6&7bAXElMRkA^ah%*pM1YnX!f`MXz;9I5KGAIGznzd><^j zwp-kmm_I(e-WGciY3ODMQ`~_57$^kZ%*Vka?H0F>M0kgx z^lIkmWSxMyV1$khH#+M zdE^uunPEGVpBS;U1PG;=8)`&>Z2Ayh1^nc7zP0Fs+1*yBh0R;%6K;}2I>x@u4eZ=s z{$rWeKM#DZd0b~&Ka#k~A5rXo+x7pf(faZ6lWJ0S*z5>h?`qQO{i~oPV*-9S9SJZC zka1`+Mlb$&5qWuTVn{}Gt)ym!5D`9V*PkYX_LV94b=U_p`-=Bj*BO|nbwBg&;L7G| z{Bqq$AyyUsB6>y8~(rR(gftszWBSJ`IX4I)z!7dWBzExhJ|8hq9o z5a~m#tC2$>=0<1&+9Z2;|EAU+XIf1`Y&SgVTX7D}3KFHHNe*z+{nyu8cAQ4jw;8q8;tFe5jXVNlm;Df3dJA+zk6s z@GK#QDB+r?=%QF+?=!@}X-h6v6<3aEAR$|)rjPac~Kd6Iv|6hYCt-(TG@OzPLhtmMq207`gIZh$-y_e zmN9rMyx)Mn?jzp@1*G`UIWXv!DPvC-3mVpDu<$6drPxQm=;LnI0&cHgW_ zjyKYnm42AkP9&I!BcblxqFNUXON>^vbLD(4f#Ab9s1`=~jkNAnJ)Q`|t$0bh)3$OU z8Jsq0a5$r4?(6i$rc5;)q?3?ciGv=2^1v_ihb#+l)US0rjSf}N%r}P34OCa}!*OqH zOaqQ8!DQB8JrHdoR~V~(4`Sf;c$|7R09$P$W@mYP+Kg#{^K6$Ut-8dmfo!+gPT$H% zKfaG>l1QW#Eh0%9G@fTN;Ey!dlV-tl*}xMPM#qTwjNJlJ$dp*iMnhL!AU9@?Gk z?%g~%=c)*SCXu96wf4~HykH<5*VQxs;*5`R^<^@s z!kdH>mLQ5A$j)X{EG4fU711$}7;0F>*QFhkWBtDV2xO}GVDaz%O>@WKIxklxUlfif z)03k7sSz3(TC=1&50-vyfA9SZui%?2h&H2h_B;^Sz42mpA2s%|Yxwz{;!WYw9Zyu~ zSStSR?3F=4ssnWRwv%u>}mb z^(93;yAKx60#1u3XF9av%a%A>OCmG;NiufMZd6+Hr<1A9?lx4m^(JV~);f-KjK0`= zZ+2bRqe-ag=jz;p7`iQ5_Mv$v_E{ts4Pqa7FF|81t8xyUE;pM&zz@AAG^e|hv=4Z^f zrhPKo4U1xOSda=~VS*k2snNDa+TYi?)ia*&WF84{J}>0suJlN)?JqSiuITL*U5TnY zwZL=wDBay5osuGgr1#^Tql}#MyKCLM?tgc)X3Y$1o@c+a zzx_Qk^X+#(Z@%x#DulK4k3E|Us|<-M$?fr~rs9NKDenB1hbT~rqukTuRci|{=d2ct zuTD!(zFU4j-{1c{RHV1?viah0d(~1@WBrv9QH-FNj268IN}MY-RWMUCJNEKXpV1?e zR(%ybw}$*lRpOB0`HK{1ZET{&=cZWm8B0296o@AA$`?UL6NT$^0sa`2R_CdqrbY8{J+)poES@88C8v9BntkwFJ=e?zxy-RqXOA!a?jZY)p1c)+ffoD91MY zD5Mg4_GhzC<=0`bPLw~}!94D)WHv_8ESkEcXsaxn=A8>7-B6Nrt3c8`$)wAC*}0Id)F7Yo_OnW^cc*)C=;@Rk!F!JqXxfXD zi}W=yH_zGG<2_!?wTbng%-6G8u)SVL-f~)<*2Y|6o*q#(FY_1@^Ca0|X;dgoNHm*Y z*=HYHOPkaalZ!>+e2w3*Vwa$?h>O!#&ppFGhAN5oy|?E~U>k!c5l2mRnK5LZ1zoN+ z=OML%At7cP2dx*6c4lp96Bm&wKAR7Sw9_0+Yx?Pfc&g5K zWQ}lfslvyEc>MF@^Fo%fh;L(L9iU%Npliz=jCJY*nIdCPMTrCXxTFfD_O`ihd z`|oxl5`(X>Q}L0DYSqxOi5Q#`?bOw!hA2BctGXw@35BT)I5|fdzf|D_ipx#!5EJa^ zK2m0AB%PP2fZr5uBM_cU+qs@~r(vDkDnOqM*EwB$CW~y}o!TN=a;bcRyo|fnjZ)M* zoH-*(i8bX+AQT7dj}$2yGZKaTUZ>E{IAt*F^K{)Cs}rlX=pUUYnlUg@kErddu8R5x zdRmzeICE5_V8}!Kv0J~R#;6OIDZI@Y3h0ygGL96E z-ryrC*>}K^#K6gMbxrx2mcn|@LNMtK&yU$6p9Y$<$QFj3ZgZ|c>tcq9LHKMrB3$&& zX^otFX3^K+Ey{JG)2DH5c)j8iY;lXcVWf{J@Jc_opNItReZquU7)HxGrKD`D<=_6S zVQCA0#oQQ)>hG|;-%kvG8iw7tsYNOofB$SVB$fL`%~49&=Va$t9?95mKM=nKhUr{- z@hp26<5hX8KH0V@jsc4Xx^a5W=tBNDxKVA1Ik@jGfNIwRs{Qcxck;~=@&DhVy<6LO zd4&5snN(dxY?iUGI$oTN{j~WVQKD#9O_U@Y7me!_mgI8{%8Bbt&uYs$q>&|m+r8T8 zRYY0>#O2~6A})U&O+7%T5kEed?cf{@ZHDu=V>0>B@{vBv_=p?*1t8P)zh!zP1CVJS zBnX)v+r{}yrum4N|CDLDTbYjfw@eSdxs_@4fc7^Zp#BG;E_VH&g?3Ual=s99L=Lsfb7Gm0hK(O&4Sjhq2)0th0cL0uERT|9EB)lI* znA(fOL)i>dV)PvFR;-RRhZ)W;p{s1}IGR;9` z$Fdo_Hf;mg{L82kQPQm*Rlw$p+Q}}o$2>TiV9O`PE4q^Ej#cA2odyQ90*RR4M#!{= z#OYKyre=gpeWWzQ6krrZ&Q(k&9At_UudF?11FL}kfQ}zqQcRe!*0}&F#)3D@*=W=D z{Y)u}_zJS zng~;T5{4)(Vs0_b@QmaIO7zT9yZ5Ll89q+wf0!OWzDQ($7gc7H2W?=xv1C%98a)4Cg3Tk1lhbud4z-1)cSdDL?Y>!D^phvnJ+!TUqMgxd7SCQs@X!AJR{kue{B zue`=(L!4)dvgX9nFbwGvBJKoXkLLpntWop|aUi`0m4OR5jf+ql{h%M zSp$W#jT{`RwY5OIwb5P&dMyHCP#$F4Tcc_m=ubEW%iOx9??XG6s}q({7QQK`u%!Gj zN8I-Fr9z`bj)+rD8pebBYz)z&73x!^QnFf|-0pg$apf`9UWsWEqKa{=CvG1F^YuAS zk+nyT7(~4bcq{}Q-+2Y?3?4{GBR_Nk5mCo`zau`f8;fgCl;b}pp)5)N8Ct2=aqz4% z#bax(bgdiV{6eqU%{*h2X`*T3QiOLlH{X=QcN0EUfJDpr{Wna3fFS0`tfmD}C~>Gs zIpc@J_S1*rR>21@n;$;mNsb6(iuNIB$z9>TEzQy)oEyS$jmRcjpsgq#KrF@rt?ns zhD?U!bDouBaT7%mSj@Bw?O*K~7T0Q@hAxYi^X$4O7~^f%V+uTJ<8SHl`dY4j>9;WA z+jLHJ^}5w!d>M7uDe@^cwQl?tkxJDG6Z>W!CZ*kU<9^;`xGTYCLh@)5~_B*Ym#BD-8lSC_# z%gLQj^(!4XVp=17*De)}QgfJM+l z%AVIkI1lCwPzGtdr4`C_TU^z(N(`;t&>TyKzMv;fl||V1e!*N8?&@Wu_Q;tgf&S*v z*&2C&v#z!dtY6Nw;M0yt*^sj(@-21TBQCy5QbC&a8rOX#Jx`!Mm4@1D3c;peCbwfi zH^i6pyc_|Q;eAI7Ar3{{X_j*pVq4Qvd3tPkwffuVTWoZQn;D#+P^U{t0$2*hXAF?3 z{TxQKTwYFwPDTrX=b6h+b(RTPR8)>z zbBY8@Mpn!56x3t-ZhX4>el*Ba1S_r3C6!`ThgtfHkkJpLC|GZW%X#C3QBBR6XI9Bu z=S{7f$}X0p#emlR6mqI#g3uZ;tH&_ym#z?kn3LFRF~zv}T1(*v!XQ8l(Y>WrikR<$gY!7ZBh`7IpK{+S(S32C8w41?Qs(h8UjuR|;P z{C)~eqW6cCPHpkvh4uYZ=g)_Rv!XwKI)7cBdqzxT7fWW*IQ}A?&6OhIS{)XS(&Q-{ z$m%uiSD{@&zAS!nv>DCQN7hpehf|Xg@r@vsMbJ!#a7>mS8mj3svF}w^-D;KVH#Sv= zWrl!_bNE&!SV!$$wWBd@q`-+u_jC9!blUh`Ec-K&>AOlcf~h`bCaNIa+M{?mkM==A z3h_DExwcrE&`Aed!D8-wqWokVm;|di!YmrJ=NvKYkD)`gHOqGAw~e;+X2rV~ua>K< zT&IgC-(2dans6WgNE-^xBA-?E_VIK4{HnUHG?H!lJRvS=3}*#@DpECOj?Q;<>d6b+ zgBbtqHXdCtX4G%CHPC!6y*a4Kw=*eYx(y#c)q@Clbp zB{~l;ENrFOalh9EStqQ0fY-GS0X+7gWl*m2Y!oHsUJatSmVEg|+T%!@)NeH5BA1rd zKf5B4l;p|I#=7tjtXwt=a`L{olyDb3CzE8(V;T=dEcwBeEG-(C!O*SY{60Vg{eg8^ zW%96q7_9o(;S7ZZFHV#l&usk4JJ>i@P<*3 z7&ZNw>YN zp{VEuENg~aMr>pv#?h+{;VKR^y5+`+*+EMAM>c#SVCtrU5a-lt&{Hdk*HoLepx6*c z&!w_;Pofq*0sB72w5lQ__uffOO;2(sd1QyqUYO>oFZCU6SXwKMHo8tHM_85kuN2Vt zj2}>QZMHRZ3b!Cr;(Tn7f1B2v5%_L&8*FD)DEaVl|9Zno;3&Gw{E%!_KeYz|14aZ6 zB7;2~*35lEQqkxg_w#A%O0eLC+~yBI7YCMCby3DTP%Z6!sS3n~ z6)Z5=Ew8GFTE}E$!fCmeU!0#A$je_?O1XE;i;5!utT$~n(N5b+8Lxu9zSwp%EEgP5 z%@$x6*Fg>?G^;3-<0wX3NIHIbpU|)>8k%HoelZ6*2Kns^f8Oyikv3xfzK zm|n0+0UqP%b!=|y+2q)Z(sl!avaD3_-o+8qy< zdSBBQCI95I=TEHtKAf)pxg6bv_zV1=LZH@Y+tI^@HfJ@=jkB>TT01e>;F1wr?%p#8T?fiW2X#iSY-hK_TKKz)(qU%l!`)jIbi-JG+bNw3XM+;-RQKe3r`=OP zf0FC~C9SW3?zJLpa_ozsj*~JP^*ZC9PjTMqf3~YB%&Jk)>0>tx&DNyL#bi4} zq|~+VpOo~y*qN1+w~QS>u#6u2JPcAv;8x`*Gpn%}I&MNOT?>kilR8IknJBvKFLYpF@j-IGpP8PmXl-nG8NaGLz-#0>> zrAxgm(-ULrysJ;i0m{429swpC4^(Ju@!7ndu27Xa$^*^@Q>E%D33RFL_#fow`w2mY zRIGS-XZU#fW8o91QCjzHTm2(6hNXSs>KHy6wtjF~*P5a0v&Z5-CeBE77#NRno;NT~ zW0t7WK_oN=%DsufZ90uGL%}chL@n))R9qo%At;yPZXSp>e&bnnPYLD~!{UU_xJgKhE+yov_ z840=M&5b$fDycgn4&rk&z@#V{dLXf{^6XqxM)8@ctIr5cAfHPeHM^Am@&wO8ZXHKdx*l8;JW zGm9|OKoLEQBioqT5pmebHy0T%z7k7lKCo+xkgbo-s6U?2-5w0ghf+;g4h>}j*2^7Qxp%N}ISB+TWJm?^bx(s9|Mr7}q)jmlq2 zML+QkNnWi8WJ{P95>8CX$lQ$p%kCI$9;G5&g|GAkkFr~mbRha{k zgOFn+@lypk@i9rggr!bxp-yeSH$Gc4=K<2ViByPz)4vc(bnTLnbaiv|~_}rF!32+@5II{i0lT zk^YL_r1}J+j~%3sR6#;x=$V2rwi(BWQ=mi)i=Kt+iY4UWGL_Yl_v64f(I02d+7?ES z5F&K6e1pH-Hy8YJsU*N#uRGf%C+6xXM}1`OaF5f;(0NWILP=lX*}`>NcM4T}GcyjkcGI80 zE0b$D>@ORBQj|ri>GW1efxk@7UVPwm;hHNA3QL+sKc}Zm zl@P91CXK4__X%fS22A_mC9oqxAw`>Ydyp8d+_WOwXV|@;Y7~wynTwfETk6fJ!En7; zdeZqhJ08ba!rZh3Vqg4`VxO(fhCTQ}9!*jZrT3Ctf9QhTax~?u_-cUMO0Yjr#x1WB} zels5$-%cIfvxtZ$$?|yx#guJibQec{|1<*ia_kj0N<;cuv9r7@b3NL|s{q;i7s!LUzIM!g%#LE641iTJE;FuD^H<*87D5|`)NL=tnEU#o}a z9kvANw8vAq%;Q@-^$0X~2y7V7hYR=fvE-i&Q%j}q#bVaYHFf7#hPhY08gY>tGe!HR z1Z`*hy(geLq*?#E93dden0nT8*_A+zEtKHfUZUg}3~iFRNm-~8emh$qZ$-g@SZ-&G z;EpHu^Yro!8%&V_WsQyS)BJJ16Y?>%*DU&H$?wk>5P7~Bz35 zi&1J6jo5o{R8FouFQ09r2R>sKB+K59+B;(InicAP7Y=+Az;}ahgYVa;j0jympw1OG zId$Asj{eZv`gw?pu;W?N@)nXcztK*jDRuanO&s222pOly^pwrV)WX5_l!L1H-ty4? z5nGC^i4l#>y&=<6`Pp{_DO}^($&oLJnq(^TahEJNg(cZuoAV$&i|FKwe$BG3`TF2&ujYq{f&7=J^oLLbo{-e_aPx8L$N$ z7n^}pv7wwy0XuNGd|kneG?;PZ6L8w*Har z$wv{x3SBi~N{bfrxUXc~_&Elnpyw#yup7dR5Y|f~WfTQaU7@lcKLyivM~R+|bOm7w zKEC*x)ANU9L~uzF=n1Uv}EWH8}}&4%h*M4M0NPA$k$qm$yFmGKhE^6ZLxV@xbVbuc~| zyiK`QTo94VN8orvUo=jDBSt6xG{cIDzj<+X9fjO&JxD+L$QRG3->7bj+>2O7WWW#Z zRZ|VVQ5T5nN@O?n(EF$!v;>;#U7z!@wIJ(%a~T}WdU%AC`dllaVc=^ox5*XDoJp!| zGUJMOS!4eMOLYgAp6THIABH0(1qVOAnRy#Bl#11@vDv^aKcgLkS1WkuYL98@ke5bF zofW0zyba1TkXvs5vDo0C;{0PRl+ZzMbO0|Z$p2MA=}eyP<%dhPAuqt86#5<}%uOaK z0#vfWu{QAF#^&;`cC+07tL6CKJTf+LGyn=jGBKK2I9i#QfFaR0C*3&=63Y!744imC zZPedw&zJcIZQ{)6U=E4-06x}k&B;KX2Qa+=;HJFNua@Jh4E)!|z{*6=-2%)Ac7p`I z074x|Z)Y(AUVRAM2!5e}`y5{lVCBok!pt5p%?8;zFflVTv*{T)Ihup)9T*+#tRaaK z43D)p9dhjy0AL7kBmRTv`@e|T^?)@rJ;0aD z1{g8mx4i=F8_a~0^1r(IXUt@D~ zfFb+4IVkN(U-Wo@zQ}@Wuc%HMTJCUL>X<2Az`t=u*hjO`C9;CwgO%L zc`Pv$fs^&G?ih?fHeis038UMe;iBt*)i#$@$AAIw!2lljcc(XVp;+ne%w*BCu(fc! zJHKxvW5Wq-c@D5t1Hu1!@~tc1WntC({qg;;>3uU0nFF59xWJ0azV6)tBv1opCs51D z0Bivi&4C<=8{FqvYbr(HY+5G0i_9|Azmz%7;YXbbi-x`xgBm>Nong{q|k- zUEAkdbbB-;8qx$BlH2+8#sl+|1CsPPw-{MDdV3|M=!Y!f(R3YqNO^ zCrO5cLzr$tQrsMgyB2u26rE|16#p3F-G$$^Ex3g%6hp%QHZg!i-^71+*UK$hz8(^N zy9Wl6;U@gM8%J&#2A;tZ%^t!@t3|%h!+wyPt?OXr*>jNC{u>h3b1peLvABpeRZl3)w+JWAk diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..a7b82b3 --- /dev/null +++ b/src/database.rs @@ -0,0 +1,43 @@ +use sqlx::{any::AnyPoolOptions, AnyPool}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DatabaseKind { + Sqlite, + Postgres, + MySql, +} + +#[derive(Debug, Clone)] +pub struct Database { + pool: AnyPool, + kind: DatabaseKind, +} + +impl Database { + pub async fn connect(url: &str, max_connections: u32) -> Result { + sqlx::any::install_default_drivers(); + let kind = DatabaseKind::from_url(url)?; + let pool = AnyPoolOptions::new() + .max_connections(max_connections) + .connect(url) + .await?; + if kind == DatabaseKind::Sqlite { + sqlx::query("PRAGMA foreign_keys = ON").execute(&pool).await?; + sqlx::query("PRAGMA journal_mode = WAL").execute(&pool).await?; + sqlx::query("PRAGMA busy_timeout = 5000").execute(&pool).await?; + } + Ok(Self { pool, kind }) + } + + pub fn pool(&self) -> &AnyPool { &self.pool } + pub fn kind(&self) -> DatabaseKind { self.kind } +} + +impl DatabaseKind { + fn from_url(url: &str) -> Result { + if url.starts_with("sqlite:") { Ok(Self::Sqlite) } + else if url.starts_with("postgres:") || url.starts_with("postgresql:") { Ok(Self::Postgres) } + else if url.starts_with("mysql:") { Ok(Self::MySql) } + else { Err(sqlx::Error::Configuration("DATABASE_URL must use sqlite://, postgres:// or mysql://".into())) } + } +} diff --git a/src/db.rs b/src/db.rs index 35002f0..3e9bdb0 100644 --- a/src/db.rs +++ b/src/db.rs @@ -4,7 +4,23 @@ use argon2::{ use chrono::{DateTime, Utc}; use rand_core::{OsRng, RngCore}; use serde::Serialize; -use sqlx::{FromRow, SqlitePool}; +use sqlx::FromRow; +use crate::{database::{Database, DatabaseKind}, queries}; +use sqlx::{Any, Transaction}; + + +async fn inserted_id(kind: DatabaseKind, tx: &mut Transaction<'_, Any>, table: &str) -> Result { + let query = match kind { + DatabaseKind::Sqlite => "SELECT last_insert_rowid()", + DatabaseKind::MySql => "SELECT LAST_INSERT_ID()", + DatabaseKind::Postgres => match table { + "note_revisions" => "SELECT currval(pg_get_serial_sequence('note_revisions', 'id'))", + "revisions" => "SELECT currval(pg_get_serial_sequence('revisions', 'id'))", + _ => unreachable!("unsupported identity table"), + }, + }; + sqlx::query_scalar(query).fetch_one(&mut **tx).await +} #[derive(Debug, Clone, FromRow)] pub struct Workspace { @@ -39,36 +55,30 @@ pub struct Revision { pub owner_map: String, } -pub async fn find_workspace(pool: &SqlitePool, slug: &str) -> Result, sqlx::Error> { - sqlx::query_as::<_, Workspace>( - "SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE slug = ?", - ) +pub async fn find_workspace(pool: &Database, slug: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001)) .bind(slug) - .fetch_optional(pool) + .fetch_optional(pool.pool()) .await } pub async fn create_workspace( - pool: &SqlitePool, + pool: &Database, slug: &str, title: &str, password: Option<&str>, ) -> Result { let password_hash = password.filter(|value| !value.is_empty()).map(hash_password); - let result = sqlx::query( - "INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)", - ) + sqlx::query(queries::get(pool.kind(), queries::Q002)) .bind(slug) .bind(title) .bind(password_hash) - .execute(pool) + .execute(pool.pool()) .await?; - sqlx::query_as::<_, Workspace>( - "SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE id = ?", - ) - .bind(result.last_insert_rowid()) - .fetch_one(pool) + sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001)) + .bind(slug) + .fetch_one(pool.pool()) .await } @@ -87,92 +97,84 @@ pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>) } } -pub async fn list_notes(pool: &SqlitePool, workspace_id: i64) -> Result, sqlx::Error> { - sqlx::query_as::<_, Note>( - "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC", - ) +pub async fn list_notes(pool: &Database, workspace_id: i64) -> Result, sqlx::Error> { + sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q003)) .bind(workspace_id) - .fetch_all(pool) + .fetch_all(pool.pool()) .await } pub async fn find_note( - pool: &SqlitePool, + pool: &Database, workspace_id: i64, slug: &str, ) -> Result, sqlx::Error> { - sqlx::query_as::<_, Note>( - "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? AND slug = ?", - ) + sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q004)) .bind(workspace_id) .bind(slug) - .fetch_optional(pool) + .fetch_optional(pool.pool()) .await } pub async fn create_note( - pool: &SqlitePool, + pool: &Database, workspace_id: i64, slug: &str, title: &str, ) -> Result { - let result = sqlx::query( - "INSERT INTO notes (workspace_id, slug, title) VALUES (?, ?, ?)", - ) + sqlx::query(queries::get(pool.kind(), queries::Q005)) .bind(workspace_id) .bind(slug) .bind(title) - .execute(pool) + .execute(pool.pool()) .await?; - sqlx::query_as::<_, Note>( - "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE id = ?", - ) - .bind(result.last_insert_rowid()) - .fetch_one(pool) + sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q004)) + .bind(workspace_id) + .bind(slug) + .fetch_one(pool.pool()) .await } pub async fn save_revision( - pool: &SqlitePool, + pool: &Database, note_id: i64, workspace_id: i64, content: &str, author: Option<&str>, owner_map: &str, ) -> Result<(i64, String), sqlx::Error> { - let mut tx = pool.begin().await?; - sqlx::query("UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") + let mut tx = pool.pool().begin().await?; + sqlx::query(queries::get(pool.kind(), queries::Q006)) .bind(content) .bind(owner_map) .bind(note_id) .execute(&mut *tx) .await?; - sqlx::query("UPDATE workspaces SET updated_at = CURRENT_TIMESTAMP WHERE id = ?") + sqlx::query(queries::get(pool.kind(), queries::Q007)) .bind(workspace_id) .execute(&mut *tx) .await?; - let result = sqlx::query("INSERT INTO note_revisions (note_id, content, author, owner_map) VALUES (?, ?, ?, ?)") + sqlx::query(queries::get(pool.kind(), queries::Q008)) .bind(note_id) .bind(content) .bind(author) .bind(owner_map) .execute(&mut *tx) .await?; - let updated_at: String = sqlx::query_scalar("SELECT updated_at FROM notes WHERE id = ?") + let revision_id = inserted_id(pool.kind(), &mut tx, "note_revisions").await?; + let updated_at: String = sqlx::query_scalar(queries::get(pool.kind(), queries::Q009)) .bind(note_id) .fetch_one(&mut *tx) .await?; tx.commit().await?; - Ok((result.last_insert_rowid(), updated_at)) + Ok((revision_id, updated_at)) } -pub async fn list_revisions(pool: &SqlitePool, note_id: i64) -> Result, sqlx::Error> { - sqlx::query_as::<_, Revision>( - "SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100", - ) +pub async fn list_revisions(pool: &Database, note_id: i64) -> Result, sqlx::Error> { + sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q010)) .bind(note_id) - .fetch_all(pool) + .fetch_all(pool.pool()) .await } @@ -213,36 +215,30 @@ pub struct Pad { pub owner_map: String, } -pub async fn find_pad(pool: &SqlitePool, slug: &str) -> Result, sqlx::Error> { - sqlx::query_as::<_, Pad>( - "SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map FROM pads WHERE slug = ?", - ) +pub async fn find_pad(pool: &Database, slug: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, Pad>(queries::get(pool.kind(), queries::Q011)) .bind(slug) - .fetch_optional(pool) + .fetch_optional(pool.pool()) .await } pub async fn create_pad( - pool: &SqlitePool, + pool: &Database, slug: &str, title: &str, password: Option<&str>, ) -> Result { let password_hash = password.filter(|value| !value.is_empty()).map(hash_password); - let result = sqlx::query( - "INSERT INTO pads (slug, title, password_hash) VALUES (?, ?, ?)", - ) + sqlx::query(queries::get(pool.kind(), queries::Q012)) .bind(slug) .bind(title) .bind(password_hash) - .execute(pool) + .execute(pool.pool()) .await?; - sqlx::query_as::<_, Pad>( - "SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map FROM pads WHERE id = ?", - ) - .bind(result.last_insert_rowid()) - .fetch_one(pool) + sqlx::query_as::<_, Pad>(queries::get(pool.kind(), queries::Q011)) + .bind(slug) + .fetch_one(pool.pool()) .await } @@ -262,43 +258,42 @@ pub fn verify_pad_password(pad: &Pad, password: Option<&str>) -> bool { } pub async fn save_pad_revision( - pool: &SqlitePool, + pool: &Database, pad_id: i64, content: &str, author: Option<&str>, owner_map: &str, ) -> Result<(i64, String), sqlx::Error> { - let mut tx = pool.begin().await?; - sqlx::query("UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") + let mut tx = pool.pool().begin().await?; + sqlx::query(queries::get(pool.kind(), queries::Q013)) .bind(content) .bind(owner_map) .bind(pad_id) .execute(&mut *tx) .await?; - let result = sqlx::query("INSERT INTO revisions (pad_id, content, author, owner_map) VALUES (?, ?, ?, ?)") + sqlx::query(queries::get(pool.kind(), queries::Q014)) .bind(pad_id) .bind(content) .bind(author) .bind(owner_map) .execute(&mut *tx) .await?; - let updated_at: String = sqlx::query_scalar("SELECT updated_at FROM pads WHERE id = ?") + let revision_id = inserted_id(pool.kind(), &mut tx, "revisions").await?; + let updated_at: String = sqlx::query_scalar(queries::get(pool.kind(), queries::Q015)) .bind(pad_id) .fetch_one(&mut *tx) .await?; tx.commit().await?; - Ok((result.last_insert_rowid(), updated_at)) + Ok((revision_id, updated_at)) } pub async fn list_pad_revisions( - pool: &SqlitePool, + pool: &Database, pad_id: i64, ) -> Result, sqlx::Error> { - sqlx::query_as::<_, Revision>( - "SELECT id, content, created_at, author, owner_map FROM revisions WHERE pad_id = ? ORDER BY id DESC LIMIT 100", - ) + sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q016)) .bind(pad_id) - .fetch_all(pool) + .fetch_all(pool.pool()) .await } @@ -310,90 +305,88 @@ pub struct PublishedPage { pub updated_at: String, } -pub async fn publish_pad(pool: &SqlitePool, pad_id: i64) -> Result { - if let Some(token) = sqlx::query_scalar::<_, String>("SELECT token FROM published_pages WHERE pad_id = ?") +pub async fn publish_pad(pool: &Database, pad_id: i64) -> Result { + if let Some(token) = sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q017)) .bind(pad_id) - .fetch_optional(pool) + .fetch_optional(pool.pool()) .await? { return Ok(token); } let token = random_suffix(18); - sqlx::query("INSERT INTO published_pages (token, pad_id) VALUES (?, ?)") + sqlx::query(queries::get(pool.kind(), queries::Q018)) .bind(&token) .bind(pad_id) - .execute(pool) + .execute(pool.pool()) .await?; Ok(token) } -pub async fn publish_note(pool: &SqlitePool, note_id: i64) -> Result { - if let Some(token) = sqlx::query_scalar::<_, String>("SELECT token FROM published_pages WHERE note_id = ?") +pub async fn publish_note(pool: &Database, note_id: i64) -> Result { + if let Some(token) = sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q019)) .bind(note_id) - .fetch_optional(pool) + .fetch_optional(pool.pool()) .await? { return Ok(token); } let token = random_suffix(18); - sqlx::query("INSERT INTO published_pages (token, note_id) VALUES (?, ?)") + sqlx::query(queries::get(pool.kind(), queries::Q020)) .bind(&token) .bind(note_id) - .execute(pool) + .execute(pool.pool()) .await?; Ok(token) } -pub async fn find_published_page(pool: &SqlitePool, token: &str) -> Result, sqlx::Error> { - sqlx::query_as::<_, PublishedPage>( - "SELECT pp.token, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id WHERE pp.token = ?", - ) +pub async fn find_published_page(pool: &Database, token: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, PublishedPage>(queries::get(pool.kind(), queries::Q021)) .bind(token) - .fetch_optional(pool) + .fetch_optional(pool.pool()) .await } -pub async fn pad_file_token(pool: &SqlitePool, pad_id: i64) -> Result { - if let Some(token) = sqlx::query_scalar::<_, Option>("SELECT file_token FROM pads WHERE id = ?") +pub async fn pad_file_token(pool: &Database, pad_id: i64) -> Result { + if let Some(token) = sqlx::query_scalar::<_, Option>(queries::get(pool.kind(), queries::Q022)) .bind(pad_id) - .fetch_one(pool) + .fetch_one(pool.pool()) .await? { return Ok(token); } let token = format!("p_{}", random_suffix(24)); - sqlx::query("UPDATE pads SET file_token = ? WHERE id = ? AND file_token IS NULL") + sqlx::query(queries::get(pool.kind(), queries::Q023)) .bind(&token) .bind(pad_id) - .execute(pool) + .execute(pool.pool()) .await?; - sqlx::query_scalar::<_, String>("SELECT file_token FROM pads WHERE id = ?") + sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q022)) .bind(pad_id) - .fetch_one(pool) + .fetch_one(pool.pool()) .await } -pub async fn note_file_token(pool: &SqlitePool, note_id: i64) -> Result { - if let Some(token) = sqlx::query_scalar::<_, Option>("SELECT file_token FROM notes WHERE id = ?") +pub async fn note_file_token(pool: &Database, note_id: i64) -> Result { + if let Some(token) = sqlx::query_scalar::<_, Option>(queries::get(pool.kind(), queries::Q024)) .bind(note_id) - .fetch_one(pool) + .fetch_one(pool.pool()) .await? { return Ok(token); } let token = format!("n_{}", random_suffix(24)); - sqlx::query("UPDATE notes SET file_token = ? WHERE id = ? AND file_token IS NULL") + sqlx::query(queries::get(pool.kind(), queries::Q025)) .bind(&token) .bind(note_id) - .execute(pool) + .execute(pool.pool()) .await?; - sqlx::query_scalar::<_, String>("SELECT file_token FROM notes WHERE id = ?") + sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q024)) .bind(note_id) - .fetch_one(pool) + .fetch_one(pool.pool()) .await } @@ -410,17 +403,17 @@ pub struct FileOwner { pub id: i64, } -pub async fn find_file_owner(pool: &SqlitePool, token: &str) -> Result, sqlx::Error> { - if let Some(id) = sqlx::query_scalar::<_, i64>("SELECT id FROM pads WHERE file_token = ?") +pub async fn find_file_owner(pool: &Database, token: &str) -> Result, sqlx::Error> { + if let Some(id) = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q026)) .bind(token) - .fetch_optional(pool) + .fetch_optional(pool.pool()) .await? { return Ok(Some(FileOwner { kind: FileOwnerKind::Pad, id })); } - if let Some(id) = sqlx::query_scalar::<_, i64>("SELECT id FROM notes WHERE file_token = ?") + if let Some(id) = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q027)) .bind(token) - .fetch_optional(pool) + .fetch_optional(pool.pool()) .await? { return Ok(Some(FileOwner { kind: FileOwnerKind::Note, id })); diff --git a/src/main.rs b/src/main.rs index cfdc906..8775bb0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,16 @@ mod api; mod app; mod config; +mod database; mod db; +mod queries; mod state; mod websocket; use std::{net::SocketAddr, sync::Arc}; use config::Config; -use sqlx::sqlite::SqlitePoolOptions; +use database::{Database, DatabaseKind}; use state::AppState; use tokio::net::TcpListener; use tracing::info; @@ -23,11 +25,8 @@ async fn main() -> Result<(), Box> { if let Some(path) = config.database_url.strip_prefix("sqlite://").and_then(|v| v.split('?').next()) { if let Some(parent) = std::path::Path::new(path).parent() { std::fs::create_dir_all(parent)?; } } - let db = SqlitePoolOptions::new() - .max_connections(config.database_max_connections) - .connect(&config.database_url) - .await?; - sqlx::migrate!().run(&db).await?; + let db = Database::connect(&config.database_url, config.database_max_connections).await?; + run_migrations(&db).await?; std::fs::create_dir_all(&config.files_dir)?; let state = Arc::new(AppState::new( @@ -78,3 +77,12 @@ async fn shutdown_signal() { let terminate = std::future::pending::<()>(); tokio::select! { () = ctrl_c => {}, () = terminate => {} } } + +async fn run_migrations(db: &Database) -> Result<(), sqlx::migrate::MigrateError> { + let path = match db.kind() { + DatabaseKind::Sqlite => std::path::Path::new("migrations/sqlite"), + DatabaseKind::Postgres => std::path::Path::new("migrations/postgres"), + DatabaseKind::MySql => std::path::Path::new("migrations/mysql"), + }; + sqlx::migrate::Migrator::new(path).await?.run(db.pool()).await +} diff --git a/src/queries.rs b/src/queries.rs new file mode 100644 index 0000000..561321b --- /dev/null +++ b/src/queries.rs @@ -0,0 +1,55 @@ +use std::{collections::HashMap, sync::{Mutex, OnceLock}}; +use crate::database::DatabaseKind; + +pub const Q001: &str = "SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE slug = ?"; +pub const Q002: &str = "INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)"; +pub const Q003: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"; +pub const Q004: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? AND slug = ?"; +pub const Q005: &str = "INSERT INTO notes (workspace_id, slug, title) VALUES (?, ?, ?)"; +pub const Q006: &str = "UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"; +pub const Q007: &str = "UPDATE workspaces SET updated_at = CURRENT_TIMESTAMP WHERE id = ?"; +pub const Q008: &str = "INSERT INTO note_revisions (note_id, content, author, owner_map) VALUES (?, ?, ?, ?)"; +pub const Q009: &str = "SELECT updated_at FROM notes WHERE id = ?"; +pub const Q010: &str = "SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100"; +pub const Q011: &str = "SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map FROM pads WHERE slug = ?"; +pub const Q012: &str = "INSERT INTO pads (slug, title, password_hash) VALUES (?, ?, ?)"; +pub const Q013: &str = "UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"; +pub const Q014: &str = "INSERT INTO revisions (pad_id, content, author, owner_map) VALUES (?, ?, ?, ?)"; +pub const Q015: &str = "SELECT updated_at FROM pads WHERE id = ?"; +pub const Q016: &str = "SELECT id, content, created_at, author, owner_map FROM revisions WHERE pad_id = ? ORDER BY id DESC LIMIT 100"; +pub const Q017: &str = "SELECT token FROM published_pages WHERE pad_id = ?"; +pub const Q018: &str = "INSERT INTO published_pages (token, pad_id) VALUES (?, ?)"; +pub const Q019: &str = "SELECT token FROM published_pages WHERE note_id = ?"; +pub const Q020: &str = "INSERT INTO published_pages (token, note_id) VALUES (?, ?)"; +pub const Q021: &str = "SELECT pp.token, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id WHERE pp.token = ?"; +pub const Q022: &str = "SELECT file_token FROM pads WHERE id = ?"; +pub const Q023: &str = "UPDATE pads SET file_token = ? WHERE id = ? AND file_token IS NULL"; +pub const Q024: &str = "SELECT file_token FROM notes WHERE id = ?"; +pub const Q025: &str = "UPDATE notes SET file_token = ? WHERE id = ? AND file_token IS NULL"; +pub const Q026: &str = "SELECT id FROM pads WHERE file_token = ?"; +pub const Q027: &str = "SELECT id FROM notes WHERE file_token = ?"; + +static POSTGRES_QUERIES: OnceLock>> = OnceLock::new(); + +pub fn get(kind: DatabaseKind, query: &'static str) -> &'static str { + if kind != DatabaseKind::Postgres { return query; } + let cache = POSTGRES_QUERIES.get_or_init(|| Mutex::new(HashMap::new())); + let mut cache = cache.lock().expect("query cache lock poisoned"); + if let Some(value) = cache.get(query) { return value; } + let cache_key = query; + let query = query.replace("CURRENT_TIMESTAMP", "(CURRENT_TIMESTAMP::text)"); + let mut index = 0; + let mut converted = String::with_capacity(query.len() + 8); + for ch in query.chars() { + if ch == '?' { + index += 1; + converted.push('$'); + converted.push_str(&index.to_string()); + } else { + converted.push(ch); + } + } + let converted = Box::leak(converted.into_boxed_str()); + cache.insert(cache_key, converted); + converted +} diff --git a/src/state.rs b/src/state.rs index ec704c1..b7572cf 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,5 +1,5 @@ use std::{collections::HashMap, sync::Arc}; -use sqlx::SqlitePool; +use crate::database::Database; use tokio::sync::{broadcast, RwLock}; const CHANNEL_CAPACITY: usize = 256; @@ -15,7 +15,7 @@ pub struct NoteUpdate { #[derive(Debug)] pub struct AppState { - pub db: SqlitePool, + pub db: Database, pub asset_version: String, pub files_dir: String, pub upload_max_size_bytes: usize, @@ -23,7 +23,7 @@ pub struct AppState { } impl AppState { - pub fn new(db: SqlitePool, asset_version: String, files_dir: String, upload_max_size_bytes: usize) -> Self { + pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize) -> Self { Self { db, asset_version, files_dir, upload_max_size_bytes, channels: RwLock::new(HashMap::new()) } } async fn channel_for_key(&self, key: String) -> broadcast::Sender { diff --git a/static/home.html b/static/home.html index 8f50e7b..44e58c1 100644 --- a/static/home.html +++ b/static/home.html @@ -59,5 +59,6 @@ + diff --git a/static/styles.css b/static/styles.css index 26b90bc..2b42177 100644 --- a/static/styles.css +++ b/static/styles.css @@ -239,3 +239,12 @@ dialog::backdrop { background: rgba(4,6,9,.82); } .public-content { min-height: 240px; padding: 32px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); } .public-content img { cursor: zoom-in; } @media (max-width: 600px) { .public-document { padding-top: 32px; } .public-content { padding: 20px; } } + + +/* Layout safeguards and home footer */ +.app-header__main, .document-heading { min-width: 0; } +.document-url { overflow-wrap: anywhere; } +.site-footer { width: min(1040px, calc(100% - 32px)); margin: -36px auto 28px; color: var(--muted-2); font-size: .76rem; text-align: center; } +.site-footer a { color: var(--muted); text-decoration: none; } +.site-footer a:hover { color: white; } +@media (max-width: 760px) { .site-footer { width: min(100% - 24px, 560px); margin-top: -20px; } }