From 1ca607792d70ba70b5a0a2b8bc90e207e8d639a0 Mon Sep 17 00:00:00 2001 From: sforst Date: Thu, 13 Jun 2024 12:23:41 +0200 Subject: [PATCH] Support remote database access using external tools (#7222) * Provide remote database sync capability Allow arbitrary commands to be defined and executed for syncing databases with remote services. This includes sftp, scp, rsync, etc. Remote commands are stored per-database and sync operations are manually triggered by the user from the Database -> Remote Sync menu. --------- Co-authored-by: Stefan Forstenlechner Co-authored-by: Jonathan White --- COPYING | 1 + docs/images/sync_remote_settings.png | Bin 0 -> 43771 bytes docs/topics/DatabaseOperations.adoc | 18 ++ .../scalable/actions/remote-sync.svg | 1 + share/icons/icons.qrc | 1 + share/translations/keepassxc_en.ts | 191 +++++++++++++ src/CMakeLists.txt | 4 + src/core/CustomData.cpp | 4 +- src/core/CustomData.h | 1 + src/core/Database.cpp | 10 + src/core/Database.h | 4 + src/gui/DatabaseOpenDialog.cpp | 10 + src/gui/DatabaseOpenDialog.h | 2 + src/gui/DatabaseTabWidget.cpp | 33 ++- src/gui/DatabaseTabWidget.h | 3 +- src/gui/DatabaseWidget.cpp | 163 ++++++++++- src/gui/DatabaseWidget.h | 22 +- src/gui/MainWindow.cpp | 53 +++- src/gui/MainWindow.h | 3 + src/gui/MainWindow.ui | 7 + src/gui/dbsettings/DatabaseSettingsDialog.cpp | 18 +- src/gui/dbsettings/DatabaseSettingsDialog.h | 3 + .../remote/DatabaseSettingsWidgetRemote.cpp | 200 ++++++++++++++ src/gui/remote/DatabaseSettingsWidgetRemote.h | 64 +++++ .../remote/DatabaseSettingsWidgetRemote.ui | 260 ++++++++++++++++++ src/gui/remote/RemoteHandler.cpp | 145 ++++++++++ src/gui/remote/RemoteHandler.h | 57 ++++ src/gui/remote/RemoteProcess.cpp | 88 ++++++ src/gui/remote/RemoteProcess.h | 50 ++++ src/gui/remote/RemoteSettings.cpp | 116 ++++++++ src/gui/remote/RemoteSettings.h | 61 ++++ tests/data/SyncDatabase.kdbx | Bin 0 -> 32286 bytes tests/data/SyncDatabaseDifferentPassword.kdbx | Bin 0 -> 32286 bytes tests/gui/CMakeLists.txt | 2 +- tests/gui/TestGui.cpp | 106 +++++++ tests/gui/TestGui.h | 3 + tests/mock/MockRemoteProcess.cpp | 56 ++++ tests/mock/MockRemoteProcess.h | 41 +++ 38 files changed, 1780 insertions(+), 21 deletions(-) create mode 100644 docs/images/sync_remote_settings.png create mode 100644 share/icons/application/scalable/actions/remote-sync.svg create mode 100644 src/gui/remote/DatabaseSettingsWidgetRemote.cpp create mode 100644 src/gui/remote/DatabaseSettingsWidgetRemote.h create mode 100644 src/gui/remote/DatabaseSettingsWidgetRemote.ui create mode 100644 src/gui/remote/RemoteHandler.cpp create mode 100644 src/gui/remote/RemoteHandler.h create mode 100644 src/gui/remote/RemoteProcess.cpp create mode 100644 src/gui/remote/RemoteProcess.h create mode 100644 src/gui/remote/RemoteSettings.cpp create mode 100644 src/gui/remote/RemoteSettings.h create mode 100644 tests/data/SyncDatabase.kdbx create mode 100644 tests/data/SyncDatabaseDifferentPassword.kdbx create mode 100644 tests/mock/MockRemoteProcess.cpp create mode 100644 tests/mock/MockRemoteProcess.h diff --git a/COPYING b/COPYING index 7e24fcf66..e37cfedf6 100644 --- a/COPYING +++ b/COPYING @@ -203,6 +203,7 @@ Files: share/icons/application/scalable/actions/application-exit.svg share/icons/application/scalable/actions/password-show-on.svg share/icons/application/scalable/actions/qrcode.svg share/icons/application/scalable/actions/refresh.svg + share/icons/application/scalable/actions/remote-sync.svg share/icons/application/scalable/actions/reports.svg share/icons/application/scalable/actions/reports-exclude.svg share/icons/application/scalable/actions/sort-alphabetical-ascending.svg diff --git a/docs/images/sync_remote_settings.png b/docs/images/sync_remote_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..1d5c006b39b19ccd554d87a35a1eb889c233cc8f GIT binary patch literal 43771 zcmdSBbySpH*e{Ha!J`O>2uKSENJ~o_bV^H$#Lx{xgAXbV($d`}-Jx_#4Gq#cjKmNF zGn_pPuW}W(ARXu^c;*x zwSSh70d3o!VM!UZM#l>nn2`8DUkp3^j5gEUEul>`MqlP5~*QL`7v8C+MZ5OOm zIR5g3vrX6!IPDytQ|@9vyd@SQ#=d7TIWb}1Un*?~+Bp9C{iD8q;qzyRtFXaHnwU%^ zjhM^UTVjnn*e3PlmJ~Yn0^;JneVgg?+-?qEubz+viVKgfldV&WV~##A!vZ zl?CrM_J;XK6oZMXs;Z8GQ1KJL$R?piqvCqhtREyXnBChU@pFUO@XQ`pydSrx?AZcz z$#%u*2KF}Ulmm|{nFy*G^if1Ynvd;v1fgNdF$B+f;Np%%(P+@;hFs-*J+I%pQ<&%W zqbr|%E>7_8-{*7e!rttF_pZ^!lA(M!O>8bUPEuN0SXh|ChKrR|x6aN0kCi)(SHhri zEwa4jkKFOk`iXCWh@^$8!0}XH<4H*4+sCQZ%SjuDF_9yfsX1D2j3v6PFGQzNEE`pU z{<=N>&t8&VTu9}Ic$!*!Is6(ZyWG2`jjs#SGt0Ai@adg8qq?2O5&4I(G9i~T%|Kir z&MzXqN6QqE8AYLCVft;x9TP0-Rx(3bSy@O*0U%uMZ+~f6vY6-b)>rP2kO39i7bMnp zndk0va|z{!ux%IN)cwQBfzWJHUAO&Xby!4}0_02BT}4GjKDT!lbmI`PM_#B0LXTqJ zj{Vap99{B_pFC|mnUsg5)c0ccPla3HN|mH!&eLUwzON39%JEN23>r7)GiD}#GKv)6 zmca_>ze`0;ov)l&>D%!oD~sMN^dS#^G-RB-FHr=#PqilTg43r;3KDJx89G==7oV$@ z_1MlD(K|Wl<_KUlc&;tBSR^VDdJ7R(u8$=o`a3_d?(7t&{1~sV=wT2;oYD+`PRCl zGu+lZqWLPtlZ_bTB)8w@Eog_*AcME{o3r0)$T#=8hqVZFqf;G+oge&o2*iC!`AbHL z$Wp2IQMQnJ_NWElSl?^*rf=ItIYsgikYz(X0^HJ*b#HdR#L_nlj?*wr{)leUqS6$5U3a z(|K<%i6wtZ=6DY-l@r+F(|uBdTDRSi;1DvRcM`Aabp8tIUfzp-(l`8I#6g6mk25OC zSPXkg0(nM4r-Or9@|$>Cj=h7?4;WKtZsQ+{x=i;TASo!rt7&;)ye3WJJt46vTv5*C zllDrT;~l+VGQLQfxvue}w~GHX`R+D2Pa;ULo1zf)G<-}YfrJRTIrQbr7pA8Oa`NZr z?tPpFankw3&UoPspQR;*_1?McTjh-gNi#6ioIa%z;C6zpUaZ?2VqXnS1pCyD<-+DhnRDcW)|Wy1{FfR!!Oi zYv^7|-I+3FCAOH<$9?wvxdS54T`x2ZdeYG3tX(y1FvQNzzbJN7^6BQ(+OWZ(Fq9i_ zRJnp1Dq4@6_P=p6$tCS*bC|_hT%nS8*kH*92#tDRwl?;=FtsV)w3CYSz@?5@1UeEu zc;=iHrag=N9uuaB)-54Vqq);CP!_n*u+ych@Q?r^8<21P(Dv+@j&W{^DRv?S~c=+(0Jtr9Gs>|qGi-;AT8dKKr+1dWKh_G^yImvaLVViY z2A7Mm`$QLXZ$ue8dbg80l*uRb*~as4e^#NbbWLc;maa5wjnl3DLMQWb9r{p`Lqm41>=-hP0$K0;-^^P3gw4gs6e?5%= zA$7?JYLS!Wo+}C(q8BfI6XfsvNUjZ@r*!oA3fFU-H!NNKSXx^8dAA3EPb?jIO+NLWh=^mdebF=F`-Oib6DlDuCB;TEU z|IXC;%!9=wQD-3U{Ok;+uM&wm>K>-Pvb!2lQ&U%0t84K--0se&pV}RRO=ty<@=%nP zuIIZErmsPHc=e8B%<K2%BpI~nTE4~;LyjJ!FA3^(2x zwy4)`kubqdE8IeUxFlF!WOEYjW|$DAjW$G25$h6-iXZ5k4v~1=8LBq)-QCcqW$N!2 zEhN0DQm$fk(iMi<C zX(h&^ibq9$=oV0tQmXwpMk5cUuMwhEnK)R&U!~Q13OMkkpqk|8h{-AVTT)Yj^4zOlc zq7c?`CbBQVvRaT^=tBO>*6LO zJQX;NxE!e%x=gVsfi(QM&Fk!GFlv>VnfbGImzcMj?(ycfw3|%@<>e$f;#xR-Zoyh^ z%Tf0wDf)iVj-48{jxLkY-$cKyzT`JaVO*gAnf>(wM zJ})uWcUhoMekEdUTht>;OKVhExMSPlo|7^>CF9{UH~XB3=u~G!3F$ElO%0M%E)|=D zeOJI2`Q8;4Z|q_c4olUgq*<=(-I~|#z(_Pa*Px`K3$0Z|B{Ws8_DSh6*;=2FQ)(fI z%Z~z8%SW&_nomA^e0#R(4xj3te!5C?tz(RwdKUHHZ&y*nib1|1X)`f4zgFyo9v0#YM&pZts`WRJ3nbw}2!2_1FL0QDPnR29AHSnw_1Y zxq0_zJhIuQ2RJzI@731U8X7K^m2oOrDgu{NEop8p{qp5oFKsYq#m%kJTTXd$=*y## zVy#jk&*OriTX*gZrwI9gY!hzq2&bA(n&$@2S!siNMd8&cGo&^iq<8gy;3E=Ouk*)Q z^psOxG7MmL=!t;Cvth0C__jq_JLqbR5;^IPsYHH#F0$TF(VKXy*w;)s7 zF1K%gmd0~%+A&KKw^^cT$7k`QlalH}5%!LbzB(OAWJ9Fb>Jie&yui4peWPATEg|)!%^$rd$R^?ax zB^^Pvygd5lcC{Cwi=uY9p_m(cLq^gM-&C2wTyFH31;> zj@2Go!jrx2#}9daF0`bxC=?YGoNZ;R5Kz5%tEoBJX)riA_ywQxWZt<=P|&{kYNxxi zGcPZ1C*6wgH8u)>OO3}?=V_~|R@lrOX3IoU>`fM`=X)F(?vCe2&aRVJ_+5RZ^%I>w zK0#l2f$6n|XKlo>Y5()j9k9R>r}I_i&4FOrM#3Up@bO^UC zWu?=_$#hQ6BO;JU`Jn3S8;}(h6{JyPW0ff>`snhzzP_y{J+*Q-n6^HW`n~i7>!Khh zr1SRebc6F|tIxvx!bRBAhfMO`P0uSd;ga7~0BOo@7nza!L6=1J`yR@oqN1Ips}nZuvP-zV{fF6uBPUsqtgJ6?GI>= z1m@r~SnBw=IA-SjTetBw(9NaV;8(i^!hGfRkf8K*L@IPK^Ok7Kaz}1;warp%dUUi} z1g#2C7(Sju8j=`8GqZK*68dXQ3>$sjlJ6X7ZftF>Ugw`l#udLmcdG7m7@U(68%m~t zXi{1KYqkzIsi6LB9)(OEPA^Zp=ePCN`wFv2%p76u<7?5oK9JydX6)?Y;Q|(zuAKsW-dlQ!+s5eKBI1rx_ zc5DFjgZ9O*u%%uKU|hgH4)^vJws;~RM#1Nx`VCG}*`t^HP+qCEaKXi9@AJb5YHqN; zBvC(q{&d}iMUoT4`qM>$rA#XD5aJbZ(|*IXzP_%M?t2wRKEBhIGx`mmQo!Zxk4TbU zifTWOcDi_3Ljj;o7ICXFm=JYb`4n_^cJ_GiGq0fF>A^v8q>8NE zc7~91PsFr(zLSXS4xg`geYw3~wylj#3A5tk@$&S!T3c?g`1{jDBEFZ8Ywk_AiU}}p z-nl(JJ1Z#}<_bkL+3G1OEAOAcD~Op2v|cuyzEs!H(vp_474gHo2ImxgX~)LZ78~t! zxYoz#<<&&Qkd)+*#70M_O3QPBbOw@)3=RT>2dnChu1%o|h;AqXRyx=I@usekG&r=~ zZ_H2kYg=2zZ{C^r-Fv6zay$%#b=#Zfx6^;OtK##f|Jmtx;|?01;YXUqUVMC>nrKPs-f8!9TW|@O2{_Kz`e&-*g-{Pqx z$UaLE*xuqchWQsu~casjBL+_bxi<{^Q^IS7Rl7)6GJtvx7&pwDzT1pYfJ? zc&p;JE5l8j`}_Ox$T+9x<}Oetz{u;>+Nj9OZ!aw=6ua8{+`vcrtJ;pJjeNHOy5v-F1I z;$FZu2Z7-YQ%>L)M64kY%=D=udUtN$W^F?N>3~|26IWeKS3ISqmr zbJ7FzIOaSeW?gw?H~YwS>(>aq8c<=^T>+x)iNbN96STf(bh$sg5G@x=SH4vqN2H8? ztOPWok4S1f4%g)JSSkVfMz`EssNMjM`N_!%FnzkEO_s{|I5>6%z)parNsR4uHAI%5r9;`B zjheGN5)f=_-3l$YzuMbV@9Sd*4yi`mY}=(#%mt06tB_DqQl@f`gNq9s^kgSaSFeoi*e-k22|W9g zBGJK7lTS~CK?0W%3kL`9x2@uM>!~6?s?pOgeH;BdEVOTZNJ&YTm$P^5XaF74++V&P&WE*hc3GO&t_ zes^x&3VX~g%4s*^Jc3>x7LgIfoMrFAO0Q76SDf$uME?2noS1!E12{_Kj=X^ct_#$n zxtW>f+qd7HH_O`F{eJ`m%%LU#aLxkq`?TcbsT%7kfBzd;FYdUqmdJY>AlKA9lBrF+ zwND|#Y3Q}iV4|XwMZVZL1C7|&Sm5Rq*D?3#qvPXGj>nzDYV|=_QKVU1T3QM#(OKWz z%(o-waNbaS1q4V;oJoZWm@pj~K=CCU>1zrK>N7IF$3df?J$uH^$_lgK!zX#Gs;Z-< z1>CKcre=-hI827-JT)Ky4Lm?pw38Z3M%*by;nGdvRJ&amwUAH)FGSi6v1lMUoZj}` z6DcfMCr->1rka%(BKFIrKiv)-kN$r7^|4>zFcNX|@`80K&H5)sU1dF?bZzYs&?P*) zqfei*`xRak9T)yGvc^aqr%}-EvO7>>ufm_v=l1!5%?IND~il zQb_n-vXwV$ynTCid6Y*vc)4`dD&`|>BzfolA6ij&5D5mbv9b;g@+l~isuX}$*PPzh-z*gzq(ke10Ly}9$Dg7hqc4?fzw4VXq}xF{a9OD z9k8SeO{d4ZQ*{uPo_N|*#g)7ch@@nv$FCfJ%E4v3=zq=%?kB4eF`cgHtPxQx&Gol`u9mOsu~&^m_HpGpqOAeo);pksi_%Bvmn(8gLO|% zG71WEF)$Rz303(L>NmQuFf)Sy#AdR{mDgM{q<3d>HsiaepQX^^KP1V!sytIXo2gH=d;weCt?{;6k!5NpcgmliGcgxin!_d2XFS6J(+_h91_6G5ycFDV$`4$@+n+O^uivu}p$`=m)tOMcI?ZB!@u0_ev z_#SkT*-f9JPbJ(zb9^m?&3tF@DjAodn%4lZ{z-bKZ({GZpeVtnX&5%SX?lQ&ha>=( z{)4e`VOSsteEa(P)ZMhRaTpYc+30xL*z_b}Wd;%^(mCPbHR5lP@Hu8d?XJJd%C&9G z%=p3OXbzww%=G;w6N$VG)+0HZ#kfgjdUbZj#&mjh`#@C`RYpo?rd zk+6MVw93`_2$aVC_eT&DKBGGtm7zT!Fv66;q{zbAxVXyEh=`y4{Y51ugBBM|ENpBw z6=fLL{@D&b;}Z6(uw#Pnq(m$Vp(Hp^OM@6T?Ry7fLHl97?Iw%(o1cC#XT3*oDgiQGpO z6sA~rhwTVe7m@v92QCtnyRNqL1Axy-6@hNg)s6H3TiYeyrkxQ#OmUC}9|byE z)b_{EpDX8^>3R)^Y(xhqC-QP~Ap0!}2F#=J#qz1~a8X8oOx5f?wghd*t#64e>YNsaVnk6SBBsocK9kA=T%; z%v~<*)uW0M|5y-KqKT- z-`A>K6BQJ6AScHnoT{8X3IcCygwt%wtKJE#1w;dcUPA5}>ex}$oG3XuMxD#7Vn%u} zg?z^CHdshKy*Z#3+NHV)@$mkyhRDmZlMJ0UUpr;xmRLR7OrHhc3)_l{ zCIfv>FAt9pS&+ANc~R^k)`8>VD1jXhgX;TyqwwH9(tiEa`UNLu(`QL z=&$;|S6Q6{eD=)t))VSAOo0=3W0=*=j&mGgkcWcQ!-J<~)VBu| z6rPlAWd0`?pww%3ZbwFaH}=u9DNy{J5Y~Q$313p3>020OmXf6PEG@N>$CK{^*gkPc za4>p3K|Ma6)TH|pTia}L)@LE-rB*ck3LL)B`Bg_l!)2vYj_B^)yOuNLly$DV5fc+G z>yEE~jvgK!HX#I%@=}hDUW@Ll_VkC{lz#Yk$!Gerg+xSd;}VK`zo2{Zq987Tf#NNU zfPWlhJ>2W- zti<976t|7d{>c@$q6o9H;!6U76~4Lo$<`?P2QFl7Go9P4re?wKO3`exAdweRy&YU; z*n-+`La;=RkBtT1CkIYTM_>PPSOl>RR*#HKPgfU(TJ-(hej|5%rHeMvX0aqN@MOLr z8iWH>Vo4Sth||z08Q-?CEd|KTo%u*UI$Z9{<4-SkF+XTv^ecO7^yFsY_nG43`RQ)JQWOxVpkmnx@WO?_QwC6h3N$F(OLYzn4ZWxN z&I)`hl1@D;Dar15C!J#q&j zo{z4sw6gNzpJjUbjg3^RrJs_cxeA*oUS4M>r?>@8)+f-jKbd1GQG@FF6C4?hxRN=| z&HA)F*5x;=ttK0BX;U(<-l!Gh+R5gRQ;WL45XTfaF*?|iaq2&as;&+LI7OaHy463F z8sE|YfCa{~+ut_skfaDof-dlT_dZ~N_WrWqyU8&zdRkhfkGH@01|SrKQSxi9C{FrBlRes^nOez@x7F@Iln;gLDM^xGRa7AE~KLaM5& z8tK#}2X$-bT751gbGEbH8?oAV9MsAAxv`bSdLjSC-x#Binx zQizFS)1FyhPc0M6a(|PXYv$o`PQS9go+*E$p&>tR`fF_LSnOAigAbO5i)~{E0yb|kild?ei2mH$}&4*Oj@Rivq4YO@tV=J};HXrjR zkZiwkw@ii>-3Br^B1c!(^997dcEz_1HEAB=hY{d>md^M|3~S}8jwZ+UaDI$wDU~V- zknw;ow)m-A4Rt?>#1vjYrE){Vt3snFnRUDZZ^cV%)kg0~EThXmY*Q0sz{l8Nr%WknqSK7RMmLsDzG;rS{^ zdeD8U$EIeJr7Un>9-hOak#C8K3m%(6q$&GfgaI;V{PP<~OF+a7fFpKxCIHU4LCHXY zWt3g77ArbEkMYl2J}8A%rc?NM4JlO-By&^KsV0ow)oG{jlM0ceV<7=9)9%Ok?a5*k z^IZ@;%C8q~&DQK*p8KKDh;+SH@2?$r9T{3!NnY}Mq_xE*n3$bm>=1%w0_=X*7U63UcyUlO9J*F&|AL&E3 zwF3esd?zz0QPaY?_xI!X4hesg0530r+6w5` z7l)qu*49e<_SjhiPsOCtD%5)0qGlh1Y3+&3x0xelphp#0+AS7hWL4$+<#H3-+RAlT zi^1}ONE^g{bG2Pzlw{^E?($qYEH(@2=8rG9C98na#D3dV>!(khEna>TSGV3*(pV%4Ij0>QAA^Dtzx$u}Xxk7C zZjZy9IJPSvfb@Y$bw|?rt@p=Ki+m8U4Uw09akv`kCA9{s3;hEF#8KbB{~Asek&&?n z29-+K1t8>tt@wp^t4BwOZGX<1yhWg!9+oiGp~X0kh)>e1@1_h|FyXX+DglVW!Ub|? zQXR=4$OZZGB|;n^OrTOA`IK&1_VsJtvI>ut;GSe{-J7w?>;YT}HD;!!>_ZbJrT+^wH8CALclDJ8C!8t?EbOp9Zu(7sgH&cmD zFml>&K8irK5$HF+YqVcdk-q`Fl$BBDlqY=gW;pM=*?w%s3t;k=7BP_I$8tzXNdedA zb?OEpc6SDmT19|C;1ZM^>9tpw$N@+Kz)O~)Ukm>xr1e?;*#!{n?5tWdbMwf@+$A8h zx^ctT>lEX@{8P&8Lvx>JyFbvupsguK+xeL)DWEKD;X{KHi4dT$nDdd(+}ssWQR-eN zdo1*$qjQjm)mZa@5+| z8YrO&Vx;D$Bg%>%Wh5je!D31HW1BqP_)>*kL&BONpoFm6qaGcjlJX*IXJ>W}czR43 z4FK9eWYFIggTd5AlJn{=6!wDljfhsSTKh;Pd3iZyae&T^O-|<8FYVq=5spj7*2WZ$ zPEV4T$HKy_uBLply_z-OzYYL!Mo3Z#?*03e0yb9|+UzU=87W)4jfgjsML!0> zYZuk>tS9U)V!q)B94X9co4r&HsDXismr1vV(ZVdq{qxh)N@0bdhHEG5JiajA3@T*p zAJd(k4wDQU5)<=OXs3)j?yzM)C_UZIPv_Dsn~4t*{Os8=DBHonQ9tuwPaSbCv$J@Q+%)F9CF)uvC>F&{w7~IE=~V z*jJrsZ&2>)f*1SvWXGtNgQ(T`Io}j;i$8DyLVGYOlTP)*;d@b0AzoinqAP`f%@zQ4 z7w4NlLt`2K#{mqq6n<0Ds$|YPt$CwEz{gJo{eS zdQeTx%UiYF5lCQvl;{|q$iI6K9qkNq!=#6<{1kWbsl}}0g~z{W=1-oCTV0SmdX$$B zAtlpZO6{7R?FYc#z(98Clokn5DQsb(#pu;5V}N|Ux{;Qq3yiw0*m(JZ*GrPJGNu;G zoIa4M1Fr*$NX3_*ACZtG#KrBpGy+p~=d_Q{jPP4>a@*Mot)g2a(>**&gVVz8JS<`J zas7EWuW>tHhnWSpcm_cPVS!^9mgd!x7YQlijeK;?D!2_u1-~BF*4nxjvw{LXH$PfB zPH+jCh~pmtN*-+7+{-^d=&GQ;d*gm?1%q{uKquB z6aG)!x0r^}Qb$Kerm|J@M-Ly4Eiz)edl_gV3b{wOBOSDY{rdjVkvTZ-I5<{T4x+>y z35R2`_S#dTi3jrb|9q^myL!RIuVdE(9h}3G4u8=_@oL5=9KMMUs?|oy zu70;f6gtmWrC%Yw_4y*xo@56g!b(l zk)o8YMkCaSHq10)MHsD~PJgFe_xjc47an~Pres-kSN6ydQx zl)|`tL+uZ2CpPKp_VFg8{f3P@Z~n=8Lc`}^`WrsfyJ-r+emAwiMTngHORd3zQ}yHW zNh+O((7sU0#d&m4OmD?$o5Vu}QckdT2BJMbP)lTr;JXOvWOwYv`h4KLB|oZRU!R_&AR)kNq86U(vrl=(;D;c z{WDI^Dz`J6KYtCI4-9fVit>+YrSX8M@Qo>xcU-R9m=B3eCZHC-2x^Dfxl)6^aqQGe zZss%+8~N;=Xd+F_;Iu@HZ@Qnt68ZFuM)JkowG7YBN`=bX7{5S!kL&G+?R0o{itQKN zf}fQyZeA>YxiIUm%g*c6u=g0%UMm@WBbeIblgZnPazkp&Wz%TA=0HJIr~^R@Q)|95h% zYlcS^2}v9-0ad;G!3PH0lG0L{h~@!P#_RS;#o23vhldJ;qJQ3hSgD|_^%= z?(pQ}ND=di!Qo+o`}b`Sz>b5_hp5Fot258WRX2ueU*e{`9FWD5#r&6oq*q3iQh0OU zNGoC6orO0wIOjd!$4)6DpkdoNFUb4Pj}^0l*8k`ThrUjF_)*l^*{2iE>v=yJbA%<^ z-pj5N9kpsvG`fB^D1)x9*FCSP$;&GN+bPb227V`V)3p|^w#%NAvTJJb6DPd2!QNi4 zC6CQ?b8|s7CAKqvy*Icm;?4u{rOq3obFu-93gQt#-=5Hax~3^_y|qJq%KJ39KA>$g zUtic_eLcR*#DO9p3!2qeM%_X6(0YGA>mVcpMI<9Xv}ZZ_DlglcJN85$Y_NZx+vm9WL|eF6d=Pcq`0fXV>aKY-)FPZ6%1 zpDc2!0wUQjgtT`5+|%0*qq0O`x7B}17()Ke&qP|fG26r*vZ+iF-hIqi!l%}Z#%|pbrf0svZ)Y?+Feb*?YYKZbe=`9=_V?zu zXKSqC2C^65zkh$m#bv#Yl}Zuh1mINM7Z5*RE6K>%S)ycRV>9xyU4$-`5=Y_ExNjxf zxB4Plpx!<{VxrY~W!T3W+Dq33Uqlz?_j@5SgpLLic>&>pfBG@GNBLm}s8mEvOjLUu zZEzW&66F-GiQbRLUpbrmjOQ(ok&?24q%5wyRT#R^h(K~7cc&2Hf`;~OW1wFgoGI5| zDda@lbZl%b7`JS?BQUs|8c=daiMA{ZPI&Fkac4AN10da(C;UKi1fv@(*44~Sl0lN#qA3CjeyJPlvyASgUI5-lKM3GEmOr!iG*S#~Tz= zuX+xY>4TRlw_wDyO_6{IY2&|~OBODSm-aZPYQWR&nCg0)M-Mew)4I@hN<1I{lYX>wV zWu_h|haKYK?3eNOd{({FH)0EEXdpfRDY3XI` zTE%hLxdN3)CGmEc88GU9^Y2eT8YrPulPpa(Zi&eK{Lm$|3#M~?08;~3962ERfg4Gs zJ?wZad-*Hq`(k(Ge41U-!Jr;&bb2ZwCpakR6d{hFqlXUBYM)K)Oicyr8BPHle! zYo%2?5A*H98Y2seguQaMR_Hn;4rCS8~cx$ z_P#W_&={|^%_@dnyB+?6RzX@`16AzCX#9S3(z$Sja`H0H;JFKPP}@BqrJj&j+27)- zBNLW8-u@kE#o4_3VB8G(P4L=F*>-vCy+{Z*vS)kXxU)`wFqAy8Gch2wvPvu^ne%=# zFCCz&0Ob*SpV5h!euxm&2`=Y&;Y4X7dL8=CM)-9ujM|sYlb`8Mn;bE7zWctF7*0>m zb2h3%+i=htIh0=i{>Q*b&@*DkoBZ_jD{B((1j-TUEfd$IN%2ZQ%H}BZU!eLtu|z>e zM)vUG2b7f^y>wGSFd7t3pwbc5N(R_zCH3pvp+IXP)?Mc&i?!!+bKD@7mWgS;S-}l? zndXDZSWEXmJW(zcC(CzjHfLi92;9&jXUzz`R;riP^<$P;!`omcfsjmLm z9)An7u@wV`0S7xfABB?F)CVi!R6y7P2z+B)k7Z;^V#^2JPA|EBleUD~5}(1A(&1|B zG{i#$M=`B^)%E(+ce#6dnwy%&CnVHB>EIo}e$CA_#o=;$3=bM|b9@-+33U5_H+hy8m^eXB`_pi@*ih9^KI2pEyZqylj z(?fFV-JdCZEW7xO{EWcg(s>0vebdJI@Vi#;*7EYU^75LA~&DQGQ*?r!IRMI?-_)nID>GI-j5J1oRA_rdDNo z8pSk&N0fn~y)nL_2raF%>FbYwdx|Po(uuCjfQ8K(;8SHSo6oT24Mj1s^t@s8Im zOt~Sj9pLT~~3Y2p6|Jy;wQn;h9qmP*j#O7vSR$ORcO-oKmk=*AGo&lTuiUVuvH3DO$op)0 z1=MWCQCkdlc6M+B3T(sndV(b0)z#Gm4!u@eJr_;QIA0Tw^IQ-PmVg=tC~poWAtfCg z-il}ecmeH{?0-kW)dL~p=UF|YiG|8sz8<&ufzqGZvdAb&(qL{=tIzO*3 zndZJyQBW{s;>%fCCvK8drV{Z=Niw#~gb92c3g(i-j>v+8SrHl^lRvi;3XIYB^ zR#1O|SHm{AbaZrZgKKkB9Y?@-{rD$%)c(nTJZCuZ5u3sA9z$B4n5D-|wt|M5BD1sk-G4p6&;*^+##g#Gk~t+MqTB}-&ZkO>itKD`WKo%K z0*j2uv5hhk!7x=!Rd`GIFuK%^(K^FWCA*|P$uJ%k86(=h}%qqE1< zTn3HD@Va8D&aT{3z`>p?62ER71Lgv16WgFj{V)pTZ{BF6UvlfJ-_^xXS16ejmS@TX z4xAS9dhwD<-mEEJv!!4#82#aLVCd1&2%lNPc>a;Z+53v1>FMctzpKI8(sE0X|IIaB zPx<-nbxf>uVsd4K^z_V3%gHR8-}%7q`g($)=Qar7b&c&7-`SFokbvx~$Z23`%Frt} zU|v+ccoMAX@qfa3I5^drU=zLE-T98KWcm{658H3H>9t`3I|2!>&Q>Gsn-A*RuFy*1 z(k9j5e^2mQ4R1q(%k(@JzcQB4`@Z8^vl-xi+&gz_q1e;|=T{oYcXhBFP`%1E25oIJ zqH}~_8z|d*08x<1fJ%X-@Lgd}DX&Y4YcXBs=G0r)5k$MaeviBbVEXGeI5qIxbsI{m zIbFJXFD-2jze7$=4nF^$tyv0|1<9{=$=O(6U!jlHbyV?qyaGhq?j9bcdUdez@i*Ao z5)~2mHgakSaP7g%7mXBTpl6~L_kJfSDq0Q7f5?*)JA~);;=5Xe4J(C*hT`45%k9JO z38;6MN9k9Mj;l=B<%>S2Yk*Uzcfq+oyKiv4?9}Rklp`8|&kstcKR^~n!)Ngp4iC74 z4>%^1p=5sT)RNeB$S~P?xjO_SqoQhdg9E%YyOBG>0?IdazsvWQUY8+ir!%|EOC5tjR`a1>N@Ymv?60``oZwao_!)bIy z&w>)v{JMv}Zw{ioE_)L>r-lNL9Y9}04otQuhnD@MR~{8=&{(xA_bJgO@}rHRKgd*C zy%tCC4Kk&0C_;p3J<{Sx&)Wk55D+rIw7^RX+v7_;?q&D9FvD+^mgm)dt%%+U#vZaS zA-!cDM0!kiLai`^r*0S!R%J#G99qT*s?5h$!qCz(3d%=|vC%7>m28cu-?X9sMuJeX zV5Q^m#hT|X)j^%o_TNw!tAe6b*Q1ay8QR0p2Q@wFwau{tJ${BIvTj;QJb!KPB19l> z)O2f2d+d~x4lkI73R`?Wyz3Zx-J@*KCZ;86s_inXpz^s$T@1Let19S~qvHe=)4s%K z>6M0NGVIuoo^=oHV;!*ZpVmSw6U_(Tb>xPA-Su_0AVy>)(LNOQvxYlCvoE zKOcRqqH0O-SzjIYAbQ5GAhKxk=_j*-;>zw?V`xR(i71-2!Tajlf|(oS1owN}lk!al zLfU~ri1-o4+x$<3y?FZheHp1iKG#lP+0yVcCA)8PhbzvyqS`>f9+KN+>ADF)mO?9&~u~2r>w?r4LA4k z(5`y3yP!5EY-&EaE)rTp(nv$k%EEaU`j?M!U)jBYvdo-B!Cg6nTJ`MW40A*35$Vr= zMCKMq^6z{eNrYlnaBmAT(fuv-`aNpww6&DPb{$Fvr9q; zhuuXS*^pc1H~CBnX7f0AR|e=GnVcS6iX$LZZDEEQix-Lfga;p)pRr7+|7;nn**m~n z8(2pfePn9Z+<{yOS^r)In zaW`uYHieEaZ)e59TPLjHQ+N+)I$SOzuF6%j6my{u4h2>}KL2b~L5qK$ebQdhvK(=3 zzWj?jn+JJQZM*UDPxh<^R9%0LaOBu}n%n*%7@gNZtf`G;1}5!FYYHWn{ZOUPqC(48 z8a{qXkJ$PUd;62TW=$!{Tp8K$iiG6!;}?;Fy~q&utRJK5P*KT`>V}MQSGCqNt7Jhz zC8+}?Y;&}^JO`0Ik3*r0C0UF~kB@T^K6-Ok=$`(PpChd|G_Pm$Z8y4N(PqAIpy`V{ z#zLc9Cb(v)5jWV$PL^<6hxn^#6?mBdLcXQW)=dC;5Z`|>XanB#fzN!4kU$Njt7?stw7g3yzt*x#AcPRafdCY?)-Uv(rBJfC^~u$%2(jfz9q z^UBzejI`c0p3T=JIY4L5N51UI#5I&`pY#l1o~%rT?BLttbB`SO|MKM}jd+HO8i|je zsE#u6sO_YDGcEsa#UZSGbE-o9Vm*C!?62`<6JOGSuH+@U6p-mzHCUL^R6Dh4XAurv z6=jel%QA=^vHj3cqF}PQy1f7<-E2+$+-h)jG&ex5?EONs;-k5@uM=!$3zI$6K0+ql ze+3X27rI>iPOIOZgsxqDv)Gi3bi%%QU-IjPkx3}gsJgWCY zVlYS-myi&tm@qB>_HEc5U$8hOKwnJJ5zAuw&hEw(F<4-|%)edB`6xYN{weGZKxB~A znxP^6z9tu2&t`lly+5_yTo^BPwkuBSPq%du?^V$=Pp6W*NkVub+J4{b0)Z$h3qxAk_}SfS$YPZQNfACsKqEIB}}SXD>Rog_7rK6u>J9wCD`r6Dq@ zo|#1Fqq4HHbZT53;3v6WtS?v0IA2n0ROzuE6ZO?nX_DT#=|tak8#F!6=6!K+ka@0; zhML&E1?^WTW@zgH<^>yGnn>yERW7I(o$NwWo9dEV8X!Tj2754Q`-hBR+i8H4p z6E{uQRgfhPH|-E>n=t&rADUcDM*j9Y_NAqq;rLJPE=@-o8@DLx`|>PV&SbQ&hcsN8 ztM97EVy0A49qHmUezrDtt5Tb^#58N=Pg7V&ae{Ex$VRCQhDibWb|!1#boPi*83%@7 zZ@KuV2Whv8Sq??=HNNp$%)LOd)sjoti}Jk|=-$8E3DBy91Qn05%f)c>{qjRe-mOI8 z%cFiw>*TXj-$_Kb%O}Z``@326=%JfO^|Enh@zsa=c(|yg*JX3L-a|t!fzW!TZ-)K?9HdVYh^+hQt2-=(MvMAql zlfgV(zdNOHx;2|djO%JXE9|htp=TW*nz|U+ zA(#iGSmT_@s;96^FN1+zQ|MhBFq++Q346amY8}L3So!zokg?r_zmC1H4f$8yLQQ9T zzb8Uf>Mr}eb6kVi->XdxI)3TclCR#W1zw)#4% zj>o)K{h;4xDrI%s-&$9BNH*-kPWOTk31{}x*xX$@@QZgK!UOx=pk_tzZ12yb(<0@_ z)mitGttt{hchXmt=PU7H7^q&~7UbP$^W|Sj zN89b|fi}+^8Q_NnW`iW}t`m z?m4*e@~q2C&F)&j66U$*qPBT!+1O&y5rqc!#Tw-D4p6?=T-`Rauezqt%{EL*&EwU* zaqH^MZzt7)yzfL}1Wp_o7MfUYSFmz!34WS{bP4~D(%$)*A&G-lc~xlSeLp>Ilf9#7 z(R&?kH-WPrPC3@bdPHOWMIM5-KG1h06u~oV6}6$iKgJN|NxS>ad~jzxZ;ElJL(z|tn~IfaIAwBPa2N@#58_qw9<27)b?k4K>iI0aq5D+x;SK6 ztp~NBqBl%_{+b$w+eV_i{p;6bqdQH>_hb2;$L(Abr+1&m^=j5ArQc|jV$%4fCcT9ht#@pganx%MV-TtOhxMcesVd=g4*A`GXX}bP8yA=svAs2JnVS%|U zF7B1&y}!jj`>(_zV>>_Mv|bYAy@Xlao`Bv1KkrR9LWs09vr5f!lnNHD@=+G=_4*df zX0R>*i?$m4?Iqz|hz$3`Q#TQ4l@3}+1W`dXpK7(q`N1+Md{Hi53h)cw*vMo%bkw@H z6ObsdBOtb$1sa>XydNQ0U80znHPGy`GMHv1HO%VXzVUqka{t$G*~o^qF}1a}>L&kr zb%+fG)zR_=on7tiP(=^`z&{J59ZoB-l2vc^-vb}OJD_)In$zC>4`~-*bcNwJnzp1r z%eizVZ|+a!4+z7*x2spbLH2vS)ugEIWJr?Ua}OBcoq@n8uhN{z~&sz``#v=g=nu$m=ui|GMD6oaq9~ReBWXo`!ad$ep0g7h}az# z*a}Rsjg3t>{+;st<7z)`H|DlXT|vs}AK`g?Ps;(uUYfY?s)_KX(rgknaAl>t8?zBB zU^xT)%g&w`7q8Mx79X!-Z7r{-U-(N={bjkXeqq0%`AoT48{y4f8>HY`Enlz9_oAfPH;58;up8P)qa1eXU<+ANAiUZxr?8 zIy_n*f<%OdqL=yyGZ;E;3>&@e3t5mfB$}eOH~X?W{f@(e)9(dY~d`gGS}8#U;+yRlHhi=-`0Vu<=lu?N0*-$ zK0WixetR0XM_e?jnc~hR5_-F;RydTFHgbIpDgBrRvm6rlVViMmQjNige(AJwu*_C} zeZmQjh1kuG}ZYA$PLkCnK9lK0jN$#G(x&G#^^GJ)QMv z*gL-gdFgfA;uiS+JX<_i5}?_p+qNMR?^nfYmIjmZQi->tiaR z4_@jOmyh0_FElp?O7aLpd}kcfGP2(}eS~jXzBhGXEH|WwODjG3%Hj}g3UYVbldZ)^ zk07>Z$73>AlbRw+v5->T4(zc46};c@Ti={mSV;15A>?cgTwUqTFJu z(Qoa0OMrvhw#n{o-|cJp{L15t&2TJLTWK=jVJQth{>o~|e5#Y848G%ezPXhl+UTni zMTcpjpzdPkkEU~x(R>(-@S=|M=AsDuwnc-K^DglMjs9#=`_@(Q>b#GFEez*#(ldrO zFa@_VlEIfvXyNxRrYF}@Y|c)W{bge{$Na41_DN-88{93c_h@A`$furV9-=D9+`=Km zTZ*=;@Hm78q`0GKII>d2MRrUm?>8ig$3xdDoA$SSkbkq^DYW#M=eU7;S_`+J_CSD9 zr=3Y=`tbAT;^al5nhD0{!_Qo^`9|YES(Fx?=wecyvrUt_J}v9Li~qXqZi-wj&IpEI zsV2mX!zNv>de~$caiCJFyAr z5_t2KS-m1k;@=;|jlNAHT%fJN$BdsyXvV_21A{8VQNKG&a{9G9O?<*- zK}Ubi_F;7Y>cy3Jk!r{_u@CA5T;qv^Y`0s=iOb2oY{I7 znwjTa)q`14yjCmA(S5a3Rg2q#wP@-qV@Xcu%S`kQsv5(SCtEF^+Aiy|770z$KL@7D zwD}Vx@6^BW;daA${Ffpz5BtT|_$V4-ORgawE+)HkiXMX6%bk|w+pE7dyP3`6iDap! z;^d(=!yj9GXwcDfy6mF>2Ia@7zz`%wfj+kSZo=rNdarQrP@Qb40PP#aI&n!zb0PKH zwrm;M3{x{m^Rva|8t1E)nCVGHVq!Zdek`7%4Y;js?ahT1l zIcnUkblm+@59Y${a+{^@Mw@N7l0F%__T3_)ZMQ&R(!2`fZSii?(sg&}tnX~Zz*&;| z?ZqRsGelN4LuSO?`pZNMw@sjts=o5;Xca&547rlg^*M*#C`}l&wJzKsqEL*PjBUwc zurHtF!ib%hN|}b{8BCck+ooK(8OqqBX4`WDFqMSJNDPgnRIg&J3%slrH(n!5}R))z|jTy${L$8^oixN_l!(UYB2A)`|+r~aDF zBYcZ5^Z8H7xN$j)1h{gX=c%z_;qKcU2&lpI7B6=&}Oq2oB>4Y$e>speea zI>3!_Sfi!V9zmy$Dlj{u~+g!FN=Q>Gn;5t~( za+gUz?csMUx$WjBP7Qc;-B2Z(Oz8NcP8XA)tM+Uv*kyNc6+`XKbx%($n`76d|1~HA zM1(@=cqXxS|BcskP!?k;JFn+`Z`xe3M@Re|@SPU}+jc5HgM`9wo>fb)k7InKwR7pJ zw5G%Hv^9ZG5=U#UvmFLEaUZcL`k&X{D3E!O3J|;%X;BgUi{Alc`cy+uiMo24M)lWWuwAr?(YliJYAx z3bPUruwHrzS23h+q7r#;1lG4D2YMVZr?;q2n$;?Ji>ss8RuVb=*JSgx?}En|n?YbZ zGE~f1RX%?FxR4h6S-0lh_iKBgn?!>Mo|jb^43Xc8+NElhz!anET+gj(K8fWg_qy8w@ol#L89NOsO_Qj8*aC9f6mbrI|5t^m6S9MFM7`6l%h}^ND7j2dawV6nRcMw4WF--X7(s4dzFc) zt6F(>5cfp);8Jlnb73^657Ij@N9(>(d_98oIeCPp#KO3WC@R6MRd7kfI~!{SJF12M zZlOUR1jTP-_&#&>Ag}3@uItb=t+0*E8HvT^=pQ^m*wmO2toAXn=z0}(Yn8NvukSw$ zk%%?E?t08#rOx@7K8d6NYb6b|uA#VhhCI(T|9*R1QbhXIi9EFW$}sP<0Yg|uUbiXC zjM=mw=}<{D&Z$Jbm~1Fz#Hiy`r%IWpPxrs=vCy3;`MhAK`~L9=f9TDut);6+^hxE4@j-7IW1OPGuw|jL3G1`p>&5doyIsIQ-m75)$*q~H%taIzIoKL2X;202ybmM8W z==tXKdsG`GkHN}?^QN27tuk}IU&h_aSXsn;I4~1F_xukoz)5fim4Y8H!tL7>V!!^5 z1fz{t)>7E)cm%AJ9PFu>hEO>_h0RpF+Bwp?-gbfTvj2Q+z$!w{qlWr zvnPs!$+UZB_ufqDJ?Ldwwa~^t(#3&9dSROu* z--k&8o}Wg?9<=1r(UB$dsGhM_<=lfRwh3%`J}EkBR^e7h59%YSKCS561k9MidX5qj zc0JPib#t?vJmKz5mivmtNyMvYA^)5%hlp|7g>Tm1IAOr$;YTy2M&jcC`BZN>7T5X* z$7gJUJH+G&tMwM12_$;B6w3#JNED8#tG>IO9)OvnBqvh<7i~Z6#-qfCHf@G>DoII$C^_ z4|_KgZ*htPhq7SuDMlX_ko#bx3)wT}<|`w99I@?A)zSW=*&`+>!(HslXi(YjoG=+_ zsX|n>7&QfL-o3pk!XH4(csBDTJ>$9g>yIEGKJS@1!dudttoNL)wBOwwYC*e`)6Xp? zxp(I`CSQ=_r~DZnu1^}x;lST~7y$P@spIJPuKL}JkGksvr$V2Iml(z+m-JMAY(Is_ zym1Rvv6pEg32jh795Z6Uq2X`TZIn0OC*a-c=lXkisc6k%$WK0y?FEME_f95U90J)8 z3-n;76dMDx33p@b40br-${*hmTDkd+UX?G~xx_;~Ly2%aef~R^@h?eg1gEbG>}Nj? zPCe5Py#n}0=CuK?NoR~H!7)Pn)j_u3sqT=My*Q34yNvo*V6T+m8o7%JlhC2Mo0cCv zldW}~t|G~pRd0Eg^Xqf9x927@CH$|nX_$3!G_o;=HyWNVA#e6v?WfK>|0-!MqLMqx zAhVbXR*&TfNG$N2R0w3?C8nVs1lr`@`K`4+|79v*pE@C1)nPd~4xvbnW3OOj7lp}c zb>f6yy4g20?Je|%ph9Zu^NG6cJBwcRr-Jl0kB3S`nHSoQCjuNUo>*lzz92?3v^cU0 z;>fJ8AuQqa-mWJg(rV4H8J|*WpfkW_;O>u<%cB(EozL)V`OTmO5A+ ziC4+_R1=_l`j~aVn6b7jd=}e&Ci^xAc=%i8(?L=86zC3Xp5z~RPg(!=|04cS@Z1e6 zB|I^UR<=3U4?1k(pFJNUr_5}Nb9)#|t?a6EoPwO!RpZU8awa2gBs#LJh8Y9^?aO8( zAU`OA6Zs3Ne&*VV-XlmQ&f<|mVz4=_iw&Pejuom zNsAu+{JiZ=8rZT5C3Mw-Ge?uT__Ssc1}b??@hA{8=bmIE1hAb8J7fi`JJEL@Xsn6` zh!-_ErJ5QY;3zi-6tbzvmv);@lWRGKm{^2%^exK_^=n;l!~%`?efv3{w_o%3?^5#o3pl;&2St<%OU~O`-#UlK(cY{Sz6dQT7~xD{*<4*7UKXSsfdxrrg{X<@E2FxM z@q{|*%N1x{d6`nQ=-8%mFtLyp3`uGzTIXlF|p}3>GV9B28F@%w^Yw2=NH-x%lI#Y%fl?LWD;|J z6Mkq$_uG93J49mAKiAI7zLv37l}&UO zm$C=0UH8Uy{HN$=xrc^Tah0FnBE>V|E6?7(0RypS36&Wf1P3dC-LR5U+R-7rE+5|` z4-U;aGhBHAqZ5I-m%vPEy434k*Fv5xfJ4N=JtHVz6?&g|jXbT>N4%{CwD zt0!;0ZWOvWJxhllG=qR(gD`r}9{->D+5bIB7{d#Yy@;**bglF>0EZXdkn({;CkV2( zpw-@gB}RceSnIeCn#jK1Eg|ajKGy*})&I(V&Kdju8-s$ZYz|M-`CmlBAAwa<=4|Mc zk)1zKM(oDaqPKqD4G@7S{Z$XsEa=gRt+B;pl^VP%_|0C=zyttyr^iJWs^z9i(%Sc_ zNlyl8x;r5H46sS<3QP9U>X zvqN>!A(?~~)?Hf4taTY(UwR~}p-kn9#R?d1H*FqVHlRaLGPetz(!hPB=P8R6XmE42 zS|=xm=5YYpSftC}+VQspCEpzJ!po$H8DotLRjUpFEfjsgtYTVOT6${2c@HkK#l?(_ z)0;WOz$Z;=!a)Smo+HNM_IK%I1RGEFqF|HjoG^p>{Z=K9FsE5J6=P!pc>hix(!=se z^XXsn=ENIojwd4u8kZwQ>gQt~*Fi1TuCqLcpwa1V2Sqa1(HA)ZfmVeVD^Vq8K(xYWWiZ)-JKj7}{(Q18XM z@^z^%{(9TO<8rH{4}Aa(tHf9?spa4$dSFlx;0nA56-Z;ZO6}7)%Zz6RRmz~j#Z9pL zaJeZg+HWmS<66YQp|6e5;|hG+<1l{N5hh%$Bt9V@xNKO(@gI5VFjC$=P)N8V$$K3E z1$5}N>6$#RlL=D;Q`T5mlfI2xe-!BZhpoQmzLtWx_=@8Gbs;3@hx$I}zu#LO=yvB; z`-9txo0++kDksY4F_p7tv085ntP5nVy~`R&pC0+#^c=t?x@6|TqvJbb0(U%q1R}pM zuDi94f`*1MFjjvuQe5nDVsSfjy3yL&DoZt@6YNH@7P9Mp>SA8uvOf4@*EMUj;dk9N zJpZ#fuXW>v##0IU#=Lxa$;CP~mpymx)(lVd&3B&uZfwQ!@*$MwoDBRfI5FDZ`(q^j zT>qdiemUgiZgZ#xh9wvT1y*^ZX08+>Hybf#@!t zBjP7>n#rHs#^U*>V3vgD;gN#JvBKbI??6J=y|0do&)KGkTrI0q_n{b>VZ4L3RX6 zn)5z`#y9V$TqcAeOTbve!N(ky?q50)P-ta1_aMohD>-};{>V{wIAu!byyB?^%|#cs z1F-4IQ$eHdVE7|GhU?I)*S>T8k|PW54=rX15(INIYRNZ5#q~P}7_2Fo6nOrchiV^d z2s`R*^ugVV^URYDOYZA#-^=uAxH&HJVKYGDIgIUl0eWQVEChZNjc$dmF(nIQ>^ff` zkNKccMKTsX=+kg!Dnh$@aTLou8FaAn#|wy{q3;q_5x|0W?!UhOAESK-@^x@cmRNvK zu%h9zJ=XY%N1HA>FI78w>uWS!ps)kbW51u?t5ZH}GWI^wRM2JnIojdxTq^S8KU}Lg zhQ_7p#KhX=F>H+1HBnd_a&Aqzxdr)+6KsiGfY*a%GW7#4ZjPY`wqMQ`O{0RwhKgPfg7Wq#k$t zxTB?L7K*qhRhhzyfs$SYT_?`h(t^QJZ>JWw<Eq$8UF!kH1F8#45xH>n6OK^X=oE6^5*@ug_kLR)kF=#c+}i>D@18 zd+CMiSYR#KUC-uLc~04hIV|Tc;gU)V<^gR(dKzcE5YZmHjTh@3-d^?Ha$Ojj$qb?S zt=S}BPR%zVM$?>YGiv)*34|6ByYRBnO)w7{adbODU#;i9-!$Sx4~;KLM4m9&Ei}Tl zq~Otn&!T4)Iaw={pW#SuE#cMA{S@C^xZ+(+a=D3aaIH$!zEr3`hy*S0uOk)Ix!tc; z6$(|Ijz`9zYrryad$?O(o*>O9#sWBh70}8b<;gFT)jVcU(2bN|kH zuB%4j>*_rvEH&)qq5d_z>#4Hux)37yyef(7|!*}6uRj#xTJ zmMZc9wBr)Bw`I`CD2XgHE%t)T1R-Wqnz`?&2HvU*`~da1l>gcg1U^~m&2zE4i{Mwe zabXZe{;-X!cHJFG&+wU~h8@L@>d45M>FE^rht$<%HW8=^J=kA0E2I@~M5>;j+?nOv zrn9#3#k+i^(v6o>vJ-pw0POEVh2zAhre{J~Ih(3rs+dKkpB0=wncJmac0Ly(oqJ~v7(0Au-wT&k5JIgat(t z?JuN^%-oE=aFHas4XR324eKp(DWwLIWL-`{ZkCg4L5JL?z`o}Uk;R*zeCi3g9$nLs z2iIn4>ZT&mVW?&q{LJ}1xzivp*qHP+6LmtnP!HP9^atjrB@~94RT3!>z5XLHaJ~!< zu0jz&j}LwFyi$>dBQ6U`eUwGj25yTI-W(x=-4U7D*z8Z*rA|v$E(cqOr!+W@OZX%6kN+32`Ch7##5S7u4Np3} zI;S}h0#6gddEMuo7YxQX;Oqx>f%*trejF}m@x7)k!}jj*4YZ>mm7&ghS8UCgkG+M5 zBfw0}IHsr}lK9mZap!ns<-~U$|7*mnc;`NUhZ|kK5Imf|_PN(3QO}T!#<$-J#5gEt ze;6>JfSs4x->3@MM|voVsTMG-l(W11YI62_XJNCAdph6BoQ#F(AR* zq<>gsrq1xR5)?ICX& zHpjcO?aU-A?14_p==QNa&ssJUJ@aTbrlTJy(kI+V*22L{rZVhK5w2zEddvL*_LBA8 ziYD!h3tvhav3jXkELOpCKk`dniYRARi=4M|feQCV=mSwy;=Tnl|HEJ^GNr-y?aP(V zC$L()4!7RR?Syv=axr9o|nv@(LX-{C9g|79V)9B>LEFAx#$!60{LmMjX0~^ zh&(BrzZ=4!i1@US(cg9?=D0LbCR5KhCYkX7BqkQ7EJk zdi~8(zK`$LNuP{FhUiyssKk&~@8wb&fP>R8045|Oj+Jbs zKL{)H$H15G?1-BlN=m)_3-nRHLafPV-EDbM-=1Bx2D2U)iIyY(9B0rd7 zLsd9eBE5LrBaLby|~XaA4p2Rdh*9k9J&k=8{TpH@#qy(?3SqB zrFW95uPOC1&C|c=-Gn~NwV}=3op7f@2ux3?sRFmYC$XL-2}k(Mt0T_t7Mz z?FRsB<(?PvNg$hUQi*ZvHCdq5)>=f*hr_$g$JvcKM$E_^HX83(% z8$k(Y!h^_15Xn#tBdnQ__k(`k_c23U*s&|QN;BY>q)`h;4M~_JH~spq7qC_!MKLD& zoyC~gP6?a0G?^eaj!m?s;3|Znu|qO2`W_|VTOg(E1NnjzQ*r${0lUoaLC5DwrKaoX zt*j#e`aYeP7evn^2NVmz*_4WGsN5wQ6|Zqlul)}WpC*;7rg(%D$0u=Ob}X6IxH1)q ztyP)Tmcol+9n(3=dHgHHgCTZh>g&O2&!29fI|IMoLE99?i0GKFVLlgnJj`_Tr^Q} z*G=E_=HwR0zJS%lf5^>et`;-c%0Kyp__$tzDR0=6G+bPctUKk7a_N~(%Gfs?fBsf@ zBa7|uA}4uRPt8@y5`AA_-@u-*Zx>l93<`Zng-AsFKjNig5w=5?%);89GBInNyZAo_ z`z2S>vk;^h;3x`F-Y-;A;XVLA1`w<9pSaI|6)Otu1zsAfv|t6dk=ET6-Gp$@AzV&5 zBdgwvrBRwMvB+9nt*~(fMX!MJh-*;|(J*(fQ#Jq5or}%D4RX5*gm>}4(P2TOzl?~z zN!^}bu}F_nBSmIuc|G&*`TP4?z5dV21b@LKBRB$NmCeRzP$O0I%%cD9ZU{oD&A|rB z;_oFM^JC_ARXeVm-1FBEh^#=d%KetmrYv;I1bEBvX!#v~okJ%+9AtfWHd5xFcmejs z%V$h#@Raaovhn7mwyDYKbJ>514JjxFFFTQ0{*il!JDHHHWU%n5gPTBkCX+z69Xg|0jpaFFPrf#dudx;&sT>$mbMxL3~-&ceQDs)XGbA$KT`f z!;j+v9|jX)4-b7S>W1Ls+od;wUjSty|EIh=l*}N0KlJQ7+{*@^q3B^dPmqM=7$tU> zkp4FjfzCdutS3MtI9tp_&FI(OaXdjXJVntRU{_vobk@pxH)RM!y)TmuHjqWHx=z-5 zqNay`eJm{uo|#gX6q)0HPQyV2v;M`IBgW;2+C!yyq6%OES#-5!H_SJD-m*5tXu%PUIu+> zXX+m|tOIApn7+5rZM=QG>UlS=m%E?^lXD%<@XTXmEKA^^sAG^{3tt?@zP0j29gm%k z?Ld9Bf(Kudyc2|X-_X2}rtPzZ zs>&m9)|u@RyI4k}^@XWYj^$L8T0k(^LzM+_T9s6NKkkKjPNV6ufwamx4GfAR`X!P}~b7&MOPFa5V*kn*v+DBk*3 z_=oY}4)%ZBzdB1y9Hqf?l+NI8*( z>G;kw4JCP_NcVN8nVm}*++ofeTly!7_=`92Br%<)lKOq7lSFZlEYdK~X-H)5HYZ=c zCJ5#kM-m?ouMw`{tzK|%?$TN&28Vq4p5ETs@k{-eh3;!&+zK@5-nw;>&dgPTsr)TP z!*oUv=$U`4BevBNh#wm`=8DE-)?h=r`5I(ZxIX9iK6Q@0`fJ+X6eO1?akvH1P}dUs zta2LK;XG1<#5k_4#NTgDw&F_=IlXh2$Vm~0KQqb+>yQvv@N#J+%CvklpbR*D!c0-Q z5-JfMXuUq=?IzC^BD;Jw++gE?{L!igZMy!TIgzXCaV^rN zM$U^=N-~co5+Pk3#Fa5tbj9O13p}B`BzRnvE@)kDg9DQr!lgML5r~NVOh3VW&ZtxH zwhQ{X2TA2a-f(TA35Vhk#mxz&nNc-W)8feslEA<2cd=$H8SE`Z<(`niqNRkKS;n{a znd#<0rY%Q^02pT4wa#XZkmr5I+~^umGU{eVgEC9rPD0bvE)YDWy(H|yrFL! z9EaFV0Kmh$f&RzYBXW3nt3{_Ny$n^gSu(NDDIbf!Ht3^b!4po%_GK6SQY2va^VW7y zb5QTm(*b*!&Sw^`?8%BJNyM5(#}Zn`72W(lY|*^^sP;}QF^A*X24!s;rwCH${MR7z z>-f^ER8#5}5@=jLrTpkcQh&tUHMr{QPIlsuNixK+&|&oBhohsN*B?brUJbhNTs_8p zmTx9W7LN7GGKLN~0DB-#Q-}odN}saL8NI-P$vK0=#$_^|CDVD5jQq%i^73%SdnB=O zT|`p7-c;$APOFtSUSZI8i5)4eG9;!(maLf7Ki1C0hO{d7ei*hl!kySnpa=3dk22#1 zqcTe(A19Izq$SDhQ$;218cXxDxuVOCGwl$cvRh!IFB^9-?R56fHobTj9(ZZ`mqn~V zIbD@-eyj8NbcNbaD71lf%=xc)`cpM{4EaMzg;IS@=w*DRgh0F?YlZJ?TnF?L`L$Q; ze!B4(sVi^P)=-bUxkY0no;v;9wPpP4jr6&E`)$y3JAAKdZfN(+&j3fCS4QYNUkz2# zRXq%_{NgcIPC_uxQTMaGB~U;3ER~};A^Vv>t4iwFSD5)bDO&Oxw`y*%i1ImR?$RsL zs@1FkOMNr@IOaJ^HNdZ*19hQ=R7x3gi{hE){vpPlQo3-cfb)6v(dj=P9mr+frcaYX z|GsZge*A3nnOdqBi`$J`qk{17cb#{z4;@=5v9uOE)NKmH7WkIs<{Wq*kj5tdS76-L zjr&|fQTQ(t9`~bp)MVu{Ll8s1-zW*L@%o;daomoaWQS93HuQ2RF3av2F(O8VPLqR+ zcfdjo6n*!>x;Ts4zzo44hWI9jhSo)}&2!G`S zLh0Oa88|=?+v0~xy{y}_A)4gCIw9~Kx^-2B;ucv|K67btd4Y2I*hO>Su+vfCNm~S! z-df|ygWgMq^-8z?I$2C(8j~p&Eb~xOvYN8oFYDF;W5gl~dX;MC8rj7@nCn!?MjZ42m>!gmwwepFB}--YRWB{bQ>9N<3kKwRZ^;Hp#fa;{PgG zI$GSzKmtv={jC*DMrikZo%yP9FEZo-aK^lS_QgDjZiIg|QemfZcW)fyq$kW*W8WA@ zZsr;L{FS_Xd^wn~otn>**ddj)$L&M*RE<2lw;=!9R@SFibm9)N8^+#dRjtmfiH53! zgTJlt!kh7sLn-%IcjxD-51h9nxrI{fXMal|0L2mKpku5|cIEXZpC}k|Ki!PzDcdA?vNP4?QUargcAIEQRUy`~Yt#cFJJbXM-PJbj0G{XytiKV4< z+q@oq>UZyiT$yd4rq~KP#IC_Q?O`h+dMg)ZYVa5@i%bWYxOkK+*IpwWUtuRfi2 z>b&tMB>yopquuiSqBIUbSo8PH&1}$Z-e%K;3E{$ z0q`4F=1&9pP8mREW;e-JtI>s3(p5l3UjHjO29m@~bC+gmKds%u_ah_B&nsJ0nt8v| zzsa01^zRVOFu~Oc%M{N`)3soT#eF&Y_3%styf?%X=miG@#AWW-oQdLDGmbdH%spPkNLDp{@Qqy^%=O{|Trah7F zsAkIeWG-GU^?5P{3Np0tKEcHB(C9^!G@05$I82fLHWRn1F~vLopNBp5Y5gPMf@grm z0a`vzJrWNb6@`c*S11&(Lm&jZSXJ1XNJThA#GT-YolxY<@~6ATvkBbeT6K_!iYoiS zOG};P_)H1(-v}_^2eL~-An+ATv-|eWxQ8Lz0azPTZIhHmR2jm2Ox)jEjq&MytiJz0 zN8`p0@Fc-@q$vmN&aBT!@xJxf3FfL+fh%@C-7oz2MH0X79;` z$ZZs`dU?h+W*Mh0^IC->F|`zh<-Guonn{q5$%3kSQWg}@lg%S@I~YV&NGN=)b0T@{ zy}?}*&p9Pj%3}>)yrwVV3(h9BSADvEWL%(baZawQ4f=BY`tCng!BTn|19}`f1^{U` z3{Ya;Wv-kMiYBkDb00)T|E;D)mnXJK+-!uFuc{hz&AZ?pU;PhAVYFhiC%XC!s70*5 zAtv-_G@tu3Qy#>Wzw~W`jnN3*LK9}f)`ds27yYRrR5-qez9mV7Tzn_`+Y+7IT6{mj zzI~)EQH>Qx10by-_yODG360p??NNA%p4LMWKlvFYu%_Lu@b`}QFJ1-FrAxXsID|Rs zZ;f~GqXw!(P0HtgRaP~MY);JorHmkwF0nZan&}BoR`0v27q4>E7(ZUnn~aZk z_+s&AovXZ6{kF%_H<#MI#6iCCBpy&A(8sm31#X0(xvn_YZ;vrHs3iePB ziHIGQo@V{iIU>ojm3Q@lKCql5!4Qkyq9TEBd;=(B#Ga0COnkq>I{^OmcGk$m>vtAG6L(HE`H_$5i@80b~( zlT}K?`MMSIm{e-!Rq`_F`khDPLI#Lt@9}a-u0%!jtCcg4K0!mgy-Ft9hpbkxbY)P&9YT)k0Z@}8{s!0L*$u4neP;etE+gt&x^a$U5 zF$5*xbzEe&0*?Z+q=5|y>6OP-Bj1I?F&RI;R2F`R3RTq`o0xuAjTzazL}QK1kNCT! z=l$I7zvpEQqz{LGeAEw6;u2-J1Ayzv1R7bHvfGfxYA#fLCPgXLqzp|=akFz#9KT7( znk_kg4pE0fw?)w0l(Ia~y{NHQAANi-dk+hLN;qJVTug;{2vwgQ`0P}iP5ohPfV}nR zw|mrg_9VdEC_(93dJB2lZcvo);+8hlLg73q)?No_$c6tbEdZx0kn+T_L&^iB%V$kl z@w$q^F`yFmMEiPW%HY(?L{QoK?PuCQT zVemhif{0A7GQqh)E|!*-+6$5#_$7cypbHEN1CvsQJ{Jq(-tA<~*dA3E=RlJr3K5p~ zZ{mMS$@nZ$G+zXl$~{fW@L#i}T#);Nin;gVopng)wycT(`bPPd{AR>M3EJ`?L`c9l zM4>O#63<_TRw9cS%AtOGfIRu?Wic-e^erOp+UL=SJC_^2Wivw;;dvVF^?lCR2!heC zw!1;$>2WXTtr_T=<#=3q^mzm?R>OTwdxxg>eRz>4bHp?OG+a6G+u6|(j*CxXFe8CZZbm5YN@B+v5GkwkOI!{}Q_q)XLQ)LVIvTwqP*RjQgbr zsQEj|ttq#dO>!KlbvoqSsZ4v235KY78#WaShzPg)gr{x?9a)ho&PN{>ryZgd)c9)L zE>He?`34d4+IL1EiinDusHe+h1%ZRTxX*Pr;YI3U^c{(?Qlp_Z!rPHQU%gmO$3w=g zmm_BhnAg5v?t1*xXvotC2iJ^Of;-jh-h3Rv@O^$cM!?-WUTP#`Z*MQryWU3NV%mg? z3f_D>tiha>spL9=J$Tl#37q+DE@q0uFsDJ+aCNZK6JKWTHW)!EW0GXOSx_-$)pknV z0PlOiw;|-WieR@juGO0W4(T1qwH5^n>(f&b0hEUQ){i5o_1ont{BX&3ium=SytM@d z?BL2oXX91%I=bwsN>f-(ANQ&1NRxTP_2_#YfdIfh@QjGOvMY(?aM-z82x#D3iQ!(a z0oMUq3as8uaQQ)+z%-AstU5RY7+b^jx4yV=G#yMh4#e#LPjA;5)>PK5@%nkinNe_1 z8AZShBHbtm2nbQIAtE-AB0-Se%TNQv86&7PMIZ(VQ4s+{5s(gv1B!%_AT5+YKnNWo zU=l(Sa`$1rANS9FobUPWy}!uAIcJ}>)?RzHv-i9180NN$+A*yynwmFlw6xEqQ00uH zu7mrS>)a~mrZ}+0aeqD7q2DII#jy0Vl0Vs~4 zD)ukhuK7JcpU>;6(Q%-+Tko$SJ^98!`69;dF`L$WBt?V`1U~et@cK+U=ok0r zU7p=x)F*i+S;TelF}D5dTH18`Y1hU6C9!%{qFneVzp7pZ+_tNL_yfAiWF4TH=Mt+c zyj_8ca5Egvt`E!jyq>(jvnnkkLm?F^hUXI|!rg(whVXMZ}vtp$ZrZF z=Sx4CeCtekrso%Req+_frZRCbOtvu%V39TleUcBK!podZ{3Z4zsDqW8KRJ|Qveu#x za8}HY!GPj(dQ=BC&wnnTiUgZyf`=;iZVg&oUAkS;P>BX0`QHk_Eypjb1tE0CMt{{g z1;HA2E+T5=UpCf4Ou)A45O9g7(zhH-{jWq@PfM_?;Ac5$og^P3ywRCzn$gu+>9#;BShD;q1SERz3@Tf?CF;VcT)({XG{~!WyAk3fw zXnIdtT`CUYpzFV?oQ{&UoX@}yYYEy|73txcvR3UR|H<~cpHW-eHMco0eV8Abj7z^( z(vJ$H6mEJDQqH#D1M@#f33T2c^2v(KFFIE-&Va2oBYGj6EGv zH*P-Vvq&bqBGtplD>sGR>miE8r3b=TJ=WM=vL&jqtFyekb(bqG4JE62A~S3dyjYb4 z*T;O6DTyd$CulrOxUmbdKelpe!aJ{rvr|_}17|SW%8zl{7_R;c$>c@y9LRG5AGw$W zRMNg0*@%2?%q0^PL_t#y0j-6#0aw0|zW9as7?iYCkJZ31ol~jn&b^tbG2t17cInks zIKXwr)QFSy)hV@#=+j9}7xr|Z#j_5?*rV^be~rBFW4*0Af#fXoNoW*WhhMBKY2~Kc zZT8|Z#JI@3q{^wQ%6Bdg_QJCn2Hid7Iz4>Tf4d`sH zmR;FFef_H92C)4Opt!uTqR*?KU6|0Pq0#Br|Gtj@%ZuVW5A@_0%UkWa7|>#{!)WhV zqH78e-=l{Nh~9sF0es?e1{pT)QFZHZ_1JoFtpRubIGb>D7L8AYj{X; z=$(0m61^BnHzE3Iy8{YuDXIl#StNbA;{5VkHL6b)Uq zv03kFzrl=bi0Dv6ST4Q?&tFfs$MgGq*vh}^7>k|~{pWjE8oNPjVRfE+RSvpg_%d=OLCFbM_Z!~4?INcuGcIZCjjdx+yX`Af9Y&?*~ z*Tpmx-|j0$R;`bBRIFntbR)O@Zz$0C(qMcrU5_I^FB3n}LoW&nE5R}Fq07)Y>%b#d z-G%|366fN8yW6AnOd07bXmfgE1lW`i-@k6BKg9JZ`WbFhw0dnW%&BO~Zf?Eat9z{o zhS?#G4qn;wN&d|kxHZ4(dJQ3Ra4!xB10y{`MU!8?d})%GmY$B_k_l*G-U@nU`j6(M zYsyc-Ta$wUNuTumx*9yDq#M^#^vRq-6vu#-gvAC72DF-0J4|Fvwucs#qK9 zU;<|?^8)xM7&( zQw9b(V(F&dvIY+iyRd_P`sQpx4vvnX;y~s_8+b{4vHoasRnsB=o8eR zz;s)KfEk7%ZKjF6pJmVx$jdf1BrqW~aqaWIrp#krjPTJa5>S-QDZrt;U(dbi5hoqR zH4qW30?7YuouE?85tE78dB|u^q)Me1E^P@g)}L~HzFoR;Tao=u^opBkE02GRKNyN0 z`Nr>C*j=ZQ9KcV%X84XW8{B#$Q&6y>AL%dgxIo|TL&LN=hNzxOQ2^`m&f0|+QysvQ z2mYh;f6KXl$F}t3J0lizRO0KuQb+VXlBjjX=bB=nsg1X7V$x0&u+!?*QdpLIV+M_+ zv~KQQX;m~fn0O%M94(OqQu_VENbP`voyWZn0>u>2uVk^gc_+|s3Y&3_tyWlq=1^D7$orT3rn!AoC}z<=dOa3;>2R(9z8&!)Cy zx0uy={ORa8X)0|nq`#&7(hsN%dZob%=Is=xi`Ew}U0EoV=a>}{ce&Y4mLP6^7z%T} z&VOWITmZSUeWs~>9qw77O&qHeODw-;Zwm*+!==3$ZD8sTp6bfp{M%LLigv8v>iU|r z_N5j{#d>XY+or6nAk(v}w?39hn@Y|xBUzm8T24Oe&gQy}nD8Lm;N*+jggNXb)CB%8 zFDrC7Ex?lSmAH+(9fk>9PrpXu@n{YHtyQnBo|?cgA50mCL0D2c#D|}#XJ^+@v}Mbdxu&}9hz{q2N?D)5cXw|&;L*MPFMlky}aeh4x@ed_u@(fsE+DL;UHaTmDI zYW&>D`}YE+!~!t#h6tX+-ht9fv18J{;AACa{0@15Zbkx~9$cj$A|xxo#oM2fLl=5+ zN1@-K*ES^^TseR%gia9tzehm56*(ZbgiQefkG4Dlxm3pdUhw;Pur1VPr-q9 z{vy7D(~dJdam=R=G%_H9 zK64d|y}(8Xx~|=7VtdpF26Vg6%p_^uEfA{QY9s& z82Vv!t)Z?zBjQu?Lg)fVQ4g?rpLJ<+ePN0{4*e0c$HifQk?&=L!dPG{0zH4jFZJlk zDkEc;qsUhhm_NcWM{WsUIhd@|5+;2MCC9#3r9rU0LZ8-x|QlXfgafQMh>EW5XhpVR-Jm6X7N0+z_ z2DB6VsEWPB)}6ST64t$-oaDxqpX6lQvzh+=@1ChQm37?1%Ab0?cO&Hf;v-sEv2R%W zCa|vb6%Kbc*_So>1SMb8Fa{qFKZWE9u2d(m6Q^m7&k z9xxwkZYDCDeGXRl9kb`weRMO`0gvv?6vWWV7o3M|c6f)+T@&I>TT~j~RTL-f9r@Mj zbJk;>AIv0S@8-xJ*Xq`>pnfdLe1WFP;a__|63o=(9O_8pP#twVdpZsM*QAiegXOQ^991X6p@8 zO(J8RKR;jWjS6h?dE$ZD*_{_EB`13^^296o&ND}83Rgkh=(3rA)} zv+-$~q`)YmZuePCDS}LX^`&`?hpG3#lzdxqMznwBTIqY9cWKU2~m6-B)6YbSbu?$zEsIX9zq68U{_j5k6 z0;iJUI=^vdDzGBn18e<%%8tau#?KM`;Hs)Et%iP6RJ=KE_HE}Y^C!JT#56dOTZAh5 zyC!q=zBS1wyzJb0F9BZw8igI3I2ex?iDUNZ_5MW}a%Nqou1}@UjY!ad2c%H@V&C@MxN_wR64_U1w^F;~yoQE`Ps{JXTg8=* zt>}K78}aS=(2#4mFjzzM8T!$_%G1eD0P4#CTslYIs8Yr4O_OQ4dj}{sG5~U^J#V8% z)Tole76#M*X{xLLw$J^`&w8E&ki8QOJXx#-!#qN$<(;FBh%Mig3P#h~ z71FMa+w-49{gl<9N8O5%-pF9h?2J&iQnIjSWRYKiI;y?i?aZ0BhYvFVj+rC9ozN50 zIiI=~6HB=rPWo-2ejj~Slv9HxA1fuv7How|YMn<0r9>TNFBKi%F}30^Q@-3ATV}M| zZXd=O?RENlbql{0Hg)f+t+{Po#Ho+0ngugS)MCydn~B54Kz+IR+QP$X502$Sb-nYJ zAEme!J-1j@`iO`?4(Q9;_HdnMt)VD%i=7tbyo_JVbxXlj6?)RV`@i(s@pkVvUTu1O zqXQL5Gv~t*%z%kU?U&1<+-&08KfHD_dCKX`-d$7U1!RZARV+85g+`fROv`6_kXw@| zC|DNYc#EP7EU(*foEB`L^`oyZ}gtP=a%otWY6kFq8IXg^6 zp9HX{US0(dm&gCZZ4$W$xyDMwHjc1eZOFHA2ES43j}9ni6oNoDHMQ5#umaIfyP%2P z-Up>huPR&F9*a{vEtFGm?^YCH^ShFEm{NH*0jnGB>HZ}~z-a42+Slu0KET7cEtHwh z>^}>~%m#rmAC$V`?&hvA|NH@~EFIoOTK$r6w*eHyB?>gqS5vU?g5{xS$|>9Hbr|Aj zFkG+HO(WFkhd^=G3*E5&`!^!3o!?%cgh z54PI~6ptV5ipmQ0FY}N%e&ka{2j9ii+-$ literal 0 HcmV?d00001 diff --git a/docs/topics/DatabaseOperations.adoc b/docs/topics/DatabaseOperations.adoc index 35bcdbb16..3ac913732 100644 --- a/docs/topics/DatabaseOperations.adoc +++ b/docs/topics/DatabaseOperations.adoc @@ -389,6 +389,24 @@ You will be asked to enter the HMAC key you created earlier, copy/paste they key == Command Line Tool KeePassXC comes with the command line tool *keepassxc-cli* to access, view, and manipulate your database directly from a terminal window. The tool is documented through a separate man page, which can be shown using `man keepassxc-cli`, or through the on-demand help using `keepassxc-cli [command] -h`. An online version of the man page is https://github.com/keepassxreboot/keepassxc/blob/master/docs/man/keepassxc-cli.1.adoc[available on GitHub]. + +== Remote database support +KeePassXC provides support for syncing database files that reside in a remote location. If you can download/upload the database file via a commandline tool (e.g. rsync, ssh, scp etc.) KeePassXC offers easy to use functionality to sync the remote database. + +=== Sync with remote database +Open the remote sync settings via _Database > Database Settings… > Remote_ to create commands to sync a local database or a temporary local copy of a remote database. + +Define a name for your sync command and specify a download *(A)* as well as an upload command *(B)*. The command and/or input need a `{TEMP_DATABASE}` placeholder specified where the remote database is temporarily stored. Do not forget to save the command settings with the save button *\(C)*. Remote settings are added as menu entries below the _Remote Sync…_ menu for quick access. + +WARNING: If your download or upload command require a password prompt, the command will most likely not succeed. In case of an SSH connection (e.g. sftp), it is recommended to use <> so that no password prompt is needed. + +.Remote sync settings +image::sync_remote_settings.png[] + +Select the remote sync command from the _Database > Remote Sync…_ menu to start the syncing process and a progress bar will show up in the lower right corner. + +WARNING: In case the remote database is changed by another user/process after the downloading command finishes and before uploading again, those changes will be overwritten. Syncing is not an atomic operation. + // end::advanced[] == Storing a Database File diff --git a/share/icons/application/scalable/actions/remote-sync.svg b/share/icons/application/scalable/actions/remote-sync.svg new file mode 100644 index 000000000..77b691b1d --- /dev/null +++ b/share/icons/application/scalable/actions/remote-sync.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/share/icons/icons.qrc b/share/icons/icons.qrc index a846bf7bc..bb91ffe4f 100644 --- a/share/icons/icons.qrc +++ b/share/icons/icons.qrc @@ -69,6 +69,7 @@ application/scalable/actions/password-show-on.svg application/scalable/actions/qrcode.svg application/scalable/actions/refresh.svg + application/scalable/actions/remote-sync.svg application/scalable/actions/reports.svg application/scalable/actions/reports-exclude.svg application/scalable/actions/sort-alphabetical-ascending.svg diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts index 6192f8d1c..522e9fdd5 100644 --- a/share/translations/keepassxc_en.ts +++ b/share/translations/keepassxc_en.ts @@ -1672,6 +1672,10 @@ Are you sure you want to continue with this file?. Maintenance + + Remote Sync + + DatabaseSettingsWidgetBrowser @@ -2241,6 +2245,121 @@ removed from the database. + + DatabaseSettingsWidgetRemote + + Sync Commands + + + + Remove + + + + Command Settings + + + + Name + + + + Save + + + + Download + + + + Command: + + + + Download command field + + + + e.g.: "sftp user@hostname" or "scp user@hostname:DatabaseOnRemote.kdbx {TEMP_DATABASE}" + + + + Input: + + + + Download input field + + + + e.g.: +get DatabaseOnRemote.kdbx {TEMP_DATABASE} +exit +--- +{TEMP_DATABASE} is used as placeholder to store the database in a temporary location +The command has to exit. In case of `sftp` as last commend `exit` has to be sent + + + + + Upload + + + + Upload command field + + + + e.g.: "sftp user@hostname" or "scp {TEMP_DATABASE} user@hostname:DatabaseOnRemote.kdbx" + + + + Upload input field + + + + e.g.: +put {TEMP_DATABASE} DatabaseOnRemote.kdbx +exit +--- +{TEMP_DATABASE} is used as placeholder to store the database in a temporary location +The command has to exit. In case of `sftp` as last commend `exit` has to be sent + + + + + Name cannot be empty. + + + + Test + + + + Download command cannot be empty. + + + + Download failed with error: %1 + + + + Download finished, but file %1 could not be found. + + + + Download successful. + + + + Save Remote Settings + + + + You have unsaved changes. Do you want to save them? + + + DatabaseTabWidget @@ -2313,6 +2432,11 @@ This is definitely a bug, please report it to the developers. Database tab name modifier + + %1 [Temporary] + Database tab name modifier + + DatabaseWidget @@ -2505,6 +2629,34 @@ Disable safe saves and try again? Database tab name modifier + + Remote Sync did not contain any download or upload commands. + + + + Remote sync '%1' completed successfully! + + + + Remote sync '%1' failed: %2 + + + + Error while saving database %1: %2 + + + + Downloading... + + + + Uploading... + + + + Syncing... + + Remove passkey from entry @@ -5879,10 +6031,18 @@ We recommend you use the AppImage available on our downloads page. Toggle Allow Screen Capture + + Remote S&ync… + + Remove Passkey From Entry + + Setup Remote Sync… + + ManageDatabase @@ -8753,6 +8913,37 @@ This option is deprecated, use --set-key-file instead. + + RemoteHandler + + Command `%1` did not finish in time. Process was killed. + + + + Failed to upload merged database. Command `%1` did not finish in time. Process was killed. + + + + Invalid download parameters provided. + + + + Command `%1` failed to download database. + + + + Invalid database pointer or upload parameters provided. + + + + Command `%1` exited with status code: %2 + + + + Failed to upload merged database. Command `%1` exited with status code: %2 + + + ReportsWidgetBrowserStatistics diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f520a3a29..472568629 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -152,6 +152,10 @@ set(keepassx_SOURCES gui/dbsettings/DatabaseSettingsWidgetMetaDataSimple.cpp gui/dbsettings/DatabaseSettingsWidgetEncryption.cpp gui/dbsettings/DatabaseSettingsWidgetDatabaseKey.cpp + gui/remote/DatabaseSettingsWidgetRemote.cpp + gui/remote/RemoteHandler.cpp + gui/remote/RemoteProcess.cpp + gui/remote/RemoteSettings.cpp gui/reports/ReportsWidget.cpp gui/reports/ReportsDialog.cpp gui/reports/ReportsWidgetHealthcheck.cpp diff --git a/src/core/CustomData.cpp b/src/core/CustomData.cpp index 354dbf7d6..1772cd62b 100644 --- a/src/core/CustomData.cpp +++ b/src/core/CustomData.cpp @@ -27,6 +27,7 @@ const QString CustomData::BrowserLegacyKeyPrefix = QStringLiteral("Public Key: " const QString CustomData::ExcludeFromReportsLegacy = QStringLiteral("KnownBad"); const QString CustomData::FdoSecretsExposedGroup = QStringLiteral("FDO_SECRETS_EXPOSED_GROUP"); const QString CustomData::RandomSlug = QStringLiteral("KPXC_RANDOM_SLUG"); +const QString CustomData::RemoteProgramSettings = QStringLiteral("KPXC_REMOTE_SYNC_SETTINGS"); // Fallback item for return by reference static const CustomData::CustomDataItem NULL_ITEM{}; @@ -190,7 +191,8 @@ void CustomData::updateLastModified(QDateTime lastModified) bool CustomData::isProtected(const QString& key) const { - return key.startsWith(BrowserKeyPrefix) || key == Created || key == FdoSecretsExposedGroup; + return key.startsWith(BrowserKeyPrefix) || key == Created || key == FdoSecretsExposedGroup + || key == CustomData::RemoteProgramSettings; } bool CustomData::isAutoGenerated(const QString& key) const diff --git a/src/core/CustomData.h b/src/core/CustomData.h index a8ad04487..49e8a33ee 100644 --- a/src/core/CustomData.h +++ b/src/core/CustomData.h @@ -71,6 +71,7 @@ public: static const QString BrowserLegacyKeyPrefix; static const QString FdoSecretsExposedGroup; static const QString RandomSlug; + static const QString RemoteProgramSettings; // Pre-KDBX 4.1 static const QString ExcludeFromReportsLegacy; diff --git a/src/core/Database.cpp b/src/core/Database.cpp index 14ddd87d9..c5156bbce 100644 --- a/src/core/Database.cpp +++ b/src/core/Database.cpp @@ -1049,3 +1049,13 @@ QUuid Database::publicUuid() return QUuid::fromRfc4122(publicCustomData()["KPXC_PUBLIC_UUID"].toByteArray()); } + +void Database::markAsTemporaryDatabase() +{ + m_isTemporaryDatabase = true; +} + +bool Database::isTemporaryDatabase() +{ + return m_isTemporaryDatabase; +} diff --git a/src/core/Database.h b/src/core/Database.h index decceeecf..d4e8aac2a 100644 --- a/src/core/Database.h +++ b/src/core/Database.h @@ -150,6 +150,9 @@ public: bool changeKdf(const QSharedPointer& kdf); QByteArray transformedDatabaseKey() const; + void markAsTemporaryDatabase(); + bool isTemporaryDatabase(); + static Database* databaseByUuid(const QUuid& uuid); public slots: @@ -233,6 +236,7 @@ private: bool m_modified = false; bool m_hasNonDataChange = false; QString m_keyError; + bool m_isTemporaryDatabase = false; QStringList m_commonUsernames; QStringList m_tagList; diff --git a/src/gui/DatabaseOpenDialog.cpp b/src/gui/DatabaseOpenDialog.cpp index 98c74ce9a..fa9383ac2 100644 --- a/src/gui/DatabaseOpenDialog.cpp +++ b/src/gui/DatabaseOpenDialog.cpp @@ -201,6 +201,9 @@ void DatabaseOpenDialog::complete(bool accepted) { // save DB, since DatabaseOpenWidget will reset its data after accept() is called m_db = m_view->database(); + if (m_db && m_intent == Intent::RemoteSync) { + m_db->markAsTemporaryDatabase(); + } if (accepted) { accept(); @@ -211,3 +214,10 @@ void DatabaseOpenDialog::complete(bool accepted) emit dialogFinished(accepted, m_currentDbWidget); clearForms(); } + +void DatabaseOpenDialog::closeEvent(QCloseEvent* e) +{ + emit dialogFinished(false, m_currentDbWidget); + clearForms(); + QDialog::closeEvent(e); +} diff --git a/src/gui/DatabaseOpenDialog.h b/src/gui/DatabaseOpenDialog.h index b1a59b59a..d630ec67b 100644 --- a/src/gui/DatabaseOpenDialog.h +++ b/src/gui/DatabaseOpenDialog.h @@ -39,6 +39,7 @@ public: None, AutoType, Merge, + RemoteSync, Browser }; @@ -62,6 +63,7 @@ protected: void showEvent(QShowEvent* event) override; private: + void closeEvent(QCloseEvent* e) override; void selectTabOffset(int offset); QPointer m_view; diff --git a/src/gui/DatabaseTabWidget.cpp b/src/gui/DatabaseTabWidget.cpp index d98be1ef7..65dbeb230 100644 --- a/src/gui/DatabaseTabWidget.cpp +++ b/src/gui/DatabaseTabWidget.cpp @@ -184,7 +184,7 @@ void DatabaseTabWidget::addDatabaseTab(const QString& filePath, auto* dbWidget = new DatabaseWidget(QSharedPointer::create(cleanFilePath), this); addDatabaseTab(dbWidget, inBackground); dbWidget->performUnlockDatabase(password, keyfile); - updateLastDatabases(cleanFilePath); + updateLastDatabases(dbWidget->database()); } /** @@ -249,6 +249,10 @@ void DatabaseTabWidget::addDatabaseTab(DatabaseWidget* dbWidget, bool inBackgrou connect(dbWidget, SIGNAL(databaseUnlocked()), SLOT(emitDatabaseLockChanged())); connect(dbWidget, SIGNAL(databaseLocked()), SLOT(updateTabName())); connect(dbWidget, SIGNAL(databaseLocked()), SLOT(emitDatabaseLockChanged())); + connect(dbWidget, + &DatabaseWidget::unlockDatabaseInDialogForSync, + this, + &DatabaseTabWidget::unlockDatabaseInDialogForSync); } DatabaseWidget* DatabaseTabWidget::importFile() @@ -416,7 +420,7 @@ bool DatabaseTabWidget::saveDatabaseAs(int index) auto* dbWidget = databaseWidgetFromIndex(index); bool ok = dbWidget->saveAs(); if (ok) { - updateLastDatabases(dbWidget->database()->filePath()); + updateLastDatabases(dbWidget->database()); } return ok; } @@ -430,7 +434,7 @@ bool DatabaseTabWidget::saveDatabaseBackup(int index) auto* dbWidget = databaseWidgetFromIndex(index); bool ok = dbWidget->saveBackup(); if (ok) { - updateLastDatabases(dbWidget->database()->filePath()); + updateLastDatabases(dbWidget->database()); } return ok; } @@ -619,6 +623,11 @@ QString DatabaseTabWidget::tabName(int index) tabName = tr("%1 [Locked]", "Database tab name modifier").arg(tabName); } + if (dbWidget->database()->isTemporaryDatabase()) { + tabName = tr("%1 [Temporary]", "Database tab name modifier").arg(tabName); + } + + // needs to be last check, as MainWindow may remove the asterisk again if (dbWidget->database()->isModified()) { tabName.append("*"); } @@ -742,6 +751,11 @@ void DatabaseTabWidget::unlockAnyDatabaseInDialog(DatabaseOpenDialog::Intent int displayUnlockDialog(); } +void DatabaseTabWidget::unlockDatabaseInDialogForSync(const QString& filePath) +{ + unlockDatabaseInDialog(currentDatabaseWidget(), DatabaseOpenDialog::Intent::RemoteSync, filePath); +} + /** * Display the unlock dialog after it's been initialized. * This is an internal method, it should only be called by unlockDatabaseInDialog or unlockAnyDatabaseInDialog. @@ -768,7 +782,7 @@ void DatabaseTabWidget::handleDatabaseUnlockDialogFinished(bool accepted, Databa { // change the active tab to the database that was just unlocked in the dialog auto intent = m_databaseOpenDialog->intent(); - if (accepted && intent != DatabaseOpenDialog::Intent::Merge) { + if (accepted && intent != DatabaseOpenDialog::Intent::Merge && intent != DatabaseOpenDialog::Intent::RemoteSync) { int index = indexOf(dbWidget); if (index != -1) { setCurrentIndex(index); @@ -803,8 +817,12 @@ void DatabaseTabWidget::relockPendingDatabase() m_dbWidgetPendingLock = nullptr; } -void DatabaseTabWidget::updateLastDatabases(const QString& filename) +void DatabaseTabWidget::updateLastDatabases(const QSharedPointer& database) { + if (database->isTemporaryDatabase() || database->filePath().isEmpty()) { + return; + } + auto filename = database->filePath(); if (!config()->get(Config::RememberLastDatabases).toBool()) { config()->remove(Config::LastDatabases); } else { @@ -824,10 +842,7 @@ void DatabaseTabWidget::updateLastDatabases() auto dbWidget = currentDatabaseWidget(); if (dbWidget) { - auto filePath = dbWidget->database()->filePath(); - if (!filePath.isEmpty()) { - updateLastDatabases(filePath); - } + updateLastDatabases(dbWidget->database()); } } diff --git a/src/gui/DatabaseTabWidget.h b/src/gui/DatabaseTabWidget.h index 59e555451..d907c1250 100644 --- a/src/gui/DatabaseTabWidget.h +++ b/src/gui/DatabaseTabWidget.h @@ -77,6 +77,7 @@ public slots: void closeDatabaseFromSender(); void unlockDatabaseInDialog(DatabaseWidget* dbWidget, DatabaseOpenDialog::Intent intent); void unlockDatabaseInDialog(DatabaseWidget* dbWidget, DatabaseOpenDialog::Intent intent, const QString& filePath); + void unlockDatabaseInDialogForSync(const QString& filePath); void unlockAnyDatabaseInDialog(DatabaseOpenDialog::Intent intent); void relockPendingDatabase(); @@ -114,7 +115,7 @@ private slots: private: QSharedPointer execNewDatabaseWizard(); - void updateLastDatabases(const QString& filename); + void updateLastDatabases(const QSharedPointer& database); bool warnOnExport(); void displayUnlockDialog(); diff --git a/src/gui/DatabaseWidget.cpp b/src/gui/DatabaseWidget.cpp index df39d70ed..4938693ee 100644 --- a/src/gui/DatabaseWidget.cpp +++ b/src/gui/DatabaseWidget.cpp @@ -32,6 +32,7 @@ #include #include "autotype/AutoType.h" +#include "core/AsyncTask.h" #include "core/EntrySearcher.h" #include "core/Merger.h" #include "core/Tools.h" @@ -55,6 +56,8 @@ #include "gui/tag/TagView.h" #include "gui/widgets/ElidedLabel.h" #include "keeshare/KeeShare.h" +#include "remote/RemoteHandler.h" +#include "remote/RemoteSettings.h" #ifdef WITH_XC_NETWORKING #include "gui/IconDownloaderDialog.h" @@ -88,6 +91,7 @@ DatabaseWidget::DatabaseWidget(QSharedPointer db, QWidget* parent) , m_groupView(new GroupView(m_db.data(), this)) , m_tagView(new TagView(this)) , m_saveAttempts(0) + , m_remoteSettings(new RemoteSettings(m_db, this)) , m_entrySearcher(new EntrySearcher(false)) { Q_ASSERT(m_db); @@ -460,6 +464,7 @@ void DatabaseWidget::replaceDatabase(QSharedPointer db) connectDatabaseSignals(); m_groupView->changeDatabase(m_db); m_tagView->setDatabase(m_db); + m_remoteSettings->setDatabase(m_db); // Restore the new parent group pointer, if not found default to the root group // this prevents data loss when merging a database while creating a new entry @@ -1074,6 +1079,87 @@ int DatabaseWidget::addChildWidget(QWidget* w) return index; } +void DatabaseWidget::syncWithRemote(const RemoteParams* params) +{ + setDisabled(true); + emit databaseSyncInProgress(); + + QScopedPointer remoteHandler(new RemoteHandler(this)); + RemoteHandler::RemoteResult result; + result.success = false; + result.errorMessage = tr("Remote Sync did not contain any download or upload commands."); + + // Download the database + if (!params->downloadCommand.isEmpty()) { + emit updateSyncProgress(25, tr("Downloading...")); + // Start a download first then merge and upload in the callback + result = remoteHandler->download(params); + if (result.success) { + QString error; + QSharedPointer remoteDb = QSharedPointer::create(); + if (!remoteDb->open(result.filePath, m_db->key(), &error)) { + // Failed to open downloaded remote database with same key + // Unlock downloaded remote database via dialog + syncDatabaseWithLockedDatabase(result.filePath, params); + return; + } + remoteDb->markAsTemporaryDatabase(); + if (!syncWithDatabase(remoteDb, error)) { + // Something failed during the sync process + result.success = false; + result.errorMessage = error; + } + } + } + + uploadAndFinishSync(params, result); +} + +void DatabaseWidget::syncDatabaseWithLockedDatabase(const QString& filePath, const RemoteParams* params) +{ + // disconnect any previously added slots to these signal + disconnect(this, &DatabaseWidget::databaseSyncUnlocked, nullptr, nullptr); + disconnect(this, &DatabaseWidget::databaseSyncUnlockFailed, nullptr, nullptr); + + connect(this, &DatabaseWidget::databaseSyncUnlocked, [this, params](const RemoteHandler::RemoteResult& result) { + uploadAndFinishSync(params, result); + }); + connect(this, &DatabaseWidget::databaseSyncUnlockFailed, [this, params](const RemoteHandler::RemoteResult& result) { + finishSync(params, result); + }); + + emit unlockDatabaseInDialogForSync(filePath); +} + +void DatabaseWidget::uploadAndFinishSync(const RemoteParams* params, RemoteHandler::RemoteResult result) +{ + QScopedPointer remoteHandler(new RemoteHandler(this)); + if (result.success && !params->uploadCommand.isEmpty()) { + emit updateSyncProgress(75, tr("Uploading...")); + result = remoteHandler->upload(result.filePath, params); + } + + finishSync(params, result); +} + +void DatabaseWidget::finishSync(const RemoteParams* params, RemoteHandler::RemoteResult result) +{ + setDisabled(false); + emit updateSyncProgress(-1, ""); + if (result.success) { + emit databaseSyncCompleted(params->name); + showMessage(tr("Remote sync '%1' completed successfully!").arg(params->name), MessageWidget::Positive, false); + } else { + emit databaseSyncFailed(params->name, result.errorMessage); + showErrorMessage(tr("Remote sync '%1' failed: %2").arg(params->name, result.errorMessage)); + } +} + +QList DatabaseWidget::getRemoteParams() const +{ + return m_remoteSettings->getAllRemoteParams(); +} + void DatabaseWidget::switchToMainView(bool previousDialogAccepted) { setCurrentWidget(m_mainWidget); @@ -1243,6 +1329,59 @@ void DatabaseWidget::mergeDatabase(bool accepted) emit databaseMerged(m_db); } +void DatabaseWidget::syncUnlockedDatabase(bool accepted) +{ + if (accepted) { + if (!m_db) { + showMessage(tr("No current database."), MessageWidget::Error); + return; + } + + auto* senderDialog = qobject_cast(sender()); + + Q_ASSERT(senderDialog); + if (!senderDialog) { + return; + } + auto destinationDb = senderDialog->database(); + + if (!destinationDb) { + showMessage(tr("No source database, nothing to do."), MessageWidget::Error); + return; + } + + RemoteHandler::RemoteResult result; + QString error; + result.success = syncWithDatabase(destinationDb, error); + result.errorMessage = error; + result.filePath = destinationDb->filePath(); + + emit databaseSyncUnlocked(result); + } + switchToMainView(); +} + +bool DatabaseWidget::syncWithDatabase(const QSharedPointer& otherDb, QString& error) +{ + emit updateSyncProgress(50, tr("Syncing...")); + Merger firstMerge(m_db.data(), otherDb.data()); + Merger secondMerge(otherDb.data(), m_db.data()); + QStringList changeList = firstMerge.merge() + secondMerge.merge(); + + if (!changeList.isEmpty()) { + // Save synced databases + if (!m_db->save(Database::Atomic, {}, &error)) { + error = tr("Error while saving database %1: %2").arg(m_db->filePath(), error); + return false; + } + if (!otherDb->save(Database::Atomic, {}, &error)) { + error = tr("Error while saving database %1: %2").arg(otherDb->filePath(), error); + return false; + } + } + return true; +} + /** * Unlock the database. * @@ -1256,12 +1395,23 @@ void DatabaseWidget::unlockDatabase(bool accepted) if (!senderDialog && (!m_db || !m_db->isInitialized())) { emit closeRequest(); } + if (senderDialog && senderDialog->intent() == DatabaseOpenDialog::Intent::RemoteSync) { + RemoteHandler::RemoteResult result; + result.success = false; + result.errorMessage = "Remote database unlock cancelled."; + emit databaseSyncUnlockFailed(result); + } return; } - if (senderDialog && senderDialog->intent() == DatabaseOpenDialog::Intent::Merge) { - mergeDatabase(accepted); - return; + if (senderDialog) { + if (senderDialog->intent() == DatabaseOpenDialog::Intent::Merge) { + mergeDatabase(accepted); + return; + } else if (senderDialog->intent() == DatabaseOpenDialog::Intent::RemoteSync) { + syncUnlockedDatabase(accepted); + return; + } } QSharedPointer db; @@ -1416,6 +1566,12 @@ void DatabaseWidget::switchToDatabaseSecurity() m_databaseSettingDialog->showDatabaseKeySettings(); } +void DatabaseWidget::switchToRemoteSettings() +{ + switchToDatabaseSettings(); + m_databaseSettingDialog->showRemoteSettings(); +} + #ifdef WITH_XC_BROWSER_PASSKEYS void DatabaseWidget::switchToPasskeys() { @@ -1596,6 +1752,7 @@ void DatabaseWidget::onGroupChanged() void DatabaseWidget::onDatabaseModified() { refreshSearch(); + m_remoteSettings->loadSettings(); int autosaveDelayMs = m_db->metadata()->autosaveDelayMin() * 60 * 1000; // min to msec for QTimer bool autosaveAfterEveryChangeConfig = config()->get(Config::AutoSaveAfterEveryChange).toBool(); if (autosaveDelayMs > 0 && autosaveAfterEveryChangeConfig) { diff --git a/src/gui/DatabaseWidget.h b/src/gui/DatabaseWidget.h index 0b306d91d..148df67aa 100644 --- a/src/gui/DatabaseWidget.h +++ b/src/gui/DatabaseWidget.h @@ -19,7 +19,6 @@ #ifndef KEEPASSX_DATABASEWIDGET_H #define KEEPASSX_DATABASEWIDGET_H -#include #include #include "core/Database.h" @@ -27,6 +26,7 @@ #include "core/Metadata.h" #include "gui/MessageWidget.h" #include "gui/entry/EntryModel.h" +#include "remote/RemoteHandler.h" class DatabaseOpenDialog; class DatabaseOpenWidget; @@ -45,6 +45,8 @@ class QLabel; class EntryPreviewWidget; class TagView; class ElidedLabel; +class RemoteSettings; +struct RemoteParams; namespace Ui { @@ -122,6 +124,10 @@ public: void setSplitterSizes(const QHash>& sizes); void setSearchStringForAutoType(const QString& search); + void syncWithRemote(const RemoteParams* params); + void syncDatabaseWithLockedDatabase(const QString& filePath, const RemoteParams* params); + QList getRemoteParams() const; + signals: // relayed Database signals void databaseFilePathChanged(const QString& oldPath, const QString& newPath); @@ -142,6 +148,13 @@ signals: void requestOpenDatabase(const QString& filePath, bool inBackground, const QString& password, const QString& keyFile); void databaseMerged(QSharedPointer mergedDb); + void databaseSyncInProgress(); + void databaseSyncCompleted(const QString& syncName); + void databaseSyncFailed(const QString& syncName, const QString& error); + void databaseSyncUnlockFailed(const RemoteHandler::RemoteResult& result); + void databaseSyncUnlocked(const RemoteHandler::RemoteResult& result); + void unlockDatabaseInDialogForSync(const QString& filePath); + void updateSyncProgress(int percentage, QString message); void groupContextMenuRequested(const QPoint& globalPos); void entryContextMenuRequested(const QPoint& globalPos); void listModeAboutToActivate(); @@ -209,6 +222,7 @@ public slots: void switchToDatabaseSecurity(); void switchToDatabaseReports(); void switchToDatabaseSettings(); + void switchToRemoteSettings(); #ifdef WITH_XC_BROWSER_PASSKEYS void switchToPasskeys(); void showImportPasskeyDialog(bool isEntry = false); @@ -260,6 +274,10 @@ private slots: void loadDatabase(bool accepted); void unlockDatabase(bool accepted); void mergeDatabase(bool accepted); + void syncUnlockedDatabase(bool accepted); + bool syncWithDatabase(const QSharedPointer& otherDb, QString& error); + void uploadAndFinishSync(const RemoteParams* params, RemoteHandler::RemoteResult result); + void finishSync(const RemoteParams* params, RemoteHandler::RemoteResult result); void emitCurrentModeChanged(); // Database autoreload slots void reloadDatabaseFile(); @@ -302,6 +320,8 @@ private: int m_saveAttempts; + QScopedPointer m_remoteSettings; + // Search state QScopedPointer m_entrySearcher; QString m_lastSearchText; diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 6e9bd2a29..7229ef92b 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -47,6 +47,7 @@ #include "gui/ShortcutSettingsPage.h" #include "gui/entry/EntryView.h" #include "gui/osutils/OSUtils.h" +#include "gui/remote/RemoteSettings.h" #ifdef WITH_XC_UPDATECHECK #include "gui/UpdateCheckDialog.h" @@ -161,6 +162,8 @@ MainWindow::MainWindow() m_entryNewContextMenu = new QMenu(this); m_entryNewContextMenu->addAction(m_ui->actionEntryNew); + connect(m_ui->menuRemoteSync, &QMenu::aboutToShow, this, &MainWindow::updateRemoteSyncMenuEntries); + // Build Entry Level Auto-Type menu auto autotypeMenu = new QMenu({}, this); autotypeMenu->addAction(m_ui->actionEntryAutoTypeSequence); @@ -355,6 +358,7 @@ MainWindow::MainWindow() m_ui->actionLockAllDatabases->setIcon(icons()->icon("database-lock-all")); m_ui->actionQuit->setIcon(icons()->icon("application-exit")); m_ui->actionDatabaseMerge->setIcon(icons()->icon("database-merge")); + m_ui->menuRemoteSync->setIcon(icons()->icon("remote-sync")); m_ui->actionImport->setIcon(icons()->icon("document-import")); m_ui->menuExport->setIcon(icons()->icon("document-export")); @@ -668,7 +672,11 @@ MainWindow::MainWindow() m_progressBar->setFixedHeight(15); m_progressBar->setMaximum(100); statusBar()->addPermanentWidget(m_progressBar); - connect(clipboard(), SIGNAL(updateCountdown(int, QString)), this, SLOT(updateProgressBar(int, QString))); + connect(clipboard(), &Clipboard::updateCountdown, this, &MainWindow::updateProgressBar); + m_actionMultiplexer.connect(SIGNAL(updateSyncProgress(int, QString)), this, SLOT(updateProgressBar(int, QString))); + m_actionMultiplexer.connect(SIGNAL(databaseSyncInProgress()), this, SLOT(disableMenuAndToolbar())); + m_actionMultiplexer.connect(SIGNAL(databaseSyncCompleted(QString)), this, SLOT(enableMenuAndToolbar())); + m_actionMultiplexer.connect(SIGNAL(databaseSyncFailed(QString, const QString)), this, SLOT(enableMenuAndToolbar())); m_statusBarLabel = new QLabel(statusBar()); m_statusBarLabel->setObjectName("statusBarLabel"); statusBar()->addPermanentWidget(m_statusBarLabel); @@ -860,6 +868,7 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode) m_ui->actionDatabaseClose->setEnabled(true); m_ui->actionDatabaseMerge->setEnabled(inDatabaseTabWidget); + m_ui->menuRemoteSync->setEnabled(inDatabaseTabWidget); m_ui->actionDatabaseNew->setEnabled(inDatabaseTabWidgetOrWelcomeWidget); m_ui->actionDatabaseOpen->setEnabled(inDatabaseTabWidgetOrWelcomeWidget); m_ui->menuRecentDatabases->setEnabled(inDatabaseTabWidgetOrWelcomeWidget); @@ -965,6 +974,7 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode) m_ui->actionEntryImportPasskey->setEnabled(singleEntrySelected); m_ui->actionEntryRemovePasskey->setEnabled(singleEntryHasPasskey); #endif + m_ui->menuRemoteSync->setEnabled(true); #ifdef WITH_XC_SSHAGENT bool singleEntryHasSshKey = singleEntrySelected && sshAgent()->isEnabled() && dbWidget->currentEntryHasSshKey(); @@ -1020,6 +1030,7 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode) m_ui->actionExportCsv->setEnabled(false); m_ui->actionExportHtml->setEnabled(false); m_ui->actionDatabaseMerge->setEnabled(false); + m_ui->menuRemoteSync->setEnabled(false); // Only disable the action in the database menu so that the // menu remains active in the toolbar, if necessary m_ui->actionLockDatabase->setEnabled(false); @@ -1071,6 +1082,7 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode) m_ui->actionExportCsv->setEnabled(false); m_ui->actionExportHtml->setEnabled(false); m_ui->actionDatabaseMerge->setEnabled(false); + m_ui->menuRemoteSync->setEnabled(false); // Hide entry-specific actions m_ui->actionEntryMoveUp->setVisible(false); m_ui->actionEntryMoveDown->setVisible(false); @@ -1298,6 +1310,27 @@ void MainWindow::switchToDatabaseFile(const QString& file) switchToDatabases(); } +void MainWindow::updateRemoteSyncMenuEntries() +{ + m_ui->menuRemoteSync->clear(); + + auto dbWidget = m_ui->tabWidget->currentDatabaseWidget(); + if (dbWidget) { + // Setup sync shortcut + auto action = m_ui->menuRemoteSync->addAction(tr("Setup Remote Sync…")); + connect(action, &QAction::triggered, dbWidget, &DatabaseWidget::switchToRemoteSettings); + + m_ui->menuRemoteSync->addSeparator(); + + // Build remote sync menu + for (const auto params : dbWidget->getRemoteParams()) { + auto* remoteSyncAction = new QAction(params->name, this); + m_ui->menuRemoteSync->addAction(remoteSyncAction); + connect(remoteSyncAction, &QAction::triggered, dbWidget, [=] { dbWidget->syncWithRemote(params); }); + } + } +} + void MainWindow::databaseStatusChanged(DatabaseWidget* dbWidget) { Q_UNUSED(dbWidget); @@ -1485,6 +1518,18 @@ void MainWindow::focusSearchWidget() } } +void MainWindow::enableMenuAndToolbar() +{ + m_ui->toolBar->setDisabled(false); + m_ui->menubar->setDisabled(false); +} + +void MainWindow::disableMenuAndToolbar() +{ + m_ui->toolBar->setDisabled(true); + m_ui->menubar->setDisabled(true); +} + void MainWindow::saveWindowInformation() { if (isVisible()) { @@ -1497,7 +1542,7 @@ bool MainWindow::saveLastDatabases() { if (config()->get(Config::OpenPreviousDatabasesOnStartup).toBool()) { auto currentDbWidget = m_ui->tabWidget->currentDatabaseWidget(); - if (currentDbWidget) { + if (currentDbWidget && !currentDbWidget->database()->isTemporaryDatabase()) { config()->set(Config::LastActiveDatabase, currentDbWidget->database()->filePath()); } else { config()->remove(Config::LastActiveDatabase); @@ -1506,7 +1551,9 @@ bool MainWindow::saveLastDatabases() QStringList openDatabases; for (int i = 0; i < m_ui->tabWidget->count(); ++i) { auto dbWidget = m_ui->tabWidget->databaseWidgetFromIndex(i); - openDatabases.append(QDir::toNativeSeparators(dbWidget->database()->filePath())); + if (!dbWidget->database()->isTemporaryDatabase()) { + openDatabases.append(QDir::toNativeSeparators(dbWidget->database()->filePath())); + } } config()->set(Config::LastOpenedDatabases, openDatabases); diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index c47c0d205..7155bd110 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -125,6 +125,7 @@ private slots: void switchToNewDatabase(); void switchToOpenDatabase(); void switchToDatabaseFile(const QString& file); + void updateRemoteSyncMenuEntries(); void databaseStatusChanged(DatabaseWidget* dbWidget); void databaseTabChanged(int tabIndex); void openRecentDatabase(QAction* action); @@ -150,6 +151,8 @@ private slots: void updateProgressBar(int percentage, QString message); void updateEntryCountLabel(); void focusSearchWidget(); + void enableMenuAndToolbar(); + void disableMenuAndToolbar(); private: static const QString BaseWindowTitle; diff --git a/src/gui/MainWindow.ui b/src/gui/MainWindow.ui index 4cc82114c..c1a0133a5 100644 --- a/src/gui/MainWindow.ui +++ b/src/gui/MainWindow.ui @@ -239,6 +239,12 @@ + + + Remote S&ync… + + + @@ -258,6 +264,7 @@ + diff --git a/src/gui/dbsettings/DatabaseSettingsDialog.cpp b/src/gui/dbsettings/DatabaseSettingsDialog.cpp index ed6b2c090..14a303262 100644 --- a/src/gui/dbsettings/DatabaseSettingsDialog.cpp +++ b/src/gui/dbsettings/DatabaseSettingsDialog.cpp @@ -25,6 +25,7 @@ #ifdef WITH_XC_BROWSER #include "DatabaseSettingsWidgetBrowser.h" #endif +#include "../remote/DatabaseSettingsWidgetRemote.h" #include "DatabaseSettingsWidgetMaintenance.h" #ifdef WITH_XC_KEESHARE #include "keeshare/DatabaseSettingsPageKeeShare.h" @@ -72,6 +73,7 @@ DatabaseSettingsDialog::DatabaseSettingsDialog(QWidget* parent) , m_browserWidget(new DatabaseSettingsWidgetBrowser(this)) #endif , m_maintenanceWidget(new DatabaseSettingsWidgetMaintenance(this)) + , m_remoteWidget(new DatabaseSettingsWidgetRemote(this)) { m_ui->setupUi(this); @@ -79,9 +81,8 @@ DatabaseSettingsDialog::DatabaseSettingsDialog(QWidget* parent) connect(m_ui->buttonBox, SIGNAL(rejected()), SLOT(reject())); m_ui->categoryList->addCategory(tr("General"), icons()->icon("preferences-other")); - m_ui->categoryList->addCategory(tr("Security"), icons()->icon("security-high")); m_ui->stackedWidget->addWidget(m_generalWidget); - + m_ui->categoryList->addCategory(tr("Security"), icons()->icon("security-high")); m_ui->stackedWidget->addWidget(m_securityTabWidget); auto* scrollArea = new QScrollArea(parent); @@ -95,6 +96,9 @@ DatabaseSettingsDialog::DatabaseSettingsDialog(QWidget* parent) m_securityTabWidget->addTab(m_encryptionWidget, tr("Encryption Settings")); + m_ui->categoryList->addCategory(tr("Remote Sync"), icons()->icon("remote-sync")); + m_ui->stackedWidget->addWidget(m_remoteWidget); + #if defined(WITH_XC_KEESHARE) addSettingsPage(new DatabaseSettingsPageKeeShare()); #endif @@ -132,6 +136,7 @@ void DatabaseSettingsDialog::load(const QSharedPointer& db) m_browserWidget->load(db); #endif m_maintenanceWidget->load(db); + m_remoteWidget->load(db); for (const ExtraPage& page : asConst(m_extraPages)) { page.loadSettings(db); } @@ -158,6 +163,11 @@ void DatabaseSettingsDialog::showDatabaseKeySettings() m_securityTabWidget->setCurrentIndex(0); } +void DatabaseSettingsDialog::showRemoteSettings() +{ + m_ui->categoryList->setCurrentCategory(2); +} + void DatabaseSettingsDialog::save() { if (!m_generalWidget->save()) { @@ -172,6 +182,10 @@ void DatabaseSettingsDialog::save() return; } + if (!m_remoteWidget->save()) { + return; + } + for (const ExtraPage& extraPage : asConst(m_extraPages)) { extraPage.saveSettings(); } diff --git a/src/gui/dbsettings/DatabaseSettingsDialog.h b/src/gui/dbsettings/DatabaseSettingsDialog.h index 05b2e837b..9f04ecf39 100644 --- a/src/gui/dbsettings/DatabaseSettingsDialog.h +++ b/src/gui/dbsettings/DatabaseSettingsDialog.h @@ -31,6 +31,7 @@ class DatabaseSettingsWidgetDatabaseKey; class DatabaseSettingsWidgetBrowser; #endif class DatabaseSettingsWidgetMaintenance; +class DatabaseSettingsWidgetRemote; class QTabWidget; namespace Ui @@ -61,6 +62,7 @@ public: void load(const QSharedPointer& db); void addSettingsPage(IDatabaseSettingsPage* page); void showDatabaseKeySettings(); + void showRemoteSettings(); signals: void editFinished(bool accepted); @@ -87,6 +89,7 @@ private: QPointer m_browserWidget; #endif QPointer m_maintenanceWidget; + QPointer m_remoteWidget; class ExtraPage; QList m_extraPages; diff --git a/src/gui/remote/DatabaseSettingsWidgetRemote.cpp b/src/gui/remote/DatabaseSettingsWidgetRemote.cpp new file mode 100644 index 000000000..b38bd828c --- /dev/null +++ b/src/gui/remote/DatabaseSettingsWidgetRemote.cpp @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "DatabaseSettingsWidgetRemote.h" +#include "ui_DatabaseSettingsWidgetRemote.h" + +#include "core/Global.h" +#include "core/Metadata.h" + +#include "RemoteHandler.h" +#include "RemoteSettings.h" +#include "gui/MessageBox.h" + +DatabaseSettingsWidgetRemote::DatabaseSettingsWidgetRemote(QWidget* parent) + : DatabaseSettingsWidget(parent) + , m_remoteSettings(new RemoteSettings(nullptr, this)) + , m_ui(new Ui::DatabaseSettingsWidgetRemote()) +{ + m_ui->setupUi(this); + m_ui->messageWidget->setHidden(true); + + connect(m_ui->saveSettingsButton, &QPushButton::clicked, this, &DatabaseSettingsWidgetRemote::saveCurrentSettings); + connect( + m_ui->removeSettingsButton, &QPushButton::clicked, this, &DatabaseSettingsWidgetRemote::removeCurrentSettings); + connect(m_ui->settingsListWidget, + &QListWidget::itemSelectionChanged, + this, + &DatabaseSettingsWidgetRemote::editCurrentSettings); + connect(m_ui->testDownloadCommandButton, &QPushButton::clicked, this, &DatabaseSettingsWidgetRemote::testDownload); + + auto setModified = [this]() { m_modified = true; }; + connect(m_ui->nameLineEdit, &QLineEdit::textChanged, setModified); + connect(m_ui->downloadCommand, &QLineEdit::textChanged, setModified); + connect(m_ui->inputForDownload, &QPlainTextEdit::textChanged, setModified); + connect(m_ui->uploadCommand, &QLineEdit::textChanged, setModified); + connect(m_ui->inputForUpload, &QPlainTextEdit::textChanged, setModified); +} + +DatabaseSettingsWidgetRemote::~DatabaseSettingsWidgetRemote() = default; + +void DatabaseSettingsWidgetRemote::initialize() +{ + clearFields(); + m_remoteSettings->setDatabase(m_db); + updateSettingsList(); + if (m_ui->settingsListWidget->count() > 0) { + m_ui->settingsListWidget->setCurrentRow(0); + m_ui->removeSettingsButton->setEnabled(true); + } else { + m_ui->removeSettingsButton->setDisabled(true); + } +} + +void DatabaseSettingsWidgetRemote::uninitialize() +{ +} + +bool DatabaseSettingsWidgetRemote::save() +{ + if (m_modified) { + auto ans = MessageBox::question(this, + tr("Save Remote Settings"), + tr("You have unsaved changes. Do you want to save them?"), + MessageBox::Save | MessageBox::Discard | MessageBox::Cancel, + MessageBox::Save); + if (ans == MessageBox::Save) { + saveCurrentSettings(); + } else if (ans == MessageBox::Cancel) { + return false; + } + } + + m_remoteSettings->saveSettings(); + return true; +} + +void DatabaseSettingsWidgetRemote::saveCurrentSettings() +{ + QString name = m_ui->nameLineEdit->text(); + if (name.isEmpty()) { + m_ui->messageWidget->showMessage(tr("Name cannot be empty."), MessageWidget::Warning); + return; + } + + auto* params = new RemoteParams(); + params->name = m_ui->nameLineEdit->text(); + params->downloadCommand = m_ui->downloadCommand->text(); + params->downloadInput = m_ui->inputForDownload->toPlainText(); + params->uploadCommand = m_ui->uploadCommand->text(); + params->uploadInput = m_ui->inputForUpload->toPlainText(); + + m_remoteSettings->addRemoteParams(params); + updateSettingsList(); + + auto item = findItemByName(name); + m_ui->settingsListWidget->setCurrentItem(item); + m_ui->removeSettingsButton->setEnabled(true); + m_modified = false; +} + +QListWidgetItem* DatabaseSettingsWidgetRemote::findItemByName(const QString& name) +{ + return m_ui->settingsListWidget->findItems(name, Qt::MatchExactly).first(); +} + +void DatabaseSettingsWidgetRemote::removeCurrentSettings() +{ + m_remoteSettings->removeRemoteParams(m_ui->nameLineEdit->text()); + updateSettingsList(); + if (!m_remoteSettings->getAllRemoteParams().empty()) { + m_ui->settingsListWidget->setCurrentRow(0); + m_ui->removeSettingsButton->setEnabled(true); + } else { + clearFields(); + m_ui->removeSettingsButton->setDisabled(true); + } +} + +void DatabaseSettingsWidgetRemote::editCurrentSettings() +{ + if (!m_ui->settingsListWidget->currentItem()) { + return; + } + + QString name = m_ui->settingsListWidget->currentItem()->text(); + auto* params = m_remoteSettings->getRemoteParams(name); + if (!params) { + return; + } + + m_ui->nameLineEdit->setText(params->name); + m_ui->downloadCommand->setText(params->downloadCommand); + m_ui->inputForDownload->setPlainText(params->downloadInput); + m_ui->uploadCommand->setText(params->uploadCommand); + m_ui->inputForUpload->setPlainText(params->uploadInput); + m_modified = false; +} + +void DatabaseSettingsWidgetRemote::updateSettingsList() +{ + m_ui->settingsListWidget->clear(); + for (auto params : m_remoteSettings->getAllRemoteParams()) { + auto* item = new QListWidgetItem(m_ui->settingsListWidget); + item->setText(params->name); + m_ui->settingsListWidget->addItem(item); + } +} + +void DatabaseSettingsWidgetRemote::clearFields() +{ + m_ui->nameLineEdit->setText(""); + m_ui->downloadCommand->setText(""); + m_ui->inputForDownload->setPlainText(""); + m_ui->uploadCommand->setText(""); + m_ui->inputForUpload->setPlainText(""); + m_modified = false; +} + +void DatabaseSettingsWidgetRemote::testDownload() +{ + auto* params = new RemoteParams(); + params->name = m_ui->nameLineEdit->text(); + params->downloadCommand = m_ui->downloadCommand->text(); + params->downloadInput = m_ui->inputForDownload->toPlainText(); + + QScopedPointer remoteHandler(new RemoteHandler(this)); + if (params->downloadCommand.isEmpty()) { + m_ui->messageWidget->showMessage(tr("Download command cannot be empty."), MessageWidget::Warning); + return; + } + + RemoteHandler::RemoteResult result = remoteHandler->download(params); + if (!result.success) { + m_ui->messageWidget->showMessage(tr("Download failed with error: %1").arg(result.errorMessage), + MessageWidget::Error); + return; + } + + if (!QFile::exists(result.filePath)) { + m_ui->messageWidget->showMessage(tr("Download finished, but file %1 could not be found.").arg(result.filePath), + MessageWidget::Error); + return; + } + + m_ui->messageWidget->showMessage(tr("Download successful."), MessageWidget::Positive); +} \ No newline at end of file diff --git a/src/gui/remote/DatabaseSettingsWidgetRemote.h b/src/gui/remote/DatabaseSettingsWidgetRemote.h new file mode 100644 index 000000000..fe91b94a7 --- /dev/null +++ b/src/gui/remote/DatabaseSettingsWidgetRemote.h @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSX_DATABASESETTINGSWIDGETREMOTE_H +#define KEEPASSX_DATABASESETTINGSWIDGETREMOTE_H + +#include "gui/dbsettings/DatabaseSettingsWidget.h" + +#include +#include + +class Database; +class RemoteSettings; + +namespace Ui +{ + class DatabaseSettingsWidgetRemote; +} + +class DatabaseSettingsWidgetRemote : public DatabaseSettingsWidget +{ + Q_OBJECT + +public: + explicit DatabaseSettingsWidgetRemote(QWidget* parent = nullptr); + Q_DISABLE_COPY(DatabaseSettingsWidgetRemote); + ~DatabaseSettingsWidgetRemote() override; + +public slots: + void initialize() override; + void uninitialize() override; + bool save() override; + +private slots: + void saveCurrentSettings(); + void removeCurrentSettings(); + void editCurrentSettings(); + void testDownload(); + +private: + void updateSettingsList(); + QListWidgetItem* findItemByName(const QString& name); + void clearFields(); + + QScopedPointer m_remoteSettings; + const QScopedPointer m_ui; + bool m_modified = false; +}; + +#endif // KEEPASSX_DATABASESETTINGSWIDGETREMOTE_H diff --git a/src/gui/remote/DatabaseSettingsWidgetRemote.ui b/src/gui/remote/DatabaseSettingsWidgetRemote.ui new file mode 100644 index 000000000..85fc9e2ea --- /dev/null +++ b/src/gui/remote/DatabaseSettingsWidgetRemote.ui @@ -0,0 +1,260 @@ + + + DatabaseSettingsWidgetRemote + + + + 0 + 0 + 652 + 516 + + + + + 0 + 0 + + + + + 450 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + + + + Sync Commands + + + + + + + + + + + Qt::LeftToRight + + + Remove + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + 0 + 0 + + + + Command Settings + + + + QLayout::SetMinimumSize + + + + + + + Name + + + + + + + + + + + + Save + + + + + + + + + 0 + + + + Download + + + + + + Command: + + + + + + + + + Download command field + + + e.g.: "sftp user@hostname" or "scp user@hostname:DatabaseOnRemote.kdbx {TEMP_DATABASE}" + + + + + + + Test + + + + + + + + + Input: + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + Download input field + + + e.g.: +get DatabaseOnRemote.kdbx {TEMP_DATABASE} +exit +--- +{TEMP_DATABASE} is used as placeholder to store the database in a temporary location +The command has to exit. In case of `sftp` as last commend `exit` has to be sent + + + + + + + + + Upload + + + + + + Input: + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + Command: + + + + + + + Upload command field + + + e.g.: "sftp user@hostname" or "scp {TEMP_DATABASE} user@hostname:DatabaseOnRemote.kdbx" + + + + + + + Upload input field + + + e.g.: +put {TEMP_DATABASE} DatabaseOnRemote.kdbx +exit +--- +{TEMP_DATABASE} is used as placeholder to store the database in a temporary location +The command has to exit. In case of `sftp` as last commend `exit` has to be sent + + + + + + + + + + + + + + + + + + MessageWidget + QWidget +
gui/MessageWidget.h
+ 1 +
+
+ + +
diff --git a/src/gui/remote/RemoteHandler.cpp b/src/gui/remote/RemoteHandler.cpp new file mode 100644 index 000000000..66f2d2d25 --- /dev/null +++ b/src/gui/remote/RemoteHandler.cpp @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "RemoteHandler.h" + +#include "RemoteProcess.h" +#include "RemoteSettings.h" + +#include "core/AsyncTask.h" +#include "core/Database.h" + +namespace +{ + QString getTempFileLocation() + { + QString uuid = QUuid::createUuid().toString().remove(0, 1); + uuid.chop(1); + return QDir::toNativeSeparators(QDir::temp().absoluteFilePath("RemoteDatabase-" + uuid + ".kdbx")); + } +} // namespace + +std::function(QObject*)> RemoteHandler::m_createRemoteProcess([](QObject* parent) { + return QScopedPointer(new RemoteProcess(parent)); +}); + +RemoteHandler::RemoteHandler(QObject* parent) + : QObject(parent) +{ +} + +void RemoteHandler::setRemoteProcessFunc(std::function(QObject*)> func) +{ + m_createRemoteProcess = std::move(func); +} + +RemoteHandler::RemoteResult RemoteHandler::download(const RemoteParams* params) +{ + return AsyncTask::runAndWaitForFuture([params] { + RemoteResult result; + if (!params) { + result.success = false; + result.errorMessage = tr("Invalid download parameters provided."); + return result; + } + + auto filePath = getTempFileLocation(); + auto remoteProcess = m_createRemoteProcess(nullptr); // use nullptr parent, otherwise there is a warning + remoteProcess->setTempFileLocation(filePath); + remoteProcess->start(params->downloadCommand); + if (!params->downloadInput.isEmpty()) { + remoteProcess->write(params->downloadInput + "\n"); + remoteProcess->waitForBytesWritten(); + remoteProcess->closeWriteChannel(); + } + + bool finished = remoteProcess->waitForFinished(10000); + int statusCode = remoteProcess->exitCode(); + + // TODO: For future use + result.stdOutput = remoteProcess->readOutput(); + result.stdError = remoteProcess->readError(); + + if (finished && statusCode == 0) { + // Check if the file actually downloaded + QFileInfo fileInfo(filePath); + if (!fileInfo.exists() || fileInfo.size() == 0) { + result.success = false; + result.errorMessage = tr("Command `%1` failed to download database.").arg(params->downloadCommand); + } else { + result.success = true; + result.filePath = filePath; + } + } else if (finished) { + result.success = false; + result.errorMessage = + tr("Command `%1` exited with status code: %2").arg(params->downloadCommand).arg(statusCode); + } else { + remoteProcess->kill(); + result.success = false; + result.errorMessage = + tr("Command `%1` did not finish in time. Process was killed.").arg(params->downloadCommand); + } + + return result; + }); +} + +RemoteHandler::RemoteResult RemoteHandler::upload(const QString& filePath, const RemoteParams* params) +{ + return AsyncTask::runAndWaitForFuture([filePath, params] { + RemoteResult result; + if (!params) { + result.success = false; + result.errorMessage = tr("Invalid database pointer or upload parameters provided."); + return result; + } + + auto remoteProcess = m_createRemoteProcess(nullptr); // use nullptr parent, otherwise there is a warning + remoteProcess->setTempFileLocation(filePath); + remoteProcess->start(params->uploadCommand); + if (!params->uploadInput.isEmpty()) { + remoteProcess->write(params->uploadInput + "\n"); + remoteProcess->waitForBytesWritten(); + remoteProcess->closeWriteChannel(); + } + + bool finished = remoteProcess->waitForFinished(10000); + int statusCode = remoteProcess->exitCode(); + + // TODO: For future use + result.stdOutput = remoteProcess->readOutput(); + result.stdError = remoteProcess->readError(); + + if (finished && statusCode == 0) { + result.success = true; + } else if (finished) { + result.success = false; + result.errorMessage = tr("Failed to upload merged database. Command `%1` exited with status code: %2") + .arg(params->uploadCommand) + .arg(statusCode); + } else { + remoteProcess->kill(); + result.success = false; + result.errorMessage = + tr("Failed to upload merged database. Command `%1` did not finish in time. Process was killed.") + .arg(params->uploadCommand); + } + + return result; + }); +} diff --git a/src/gui/remote/RemoteHandler.h b/src/gui/remote/RemoteHandler.h new file mode 100644 index 000000000..a46ee8c19 --- /dev/null +++ b/src/gui/remote/RemoteHandler.h @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_REMOTEHANDLER_H +#define KEEPASSXC_REMOTEHANDLER_H + +#include + +class Database; +class RemoteProcess; +struct RemoteParams; + +class RemoteHandler : public QObject +{ + Q_OBJECT + +public: + explicit RemoteHandler(QObject* parent = nullptr); + ~RemoteHandler() override = default; + + struct RemoteResult + { + bool success; + QString errorMessage; + QString filePath; + QString stdOutput; + QString stdError; + }; + + RemoteResult download(const RemoteParams* params); + RemoteResult upload(const QString& filePath, const RemoteParams* params); + + // Used for testing only + static void setRemoteProcessFunc(std::function(QObject*)> func); + +private: + static std::function(QObject*)> m_createRemoteProcess; + static QString m_tempFileLocation; + + Q_DISABLE_COPY(RemoteHandler) +}; + +#endif // KEEPASSXC_REMOTEHANDLER_H diff --git a/src/gui/remote/RemoteProcess.cpp b/src/gui/remote/RemoteProcess.cpp new file mode 100644 index 000000000..33c0614d5 --- /dev/null +++ b/src/gui/remote/RemoteProcess.cpp @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "RemoteProcess.h" + +#include +#include + +RemoteProcess::RemoteProcess(QObject* parent) + : m_process(new QProcess(parent)) +{ +} + +RemoteProcess::~RemoteProcess() +{ +} + +void RemoteProcess::setTempFileLocation(const QString& tempFile) +{ + m_tempFileLocation = tempFile; +} + +void RemoteProcess::start(const QString& command) +{ + m_process->start(resolveTemplateVariables(command)); + m_process->waitForStarted(); +} + +qint64 RemoteProcess::write(const QString& input) +{ + auto resolved = resolveTemplateVariables(input); + return m_process->write(resolved.toUtf8()); +} + +bool RemoteProcess::waitForBytesWritten() +{ + return m_process->waitForBytesWritten(); +} + +void RemoteProcess::closeWriteChannel() +{ + m_process->closeWriteChannel(); +} + +bool RemoteProcess::waitForFinished(int msecs) +{ + return m_process->waitForFinished(msecs); +} + +int RemoteProcess::exitCode() const +{ + return m_process->exitCode(); +} + +QString RemoteProcess::readOutput() +{ + return m_process->readAllStandardOutput(); +} + +QString RemoteProcess::readError() +{ + return m_process->readAllStandardError(); +} + +void RemoteProcess::kill() const +{ + m_process->kill(); +} + +QString RemoteProcess::resolveTemplateVariables(const QString& input) const +{ + QString resolved = input; + return resolved.replace("{TEMP_DATABASE}", m_tempFileLocation); +} diff --git a/src/gui/remote/RemoteProcess.h b/src/gui/remote/RemoteProcess.h new file mode 100644 index 000000000..fb43d0430 --- /dev/null +++ b/src/gui/remote/RemoteProcess.h @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_REMOTEPROCESS_H +#define KEEPASSXC_REMOTEPROCESS_H + +#include + +class RemoteProcess +{ +public: + explicit RemoteProcess(QObject* parent); + virtual ~RemoteProcess(); + + virtual void setTempFileLocation(const QString& tempFile); + + virtual void start(const QString& command); + virtual qint64 write(const QString& input); + virtual bool waitForBytesWritten(); + virtual void closeWriteChannel(); + virtual bool waitForFinished(int msecs); + virtual QString readOutput(); + virtual QString readError(); + virtual int exitCode() const; + void kill() const; + +protected: + QString m_tempFileLocation; + +private: + QString resolveTemplateVariables(const QString& input) const; + + QScopedPointer m_process; +}; + +#endif // KEEPASSXC_REMOTEPROCESS_H diff --git a/src/gui/remote/RemoteSettings.cpp b/src/gui/remote/RemoteSettings.cpp new file mode 100644 index 000000000..cc7437de5 --- /dev/null +++ b/src/gui/remote/RemoteSettings.cpp @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "RemoteSettings.h" + +#include "core/Database.h" +#include "core/Metadata.h" + +#include +#include +#include +#include + +RemoteSettings::RemoteSettings(const QSharedPointer& db, QObject* parent) + : QObject(parent) +{ + setDatabase(db); +} + +RemoteSettings::~RemoteSettings() = default; + +void RemoteSettings::setDatabase(const QSharedPointer& db) +{ + m_remoteParams.clear(); + m_db = db; + loadSettings(); +} + +void RemoteSettings::addRemoteParams(RemoteParams* params) +{ + if (params->name.isEmpty()) { + qWarning() << "RemoteSettings::addRemoteParams: Remote parameters name is empty"; + return; + } + m_remoteParams.insert(params->name, params); +} + +void RemoteSettings::removeRemoteParams(const QString& name) +{ + m_remoteParams.remove(name); +} + +RemoteParams* RemoteSettings::getRemoteParams(const QString& name) const +{ + if (m_remoteParams.contains(name)) { + return m_remoteParams.value(name); + } + return nullptr; +} + +QList RemoteSettings::getAllRemoteParams() const +{ + return m_remoteParams.values(); +} + +void RemoteSettings::loadSettings() +{ + if (m_db) { + fromConfig(m_db->metadata()->customData()->value(CustomData::RemoteProgramSettings)); + } +} + +void RemoteSettings::saveSettings() const +{ + if (m_db) { + m_db->metadata()->customData()->set(CustomData::RemoteProgramSettings, toConfig()); + } +} + +QString RemoteSettings::toConfig() const +{ + QJsonArray config; + for (const auto params : m_remoteParams.values()) { + QJsonObject object; + object["name"] = params->name; + object["downloadCommand"] = params->downloadCommand; + object["downloadCommandInput"] = params->downloadInput; + object["uploadCommand"] = params->uploadCommand; + object["uploadCommandInput"] = params->uploadInput; + config << object; + } + QJsonDocument doc(config); + return doc.toJson(QJsonDocument::Compact); +} + +void RemoteSettings::fromConfig(const QString& data) +{ + m_remoteParams.clear(); + + QJsonDocument json = QJsonDocument::fromJson(data.toUtf8()); + for (const auto& item : json.array().toVariantList()) { + auto itemMap = item.toMap(); + auto* params = new RemoteParams(); + params->name = itemMap["name"].toString(); + params->downloadCommand = itemMap["downloadCommand"].toString(); + params->downloadInput = itemMap["downloadCommandInput"].toString(); + params->uploadCommand = itemMap["uploadCommand"].toString(); + params->uploadInput = itemMap["uploadCommandInput"].toString(); + + m_remoteParams.insert(params->name, params); + } +} diff --git a/src/gui/remote/RemoteSettings.h b/src/gui/remote/RemoteSettings.h new file mode 100644 index 000000000..4ddd6e341 --- /dev/null +++ b/src/gui/remote/RemoteSettings.h @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_REMOTESETTINGS_H +#define KEEPASSXC_REMOTESETTINGS_H + +#include +#include + +class Database; + +struct RemoteParams +{ + QString name; + QString downloadCommand; + QString downloadInput; + QString uploadCommand; + QString uploadInput; +}; +Q_DECLARE_METATYPE(RemoteParams) + +class RemoteSettings : public QObject +{ + Q_OBJECT +public: + explicit RemoteSettings(const QSharedPointer& db, QObject* parent = nullptr); + ~RemoteSettings() override; + + void setDatabase(const QSharedPointer& db); + + void addRemoteParams(RemoteParams* params); + void removeRemoteParams(const QString& name); + RemoteParams* getRemoteParams(const QString& name) const; + QList getAllRemoteParams() const; + + void loadSettings(); + void saveSettings() const; + +private: + void fromConfig(const QString& data); + QString toConfig() const; + + QHash m_remoteParams; + QSharedPointer m_db; +}; + +#endif // KEEPASSXC_REMOTESETTINGS_H diff --git a/tests/data/SyncDatabase.kdbx b/tests/data/SyncDatabase.kdbx new file mode 100644 index 0000000000000000000000000000000000000000..f72e6fb9ef638a3820874627a11af6a97d296895 GIT binary patch literal 32286 zcmV($K;ypy*`k_f`%AR}00RI55CAd3^5(yBLr}h01tDtuTK@wC0000000bZaq+vW3 z_*sl#1ADsdxs(qN++26Q6W-FS)7=zFPx{521t0(?>9ttKSshz9E!*`07Hu5tC`#S? zXbNiiazCu`pmoOv2mo*w00000000LN06YB(<1=BU7QB9n=IWjA=?EYI%dZ218)1?_ zCIZOb5~RfXp%Ilu)p411jY)kMRv~f`2_OK*Z5Tzv4=SVvevkR5(&s)%4yTAcYW@l#0O5&qt}fL{qm81qWjy#+7NhopuZ3;!iE2~zMr}2R2dX8 zKZ2q@^QpSXYzBuF!0N%Gv-FhBsehd*H;1WLut)-#7o&QK8uy;N8UEz=0E%ymWB-6J z4l+EE@5ysON_KZ#--6tvd*Vfgh<(?rU(FLDy4IT)UAHGlW76OvAX{T)iQ@y;2Wce< zy`>P3kSEg3YcOP8SZJ!b-Rr`oZiWGszDK8rd+AEnC*bN~F>BFmcqo1rL9Xp`60va4 zV{x*wh-e{G<>qUq;jyz5I=!9$@tAyWPQpG;WmP||I}2N3e=>HFVnrod6#j0sh>W5I zfW&SDO33QKB~l!aDf)ja40J~zhTR){@rjz^miLIpp|sZgc9h^SwuLy0pxZ%21jV6= z?1|3PF7eu(W1Z#8l;%qb;|wGo-JTTd(_PyTFa6?B&#T{18#0%A?f=VZF3fPg0YO}+ z9*$nacX!^8ST$ssG(42oNz1M2O%bFFEKiY2Gd;+WpT@Esi5P6$bYrFngqMU*A=DW; zAa6o2)^#C}w@nEbzU;F&4N$7Ih)OZrLiUAbNZ>l>^^hEkl9R(|TAtOlJ+YiP2X((5 z7e%-Nrr)^h`%hpZKKm^so+j{WJg!Q#!RHE7p@CP64H#DA`D?mSyHcMI;l*YFuI0-6 zw`S#4Iv&+$El#OrPLg9xUO`}^qo0%?zp-p=5|MYII?XCg8<0^X^s8z_aY!+1gGyB* zQp~F=5|3b)Kq{2iAKk23;3XN+9!`OZiHxm1Dub?H%E1` zAyFo~Ig^<3$BUp?Rjwk3m;`WBu8B75lC_EjGo%S%HIZQ%*^T#L;(#z?P-p-4_jUROQnp3EC zPc=*BUk1f|vW7@Ax}Y@sBBNGgcb57EhTw;st;@fgs~HDjvQjJdB7tPyRXP%NnqL4O z??az!MNtayq34%RiTJ^LCBS^~N%pQ}AS#exR_z|y-z_RR`vtWa`l8uupkZ9WH*^kx zWI!&HUhN_P_0s?xrgG_p2R%1yJzffD?<~4K!ClHc~-xj?H8{BFLsWN$el0i{Aisr5=Q z3+VQntgb|y!@^aY0;7(yK_2aC;88${^)q7rEFel31*O~Czz7u5&}*fmM5u`r?n?_v zKaZ{5mI-k4Iyq6h%7LlJZ7tM#S=G10+b~*8yUpWsue9$E{_g)jD!gKV{P16tk*F8B zhKr$vapSmWne5#_BH$rCiVZ+#|C%Fg!-)N&-gZ)C%FB3>q~|2F4GPpEL5A;M3L5s# zJDRo}Ap6D<=9bhbpF} z9WNitQW6zVD@EGNu|J-UvR$o_-^*c$RSNe{Zg#0rGaz=WqIzC$Fkre8e0aw0 zY$oYoxMSIPTkJFo4S^sr(!?sMS=Nl`&c35%2C)M<72UibZ-L1ER2uBPBp&>Qs4JNJLxdu#WO zuZx_C;IN`hzUpQ+S8;d)Ouy?3+p}>LY2#Ai@#Zr0&;D$3d9;hfd4qc^owo0XaAwV0 zEm|vTT9(af^p%aG^`?BVbs=wVr-)wSzW@|*jH8EG3AK5S4XrN3k?Z2mQL!Ttg_QH2Dvk_~B0%mZ%ja7IzVU5bvKss7 zdfl_23ptu`cE&{S}>4!IZ_;X0>CgV4X@W-pmvhkj<>h>;}hVrUnozb zyL>33=|kzE)LDkv8+{)ZXWEqyztZ>Ao5 z7~89uhCVR6wqtafTSG@@Fm>G`3SZQ**~LjN8rRjjoO^ zv!M0okUMfw%hPLm|~0PAEAUDq!GG6uI> zB+6KyZcn+PQ4?El`HHF-12ASsDyIF;{E5AI+q5Hgu}2GX}1V46uyr7 zwUHqZehqmEIgdB|-F5KG6M}vL2&WczQC>Ke&>-}#?U`8bt!O{*KVyo4|B2U1G${r|eJN6fUoGkcOiAq#83K|MIXYV=RcI+63e1NJu|6MvJu)hR z5WBh;LS9+fHFzZI%<)poPeZ0n&bezDS?-lzEdMv>DDb%IP`D#IjI_o6iRlLr^pVTb zyy9q>rT@_Rk;^j=x2z~3tg-h$U-IS^!VpHb$~pqc!mbh!x@W=y-fT^f8EpOwkKW&X zac>E}49zvB+nt-5tMI8^^3_QL3mk&Z&sC6c^G(q6NFAarzUyuW3cE`8TvNf;%@?Ns!F*rH2MJm+HI~BFtI3Co zK;&MbFr~92hive2tZup{{{mJ!)ET|Y({r8`kvHM^EL_d`C4D@%a!L0r{% zfvoAPGOiZ3}~I^UDn-8Bqj_#`h|g(X_^wuuU+=mJ9V(;L{d>lEbX_;$0^ zX4{P+;KQ|YTFCr&zrNul5idw_JZeeo8TOYJDbEyVQ|{J}Q3b+=rbvsd4yA$n)s6hE zJ8pMKBT{GhSg4W9i>cj&!XohpqO?XzD$5-oIitGdt&`ZG$7vaILj17l@cz0_NNez` z6?tt`r0fTFsMSc8w4WF6QxoyW0t%t*P}oJ2KZL?+Mqp>f>_KFT@%b9@=7FrQ<49M| zbjqUb3+ScSF(>F%Taf#sBS})=O>Zlkfi{P98Cs&EM&*!ZZo6Ecy-f3Q6a&~V@$K+D zR|^ykF74BZB;aLP404csTEq5=+^ILY=#WY5RFyjs=%ZGM3YvInN^(|HZ2Be3&j7eN zVBMCFR(}6_451tOT6UaAy7P2S5p=quaS)obK>v8+N%f6v@IebF23GOnWjmvC9J4|I zxsn98o5l-8s;5jFsrEu$fe8xfxP;fy7Wfq=T(cFFuuA6tG@ClP4VZgI*tFbSn#_YF zXb|^To__YImPPj7YmziQ50zK1&V8Wx0=)VMOPy~mkUJS}U%K{**a=^{Rw0@I{qcz3 zS4N~7vt$``S-Bnk%(n%@;KQaRru5n|g6Rv6KD@-mJO>mQ3aoK<7UUPdE7~%J^@P}0$F_0rt`uJj_T1R*S$8GiYRVCG9+ z>bSYOQWQ@>HZADNUl(zKSP<^0&eY=H-T#nQ%1d--?xiQi!v8#vw{P}An|hrS z9jYC-%X6)NRzqUs4YEQ((@kbk%#8JG+ZXSRO3ZVWB8gVZNn6Q)u@=v5o9I0-qx2~? zli)o`7-}KpiE<5Om);C5neWK3Fgn#>Gel^MfwJvbMxk+hk?b})ig}mFf7H!SgIONc z2^WDbTz|i5Y_SZgIU9mKczK6V47mx5d`Rsh7H^g5M@y9g)_t980U+FO}c2}!kLWV(tynn&tpdOIaJv#cuZdjw@#pD`Ee6mS(8(A4 zycE`A5F8!&`X9R-HkYg5#e{-N1P4%YG9dE&;xDcXAea{xxYlk~nsvv+YI#ZeU|0Iy zR$v0@ZPPmP`C$zO7Bt&LaTZ72Iz^%9nx2E><3|d;{FDSsWJo zq{W);SE@TUYHeO9n2Hn}a+3Q+6aGv)Q`_xB>B%8a$g{{iJ;PD%v;7`n7zfuY{KL56&muT16yHXIkxPYbV!XEW@c6# zmKL4ML}!Y1nFk|x0i*%1vB=jl=s@E)^+!7{2R! zGiq-nOhZ)OI?>6#nq7bi+uC9^5Kc2fGy!Qu!#H$CPnhxD)lsEaD$!+894sL;_ND$f z;8cW?8VlHso6eLL7H_BP#}B8r(>#&Lv?{VDEXuTZD7my4OrnYr2-mBX=fp)|IvokWcMHmA_ zRgcmw67V;+%IC>-H_H@xX1+6med_h>JC-&+(ps-tr6f6>jN6wX<@BlOLs~Ro3TuJ) zU2aCkckvo5%zf98o>Gr%oY6hfh}*Q<@9*h+o-3sumLOV0riK<|TZGqhC(Y}N(TL;a z>?3GC{oEv>RVcso&AoJvO3V8LnB=iVM5_hw<_BcXt2%beyIw%}D)C09Z-BD!a} zQHg53C4x*tM*2F%BPB{u8bKPRw> z0-mWaaQ-_XQq|({{~@=-anzJOPd3Hl^gxi}4w;&HU=QK8i0h_VauR4psMO4uNQfS$ zzCUwFW)h{mI(wDqt8H*FdaGWMQ=thZYS-F)J;Kz~1I-n>ieLfCNZgLVbFoLbS%m%g zm;GTICrKps>aU)QODJe#?_z~7;5G*|vOjc_uB}k{eJlfLvCL7)KFr5ani{%oJUE0t z+PyMYvW$>7G~edmT9JlQHjbeX9-!s4%T?Zqu%W9f_Id*xuLxy2O2IX=7TQ}-yZ3h! zM+4x{*mM;7sUb{#tS5l$lOWrqGx2j~Ykno0V9ngSzK5tJ=5Z31MM4WqrrYrC3r2mP zq8h0UjXilf5HZsU;61NgyhC4x_w37rG2Abq%F2(Ox^O-Wx^i=QixNy664ZX-wC5={ zWus@ex))67CVS&X;SRuP*c3!vIaaVshWA?^Ikr@&aYrs`x#)mf2sS#>yYoxpSJ_CW zy)g{ce!2%iO3hfE3hE}l_9ug9afo{N5!N71bxvxAa4!wq4h~3-Ws*OcG7Naxxj&LQ z9>!|cG)(MGY|N-4_DY1!2IGrMfc+e7yZA@>P!7X0K|Z8yM`y{H*L&)I7Hud27uFA~ zF-U2zB(IWOZvoT?n;GQ}O&%$kQH~Dve7XtOR%@S3mq{`Z=sl~>S!{2>&)n4pNhUe6 zjMHWFlq!D_Ey9SAQc`o8@KE*eQMR#S{<71z!V0|EZ$r0pz8P6&d7_uNtMi7~FHymH zs>|uYqm79mtR)*KC_BB@?`3w});0x=KeCO#v;3B_4UDqVt=Rp5MXG+O4=9jck0k71 zE|i5u&!c;Z%jmG05IXQ4O^^%KNZT|I?5UPt5ZOz{H*k-elgB0h$dry$l!kFb8O;r&HPwdk0aLBqNNcd(?hJ=U0Np00Z~a#ZhtFqi zdkVXk3NH7+vr#w?_9m_lB!y7(duJGEF~VvKJbt8&f?0BM`4;^XC#0eoo{g{Pyu*>E zsF8CQZGh7CWx$t`oQISvQ+AoJv*K=2&t`{Z5N`c8&8WtJ?$9KWaN5(QC7*UGT zIl92<^HPGU`;G6kgls~6`Ze4bnkRwZL)#?QY#U}8qw<6%MeO`U^Q zM7P9=`LkCk-HRmr#B#31RlwYwKO}Cxd9$TllLP(zr*wC)w4zlPl?6*p{zzJG`BakG zzKkXDQ$;%8bk>Vn1v4pod!7^JidSX;t^M9NEWIRbb5(4vauMUoo}6;fFqULIneZXa z3S-Tw{np+z#@yln&9o@f3wVi;_jRYvYYZDq0tO}%5;{Roo1byaT!3s?{l4E5AcgE% z=|C!S0(TQwpcz_#`osS-PwzhZv~N|;=(6AKcObk0{=E^o?pz`4){K_KDw)X;1%m_= zB;{!1;h?%RZ>pDQU)o9?Rh$ATr&Dz{hE(3RKVrAUeOOnjXmt-EpLpG-gFAuIrC=mp5t#${%8Aa2$axOcU5DTf;`Oydv^y2b%8|4Xzw4BB<`)9s zDqQlMv7gQfC6+;G6=>%cv9&o5X8CNQy%;7}v`m9Fd1!rM8{6|J9qYC}j!GOGha*;y zU#4DNV&6FiehDB1T}L%iv7RbDgV&5Amr;#~!Al>1VvLfY185!TfVu7DNXNV|tAZEA z6L%#Qt#UBD2F3k8@%iUL$@r;JOIA+?qGiG6(RzEq6DPlRG;GuJ-!D_ziC*3$$B9_N zAO{eqy8*2DvW`D35w7uW{y5+~|MOp{#7?%t1)>-b)6GjCS<{#?E$f!|Dhmt? z^BwZ5ht00{`~(fAlr|}4VQx`2s!|q)P>59x(>%B?E&MkBljS{;K3L--PKX4_& z{yhaD?Jtp4CNQC20*J&4-gM|fm%jL%F+a6!NYy2Vr(6YVDfZ_RD-V)6>FrRX+Q}~! z+%f-Tqllu#KfuQODZ$BEg6NqV=;VBpKd?KVeHjh2vby~2BoNk%ci7ZA!VvHWdS90d zW0)qDqgSb5D{yt9b~8yK*(=l_uGaA^H|^&m*3*)E>>=_?*#n^0MNUp# zn)BazI{~^WT|BUFT<%yde%V0G-ZSF2)T6@N5rSg@DDJqiOO81gPJb!0S|7*YRE6Q3 z7cf{7>LxqqG2iDcyI)(Sk{teTZ#)#gb9%@sBz^ZBHQ`Ehh<=b&M_1cI=Zn+#Du)4? zvPN$o0d|A_y5-JQwu79_w>?loZ<@Vk8_!%X8b?BBnxBo@+MDeBYfRoOwsKuR* zMfkl2uLBd|SOFy1185$p`r%6RQ9#+-Lzvj*Uwgkr2LAzYyMW52KS+si$Sj5I0>OI@ zn2Vz>g&4h3)u|d$<|COjLkNOowBX%;J(b#TxdU&wz7<<$&-(V#e5Pxt!QERghxyx% z3H?pEz7x{TG^I&OasrdCAWcnfDd(_x6Bt&KFrF$SuHZki9)xU zS}7F|TX$V7Xm8(gWMrr#a;BF1$UR^cnE|ioN7Q@)ejPLleL;7dQ~|CGJ0p%xYf{ic zRcsaO7`ZC1Oi#?P1ffl#7&DPI{BZMYMRJpY;a_ng^O&}250*@-2iawQ8*!%CBwx(2FXe)^bX7=r zGibTwwHF{!`}8ZljGYd^ec+?yU;MF71F{Su_P3%dl}Q2{I*^eA94|$F(TW}FV1}$W zF20L%9$}lfY!gVu^|pOtf7M*RUy)*MIDDnzC3M6|q+56Xu_w4&htP!JszAQX^2U1R zMQ-o`g;_voIiGp^`u;wC{sE1i!VoMMolK~V?xahky z6Bg8CQY<%o5{F);VwyWh9wU8}NbP?C=`-UQnPO985_!N0iR4G!u?92kzxKUjtUbVA zyfw$XW)bxhL=B5ugRmiPej<(Phm{@^Fl^TbRnAT$KD0QrG2&H&fLJ z`xc1q+y1FGH_Q-%^L!q~_=*sn4;)8x7{;iXA*c*yF$@Kx(}FI;vd~1)Ao>G zPMr`QOO60YN7RBNwHfZI+WFViW_5+@~)&q*SGam{A=5z5`G}u7l8OTA~i^MsN_xdKQ~Y8Ec(3 zi2;&AreDK%yNdMG)M%ayG|FK=NWwK`=j#)zSk>!znYCMXB; zI9$`_==g>~7?7pw$!kAetADg((y4De^`3Foo(_U4s~Ju&bY@Cjb#=VOpP-GAfqfQ~ zDJtB@Z2?c^T?o>|@>zB8_+1QenBAQ1HB-{WRo7E6LWmrQfyq0Nx8v08MDIety%q8$@&i7h)|DndEs|!u{W;_o+Q=#B!5b5l9y_pTJ>yk zh|9Z;jUl*!$5wRs>zcTs%ap1LYVIqeS|zlrU2i&zsV5?~0MGfEl7zgFYj+#kItmt! zC8a1}M|V~p$XQ>69?<8w84nq&zmfqMaBzWnU|N%frPt>dB#LM1zl%xTjI_Zvab;B9 zD_E*;01l$yo<1>XYw@XN&1Nvkc24`(2F~I&9MUV1{64xtWQ|Q4baz8rXiyXpV>w-j zCtO@qAvjj)*+{^H2js>$&6TusEdfNd{c@{8(y8?_QL z^44z1FCi+&?o(<_O{7qa?_{rWy#shZEaqfv)~a=H8bHH-3mGh~wx2(eQMY2{0&FiV z<12wGyim)+CP9s;Om$$!G$s(Q_9U;7PU_RxA|Vg$t(Rjt9jGER`n83yrne=)v@tE}{7f*@QELwEuOs$+Rd-x0UBmw-g}&R* z06?z5#fR{GREob;g+Qe@?mByG^E-R8JsjmH#x?0)bW3$qge>g&o-M=SH?M;0851V6 z@9WRZPHlyEFpK8bPt5N<&!`!MjV4$xp%0?u<|O3TvCvVOIcL|CxD`&?$oC!#fX5pSMu-lqgA%R+L89rM-Vyg0Z6GKrmv%_IpM>EYH~jw zB~t6MOShe~J0o=8XwnEb=Qc*{E&!-9&b6Nkl$4B?ME~C3I5r--pY!g_0CC5gvK9(+ zq^p-d2MB2Cr6V{BWX42yt1CzeGI2b@jBm%A7Gu8*g~x!^^6xvtIcjXD5{Tvgm>;?S zhnQ7EUZz?_%2#fc+5J61$i=`?L3_$M;VhcW6oj*XUIwPA&xPP;jO3|!e zFm4?S(@B~y@($CJXIC8bDl1k(=cL9k_%80yJ|m17n1@i82R})%m+TXK?>FoHcCXCn z1Ekd%9Uj}XtSXIGmASze>adRGgd&^y2P$=b$oJL>lk>3*zYTOq}cXuIM|hcY!xl{kn2IS(oVyUS zFIMzpmrzG+X5JEFH4D(900u2?P6n{uWMEwS)?N!6M3Gw7NumxT-BTeiz>czpij9x( zJ0#jhn;AKP75-WsOVK6oroBuD_gLtf4!fN7X-FLR%d%@Q}x6 za`irT9RY`&q6a!GOGj49t?9zicbf}WR*gUPL%9k$KffdM16Ro~rG)6E-T}NFY>_~* zVlTwF3*GZGJ}nVhUr&x2(@4A_!@sjC_(n8Xx{rf%Gl%y2uiJY#>Blg$WjRp@RlvK>Vsz1kJi70cSyKgfH3*FCcy*Ghb?8>yen!qj$oK8nI2XklVaTS`m;Mf&XPF<1;NuvK z7+4KrCMPTsKOK)q1dMZ}^o4W^l)KCTg#oWxHz4{{c6abiju`(9jASbwt?W@`ymaJiEow4iUd@rQZ&|+uURPXLQ@tFY>nlrhy)1N0-VY*7q6M7DU0u-xeXfS zs}zLcMV|@B>twlc+$l=lrA;O7SB9U^(RqZvDYCL?9@M%kT{<&?rT7A6jgTJi*w`+x z^08fCO+0Ktc1X9+6dO=pzo2ILEzoDS9CD}6mom&fsihhQV9 z2YOE~P|{ujD!Zw>xxERmH$5LPtXcq=&#`YV)Kx@3+Oi7nRMai<+79Qgni+4#vaB|; zGy~4wGKC8XQPm3?$)kI5R4cv=&YkM$N<;Ad?h=e(9bZwR>sME=2`roxQ#jtvsgedorzVaBL++@vk^?mV&tc@8qj zj*&B)fMvJ7|A1#_%^#N#Y3I!k4+g{JbZm=9=jc;#pa=fqp9oZTl0h}A?vL1XvdCG5 zwS>x{ItXq@XHAGCcH@Mjo?(#3Q|2S8z^PZGfBBlt9G^0!GJu~1(b%quflI)4q!DV( zYaZ27nGoegPipk1qxSBhRG(jWs{tL5sa@Q8E;)JXBZ$ZqB=`$5+1yQ>9Ir4gm~G`F z-)OHzg1uG}w^(a4SR#SsFx1_c3*BZUU~IKIPtaeObT%j zR(=PaB2LeN69N6bwxB7Zw#9_C5(cGeNY1~Fv8)c zOVEQxDJyG+)y%SW%`lZFkC-9#V#CbOWa}s2xvM$GisS^n1CK25tP9&I0bO4$xpJ7} zrCmXu2^5XG)>d40CB^(N9{dTyQ$+KLeaaQjeOUQ4!y__TS|n$&z|k~{;vkrCQDgpY z!oH#Hcv_~#d^Yt@?gdQU423wi*|xmSFO!t2_dfXDY9q(H`Oy6_vwp~2Oi>%odHmLx zDxa0k6F}1f8`fnZd=N1cL6|~&hXKhRv#Q;x6b1u}8^N^gC2}o0w+H=QR9zjwd!OLZygUzDFYCD=F$h;-7@H4myWI% z^AXon_aG)M6o#QAOQNK*^4dU`dN>cFRu{Pn+)MXdWP0R3x^m}d6@S$k**{&t$u|dJ zh%()UNGpyHyA{2Zfe6--Oz|+T98~wDArUiE({3E&z;$|1-&LO+7D^j|B0ZW9O@hzM zSR`)B#m20V?$g{A{PNwbqNHjvOdV43FHJVTOkJ^+@c0*kst{vi0vW3pspwVo6@lv+ zmE^~Im=PFKTvGyB``HpAyq1`tz52_bsT13fOuy|u$|w@?O+fVgu4w7Tf3!p^JWapK zz<7zM+j2;Vzq2@p(_(mwu&1XYL~?$!F)gs9t@szlLtmgcc~59$U0OWT|8EZapg4Vj zuF9m`H?Vl8ZM&*^IxCGC6`(&3di8ZbLAoMX`L&)SKcnh+L=G_M!^GK|3WO>sN)SNDC!^ zP7T=I*7DCcOyvOs5!Zq*DO5TXfADa73)B<IDg}}qR|xdgjz09uu$7(4 z$iOE$ZQO;#lzw*_E1BYNNor=!$g#3)INk)yvNEe7P4g)3)%zj-^Trz9wW=hyRz&&% zDvG?Tm)q0aMwkF>8=~5jKOp({*`oI5c~G=nb~=v5Pm@ZSS;xH(Gi`i#!g9+Q7aSO- zV@fu{-!1{s*A;I`cf{w{^^YMjnol~9c|39=r9A)`XE5z-XMso)ckJJw?!RMuHoSi! zJPqcDcKV4aj?Li_le=3{w1iM>dD2JzjuiR_4xGyYW9**?T3x5N<)ND!`0E?0KOmkm zF}1(7Jf&0L)ngtbA^O@^=o0>yl?l-39}Wk!L%1&*WoT$$IbgMl@m|ati&%}xaKcrc z6zhW&-(?ywctmTCgOJ^aZ;jTQhf~+A8j8U8Y-FiOWr)G)^U3x2WQuI&<2ndkg2uTb z^*t`lKk*XsF2ywOP(t%}{_9kfb_$x|&^v7R#pa|1Hg6edqbYc{6 zesR=Rr@w?Q7qV|sM(O00xDZIHrP1pI#(~Pw`lfguf`6ya1{n*hBsFifgnA~GPJ2gk zx=9s;4J;9Zr9bpaWWqqM}Ez#jV z3olXHomc)Xm)D{+m}=7D*fT`Tt+7I+0oftD(kc-KXfKhGur=%spsh7ELQZ2l=z+SD z$WJ|=JKZQ+5*nFcnP8kic-YbNQFE?LZ);iSnGrA%A4v7cC8v z9H`E=0J2);iW|(uYntHGmZAH8>+4npVtB3CDbkD+T^X=AZmw2l7O1d_Gd5+Z8hzhO z^K~oN$;xU_Cks~)`pU@JfYC*#)w?=A%oKMT(AhQlRUPH-Z4zw?)VfyOrs*WwgXt{8 zdTc+?EHU$?R6OaCB=`Ak8e*?#Rge+PBA;slr!VKYbT8>5~41!kJOWtul zaEL+#06PtRgm=6jh%?~mg?pMO!(;cAWdY7_iuqeJ(8ZBjEqe$t{Ggqi*Njq5FOAu! z_jSv1s$b;JkY(nY)bh!_C!ljhDx0znV$+@DXYwHP?o3>Ky*_TCY$Fr}u_n>lb@gV@ zw)l8(hozC^DO)*!Gx<#@Sio^QEk~6tX5LmO*gTDo%uu8rR{+79i$Ra`iiohecnJ(q zfH7&BE*U?}%TZo84;+@6uT;t~+*>AboMK5-eGL0WIY30^-pZ0wzrFrrK)wMy*(AuZ zt0vL(6~DNjnRK;c#2p+hu_G+=mZX<54tg6Vzax;>YQ5BcXWG}S~Hm%Nnys-~uyXx=^2X>LS;wi%CA>4+T)Hn>>?>!0n5xA}l& zUzLadNE&_sHh~RkncLxIzeX>K(W}Mk;xjb%W!y!OR7viZ23yb6WGZDv?Dc`)ii*v+ zb@*{J7kDQQ2>aK^^{>4;fl!`YQbAXh(?sYAb z@t$#(Ay0G#(1HZ{8bUx1j z?AYeh}X`L>qK1ngo(7?UnYm|y*(Xp@bad+C`0di7z%YxBfg@KL9oJudDiN?On@zI6xO z4@W{hy3I&TeLz$M2^m73kbUxloz(i{AG7%-{ux0oSql_Z46`Q^y{jA^>S2I4CkDqb z6`i_;10xshWDWM?+O@*6ydX{x=@ybK-N+MY%5)t|A#g)%1)OsmB(R=hfQW)_gPA91 zbrvsU0=jVHjPZIW2RP@=!B4Ck&sGj-v{ejJf?-&EoJ={mOk}zUYGNpeS4f}b94WpoEG?ZEXqga^VL7ps$RN}pLp@#b zEfA7K=_kqwA4Co3n67l4vN285l70;Bydd8Pf3%^f*eOM~dEE%g$3F$XZ4p2(Oky7+ z*7o{(1$(e653qj!h1%Ga#PdY^_lT70sTA;Lb5kCok5~Li(t8gO6nt16`(xVmp{mCm z-UyWN+V|r!E*JMN?XaOzV!@j77`;5hvQ+70rB(no0?hhu)LwC=$CQkG>bOH9RL9-& zIuos^4hMUyuU>sS;&_3W49pdll6AB`$ilz3MdgG1;Td6>eYlc5Q<0(s*XN1pymcn< z>{-cWwO&q5jC9pZdqdM{h z=w2^mnL(DYzyS3D{~ON4%4|l^tv|y!SfRj5Rk*7W7X&vVgIb}sGj0DlEEWG>vx1zk zPR^Y*#v;H42E44$Vy$F!5E*6DgKfNh1RgFhf|O+Qjxvr_78Mt1QdlkTcaX07ME!v4$v6P9#88xWnyBOwfoW=IWdJkFuWU|27DBgaAJL0=6aWsr#~b43owX*nHUC zVa=P-hSQdk7Tq3=;Lg&ATrEr9 z`cCS;KrI)kJ;eXlUW#bD?oHw%P+6%%-yf%3k?M>zY-9z@5IJHXsyfmbMZCDBSfgta0V0^PD7Uhw}0?`#!l3YNaD$D=jIpw(8LQ1M70Q>YI1D&ykV4m1+*SU19%8 zeeuqjgaG2CMp6?@C+(3XT%Nr{xma7Arb-Nyo5T%Bz2oBj+zkN7MZH?^7Yy z!QN$Dm}%i{rgoW(C2buN@iNe!eV#$d%u24c?AC-Tb>n{I&N~GY9Z+e0q1|D6Q{{-w z8XQuCCxuqzf@DN*@8Y%hSOSA%I22wuQnC-tGYF^LLsa?`>~G(eKUKe9k>}ZLiy8aQ z#N}Iev@@yD(bGu0>Mop9h`A1uBXP)4j}Wu=k3}oK-6xBWl@%=FdP3VbiM71AC-RL8 z47NzfkMo9s??h=!=8n-o8B`%+aJ4cWF?B8-$fMj|Y~UpCgrOfYXQ}G0OrxtS6M%2r zO&*)!#ZpV{AIM<((Gm;Pbq&(%gsPw@L>Ks%o}816>3NO3@p?MllAcd|a;h;2WL*(q zi#e4`UMwQ64B(T0&vG?l0IagjJgml1Qrw?bL3(HQsz7#2Y_Jc^86u<3$BtA+gZUy% z-ZfF=dH4>_J=P3PO!`(TAJ9&yo>HWHM7aDNEH@P*S#|3L5>~W(Fnl5?^q?%cX!cZZ zjKxnHt4~wDznFa8sTMF0#WHFfP-gzK5i)=1<$v!l(3`P+=c)my{N+23Ipb^~H(*o| z8U+Ev98KVC$=x6z0a~bZ;)@04@I|ikiPvKLRg1@o$%;H;vU#7zut#Q5E}xNF8)^#x zA$~I;MH!V0qi^3p)nBbleMTXSp5axLb`27>k=-5|K>L4AAO@sjsEO5U>h^hoCYRB| zr{dVB#4l`w{=t7pY=#$oGX%1xSU7@HPCjMOu1mg8`1QskAOevvN?n{q%*QFDIbj{!7n`%|IXKWe(G)q`3UvdV~GwyPk{%mA%Q&Z%cQJ&l0!r1P#ya{ zwsOsynLc`wlW`ploLa0P1xgDs84_43Of;ieTNFze5F-mVcGq*4V}Wuy&_(2B?a52H zNI}W;h0KVEg{|Xjqm>{3)=h?Gyne%h??nozrY1g&W8g45JCO5lnMu%j%zo0${iBD$ z$lrlU<$QO1bzWEc)6c^B){8sG0B^OtFDA)o$KI%lvcx@Hx6ywdn;1fJ87ldDF%IsC zaHw(m#M!CteZ)HZ#34l2Z^m6vie$^Imy(_wATw#K>c8?>kz0+c^Wwf_N0_DTa4&o7 z7oa?(0iK%15gb3lm)JdPa{z(i#UU-63*OT2DU$x)jGToLSX@5k=jfrCJaCdeATD&& zU?ph>Hq}lUeKt3o7AyJ!SKI|=DvU4<)^gO%XJWPgpQYx0I|#@#kp1;JRJ057JrpBv z_-7tyJ!~oOl*ci0GQZta*giZD!gFU4X&gXLws9ZU40l^f*gbWqrZu~`Z3f#jl&8e= zL|7z>)f2_k-lQVRh~7IWb5~y3F8QnMDysX5JFWO9E8{F-Jz2|Gj;9;O+dryNYfEg5 z&1fLIDZl5^-{k23eqn-wp@z!*Evf%oAtj2d8_*9tAgCh(&;}6-)*S2vdP5u*gi4=-`D-EH z^pZN-0TrYL6S!*_gET5k{lg<^Dgo=%W&gfElmW3y z(Q%QUdwMisJv9Z~*b(B3f5F6%0RdkQ0CJQi@S<<}oAu-t;Pm3{Q(IHk{2id|ac>lnSG@tZ|3i|Z@{3Lkthsvipgw7nvWB7csDQq$|F#K-0RPB4N zj}g}taILj?Sx`q>f^?1=ggZVWkeEY6rbz}4&mjD~ZQ~*_v0U;20zUo0bCb3y=6*fv zKW8Mr{h(Uvr7q^x#6b4}Q0KKfd_R86L z?fk^nBtHUCSLRayg+yH5Kt#J-Z$BL_{Z;EPQy!9_pdwQ*E7FEfcHatSe}3a*5A;#~ zGXXZ*7=Y+hkmOTs`TmnMHT3tiM2ryd(8H_0)<*?la_NxJjkLbxX~ft*^23%374#DS z2{@R!P-v?bRz&$R2!lMk2mYV7lJ0!@+D~@`(+UY`m*#b}(8|TLX*w=SpQ>EOCSY)P zyq)aWQZLkMFDq0p)hfRf#(Z2P4zZcGSAhp3C>8nz2PYi*68!1pU;Je{Qz!WAdAGt^ z(>MGo9ntEppmk}sF56fl`QQ8L&Jde|;~)zmX3oGErQi_mT%3B{Fm)RoHPlXCM(gpI z2P;$Ue)Ev3j|(f6(v9pC4YxI|jp@;Hmx_5xR@sA*mR0l4y|Nhcwv~ZHAU`IT&^p$# z);0S_dN;>rrQkH4!540=5dUVUV6^of)AllpB-K}Nd*Wi5{^6%+5|pAsk}*&q0kNrz ze2jG3kkWk@2oLofj;yD(9XOZzeb8<#1PH^E){zE=+sKE#YwJT>X-NwU@KEsQ6B+ks z?)ExuBbeH4rw|xE57HB$?|@;!>vwSx7G}HEYRyv~kMK~+S^(egtmQ8r4|WFYx_(w) zb%)THVFp?-W3Riedf#)%J{|22(ze zx`DYB^RT$X92a8k@7~Qp!t&UlQ_F&ahcm#3@9zru<==Q>hoLcseGci%5f|BlG z+2E?gqa5P#{=fj@*2&CL)Cu)H~6k zzDEQ8b>@6ZsOX1`{Wc)n2J)SjTH{P)E#4PXTlUcSuNxc#@Y_P=sG=o-zOFNzRIoO2 zedNI2{OK@=SL8mx&k5u}Kw160mz#YIAOW2dKvcKZ^qM-Zjx0-4P3XF~p7N3}iERdt z&_JA?c4lMQgbV61K+N=i7F+r@)br(1CjAYni9T;NBZ~l;#qpy5r5s(cZnl_6<%zwk z9kJYK_43`2OIh5Mw8}j36mhwnMJ0w5qNt9{$rz@Oj$4^9&jF67h-+&4r~so}&uPxt zG%)tp-HNle=%iS-Yk%>_nOkwqGJSo{7r9JV=8|41YF7gK+oUd8o*qnk8Pff_L~zE{05&hLqv(Q_?P0Clfh88E8)TL zxFgowd*bT)yoi(&a>p5?lW?;b*4l@9+T+yNSoz?~94KlEI4n+EHtr(etpv4i8PcfC z*=%TJ3L!ABY&RR+J^%#6^m4nC&xk?@3St0!>zehkSTyu}tr*I23x7E^bpv-}4A?~2 z3^r2gP_pDrZ|Khdl7NYZ6_`JOOb47O*Xse}1Tw=+<@hudUtYVE4V_alyWiB(J%ecr zmM*^7Gqda_Kgm|jfv0bE8NjeLyiX4OM!zPlim+!XZU+Wnwwo=QbfD-O`~ZHpURVhu zu@lB;@ms!Q*Ha8*aX|t(*6ByEKacmvR-{HY`RqnZzyLSU6vnf0Lo?nB<<6=#>X|u# zA#r_VGwMzsXv&(~xOU;Z^eIA+#Ct>pwCcjU-%4=1o~wl86F>tOB+h1UUv~}eV-v9+ zL;dr?)6?jM2z06^#@7hZx=Bps=3MHxg_7JSw6Yf?=b^P)q;Ju)1J0A$;+hS=Pmbc0o=^*ur3KNW#1N zo)mGsgjs5A@8d(+ZToMFxUhA z6WVk(9&+}$&$5BVkF1ZCoJ;ye%r4zvU(a@@^+PlZu;5x{rV#}l=f2-LWA(jE_!4os zc=V7)^a2F6x5F*IxoE||P)ekF6r<}AR8o$fni5GO`!TFuC=jUG*UBrrqgFx4+*p(q zHOe!RL&;lz$HfY4zCG342zGFBC=(*qb2LDDI!_KwemXqkpR| zvI8iiP=Q)e>cn!Pg_m?AEM6-)icw3p5v5anhjl>0QKN5s zwqH$OygC5(Rd-O1y}wu?;T*aI6~c9nNo*p#O~hc-4m)^!BW^_=+3`2+HB54aCQVKs zBfGR@lA-pYUeE7me7ALOo4GiTrqePT!es6~;%r++`o6Za+r%2H32-6aD_0IYm2m|@ zZzT0@XJ81*JvAS19^)dbF;*pI?Dyou9u=kroL1!o%=2cf9f#VSQGWUy6>|h~MMEkd z9yR1O%}4jOf!q*Y?v)yQLg6ZuuVR{njiNDuBE%xekOAjPeAwrm9qJn`L9k^?Q^Qkb zj3^0m(XWcQ92aLH5ZbOs62o#1MG-_d_8p_t-LYiQv;tD?=)F?}nHZAAUJar2CqQ$2 zG4g)&03kJLFI67T{;NN~m_KThwcFH#;S#lTD)n#yJDG&;nmS10sIxv5D1pgOV2$+J18_yX;dsJ(o zVt+l9oeZ2SllW>!))^)+@q29#fb2smxPG4{(IFlgZDM^FnDAm^c|so%tR1TaL0->m zUNL25F%qim#;6rs5gmP!k-ysv(*jtZGK*L+k=pToX3ODKiRI5)jrj3vn_`B9JDI!{ zqDz{2g|Kx#h#wEG`uacTa5oU$2{W=grbIilkzvxL@&s)IUmT^R1FJ0{6U4D+E(ikL zd7Bp-aOO7&#mVdxpD7I~qw;7uzzfhYG+)uzyl~fTv zVqjq9G!>8oeWGJbm7B^3g|b-a$iJ-K{g}e;i)= ztNkt96cQ(yID7Y+sL^$n)zYfGIo@o7Row%Lff!vtD3&@Un|>ZlryK-$`KiycAQ&1C zC&Ck%pr6f(y5VdS%-%*Be_hTw+j49Js6@BMasRLAJ$Ak)JyMfdP|Hk*{M&IJecP1i zZg#qzmwe(nrG$!wId6w}kl}4^JVifR_;zZ>H~u`OC3||(cIml7}1MNXvMQjoTKKk zWXYR3s!0+|nrrP$6PLK+iWEWfVpM7$i_35cOTmLV5N_3IgTpZY&l(^{e76h$?>!sQ zH$9iIR}~wTp9zox%Cc*Dwsbb-I>P7|ia~YjhcNxHVfPs-Bha(+jGGqDPR@!6quj@Hk}rb}!~WH*ve zokCQsx;%0=vS10|Y{Fa zddO`ui{2Q|MacW8P2%P2VdQQXDF?*HEHprMTndf-JuOr2&ZFH#eu$5!e|pgu?tn2g zNWU%qID6%64IR4?K!E1}lkZ?^-n(swFW`EVg4KX|soS7DI}tU+83 zaQfaFaX}}hYK=Vfr#8+9NY!x7+ry2N><7e{3VC3}=qR~JvYJl$)aDOtFrGeK}Nh%*Iy2t+D`uIl(N35@j{_Yo>~p^1#qT$5HwY1knR*Jz`!9CET6oXJio z@-JzX?t}_9TP)t#Q1(_>UXekfy4Ny+MpCA8@06zeYxn(h*F5;43bMJF@oiNT?{fR_ zWjJ3q;O4s8r7BP?-nWydESoL{H}3+JeTeBtk-Y_n%YDI_>HUa8RY^>b$`=A%$1>GM zN9Bm@*%U7#rLVDQ<5(L~U5tmmQ~(p1r{8y6%cK>TGEz4KO2IrKv( zrRAj08{Db|DjO%AQ1f-X<&5rrKDb+#K|Tbow>i>zqjr&@9dIR3o1 zkgRV5jqWnXg<5xk;@T#IWl%c(4^*Yz&|~rt->^eEZVGZ) zSd;q8!*-(vX4z0KTM-N!;p+*a+A`VFbqgK}m+6pPPM3|i;)!8P!rwr;8QMy$Q^Bp- zOw@@YfY0H^B)2xf-Ry0f{-d{|z4Wc4rrpfw>`ft*m!!+>TZYy}ew|R5)VAndF$Mbh z)$Rf>+@*P}W?xMmlq4zBS931DU6W#Nz`1vl1fykvE^|c~vZB0RNl5ApgQ#w{n+4+j z!SqBmmF;HLmkaiF&c3JF&?1gQ;Z$6H1yJ3CZu z&Wmk_gVTYhg1fh6%Qel)WAm=p`v55R(AB5}pk08n@Deh<*XD#XK*XH*p)6qIcQ#*xInCl=$w(eH3!d(nwEUl^n zo|tfWFoc?vM5bBPnwT28%*5KG!J;{~r+|$ee`&G>IsZx28vN<4KNWfCxHPLHHzkiS zn%_8iuw?>{I#r}e;G@)RA<2bnYribHIsLQorjTtR1#i-Dma3^S6$X0L~u1+0C(Lo!LU)#!NZhz_@m1{eHK0x&d ze4xTWLhcyUk;GJKy8^R30c4g^=4_v2R7x7dH8)@%<-(=&1`Wq&Gf8e_;gQhX&w4T2 zS$;)YLYzN2+xuGbEAuv(*upb2+fv^_;ILJ;VsRdyU2y*r>UA>Mq*8eAT~Le4mQy_i z^wvmu&!tUAq9@dpumAse0;sCyC~AV9y12X2bC+t#3B)OpI;f4@tA12wcU4rXq1x9k zH2bnuq&WLq@p}k0I*nkv=0$~+t~m02jA6~W*qG#;tO$1swSs}beh*j>7}hd!=lnRL ziq{$^5_J$;t?`UV;5`aVrS=~NC$aJ@?S!L<7}u!dt&a9V<6Y|gg0~L5bz9;D8r?Px z#RCQlJro~f%ORQ47){XCDajRQw-py`AG?VmZlv59)GxZyd{$b&>)hTpbazcfoieUVx|c!+4^>abeRlx{w0Wv={GINev^ox4l=QNkoy)oE&X(^Wr9CqeKU^J`C3YPE3mYh8D z*OhES$znT}4o)0YZnP`HKw%F;xMAPSJ!bqj@En6LBd(Wv;!0o!odZ3D*_bmYZAxaQ zqRIe-q`!CnL1&w~{I$h-aDz)F0mheHv@=s>uRhx36|Z)Tb{m{Ckn7HI&q0-}z>&4F zz&~B!;NTbhpVT}lSyD*qxS<@rT@tir63Gbi^9@ynZ*y)=9LhZ*?qPf$3cdo3lBki_ z0OAa7j{P5e%Q*a758H}Lu|D(`uLnc46b3f%g;@#G1ikR&Vn^w;d6ebL*PYA$NCQlu z3e8mGU3Aj?RdA*5GP5X3PZV%Zq3x!o_<8>{XhG)}o{bNg-Pq9iw6eZIT4s={9?PP} zmTAd)*IxffeU%4d264*iBp6YaY(4Ad)%h1Adzy)qOzx z7M>Rav3(;vJ5(Gmp2n~egOO=6zM>d@q4|l754;&$x0G3Dy>ZXwdP~ARYT=vMI$uS~ zFe*5X3!u1Jn8KbUuIj$~pTYSFyAF)hzX3l+%p&7Y{twKaQ4w^g>NtaNonVpv?HQU|8#?&(+XB##rHi~4ko6X%cbzCE zS3P|NDC;(w^`-Y|&XQM;^RuImM;oKaaOh$t)9wWb2;b*9H<6MV4tS)0%CVaF{68ac zMYEb#C2VB(jXq<;XAF(Fpx!BL|66#EwffJYq#tKSggxR2%J?(_Fg2~~4}zUd5?9qxSuyCp00)D<*mJy` zQw)-VLv`TTX>u~q?GhA*fPx%^v%i)Ko^;Uzr*2QmB~j^8R@xJSPWNV4Ksz?)k$ADdu%@k9vF0Dx9N-MJ)`% zvH2@!xCdM50c1gc7R#}-f;I7N9PMphEFW>hZgx_DSV>EdEBzcliq_Z+S*G2waij)O z%j`LpsZ_3U{uCV6DHzHuh`iAD^D0d(=y2`14eJUBR z){K%#@nga$OE9qV=D$BQ=pMtNHwf*EG<8O5KBsM=rw;%$p%0uRVH=?W8l?zxOCKK^#Z%CV~r3d;Ay!xu0~W34GdVM=M`wmldU~V~A zw4gQ8bG~%;8fhTvGtaCC?a$*$x3CbB5K_6cedq4U$#IN>Ku*1bMPv@BJ{&K$3f{E! zy|}b~Kjg=ol_e=q=wrAATYSe5n0T^hKC&CHF&n|W$&BoKa10v?IcjEb7YUOIsZ~4v z&5-*zUsXj(hTm2Rqys8rnDm|WnTiyRQX|h5Z$Ic_;&L2B>OWB#eqWyIIl&ghKW#}w ze;_OR9vYd1Iv($?UTEa5ojl_sHPWb}=YBeb(vq`SH=jMoQ1yFFJ9mRwOxHiuea{W-qE<~PtnsU)rK$fFcp15&sw5p z0&wS*sV0m2)#t`0S21o!g`7=Ch6VwW%e5Fq1Q3~ZOl6xI#MbYl4{|uS{){Dy}^Cvm3*m0OLKdgk%QIs-4H-W`=ok+9XkdR%qO~p;g$I z(yHPQpI|2asHTUP@y|M$&qUKF)8*r}@8CGkAPteo@KnCMZW915gIemo!l1eCNz-KimJJP_L};ze%n%g1LLI8q{o#QVfWf$wr{)C9lET(n zG4UR5jpX=nzSIaZW(poRqydURa~bQiOagg|7YTZ5T`}7R>`vF$X2jBCQ&{a&@GAAL zr2l_UPzA++s^DTa>HTD`i29xudxdOOv1&(INI-jBAN2OyD+kI%rpc1()3-bsRE2)+ zas>2VMDGDAk@?ppn|8;9aRpab#+aueWPQrn!cIPz+G3At+5WzHTdZL_PYKm8_#n2T#EXPXRN}50eR_o?=41jK2QC&S=Pg33B$PK!z zzW`q#7&`V^Pm)cHIx}Hh$BsYqq-1c`K#7!0Tmr@j-uME<~0P-vi*2C^AswM`@)n$??MrTz9 z8Ghf11H@;RSCz%63+X13p2sX9f|6^gpT$kDussM+3bIWAqR?8yg&&w9*>+l&h7>UeUF{`PD3TnVI=&Efl^CWkYpotu3#y7J z-|J@;T|$D+PPt*!fOnPGA$~RraiWsm8)QcTXyn2|Ar}*s1qvr>;-!W{NrH%g2?NY} zskbh%A4;?`OaEwwBbeQ#JjX17_rydgKulfHHp(Q1Ci_9Y$br}0$VZ{p|Ib_ea7l>N z$|_7GLzssBV%rWTiw>@Bc>L>rQPT%M=V5~K*|IffS!%F@h&V)Vl|N50<@T)(kW&;4 zkGis8I1?5%D}DjO(e%e9e@V4)!3aG>0?V~d7O|?E>+I-c4!!z9R*j`8nas78o~j}N zUlZCe)Eg`GLWBp+Mo}5!fDP{woNx5xN^KT4ir&ZP-6?hK3ltYB2O>zwBTJI%% zB9VFvsa0Q_nck2qy_@?P_xW{cez3nPXhV1xGmB4_JJCzP9sX^fXWmF_c02mjA4*8+ zzDLbOzZFEjL*~h?(O|y|J<{2~E>z+LrO0_mMp+MRz>yb?GrAOf{~TGu%M3<6CYLV` zJB}5H86{^ogaHvxJAqGN%yjFkUTmarSl0h0TYybzV&k8OiitDgno&6LTZ`~9KL-_9FOk)tt?Fmwj z*eOh?svYIR_L5;S{e-1`DAi0sJU^4U`)(O$T9Jxk)oQpH&98pGdPujQsXlv}@(#B? z5Tgd^a|H!j@`Oi>D3UQ?3A!2Z0G` zzB4QJ4;&R!RE!5yjhA~$;NZVUy3G^`jrFjL&EhK|x!sWb+%8UhztdGewG6&u7&R}; z@mB+wfE=~W2KAT%f1>`xk$h}-!Glt5Wjp7yRS}*wx{JR0rrL0riv=$Z7c&O@HY}G? zm*}$;m%T%xlmU6gelLU0!uEG{>S5C{6DO0&i?7NeEACu3oom*I71Zf-U;8?wxOc%j ze4YjvSCZOgB9k4Q)7S&1tf>8zjd>_KL_HSYpIjfQa;l8VC<5wnV%HtvUnh<&hi#;Q zlmLL&tkl;q@PX~URrSMUMonknS9jro1cn|BL4w4MhXaC5FP_$WZ+zRd)SWLz1SX{k zMv{O$vlkU=(8q8d{egW0Z(DYhx8hjVL>xwfJ}j=_WI)9i8w)GjrcTl#LAL|%{o7tM z-s3it$v_P2-!5tt*p3Kuk%a6iYwj()ix7YopfrvJ`6x1Wy&uC4*fA(N+6nACa$_wD}C_>?X z^W&cIb}V9;7_E&XlDl1&F!5H+qQUCal0D7ny;QqN2TufT>vur{m9&2Ws`>@tt3E`T zjK{~2F9r~KK{M1rjh&C7)HAcMY3-mFbO$Ue54Wrr^$=oQ(8*{wj|BQ<@58Ng=<#hc zR{e?pjgpr%wvhwQ@tqM+f#T=&OR|g4?hO4l_IG#4fin2UBpXIjr>o<4Ah{#}4Xg?! zoy2?M40O?Q@Z2aifm|!=rQ!EB)FKY}4EXezqOL ztCqxodi4h1vL#XIN*5TAUuws}&O64D%?1F6B4?km7x3v~9&w|&F4%Id_oW8zxu&op z_I*|Pz}f`{uZxn`g$gNOt^qfN*S2JICG=nffCO*g0f5lN=X=e6>~0tB$AiDo_>gu& zl^ZCj!XNtW;L=tK8FmTlO##CVh1fGfmUbJVU_&z0^`1Ct?d5!lBjJD2S-GvOub-Aj zWk!AU#{s07aXuxZEml^Y52ZZYYETA=HjhyUSFz1>*Xzbats45<|l0B>AAratCc*Sak|YY@Q($pvQn1~w624r zG1#qfOhI80BTcWl2yEu^*MK2x+i(v-a#_Hbs}ed9JBWGhdR}J}&CymWkkHKmx^G}* zXsiuODerXKPZZ9OL!PP%)Uep_B#a@!YK6pG{xNmuAF@%x`1+sFdCTcrdi00&Poh5TruNAtt zeJ-rF7_L&MTnv7kATj!EGi~yW1+%}_1h<{AG8YWgi4(x3Q@)uUeWNCY+05}jaa`L#HQvj>MfOy z%>en7?pzhimPxD&JMmxnLjDFsQ|KffmJ&w0@?_1AF>edBVxPq;Z}ymq6H&v)2C&z7 zkNpIjzF-q<@J-}l6e^-C@{?e6@X=Any)gBTuz?*uz^W3kDwU>vEKbV>_RUu>Vso4idTOols#0OyP zuxD+%TdCLa_5no??hzg_`WiOTxI?*St{idqb)Hur3iYgD&Evbgis;prq_`6%2?(Dk zN%%e0R-dgv6n^W(%~*d>Ewzpq4Oe4Bd%A~w3lKCbw;XPw2yN@{)@dcaK0}sHKku$9 zTg?;jmLIkA1GV3)v}5bu*vq2js)`iGYPOn=N*nL{S%F@U51HxP{a~s?%?g#LKfs2r z_UBOAi3@%Trf7EF)NH%~HW%NeP$n0^Z1vv~xa8pVG`Ay`AOFO?ciQ-v-z7}~mrPo9 z&g3#~RT?r;pw({1HJ4__niH>R*$WZ7ZxVirVxVPcU|N4>-d|X1#+qGvK4uxo@(pj@ zFq1kecY{^P*N;3DMPkUo{X6|q=&m2V#MctWRAr699s(%iwd8Md#f9BEKRXMqoj-zb z>mNYiRAHQuVgqe2qHD)%ot5pEE1vUB5vV$ls%y zoYbK_)B|q6>OZkv82S`V&$U!Dx5$4Wdu^@D;Ux46%`zqTqBWZ#y zAar~7q-&N&o_II*|4FVi5tD2xPXkyIiGm6T{^0K{bHFomnO`7r@X6yAQ-W#mYQ=&%6}`&3 zhZi7TL-H0j03*q&VJ()dh@35~rirH}kSt;c^myyd>6TggjvRyTo8=`0}^9r2hZ+fRn0jX8Q$wY||1 z>G-X4V#a;<5TB)x=eMEHM^I({GkItpesIwMJQ$ij!~+D)-2JkDSQgy_#scI%Rijt; zKp26t>baMd7_%JW5X@jg*cPipDAn4)>Fwj}vs5@vA}1ml?vBA3FQwj)PusO`pBvCEv__#=F!F3I2{zk_+wxPcVljXfQrYxEsqbZ7|SQK`AN|)Ddbz(6H=g zQmmIEuszS{bD~!~HTv4AO@Lych`x3T5cr6jONR1WyeRf26+9O1YO~I?fqq2Qf(+wCH2k$By+l5qHdb*7%EBR zb&^Qw7D%%lQVAUx^5jHOTUDU|E#u#bisl437QSO(uG} zsSOm}X3Fs~=!$QUS&nnvCau(fv%2R4FHQH8axrOQI@Q01hxq5iqm8iUj1Xqy5aa%Y zdPJ7jrzYv*RfT%c+U|IPRrmuf>KF^imnTxz#JAY8qmG>{cFrUMa}+7o(i`Or_*>gK z9h?0@cS!TflZqGOs||!JX)`Gdn;PTtfD8hNvMS5Oo@iyYE&fpLa)lIAUf%mCyIiLr`?=e$HEa7?cQcFtwls|9bPlxdYA z7U#R%BiN5M?G$Pt4LCh~E32Dk$qv-tuh4ol?2ZrwE}#$s*>>B!axA2Wn+tB|^Io9P zR2v01EcZb}ERb9SV+Z~7N8~GrlH#3W+{k+Fjb8|{fC7cxkQSE90hkO->MI*+vT`4w zusRK%6|o!phVT4sB#c14%uO=}&v>Pp-~{jF-ebS*-~QMs&+~D5=loo@(TRla-L}Hg z`$)VFQ{nh@Dw$GuyG`wZhimw5vv3O+tJR72()+@NY+IeOeesMXv|;VY;wPV~xPcBR zVeg`qk;GdS&ZZ{5RCL$J`uF)}r)~Gh^L3LnwR8ON!v9RZ0MSS+&u0=fO&N?7w}%tK zT?`vx2b(M8{zz-V;cM}NronD#2kW}MJ_HNk&T56eN{QT_ptE0WxH@pdrHkT;p`yvg z9HCd_nuKblRJ<^|ltUC_zs(#GAzaett216Gd}~I2ftRc&;l8_I`T@MC>*1fBs{Eom z*gz;enc>pb9)7&mahFVd^Ig~6nr!vYo-qQeA;+__>GaQ>o`$U-QSh$Y@g@%u7 z5sd^n>ht5VAO~oTK+7WxUZ|_pNl?W|jh`FOGrL@998xqklakYf-IbckWgpg6)81*9 z(WM+sEhK8?n%gn^{*F<8zmK4`JNqoWr}v{8+LRWu4nBTL0{J@<5L^J1_Dq@uxf^4t&*gnCZ~c=r zr9-dJ0I)J^%wjg=Xg8yFFBa*?d9cfKgKBdLqlq>FxS>WK4qTOF(8>ht1H%WP%U6uiSmZO10-%VR2yZepBrC2~7kTwS=?YL;l0 zjbjo2d=DFQvj{1KFn&bVpCb+z!KvLi3~2h~LV-hSOr{Q`U)H} zuI~9uL@M|YM8)8oq$PF9ye>|@Fcsw71Y4@&577bf1XAVxF+SF8im!;GuElYZ7yQdi zQa+9U5{^kX`aP%&1t+*u!KeUkbo|C4w{LcW6p~ctlyB~JKFLS`d@l$BuS@yKUN}Hm ztG>K6Ow1~wsb{o=#Sd_kU3Rgp7=P|4p|XZ0S8x~5a)#iO`qx~fIwpbEq>xXR$AChvAo+%G;o*R&GWTd6bL70@CFYY%XTfW)oXzu;EXP_eXme#I1 z+J?Un*5TcGeGpZ4r4ISQY7^V;3s(@9qKRAu|=veVDA;ESh~ryPS1 zx(5UOs&l;1m=lst0GW$R%}ILpI~IX{1sgY%g~FBO2D1=X@hwHW>R7qA4SVhehX?TD zk7}1yr1_tMe(Q&CfyDU?2Ky3CM;qYo|0SCuxMblA%Ff-6fkb9<9Z~;Z^0}Gxc}gm=~_>#RaZlseeH97rYCrSFxxS;jtS%gpM!j zmc{h2_0JAPly~;7$Us**plfilmWkWw*cllM?Wzo4IZjjjQ&DW`l`PXYgit36S#huc zBe;%Y_B-I8Y(}2U%xo2XiG?ilL!G!40@C~YU#3+$I`h^jZ#SYKK_R2Z1lp#CVjZe2 zHY$ZXs+$ZQPqNDlpAjhze3~}y@U@W&!#vb6L~)K&>dc@QJs>Jo3N&Ob!2@tHi*tM+ zHE~e_Kn|Fh_HTAYlhF`~up>l>J0iiBks;7c3X&uY#w+ZWew-gnzJDRqRQhLQ3%vd) zlci-8B=T>T?1@{xgB}tFuYH};QvVR;)5%?LaXTpm#0$%e%$=Rs=mrN)4$~_-a6TZW z!@b|TCLy8O`ujY>qotSheLA{M+aT^H=j$1h%L2FBgmHH^-6*|E!8BClk{=rYSqx$y z60ZdwBq(faNo1e75w(JRYt{R5-Us9`n6j0?UTk6nhZb`B(LOgnQfD29rskFEc%r(^ za#WiRzQnX!myA7eAvm)y|;Ed!(X!phUnZjKd9V@L_8P6e>+G6GP{vlbNJ2`JMImbb@3QQ`dgBe{Q$1A%_J zNcR2J7LLV_Ut*2&7IkhZjE0VTXvNZ(@SOrty5Xn~cxXoBZe?h|`$RwPx$sM1GHELv z!Js){+FGdN)?=?iOX$$`XluGQ1(y~rNebbLwv{H8J{avemkTj1c)eDXKlQ~l`c0% z?*dcV_sq_s&ZzMdd2Ph!N*=V}N%?VZ$vF^-?mW`Y-MMhk8HI9oV2%*j>13d>e$L*l z>ZzV-tSAd#5iI9Bve-M6G93$0YsAjS*>tI~U~~mCg+Y~##(4k3jjmdogI|Ec157{G z!06(inQsN42bf0Uka|b10S?NE3!A9lfACz|?xShJzj4V*$-vgEpL2=I$PlDEVBw?N zJWo|&ijtYtdixkW+6}bK8j|6(b!5y}%W3pIvt^`Pm|*j0{e1}67!;GTU+@@A6oU?B z!1_kG_op>jn9WRz*zFH^$DlbW4}=^M5!=Dk6opx3tkPLvTW6iech|82Tj4Ek@gbC5 zL2c_wNL7)i za|Etz9+MYI)zKba#~NCa+P5WKF5ym}UEO+q8@4cJo|71ux0_kkO*ec38Dcz%Or!0l zrE!<62T|u>>^Y%5IUThFxPMO&9bG5!sZ~c zjc{(v`~VN`qq_Xr4^Io;#Y6Av$pi6u*kXzwr$3b{D$gZbs}ky_>eUlEXOX{g9CoOZ zFit*9f#mWYIiCk?me)lqthvL(&Rj;C11d>zh9&hox&&c3< z&p+i9+wj8>aq_bO6vL`{qM|g1b&m@0ziEjEBL5G%~oUAGuVBGD`$#$-=sqDygc%L7lH~%^Ra)>X;n=&j%ae>U4 zRoO0+F!1^**^2O<qe6k`X#`U1Gt-=`3m}DnBDvR5XzrUk-;2-L}kZWs^R;*?up`n&GE`0DVwb zm)MmYW5qr%&jIvlzw5J$dJbGFG>|ttZ~>A@@dljJ#lK*%x^Vzso1hY`y7# z@jF(#MMhYO)Ad*1JY8&F`)VYAYyL{Jybq7LyKP243^gqEAo&{Dzt(IJn`ExAx9G)U zwpg2=k}Pbxl4*UJg01U&X3&*k#K&Buec+aFBl4Db08=2h18nOGo(7wnsP%F^9va~{ z)qE@pDg8Af-m49v)XeHSm5=Ww)DdgeK6~hC8lV3=ExetF{VQB0O{gyYk^gW;CUV!@ z>h*KE!g?6jNNC`m;Rarlq8m-N| z?5Lc7SByt7iDXjb?QJuW>qOH|Zn+RO`(FMUGX*9lzD^as9&DK;7y`qIJX;-ixKq4h zp4q0B5NoQWYeCzfTmYCzH{{pld3Ml__<_YaOXL5=teX97k*38khYgv|c8 z^jjn*Lq%;01ONg60000401XNa3YgJo|E^5+XF0le7@ldVI9N>cIL%HMILw7R?Xwfa zdG35o9Y;&l2rQWT^qW!D!pyC-7Pik(BU90gvd8XtW#2AeSZ{~d9V(*|1lSdo{!mv7$H$5yS%!bt^Lxh?Aj8p$gwcPYv`{qy ziWtxZN@ri)`lcCqll|||uzC8=nmciX552RE6P7k>5vFQGJCc#eA6VmB%m-8Gir|mR znW)A?R_-fFwnThw4O2G`1E9m@eO*8Is?jZN4G%|T}cj2t-OYR4Ob6zxM02+Q>* zHbAmnLYtY?ZL`@k5xInaS2o?AqU;jwZ_-M134IW-+#LD4)4I$m5(SHtXFbI2yD@2o0nFjgs7#F^~r`45|Z? z@>CQL<+n|+Hyib(InWW>MV%U45R3H^StjMU)*HZ_$M2sTd{6aiaD-@r^zm{|_(Ny3 zgsr^ThF=JphkTZ%_`^LboOziei2&B>cKzvA8eW;6gZGQ5wg$)|#J8}j`f-7ATqVJr zYXvEdmys4jq%9FWN1H6+igDVG*z;`5?qNfFD0zhMWi%+gsU z0hC9-`6*kq`Rr+pYe4!R5MP)d_TF{4_NkNQw2ug1Nmf+>HP%#zl=?W4w?!d-%)j#p zC?0<1u_Tl=8lan$G@OO^<8_B*UiS>cE;A)S@u6(FI{nc>f|yFh5{t%H$v9o_&pz+m zg8+FhXW(C&q;TYq_n2g%CtbB5kLSfEydr4UT{+E5e%3250yf3Wfyv_?3jLbf@PiM8 zkzT9Bz_JlPZ+@`I=#Y~xbl2_r_?$W2c6(UZ)+uY+Yb8k72yX05RBV(J`?rNKis|ik zM(YYSJ7gpbB&&lKtV=apwr@+ygkccnyFvz=NYlsFkaN~12lu*pT+q592I``}+|~4O z+e2Nf*gg@8b)tYh@64i_#8GA3GrN=@@ydTxwu;J{S#1u%j@G%DJb97q>vCi9Bx)LV zp`K5}X&xORIj9F=Tro;HStq5!o%l0s5P<3lZq7|mwLJ!?CX%JS2c3HaLcBh^+r5V^O%f!cLp{$AY?K4i$^`@&5H_?(4Wis{jz z4Fb{qy-OsbMhAmtxDwH56_6~j!AE>b*1Fr~(A0IC2!6rfHr6L8nQ0~;!XUD4;Pa1t#S>NbbBP`C~CDQ!4U z%%K8%8%CXbg14&@7Nw14h+(PdHiD_hyQm^6v@a%=NPy(rZhm|o70LM#<1muFpaAiTiGGZG%74Xk*t$~$x|s4?E83I zTjF$#q$?D^BMRPBVuo;y z7_#V>@Z6^!(@sjn*PBKf9Swi>FDAk2aX5^V4;o(+uGLAB$zllC0>=Cl!3ivI|3z-v zrkoWKnTD}SjLpMeL^xQ0D`(;yzL>plg`ATs2o$b2#F6(Yg{*S6+o%W>YgVf7my;Gj z!QF_}1JrX~u|UtnVkw2yO5O6}goBfrO~uSM%j^EzZ^83m>rcio1R1}MP%p`fzvK;` z-}N+(`O@0IW#ey!3QtWK^6-J)d4(!uKU&e6)np&!UETv{awUKO z86B=sMOxiwMbuPs^!q@l^g`HJwKYDjLC>&AwC_gt zZ_Mjulj$nb*caMw(=^whn|!LN1*Dhzw=LLrqQ3TTxiUeA`G*RtY4{R@+0k^7z3+E@ z=Z^a+mLwyt*H&(mX}G%9Ez2(@4$!v-wDUCv~<*!Ze zZjlsDgP~$E-f^QlXA6#WZ4Zm5@Bq!n-_~2=5bp*8*zv)c6<+nwC}0+1`J#+^qecX> zHH0rt_k39asn{y$8w(IO0`7XRT@B!HX&4gYd983-qm1=) zu>*Pkq8Xibk>oF` zCXviY!z%TysB=ux%~rmM5iD|^pS-DWjTi@%HPww%>{|chJdJLbz#VZ7DqHmyX|AXSObmH!=FBB4H8tHgs|6|iRUr6M6;)9jb}118 z$WB=^upf5W`vw^)N;^0!wvxlLaeb6=x7nqR#>)riHBC!L@}tMo$Ap3x$!t%h{P@Ic z2*20aZ_49EG=&{)o`om_W=CX9WeP;EB!Gf1FhslOc}OMyGGG+7HOwsFWR`+ODa+Xm zn@Pv%vX1%&3YQ|R0GVHkwYS!6QuJbsGKE8al8|K8E$Df!@rwF&OlYFcSj{-_AgWcP z5}3en86)1iengBx=ylMagmPP<@t+tD>k}kg4~CQil+uvJ^j!jjn{qn3!kbJZJ9uWg z)tZ_f5OS04DsxfWNozrD?qr;iiUf_1tU`vxH6Wr}?QG==p2cXnoO~8d4H?KX_tT;t z!Tym=jN$E1>XO|yroA6Af-#7hNoXNrBja@Nq#|%2sn~Q%kNkARIQ7x>E$zrPuqr}1 zKA04^2`Aq@%1VS{Etgo7OhUrl?Itlg1Noq28U_|> zvt!}TO&qRjM%NF%C$?GKUlzT%jC%tgn}wK-bh`2+@uuIS#EwL)4=X!LQ+*hGY+=yA z8?2aazNfv}UYJ~H`4_cg>JxkFX8NRm*&kE2b|6U=!i==D3$Y{Stc`df&}UzvM!js;OfeFIGl{KLBR^>~2;g?9$$YqB1 zbn>S%w^Dd?1-`OW^^#>xbQ*5IxjR1bR<~e&6_<+aMUHDrEhK4gTHGNcL~?_I^AU z8~v?}1S(=ORTTpH759?Aik(aAiX=$@p$3v0gA$)Og!)3-A>c9gG?M~@b!0%UD6j@c zS5;R=$OUujlEDFA?&}lls5)q6EgLa7wZx9%lUc|XOO`uqSvxjCc^jsbb}q1%!@*bP z))0f230_EaOKr)PdQwVWsB(NR0zg{Mc0EOi-7Ft;p}tWI{oQDi_;001zIkfD%yHah zPXQR~;%!d4G&z;0d^Ezw3t!C^qhW$g7l+L%O$Q!sYvMP->Jm1$&jcM2J?BHnKD(x< z(byjexuTRLRr1~(h}&=F#bL2F_w!cC43&X|V^8R5>OEUu0^Q7J+gz}#;~D{Vy<(^y}~4O%N@-J)62-|G0L<_1g+JebsU+2!GA zrC`j5C`{n*_+&EN1gwTPKA9sreR$kw?`ZQ^3HHI2Fe`k7OnCIzQjUwOs z*RRCp4Ur~RKu$qzcJ3QoLar?YNkX0^Ol?8%qM5x2=9g=<_ys9+7}c?f*jxZH&STtn z;cvAtcS-HI3BOxH);o_z$>M)yQI}?NN{7^`BkX6Ti*E-lcVy~jD{1a0pS9bK{P4yw zYnaHH=3rj=c_s{ZRt@;%9eHZmN*rjOd~AW$U|??utN@y$xP9+837$!;5VB{RWSmQl z&bA9MD*(*$O;E0XG7Q&ksc|G240Avf>(gtWv~2$z47i~5}J7#J|6@Wa4L6CdYru&$x9qayyL&rCb z-~jRQCr@rVn(V1)g$i^d#^8>K@}c~{{8??0fvvXViw!|x%&ol~p7!AF8#W8bava1W zJIRTBpxS5QXf16o=t1oJD|txj)#POE>_C3c6Z2}kvUXP~NZf3cNLwGg%fdOre)lI~ zM4dVdCDuLW9q$Jv!>saGt1z~QV)@Jv8l z8SPq&n50D0?y_%oijX?+xb8;Hd*IDwY=vatlloC*=fSs4X>O^@>Y6e#rJ;eai~xaq z24!Fa=*Omovzk^ijLqii-6=;Ul(uHYvL;5)`tX6yo%^?E8#|{NK9`G2o%y;GS67GA z8K9~Fx$c-*(+!CIOr@?Grp5>=7>uSq#}+N)oTLO$A;a7@fab3%@D6oYwWXO)VJb*G z)IJYcCGzh)zry(FKQ(RnA1h7Oa|@d!%n4l{d{@c%^7`JO?e=QDtQu)D_N@|&ucv&M z%{LjdF^rJ>KweV@{lKD5m}$d_)eA=p#P886QR^WeuE+8nb5iH{l3^ME_)tCj@H4D3 zZ$WSZNF(Hqk2HOKJWDvZv{dQhxDRxRA=aFrPuir0(o5j`y?3zc&FA{6i&Sn~yFKTA~Ykl7yO!Rt` zP98$%i8r_IQB6NhW?ixMEQXPsLOv^&Rol4P!@(b?NpeTE*OkT&=`g|uZMSmof|GMl zrV5_Wbx)vVFMQdfMt0boYZrn+L|Z#gytNj@W?0Dm$&b_76i1(Tf4?5o-1fg!ny=DL0iLt_#uXKz zlZlrm41Z%^%1d=rnkDrVZy!CmHCidaKkc)w#Mq{LmNgO10`JxzG@+QKB_p{M0#Yu! z;Y01vUnGeTF1k`p_r!!Pn_V=i3tMeLW%s%b8z*lPpk&Q+WQAHc?Uf}6?}hJ@0>pBH zrtoej@i;!6peC0YeezfP72IdoEPyF%*TWnf0&p*p(((1sRlry_Qmh^sE?F}(3_YE6 zVX#F_AVB37To4qUu}n??wZx0KgllIZK%|uhuNk1L#C_vqdRWk6A!oc9Yve* zKP%H|u9M5VUT~uftwVDL{ojf{yK@?$pa^^b>c(~)Y65J*YbkFYOPZc0lfo&9%IabS z)%E9oI;|6h#c#y2EgQuvKC(Rzrrvj3uh8?*|jX818-j|a;St#tsxtv zg|F;4-Xu1aB*@iR$Uw zp%6FGVnNa#JV}T*1MGgR60Hd!vM*<$h{=SAF!p>$qdQld(v0vLef-PIy{UTY-rIrk zVK6^LV}ts%;p{~Qj_tBxf&Lz!FtQgW9WFcUb`UVijAc2=RHq(drBjcR(|5(;>aRA}=tEK(+UQAN|V_`NKVSulao6%L!LWejl zIimaxlM~HmCBV1_ATUO~BpR&%iuWi3BFHj1C*kv8%|Q5zifA$>!5%vkh8P@`MM}mH zWtC5kBya7=y>LZc2=HSY8=<0Yoa0+zWcUzDL=>RoEd+mmVQ`CGvSrB!Txz!ZsaeY%IRnwgl`3!G5`FL-P47K%s~VpvsIBl*4%Nq z+&>N$+a4HT@QdmEfJgg*$&Skkrc$%x;ldt*|3)#8J1_dN04@G17#<0Vb)T({K^r_u zd6_{pCbE$GC32pnh<}tLwo-~W!`DZ9=)58_C-=fr6m^r`h%QhJ=cV>PC-lz1%^mw| zh^|W?k0;)GV$mQgZFdux{HbpAAYsVOp#~8=N!aPxvKvh06MYUCyY%&InyiMNxh;BZ*hdVJbg5X6luE|;^ zMgB(s(#|A*FbyZldhi6Ohjm)hN*F-%pmOu4Q2>;6CRT_N*93G#gWeltl zm*?bbuk~8C`XNwy1D2k2c@Ci-J2uA`2!74jlnB-i^Vk3TOn~>P8r<5>%pB=t~0|s$@YN!S4mDQ`x!$95(U$yfebDx(>N;0^cQ4rYyukmz;Cs8UbhbG7-PVamGqms;-Sib6?tnva#0+T#lWuAEd z*X%!$N_%guqOyD zTtpS~DYg1%TPPk##CE=U!qT%l_S_`sIrv6*Iphum~~8x26rDB^I7qRnF+k zT>M&D$anXMc-K!%7uGUkU7C1?&rmYi>~4hQDe`1;hXv^O7l>` zw=zDCY`Koq<|?1%Zc`K19U7ZQ2)Ys+u4gBQ@Y5mdf%?aKh1TRd;@Tu-sbiO;H>4ce zzzJ%96pT#Q6Ojb&5rO+^_T4989z=Y8I=aW*Im^_BZZ2NQbV&brFl-M+pYf)pVuPj` zX{Asw%YMGsKoAvvv%F_KKWDcWPWeen3REr=U8Ix~!s|NC%vP|w#Pp1$xz7IeZ9-Lu zefFq$2oc;$*wEHZBELg0;kvOq(-d4P zWAAI=HsjWL_Ng|7-C(pBfPODBwV(mgZ>TuA;VuF zyOz0C`oy}@b5jz)GD1D{;!SI>9jVX;<9rI)xsOKdcqP6$u1sDrD zB75gkHKIQ~m#)j`;f5_-CLrKs0a$`RywinN=poh@w4BYV87`}NhcuBuMXNM$W`+Z| zrUK$Vtyz$c=+z@pfS1zFk2iU4k{ve2iY$G5b~!6(=6SS)4d`$GG08h`J*RqMA%$gS z=1?AC)KsA>w>acU^d%_e>dNrSqC70-gJi-(!C#3leMP#?24ZMwkR_+B^~$0{{Dks) z`1l-ypRm^ro?&%1le3w|C`vX$tt-;${hD&^$^F-ir{->hSoIMjrgtJGSw$@&b>q25 zLh@(S(*m4h5Att6_<3e#*4!1F+uwS`vw_8=lpBxaWZ+uVX+ju1>!Y(W5eQmu{sc^G zm1(^o%!*fy9VUX!ES^9LWfd^_VYN49$3L;iSz(o*o73>j_Aj8HpQ12wf4l|zVg?YW z)CknKO-;Le&TY7k0iAD{^12*g%(eDE-%E5Zb4@RZ;W0+6DvK-J#TF6LdHBc)**`B= zXV6b~itF4^kSd8SqW3s5SARBRr4X=mf{+G~r~D09%ujrw!K@dwuoL=NMVb#NN@fs9 ztp)cdBB42tCr4(BZSH#Yo0IXx=fSFmQ#z+MwSdFSJk0Q5T}i@vu5&`s_mpqgQeLE9 zS)gslg$ct#L`TkRXXu+3q`=p$Y5glO*EB^{KD5Q@p8U#o6S;CPKiI;#(JGA6PHQ1I ze{+2WKOLl(id{=*le#wb?$6@SRCbVH?QzD&cZWq@;s+&{@=0xpExP^BO9MG#kSJxR z#AdMx!ln+q=wKm-DS_>yUDxbWeXbB{ea(a>zoYvNl(KCH?HS86+)rxzpw+`)1Z#Yw zYSs)YTmq3rXof&Wu?oRUa`%dHrQk6XxrO)}M;KR#;wldi=83tmWk{ip9TK%&orQOc zX={{mN?;Y1Gvx;O)ZR(d=08I>Z}G!hbpggz-J3xsDU3 z!B8i}G_Zfl}GEl%@|MgIs@zQ^PF8%-9f>&PI ziJV$>CED>jTdTzDSrP!JqBG{H5e4{c#T~KG)x`mRSQW3_<;y?-KLz<_%{oP@;1t2V z@8fHL{YUoZ*|bZGa&J@-66@a)bmNd4)QK1>{aO@yVP#;gEHZVx9RGliaM*|o>4l*n zIQS!G_qya_8SnC?6Ck#kqK5gSW`W216=mnwG}xVx2ogAa)FNnNsD5xDOaeU@%8i_S z(!}d4B|^KN<$Lo**Z^bQ@3ep+i$YyYIx_LS`+nxULcVelv zrgmFpoR8=KUWs{-lZ*;#giM);?5}V5uk!v7wq(y|`+yBn$%3ujt&xvYzRkdtNue5a zlHEkNhNuS6JxXEB^r3}7Sqi_=J2qR_-LDRJJ=$C()2!e}e?r2h@h?ei9S4(yXTo)d zjVZ>OEX*_uqF!D$(<<5uMIx9;?jJhv(Bajsp*3sx$UC++tp{!(A4K4!6LH2%sa5E){(1zWPQ0$C7!YB4k^_TOz2icPQ*6Ku{3c zy1}RPZh3@~h0UvPp0gEpuvnm*DBt_?)!N@eYrXO~ebW=gwiJ)1YxKtZT}uD)H+xDQYM7rn8=a^kL3sOo$D`JQz`}kX&G`A4Jr+*C+!iJ; z+3u?ZFCFj!o{@XIX!7-B-Mc?H!t;KaHj$yaIi1){99QIP3WrYHTx;>kJ#K{~H`QPB9y0qCw=XjFr@j83ligyDL9u{+Msi9u z;Su&hsBEXbbC0=2!0h9`2sG~`f%1D!kbgASQ|XE#_x1`Ve4e@1%pj1^pc_YmQ0jXu zjaGVFXg+aC3NBlGlpMfje1UUKD(01xr+IPraT|aEOKj_{DK$5R&8KC06foWoMQw`t zRO0IlqoEBQwL_PW_Pb2Uv^lcU8EjWLK4x?j{S~6*mGDSLA$|sMrI#Lk*83;^+A*(q zObZGgq~#cq!}m^$>D53 zEB4Im(8-ZKQptYpcN#;@EU4~lC-pESc+Sl8+>>~FEzg^08BA$r;0mHb9GY_d@wZ5k z53Ae^#2d)^dQD;NB8uJrOrG4iGI4q(*48GdgCtC~cZdubi>yv}6@vIiglwyQr=6_Z zn};Y}m-};hLcU7w(`K30*I zJ2Gch0PiiK+JO%w#k*ct|Gz1Hcb7}IIxnDm6L{hv30n|e*k@Yo$SNd&TwXws_f3$b zJ{CA~A#`eI`t^yw9(I4&DUxGtg+~(PY1ygj_mcvXS)@nPP{)?M22ga3ElRdwCJIPW z&%Q>kZ>z?ws?&%>D_t9&H&oV;Oa`<_y_TBf%Wx+&LS35gz;d8 zopD!jdl!FXN#rEtyzhD~*;0b1(W@!&8s?NGpsoGu;tYk8l7z&x*_$H5rcQ*UBI1}@ zpHw5MMQU=q5qr;6LvB9e40}YMg!jvw@DI9dbY$Eq2MOxD1SdI(NXF(Q;WCfBWp_>* z1J;l?)>MvQXp^+ge?0U8Q)vR9(&RU;jU)P+Q`Hbh(k`FnzlrJ}D#OVlHyMulhmzTab#GjmYwhM?s&%-xS&)xMxKFZ zM3C1nwlRNj3Nh$}oGsUi5bnbHA)govugQ<;%TLCzR!*{o(q+;<)*_&UB72Up@I>rH ziKQcaXZ-7I3;i{g6a&Z}LetQy{D|hTnCzP0j`t-Sdl$VsQ}M`-Pr6C#k(=O}=Ho%Q zvfCO9A1=X`x#qz0eaw~Un>dhY?EPm`F~&Drp`yN|n??Ya^7Z}c}VqDrpMq3na5*q29=Pb}_NsfzDx+#p{uj%H5K83K?#WN5R zf-G}$v1wA^%@_U!m+=jyN@C{Er9RBYlw6A>EH2jCE->)H-sr{hw~XBLhHq=9~2A_ zm(=zW#9-gso{0y#s%5=)afm1BYT}|&W${={63Ye3b=vkG{44BxNI%sEi%~7KE-TQRp8bL>z`_T8!_V{ z=kn9qC+!g!%#qW5@TnC0+qsBpQ?_MI8uZX?Y>xDn+mO6U%_gQUi6Dby82vQ7i+Hw@}Qx=#+&P6&x{aNVcD>{w;o z^_STG=3Ha$8ehIa8ORx}*}H%nX5w=$VijCtSkTT*b@G+~ggbiFnf`f}8$1aT=^R;L zm+LE`kFYyN0IMr36>fSdl0G6dBTk_|C2_3oMhRgUNgh;f)5)!nta8~bQw(~ht|~dm z1d91rAe{#^H?akcX*5MBzaVrJ1hh4xFxIvMD1FwhkqrG~W8aEj#ZaIS)W!vrASt0L zYffJ#od#^zO#yB+9w+{dYb4+ZgBRunDJKER3 zM*95a7jZ9&M&@~5?l(hmg$8;wur=gr+bxMeNx&*w`D*IabL6ql(uR{AGq)a}<42Ob zQW}kgcx_Z|OMkh*^n%5<*eR+rH>47lhR1wHKi78_-Un~jIinLCNRa+1Py2e{BTFX( zyvl0;ge#a9&r-g)Q`bTOr_k-~TVWQ7#M3@H2BvO5lwPw16+cI!2e4~BQzYrJhTu-B z&^oP1yK~~aEC!T!0kTNWh_KU>KSCSTA!-SIzp>eaG|$F?*hxb2CwP*A)k9}1X+5ZH z{Onh)$xJm070z*fe{=JB%r%#aN@+CGUYXA*NAu2lCYA3P6^Okzt%4DJ2^lRFcg}=z z`bqXd=zBgZmo&iT5h&J^<_tv;l)g9^=``GGOb-oz@T0DhM)1XZS{#oJ4xN$v00!{< zh|GZkk84H33slGnrOOx;qk8Q$fu44dEO_s@0_B&fh4h8d(ST4L|9=$v8!K8hV7P%` z?T>65h4p&H@g~7mKSi+}QEN9Ijz(9N8;;32RA*+J$u~L;Y6=dY3Y78+mHhC7mAa zNII-s3YCh3!Wxt#--v$f5eq6Nai~vN`s3R^1AV*}9z<7L@TRz)nVi%|nS6u}nai@6qd1*TPU$ug` zS(-+WxUnM-K%>R8VtEe`j7C1(^`!q;HnXiy3GCk+q|pK^rYbgBg;l9L(Tcn+fw$UiLYF|p5Vzw_E`l4PUsl38K}pq02{mu+v04}o4|uFH!$QlM%thrzc1VD@)H>qj?6&3OYmd z=+X_(WheFmW|1+wiGQ{W`kiu#j}N9;<0Wa#bi)ChUaImw2RvHs$T*R|gMUG)n!FkrW( z)1HR)o1Y5N!j0e=LMdMdW0viAurBzypfaVi3WgDwz#=%mJ6n`s~sN z3;(N>mn^ckE&zF(ArO-OkEHxQy%-6i`<%Tg!MrHx)rq)aU4jej{=~J_Cfb5S*96U< zI)Ye7Bg3olxZZaP>uPd36`;a<-zR^=qH3t}Ek#`mzgr~~?u=|VvOB=V zwW2HwxYPU*TOCNaPe@JPeer;D896ryMeF!-+L~i^`W}Qik5ngz6ZsmZkA981u(G3b&b_vaM5rA={l0`@m2! zB}P|OO>eBA(#HCtvJpzshtZhlEOVETpYmv#H=%)SlypMaZcO`x7Eo~5x`Q!MGY?SS z<$sM8go>7fn&)@(4hb$V99f}JQj#x!0HwjLjpfh;eVn#|NIh})KPnBQt|Bup5rHvr z@dR@TmBO@ft2xbWr>&l(J8@Y0>bMvSxa|rM7K`6bk}yJv@>Xd9mlVsMum>?zsLFYI zkv=lNv-$oeh0(h7OfH!{nv3dWD?YgG z*}m@}43YjHp{r5iew-Z*=yp&L+~`NU?Y4eiG2_}lTtKo9F0(i3>mQQA*N{aNj1&}P zB~VWLHLHu{T*eW9`AJMDHsXd9R(l`m!8q;_zciFL51q(N*5)c5;S~sxqBBXUKv7KK zmsF%;{ke?5aKlvV$dK5CU2{E`At>?L+iSpFHAFI8ze`OFh9NcGaC%@D6UtUjS@LI;F>&Sbqc>U$>+mF&X$>^R+?%%!>Zs* z%delLodLWK?4gYQ;4%g!NnP7+r07YPUx^aSn8r}k+=PhNJL+|Y7}2|3Sch}%@CT-$ zaooyQzjMsML!5#!Zt4ujLoJwr0@{Z@Y0N`mC9{N4Xn&g~zbPZ*F~@B{H|DVyY90wqqpoiPBfC zY+5T)Un~a3umE$Q;Gr-+d_jYMs9@!N#2uT&MtyJ`8N1th?Bkil-4d2-hpSWd=l7tH zx}pa78L8v`D>0=Yr84oD49Si1%en3btlfMIg?R6<{sNR5~KhWDfH|1GQwuF2< zW8ZP)H7v+LS!TqE+Wh#scTo%th|#uxvCX>hj)ibvaCXhQ@|L;Kbv7D{1n2e7hAtAPDT9Kc7#3o#eSw~IZOfjtwnu47FC>zP?2vL zolIXY-YT^$3!*AYaa1fvc!~#;PPG(zL-!Bh-O5cv76dT{;WBRiv~diMK={8f-<#*r zu*eK%-~c~{1zlDQAJp&iIIrv-8EYl9&9DiA;MF6K9EY4%ZTsCM7Ye=u+=U9h2c!#J zl}`lb0I`wM-VuWww+@V#GyO`~*13!RwND4bALV=VnRLp1QgXb+U_kPPPPLZ=q`-Y#_6zf>$lw@mWImxv$ySDkTR`b{z+ z{A6y#FV9iHG~)B>R?eBVN@f6t7tnOY_fmjRTX*lC|Nl3dMctf>8lsO3#@NIf4W!)W=dv#1))3ijalUjjVXI89f;k&fTFAToUB47x-x;(W{zi+wXsgNyw9x%uZ#bDRq zxhB8LYS-AArpW;8&Yp5SspD=BmsBMBnSoA&&(|V<-tGPvYUW})W;P8n3jv;x_vD{%u?u13XkGwq2U${=Z8bEo@1DG1sr#C8P8fYMlzc5B>_=;XI~7qt2x z?Vc2QmhL8;SKAs*fSDnY&TA@|aI%7HYcQL#br5Xp9yI})!_urD8A4WS>|)z*qq@3hbAdh(Pcid}P{Oh4-EX%@wr`Amt1*zE`;gQ6j4h z$J?7m%2AlAi#6&SpvqdRLK-<}x5LGF)+k5@p2`Mp#4&#_P1L-Tm}OGIO)aq0lA05) zRYhWpd2oYv&p_%$|6YX>k3sBKZF=zPuoxf0G4qr$9mQI2)6`Y--CG-$0r9qqDWA4_TCs ze!E68{!`Ea+p@%r~T_aLX!bO;$KgKIX~n*jz>d7SO zwzahM6DvKCn{#OJ+2FYA@)oW_K&qK2|7MB%- zIZH)?&z*U)p-tf`TXCtK$-2TvzPhv`v3beiLPJvmknM<+eVmUJ1v4w&Z_wczX2q5c zy(=6bM_S_;mvX~)Urx$=PV*PxfW5^6YOMJ9?d?_K4gYh~MdWNT@BHif$g5CJpLK8G z{bD~>?8c2mlM_&w$UF(XA*waB8u7A>QBV$8M4A%;3O@D0ut5V-;vEe`;FdQTf_4;8 znnYaz#&&tPbAn)?k~EM`SS%pj46OFK>z#a!Z+;ZVS4q!JeYz??a%lv8S;R`r8*`vr zflb{e;r)(H9jzg|mpQqGNGIsuen$G7w7F97IyxSYk4S@^KgS(ZBPCrkYRAfyx>(5~j%|lzAIe zEE&vd6I(-8>M+w627IbM)dk!wt5K>>F?pT|%?VK=m5UwjKGqHV6RAY`U_hg)|Cet$ z!yzMKu3@CT{AuuyjOnZ%ZrR{}xpIQ`Q<1r&s7d2=ucan*74kKo3<{GPXn|4SOt_H? zN4U?U-u$ELe}e4bjlda%D3s{5Z^gBu^ z;EF!cH(Q+F_Bne@vHkPh?qgXRKgtl^!2D2=gV3TAEpBs#_yNpxU8RPphz5p3U3@3$ zSTm_m=hOw2eCV**qO!iz-PYDd8lA0NrQWD0EAfJ#@2Mq=>EDeO8oS!!4N-3U6)E

cnR2#cENLhkD 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 JV9;GW6N-!MZc_jN literal 0 HcmV?d00001 diff --git a/tests/gui/CMakeLists.txt b/tests/gui/CMakeLists.txt index 3264da515..2734cf582 100644 --- a/tests/gui/CMakeLists.txt +++ b/tests/gui/CMakeLists.txt @@ -15,7 +15,7 @@ include_directories(${CMAKE_CURRENT_SOURCE_DIR}/..) -add_unit_test(NAME testgui SOURCES TestGui.cpp ../util/TemporaryFile.cpp LIBS ${TEST_LIBRARIES}) +add_unit_test(NAME testgui SOURCES TestGui.cpp ../util/TemporaryFile.cpp ../mock/MockRemoteProcess.cpp LIBS ${TEST_LIBRARIES}) add_unit_test(NAME testguipixmaps SOURCES TestGuiPixmaps.cpp LIBS ${TEST_LIBRARIES}) if(WITH_XC_BROWSER) diff --git a/tests/gui/TestGui.cpp b/tests/gui/TestGui.cpp index 6cf096201..91c8a0866 100644 --- a/tests/gui/TestGui.cpp +++ b/tests/gui/TestGui.cpp @@ -21,6 +21,9 @@ #include #include +#include +#include +#include #include #include #include @@ -56,9 +59,11 @@ #include "gui/group/EditGroupWidget.h" #include "gui/group/GroupModel.h" #include "gui/group/GroupView.h" +#include "gui/remote/RemoteHandler.h" #include "gui/tag/TagsEdit.h" #include "gui/wizard/NewDatabaseWizard.h" #include "keys/FileKey.h" +#include "mock/MockRemoteProcess.h" #define TEST_MODAL_NO_WAIT(TEST_CODE) \ bool dialogFinished = false; \ @@ -370,6 +375,107 @@ void TestGui::testMergeDatabase() QCOMPARE(m_db->rootGroup()->findChildByName("General")->entries().size(), 1); } +void TestGui::prepareAndTriggerRemoteSync(const QString& sourceToSync) +{ + auto* menuRemoteSync = m_mainWindow->findChild("menuRemoteSync"); + QSignalSpy remoteAboutToShow(menuRemoteSync, &QMenu::aboutToShow); + QApplication::processEvents(); + + // create remote settings in settings dialog + triggerAction("actionDatabaseSettings"); + auto* dbSettingsDialog = m_dbWidget->findChild("databaseSettingsDialog"); + auto* dbSettingsCategoryList = dbSettingsDialog->findChild("categoryList"); + auto* dbSettingsStackedWidget = dbSettingsDialog->findChild("stackedWidget"); + dbSettingsCategoryList->setCurrentCategory(2); // go into remote category + auto name = "testCommand"; + auto* nameEdit = dbSettingsStackedWidget->findChild("nameLineEdit"); + auto* downloadCommandEdit = dbSettingsStackedWidget->findChild("downloadCommand"); + QVERIFY(downloadCommandEdit != nullptr); + downloadCommandEdit->setText(sourceToSync); + nameEdit->setText(name); + auto* saveSettingsButton = dbSettingsStackedWidget->findChild("saveSettingsButton"); + QVERIFY(saveSettingsButton != nullptr); + QTest::mouseClick(saveSettingsButton, Qt::LeftButton); + + // find and click dialog OK button + auto buttons = dbSettingsDialog->findChild()->findChildren(); + for (QPushButton* b : buttons) { + if (b->text() == "OK") { + QTest::mouseClick(b, Qt::LeftButton); + break; + } + } + QTRY_COMPARE(m_dbWidget->getRemoteParams().size(), 1); + + // trigger aboutToShow to create remote actions + menuRemoteSync->popup(QPoint(0, 0)); + QApplication::processEvents(); + QTRY_COMPARE(remoteAboutToShow.count(), 1); + // close the opened menu + QTest::keyClick(menuRemoteSync, Qt::Key::Key_Escape); + + // trigger remote sync action + for (auto* remoteAction : menuRemoteSync->actions()) { + if (remoteAction->text() == name) { + remoteAction->trigger(); + break; + } + } + QApplication::processEvents(); +} + +void TestGui::testRemoteSyncDatabaseSameKey() +{ + QString sourceToSync = "sftp user@server:Database.kdbx"; + RemoteHandler::setRemoteProcessFunc([sourceToSync](QObject* parent) { + return QScopedPointer( + new MockRemoteProcess(parent, QString(KEEPASSX_TEST_DATA_DIR).append("/SyncDatabase.kdbx"))); + }); + QSignalSpy dbSyncSpy(m_dbWidget.data(), &DatabaseWidget::databaseSyncCompleted); + prepareAndTriggerRemoteSync(sourceToSync); + QTRY_COMPARE(dbSyncSpy.count(), 1); + + m_db = m_tabWidget->currentDatabaseWidget()->database(); + + // there are seven child groups of the root group + QCOMPARE(m_db->rootGroup()->children().size(), 7); + // the merged group should contain an entry + QCOMPARE(m_db->rootGroup()->children().at(6)->entries().size(), 1); + // the General group contains one entry merged from the other db + QCOMPARE(m_db->rootGroup()->findChildByName("General")->entries().size(), 1); +} + +void TestGui::testRemoteSyncDatabaseRequiresPassword() +{ + QString sourceToSync = "sftp user@server:Database.kdbx"; + RemoteHandler::setRemoteProcessFunc([sourceToSync](QObject* parent) { + return QScopedPointer(new MockRemoteProcess( + parent, QString(KEEPASSX_TEST_DATA_DIR).append("/SyncDatabaseDifferentPassword.kdbx"))); + }); + QSignalSpy dbSyncSpy(m_dbWidget.data(), &DatabaseWidget::databaseSyncCompleted); + prepareAndTriggerRemoteSync(sourceToSync); + + // need to process more events as opening with the same key did not work and more events have been fired + QApplication::processEvents(QEventLoop::WaitForMoreEvents); + + QTRY_COMPARE(QApplication::focusWidget()->objectName(), QString("passwordEdit")); + auto* editPasswordSync = QApplication::focusWidget(); + QVERIFY(editPasswordSync->isVisible()); + + QTest::keyClicks(editPasswordSync, "b"); + QTest::keyClick(editPasswordSync, Qt::Key_Enter); + + QTRY_COMPARE(dbSyncSpy.count(), 1); + m_db = m_tabWidget->currentDatabaseWidget()->database(); + + // there are seven child groups of the root group + QCOMPARE(m_db->rootGroup()->children().size(), 7); + // the merged group should contain an entry + QCOMPARE(m_db->rootGroup()->children().at(6)->entries().size(), 1); + // the General group contains one entry merged from the other db + QCOMPARE(m_db->rootGroup()->findChildByName("General")->entries().size(), 1); +} + void TestGui::testAutoreloadDatabase() { config()->set(Config::AutoReloadOnChange, false); diff --git a/tests/gui/TestGui.h b/tests/gui/TestGui.h index c74783ded..31b0c2b46 100644 --- a/tests/gui/TestGui.h +++ b/tests/gui/TestGui.h @@ -40,6 +40,8 @@ private slots: void testSettingsDefaultTabOrder(); void testCreateDatabase(); void testMergeDatabase(); + void testRemoteSyncDatabaseSameKey(); + void testRemoteSyncDatabaseRequiresPassword(); void testAutoreloadDatabase(); void testTabs(); void testEditEntry(); @@ -85,6 +87,7 @@ private: Qt::KeyboardModifiers stateKey = 0); void checkSaveDatabase(); void checkStatusBarText(const QString& textFragment); + void prepareAndTriggerRemoteSync(const QString& sourceToSync); QScopedPointer m_mainWindow; QPointer m_statusBarLabel; diff --git a/tests/mock/MockRemoteProcess.cpp b/tests/mock/MockRemoteProcess.cpp new file mode 100644 index 000000000..861c4a5e6 --- /dev/null +++ b/tests/mock/MockRemoteProcess.cpp @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include + +#include "MockRemoteProcess.h" + +MockRemoteProcess::MockRemoteProcess(QObject* parent, const QString& dbPath) + : RemoteProcess(parent) + , m_dbPath(dbPath) +{ +} + +void MockRemoteProcess::start(const QString&) +{ + QFile ::copy(m_dbPath, m_tempFileLocation); +} + +qint64 MockRemoteProcess::write(const QString& data) +{ + return data.length(); +} + +bool MockRemoteProcess::waitForBytesWritten() +{ + return true; +} + +void MockRemoteProcess::closeWriteChannel() +{ + // nothing to do +} + +bool MockRemoteProcess::waitForFinished(int) +{ + return true; // no need to wait +} + +int MockRemoteProcess::exitCode() const +{ + return 0; // always return success +} diff --git a/tests/mock/MockRemoteProcess.h b/tests/mock/MockRemoteProcess.h new file mode 100644 index 000000000..2ed0e72d6 --- /dev/null +++ b/tests/mock/MockRemoteProcess.h @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_MOCKREMOTEPROCESS_H +#define KEEPASSXC_MOCKREMOTEPROCESS_H + +#include "gui/remote/RemoteProcess.h" + +class MockRemoteProcess : public RemoteProcess +{ +public: + explicit MockRemoteProcess(QObject* parent, const QString& dbPath); + ~MockRemoteProcess() override = default; + + void start(const QString& program) override; + qint64 write(const QString& data) override; + bool waitForBytesWritten() override; + void closeWriteChannel() override; + bool waitForFinished(int msecs) override; + [[nodiscard]] int exitCode() const override; + +private: + QByteArray m_data; + QString m_dbPath; +}; + +#endif // KEEPASSXC_MOCKREMOTEPROCESS_H