From f4c20d050435a3fe8adb24946d73f60d1981e341 Mon Sep 17 00:00:00 2001 From: Jens Date: Sun, 20 Sep 2026 13:04:43 +0200 Subject: [PATCH] =?UTF-8?q?M4:=20PWA=20+=20Mobile-UX=20=E2=80=93=20Manifes?= =?UTF-8?q?t/Service-Worker=20+=20Icons,=20Auf/Ab-Reorder,=20FAB,=20Touch-?= =?UTF-8?q?Targets=20+=20Safe-Area,=20Nav-Entdupidung,=20Heute-Highlight,?= =?UTF-8?q?=20Aktions-Trennung=20(Abhaken=20vs.=20Bearbeiten/L=C3=B6schen)?= =?UTF-8?q?,=20OIDC-Fixes=20+=20PWA-Tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 11 +- Doku/plan.md | Bin 6094 -> 6617 bytes compose.yaml | 8 +- public/icons/apple-touch-icon.png | Bin 0 -> 6515 bytes public/icons/icon-192.png | Bin 0 -> 15173 bytes public/icons/icon-512.png | Bin 0 -> 21702 bytes public/icons/maskable-512.png | Bin 0 -> 3003 bytes public/manifest.webmanifest | 17 + public/sw.js | 79 ++++ src/UI/Http/Auth/OidcAuthController.php | 3 +- src/UI/Http/Auth/OidcClient.php | 11 +- src/UI/Http/Essen/MahlzeitController.php | 9 + src/UI/Http/ExceptionSubscriber.php | 2 +- templates/home/index.html.twig | 14 +- templates/layout/base.html.twig | 432 ++++++++++++++++++--- templates/mahlzeiten/_mahlzeit.html.twig | 125 ++++-- templates/mahlzeiten/archiv.html.twig | 11 +- templates/mahlzeiten/form.html.twig | 17 +- templates/mahlzeiten/index.html.twig | 74 ++-- templates/mahlzeiten/inspiration.html.twig | 27 +- templates/mahlzeiten/tage.html.twig | 10 +- tests/Functional/UI/Essen/PwaSmokeTest.php | 75 ++++ tests/Unit/UI/Http/Auth/OidcClientTest.php | 174 +++++++++ 23 files changed, 938 insertions(+), 161 deletions(-) create mode 100644 public/icons/apple-touch-icon.png create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512.png create mode 100644 public/icons/maskable-512.png create mode 100644 public/manifest.webmanifest create mode 100644 public/sw.js create mode 100644 tests/Functional/UI/Essen/PwaSmokeTest.php create mode 100644 tests/Unit/UI/Http/Auth/OidcClientTest.php diff --git a/Dockerfile b/Dockerfile index 95f39ce..8763a21 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,21 @@ -FROM php:8.4-frankenphp +FROM dunglas/frankenphp:1.12.7-php8.4 ENV APP_ENV=prod \ - COMPOSER_ALLOW_SUPERUSER=1 + COMPOSER_ALLOW_SUPERUSER=1 \ + SERVER_NAME=:8787 RUN apt-get update \ - && apt-get install -y --no-install-recommends git unzip sqlite3 \ + && apt-get install -y --no-install-recommends git unzip sqlite3 libicu-dev \ && rm -rf /var/lib/apt/lists/* -RUN docker-php-ext-install pdo pdo_sqlite opcache bcmath intl +RUN docker-php-ext-install bcmath intl COPY --from=composer:2 /usr/bin/composer /usr/bin/composer WORKDIR /app # composer.json zuerst → Layer-Cache COPY composer.json composer.lock ./ -RUN composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist +RUN composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist --no-scripts COPY . . diff --git a/Doku/plan.md b/Doku/plan.md index faea34ab42dc13b8f2b1b07c99ac2799c5ed04f5..0345f31382c70fc1c992d38ecc7639024b57c89c 100644 GIT binary patch delta 535 bcmX@7f75uwIq`aifd!-X4{Z4Jqgf9CBgF-( delta 7 Ocmca@NZFo<*rh*YI_>Agq`y>}E8q)0CTk*Y`$kX}MZfqYPcV_O~eWR(aL``vv0ssJN6=ivC0LakYyc7(*Y%INO zC9FMc2?TfqdnCdS6X$;f(}jskJQkD?6yt-zBw#R|Ntd4gBH-d?<7nsqzYB0i{q`pa z-1|=rUXCudo?ezNi2p7lB=qk}glkGT0DuXuBL76!Z)$7S-cL{OWAvWyR`Rp!%B9+O zm3QtbL_JB=B6;vcftHUdei???6kNp#4EXUh3JunE!`E#HE>;nXAT+tL)_vVNMZjvb(Cd^92|pdor|1 z6m%QYVTUp}JDnelj)WR^aF0hA_pmG-hg+D63j2wYxViIzWx&SMX10>7IDcBG=7%CH zu)BK)()A8jQCWh|(l+(wZZvUuneR)oRx1esiS|2>n?9rOP*;G3rVoYhbimQg#|rF3 zY_wO=c*W~a?qZC$4vwE7wmn3|?Vl!x=J9!0e#;F>9s99)Mm*jD6qcYn(axBBy zb)idx9~Pc=+;m05(gD|Xna7+Z$?+eG^$QLQY0oK>fvnRh0lgZoTwL$-V#zjKo$ z{`j6VQY*V-Vg^@Sd0lrt)yw^8b5Ud9EUwj#w{{v+suvrG8uE^A-U`9fmfv2Eub#~x z^B=nX%>^Xt)^GyFQ-0W}?(Qoy1HprEC!-u22c_o)tgqwm7*V;l6Oqcy*{}T8;i&V~ zU0r)(p(OaTjd^1-1#uL5I$Y5Iu-a|fq?vw0O5eq+e0*fToOCOz5On|ez`}Ks=c3O= z(-~h!@@FSDw&LQk*TJ`~plzqB?eAiv- z9~7hg`qzu^IDBIAr`H6y=O2h}h97$^ngG`ly(5N|=v3%|vQwVgAn``WrEiHNjoOMA zK~*f+t>aN0Ol52=*eIjMfoi4n#WVHEi8KJ+o-+pbvB{N_EUms@VJCOP0nT}Pp*Y=f zY}vp<)nJJHlQ^apoNVG^i&r*7G2ubn)#;OgauaOzBpsr|xI8DT70)!R;ss(r+w-Y`Gwb9*;j z=*I-jEmeu7=zy;a(@V97tz_7{k+qt`pQCPrjd-I}gqMb-`jKWdnV|w$)q#eGF0=qk z<5gj{dd-gPpm&m+q71bj{7qR>BC$_Qa2h0}=hA#p{eT4Pj1=TAr(kYvx3D+k0?Zbd z%rSm4y1@_gjsI*eWN)1Rm|(#&jACdvUZf%lWS{pa-_7M+P_M!0-t#LEt3!|j#(pJO zAs+ar?4b~tMy8GD4f!uPmqSxx=VhgHCnE9wLpvbl0kw&N2XV@UaMh$aU?Cu4aMfA& ziY5u`bXyQK=n5AZ2$y?%pX#XaS%VfK>c3du{zS8v>y}h0sViyJs5dGCWC>Lel(aS0 zaBXp%ygyI35Tw8MAf&|)hG1dL3Mpv|oSuiHT@qofqYC7!ax;ZYp;&9+STJz&p|c4i zg)8r6)vf@-6RTOzm63H<>&~xa-bKZKpk+MGQO9`KrceD{^UaNfa({i~=$#R$^K`O{ z)Y=neXg#fg2m8Qn;kFz)fb>gi1l2g^exRNk3=(xcfRl zpKE4~j4)HA<#@8myaV;;O*Xk>=GmT`tT(PUi=B!um_B%I>UjP>BnJRn$5vCw07Bz4 zj*2;*A@jC%NQ$4qoY6|sBu9)BEg!Zm03v2kRYxefXQS2yv4;|U{P6Ir**rUVZ9QMc zs}_gir~nZzlXWtq5c6ed$YBXjFvop(zP459?(hAhn0}I~`KD&|+WfuLw!(f9&xFqS z!{G0ZlpS04Z0^Pp52w5i6!6mr<9&m*1DQ}QA(k7OL+`B-w`sT)nQ3=BMIk48*iIEpvs4a3oKk2 zN4+7c_%<;uBm48Hr{FCs_&f*MZLI0zbDD0OG7GNmJ@m*0n|~Y|%Oux4i&|7Mv8jl7 zza9SSlC5Kh~d$=wPnEwFN>XijWeLTe2N1AIsiO0V6#o ze$~`2akHetoY&jGoIy+7mHsTg6t|eroL(WIpuihO#D8N+?e!iN);MI%VSHNi#Cb3N zV?D?(tUb?bAZf+hA<(gFaOIo>en;fawn~)&rVpE^VcA}vH86Oj=@t6@!ykHO4T%Px znnQUrh+?kSz_q$Zy3=NjTBE8CdnJEkZw-O7T_iC-@^e+m4Jc}L$yV==^*3HSmP+x~ z58$w&k~s9%HwVVLP0Wv7W0y3%nI(_DCVithFx7qHMS5bkH*_QnNlX5bWj*!NTMY}?{@q~!QY9Yy8t}>(HS7)6r?;>bSO520)g7;T?m*%$kz`$rhnvxlYmogp zft*Y>V!N;YlR;h-k^;t!JtTutHFV>>Zl;MZ+3`z}!H*1xGIUFBhP@H|%Ixbt>LuE> zxc2d)(}d00smb?OMIQ=l@%{zFKZ;lpA;p#Io&81f*P6QMW%%PJp0DIl&d+>ZZ=bLW z!xsd`8WY3fhwI7xCNGEP$Ua&7WT=%bTHCw-O&?>|lr$8FF|0k~o60-eMhX_c5+WO2 zWxWVo(e`WaxtJ~<4$tYnvs7&W%D|~IgnezBX205um^*#gRh!L-l-bn6NI4HPPA7L1 zN`|_Jtk1G=-jL_}tlaaK*Ery1n_5+K{!8cORXYBhi6+}}PqWB?&ChOM@NgUN@00Eb z;V*uB=SQ0_QI}|ONvK`qy4jh2i}_H;LKw1_(=I&uC22PK$e-!Ig74@)1REtMm0yw3 zYQfFB>6Kk-oG@vWmR-eLm9E<>Rv-XXmOSW3d;-Ye31W9_PIk!UZ6my#&`FJSYP!qtMbe0JAd}S zktXmnvH!Ul@Lp4{;38z*@MFxij>|itMlX68H$Ksv`g$d4{tqj2)4NoR%k|miOAwKW zY(jQdwxBk4;a%t|39XR6t4zbVrYtl@nj>LXut&V*DCfi;^BndoGUaWtY;^3Avpt=d zxO(8KOX;!KMI!G``0&xup#t$IpOV?@8&7^{Ol7<-7AHw3=_7TE=!!nL^tYiQnP{GG zst36^C?9A-wRy9MSh#1l>fU(gpBa!FaHGR%aa3(Sn4nVWhK(U%?np`WoV7XC(Y>M^ zpMm!Y|!^d5D%?JqO7ZHrAR?2&G7exG~+n{2G z>2qkxq0PHo;8)#FR6yc>@+YiNLH_Q$Kaf}-kWyxkw=79pHRR<*KI=zaDX@fUhU8k` zJ53Mc1=lM{O;}s0yCnlGh3hd1Vr&+l&sxO1^kw(=tB8`fmHa`_XQU)VTso+WR|!l| zl&=2kfH>`+^lON{&o^Hx%4zNVDNaZx>9SbVtbeDHa%r*u^#zB& z^@m-Ad-R_GSv8?wK~#k!@8Pk}Gc9 zD;e99iB?Gb#(Vyg_$lhj&;luQP1Q|eOo)KUm_qn9#w2+fP>0hDneTP%c2;J!FL4-t zu$2PFJ+4b}#16K$QezIokEn%9$JTJ~NHa!+dY3hsUtn1{uago?*Qy&Oqcn6$??l%h z3r`;~)0^tGRIhOW+l%W2sKUKGWV9D3!#ImF<2+NUKCpRbxU&k| zOBkSXPX>?H$JjmPkGCKa08`%S4ZbvG1wr^2=n=#gDn$c!B(8Z`Qc1K*tJ9}KeuVk! zrSqXH?vvekrc{4LlH>~Fy7NX$+3~ml5GRu7u$-7EF0xZFD339$jx8VJXtv+Rb`hiQ zX8m44ltS*8WD~Kt8o4v5aa3_-FfajpIwH8!W8z)t{TD`$uf@O&GM?nK+AzI^;fH8; z^R)J@ep(p5xH>=au9sav>}oD($PSF=Wk)fZx{`_7^2Lbhs6kgPPh6MLsm67k@o6_h zrw_o7OFAOeN(cq8?r#Icq|hqm;reohBr?JB{9v60*Dbe^gnVV3OT21IwVfEsL?K=rYLU_M$+>s%;a`anDs_>Z$7In6M~S}>0^A%DMZ4- z>zm^b13@06`x2}#D0}T0vJVL!mm?r*;NDF`IV0;f#?}_#ijPSJR~0BI&gFW727~Q< zCL6U{g|~3Hv6d{zpa~{7b?`5cibV z>n{r83CwFyp*C|LTuToPGwn$)o1TyQnH?$(|9-wWh;;xh4v_?#?N)}}oK5dRZ&AMZ z?a9FA$Ck6HR=8dYiRnZVv263wDM@93rQC{95zw~QfKyq+8#5OhugiczVsuNjg4t2Y za{aj@>kz`8HApDH6L+*}VTtpmY@Y+{xGJ+VWo>zB*)}?!m9AHsH1J8v2|*&HYe%hr?lJ%G~T;=z_dILpE?yE4KgzRspxC}?qAy|DFoXnP2=^aiR zU;iqGFEFveGlUR&B|SJ4&ukiV4xSbxH1pW^F^%Qx%#wjLyHSPitMVW-&Oz7#J*IB^ z2@aeAw^ym3^tM#r(KBU8Abme{^B3s-Cxuo`s8I`HL7m6f{BWYB2X;VpVhrIv_3ea| zt+*jW_=4K}mkYr-P$c%|I5=npeh?Rb5uy!;wvECLN?oj-Iv8;kU)px>wa5frcCV*) z%nQOgu81?-XRLE2wnH3}mhO#sSaFf;MmpLaXBhU!^2Z4@5YL79=`X3IqgIvlv8KD! z7&6gbBvd{Hc}6zj`D=OJH!9OoB&>W##JwK1Dy@HLq)9#Z7MXfx$@;g5(T zD&;2e6jzP)t1Rbj&)SC_Ry|h5@8#<5o9^+zPP1P%+l4?XC-MXXtZs;n!a|+4Um^$< za`3k-2N(UTvuv8mv4S~|mp`83V?H^ZJ-ICIJfIx!IBBe*Ea8&teID&Reu~if(G$8A z%JfpNvaUg&ABAC@F_T59*VFwN|8+ok#VIwB4AOYp?UKY|+q*3cpJ$Vftt6*IHl~NR z(67*!e{MZtKM91`o%KbIbz&bIuw*MG>Ez1}ROq_jFtkTqC^>zLrN(BZAMoLY-##ecMZa^KK> z7{Lmy3tVpzK`gc%&o)sJxH~1odlJ(7xo$q^r!UU53iZXb`a;-n)Tb*g758+d zKgal5EuTiUmo-7d^jq6 zU63UWHoaDRQQH+c5-{*$UO1c5UbS4JUXixjIgXWCh#EzOC{D-ENdqTY9l%8w9Ez%c za6UEv<*SQG!xt!=9;&YA{uljl?{#;-GjHsVAry0AO$B}qrka~s`O*J-flbpLZ$mN&~=OYe&p-v1ok}JF5 zT*P6mZey6Izw3i=_nRk>Ag`i#j?gyWoS+gmdHC(0y!-dM+vtLw4kc;yuJ7SW(F literal 0 HcmV?d00001 diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..1220f78426fb8435927b87e8996da0403588093d GIT binary patch literal 15173 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 K={iaC(EkMtyS3i{ literal 0 HcmV?d00001 diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..760ec76a85a8dcdb889d23454b857affd6e23876 GIT binary patch literal 21702 zcmeGEWmFYyA2^H-dV@vW7$7JhNQg)X2uiD@bayBv-5`ykA|Tz}wdrm)2GY%@Luoc8 z-SA$sH}|vtXPtFEo%hpw&aCAc+%s4GI>%Q|MvUMp`Beyl2qeUX6(HyW`11nvFCMt1 zFJ7(&w|n~H3ephdLJvV+A0g-v{N%L=K@MyXgw}%~o@fXnwT`cm=K~M$-bjiGgFCnm zUe}*PG&v-Z= z^E_r}Vq@cBW82GN_y2zxU}0roV&we)dBEpwKW;GK!T(mUH?c6Zvwv-2{r^6Og9E$B zlhQ0|2vSU!5Pq)oZgh3R*iFfB^4G>$jQxcx)YSi)2GdLbdo!5aui&0V(>=6zO@Ut8 z7lLaw#U-hi6>fUhJoq3k#Q6BR>1FBMW*bqxn7eGzogU z0=v)MQftfO3j(oFuugrZ6xl89!o+6rtclS+E{kmmJl6bANaV)r)%3c>b=Lb|eP%CF zQLSoL-@9L5KPUa;+M$M(mCX>ZNt(NxfqzwdX}}%HWC5|szuVy^8l=_Tdchkd8alh% zoEBo9iix2tU0tIO$QX2MYjY>906dhp<XU8l z7EKE`i%r*Q$TRj06JX;|ykSw4*V+~76p-|m$iR3x=#}P`_O8gswhBb89h~n>x5{d- z-(ZSsHMh`ob2BVRi?ZwA-@nMbFm>y?fDaY+g(~n)zDL9Jus@q9j2h;c2n!w+|A6Ph_N(6Yj7v zT!qvkAZMLl5EVmUQ2%RP}1anVPpY1F&bZ*~!<}#!`+Dx-)ySLUS=PEhv?xwVW z3uro`MzPI6#VGT%p)B-ubD+tZM!NYFQa3l;&Dms1p!rWmWp&vw(sQih_wV4~IzE)m zd$Z>_UOa}4UdD;l>jJE-KXl_OZpTizSN@T9|FDlwf#44591S};YB$|LEjz6}dfxXu z&YUf~>l!#8?=9MeZ9~ztyl^rNf5j|=s5*wyy_FCn7g^uQYQ?d+s^KD=<-yD9_m<~2 z4N*2YDUjF0my>P<{fn*IHA)$`=Ow)5(`GrIrMh#uRqZBn_7D+JJ_@;>TmNS^oreJ| zV10jkmHh>KF2bO)qc7^`&)c8B-2QAnMxQF8vU-^yAXrq#X?wI%mR9`3&$mhmq`U7g zKqQ9bi_7cecL~hsrTe(sr?wNr=v(jlM&WhF=5$>Gb7m)drgcY#M6s9%nrzumIV_jO zjGDPD2M1qPU%WMHwmvyUnUULdW$XnGRC;^Ale5w$X>ZNqow9ANExkSv;7LcY1_?J#@5j1M11$U zHgnt-(Fh(YFr8i$UJKcXyaG-B9PY^r@h^V}+$$Yjt^am)G}&=wMzc^ur#EmRj~lel z->~7Yg;F?jq8pBAWg1YPE+-C4%mH&sQw@lI957QAUZr?}5+y%^2u0d@hw>U`oV0vjLQGEbC^y5e*A8UJj)Ssp}e z!IQCh`)>va)6*{ElxTRqSyK3+N_d|_;9&ZirR>|J2hhq#lg9OMZFVh>_{T5T8M6$V z{aIgKTzbfShf4_qB_ZZ$X6nPd_`CImPbJ3doIm3!Z)tI}Xx*kxlD>R}@sSb*QKSft zUYg5JEW4qSeZ9+CK)k~b)i8#3+f~(8=hY{xua~Hsq+_!&Bq7AlFKij|V`OAA)_#D& zV_RNcYQ*IvM8rQe&D;B>>n+mUu1i~~P!Vyxi*yXTpds z-bD{^DrhJ#%dL=AQW`}yhzxF?bnUjz>fZm*`R!XCwcecvP@a~T)H2tg-M)3b?^Cqc ze0P+lp;yPssImhKJbF;}OsOHfqttlL&?10q;}a=OCClCV*P?3CXsxQpHz5_Ho{4_$ z0Mp5adX<7b?roPdcj5ydalK@;7ChWMsX-*RCDLN->#aV;TDxCg`FBVI#*2=&O;FX= z7I&2{HJ%M}JI|js3UOCzj!qtkjM1$(_D03mukCIKus=(9a zX_a|QSs3kz+j2g_4n}n&)l!mVO2grI+-k{n23_fx>oJw@HZx6l9kaVWoeAT}iQ!WT z7{1a=&N6OtXCaWJa#^5QqHaz<8_2m%OHtN}=Z$uQ_hhY%!>;`cUGuTkVl;fcf$6B% z#%JrKThKFk?zr~F3tL;I=2EAdkY5bR1_%A(*fvoV>kwuCWLVLBr}6@95O~E!($gLC zlCq*rT&PA)hV?t}<@}#OrZG#7?i7kgV%$L<%gpXs;{^=Nnwk8C34H<^+gNIYIx-uc zn(*@TmHCc(YgCF+?NALh=l_@qAa`i!SqP};<#qZsFP#6iGY6qS(XYdHF*y?7<@5k~ zRxX((qoJd$uDl$%m&Ga&LA}*Eas-wpDAE&eVjjx4nvxcqh${z+M|3btf=1EXbeZ!2l1uyM3$#QRWjadi9F67 zr5r}$!Hzn*6)xpZ+p{)Pa&ymjkz3~4bZnu?1KRhl4XWo{TGL-!GpvksuwO3qsCNuXZ|DEvliLOKIt1-cZ7pyq`2aNpkkzqVV@Gb&bRJdh*L%1^UUkD}N@YPo4Grj2o7LUb61lZO`t9W`>{)-~(V2F) z5Av?t2j<5s9F9gZ|DbedF&!tfvO*Or*~w7|Mf~}ZC3OXVdpobKFY(E#Sv!eD@LXt5 zZWI>K&{hx4PhJ1FgB?|3YwQB=y7`%1clySX)Jy3Zm;V0QT9A}fUdY{_;~)YTEQ7IfuN_ zH{7^hCd!^s3ruH)kLDMW=p;p7Wl_IW!Z1EGz;YsSLZM#kUi(@hMYUloTb{T?i{|I9 z@jt_EI6b*T;f#uzPoJUo?8u^ahd&c2-21cn=L<1bd_gzu!u~l4k&Y|toQ%6lS$B4N z`AX>JoIE9pb_H_};zqn)mdTt+f0D--_Jodou5S#f1X41GCaf1bEh(UE9(5X#;@mx+ z9|#gndbcIrTP9_()6SaP1tSN_PCGXASy>$^-T5oJ7F{|@|MT8pHObQ)hIE;TcnY`BD71;cO<};cSMB#K8>aE!k z1v%UF-0MXBrYkY@cPzX;Tj%e8ju9qB77haylN_5AE=^5t^WimK(t^pqtqtx7{fS4p zNnpi;1=Zsx{oUPv17G`}wxLn@$FLlSxSP}kB8arL%Z(1{_~)9bd<3}c&fFJbxm78a zjnr51p?>t~npO2zNv4Tv#UtVGbGB!v=P)#v3O9Hab@38Aaz|(NI`6x`OO5xgTw!Dd zBVE8qufq(MnZ61Z_3w?a%>wDkV`a`z^E2MO4vR@(rdo!hCUEusRxNdkxyr`U;@R}A zy<6u;Im53*yU)j1ixoy|D?O(se`zL!gk4o6UVSpwN>)F9;O?sMhYD5jv;2Wq!XB-3kDjM#fd+BBv~y|J?0Msp6h)~6ImeJ;N>Fr5DY-v5tLPMT6vAo_BEf-^{<+7Q+8@-$OQT^Du zG^_9(8=e{mqMv#y8DI6H4g`v1>e;&@zI&^NyJ4~jEXg(!EYa`Xxsl|-^Pk#xxO-zR z3W`=aU}XtWlwtk(Q=C^Kv~$qz4n}Z6r*}>i4;0wD6Q689c+fgEl_vJ_QPgEdX5|R6 z1AMR4g*nP*!{^V>iL636j#~EBOdy4WSdc5Au7Zl_2PH8*8(sZdr*iFYx ze&;>BbEuJ~;`YnAUiDJE34Ztf?0F#E{8I9u9x)TL&wa82A>zKMNo!k$2n?@5{w|uX z@-=+q0H z>rP!f*Hw7}N5+!hXH@7HUl8)_4^}%I?1yY2uYx~z6DzyTYs%xlDQWCwED+bZAVT=@#HY_Y+}6I zs^UVU{n?Av^}gqtJnk;}RE(IZ&AqrZ2A86YyEp?%J9p}gn4quzrEdJHGSTpf4;nc8O-ccdE5JnZ=MN3KHoF}oj%Prs725&nyrkW2VTTsNJ< z<-pL6e@dDZ`kzH}pu}RLyR(*m2w@PTcf&Hu+Qgxm;w8DPyM50|ulBEZpP+fda zTBek|JH{-BibPy8zedT2F-)y&ja%dVi)CH8_HcLGUv5LRm?gc)5ATfTmY9D3h;uQ~ zdA{MOuFi7X*|G{^ODs5yTRU4!+YJ2JKIgKdM1|d>c^~Bx?aPUA{4I*lcMy~=i=s*M zzgTfvA~bg*fX#UL6f>_vZ!Qiy=s#BW- z2NEcjUbkH?-}d+&iWh@tFYQ>`{<3eG7ldl(V@`&&pnqDn$K`uL_&_itO1RQA!^IN= zx+2NP?6N#%=Fif%z9uyv?yb4!BON<&y(UMirh3f3|ARWZf>}-YtE|#a|5l-Uyhi}8 zS7s~eXyGQC;u&U+>>Huwd>aJ#d8gCps0&&4wVt%*u5}XEPiuURD}F(>qc@g#cOL4G5#Lu@$HH`<R9`tL+O@l0=z`TJji{x7*}8YS&UeqUF5DAJjI!w~ zN>O^;OrDbNJCo5ETREDeJ0<7hW82Jowv<&Em%b#UH z!9%lDzo>W}&s{rcoR3*z5G_E#6Wvh22<=M5&-Z5rpuI42HsbG!N6wscWp__C0S=KtP_+EVN8tpqgZD!?~UzeGkq(z-3bJQeyZOM4g5%^s|6Yh}d)YTJGLp4bsT@K*JmU)2U+Cx_y;Nr|5=!|z6tR{evWU@V=8BxH+) z1k&Jz}kx&l%$N#gYd5Rze2nxv7o$BnI(Eiz;`}`si5iuz#0|O~($oKD|p{X<> z9UbjacDBL@#MfVlhbR-9`no!D&cE9?H@5Aata^lg>apF%|15}Xs3};kDd1rSAu14I zh2*k!Rz@1!D=leW`s}WZ6ehJ$IN7hf8?&I1j^i>NsM?6|EKE%;DM@`Xdwk$edgHqA z?z*7ELmmx-t@)QeG&H7o!E!%(d%u1A_S~Q+dCYRTUku#WCXTj=mnQ2i##ppX{SufM zA3kJeW+8cINB{USmx@7m;td8uit8gaJAeO7)S7vK!`F*Up_%eT>No!i zpqZPyb!%=85d^?t3R7V?d%_iu-4UA^N_VB%)1ym-cOR$jRwPBKDk;g`+gz)i@;F+` zqVXY0;&(l+LZ`jq<>N!6a&>hLc2~rx5(KouA{B-WBRxyfo-R zsH$pgOd5q=CAxhtG&D3JA(7=eX;f%vc>3W3byg}WSy@)rkWbgLWIY!sLb?((D=nuQ zcI`OL)sPK7J}vxvO5J=(iv z7XB=288<8BKmKdi?Yq4?meaplnJZ)HK0dCcrKK$*aJs*gW!z0-GSW9xsm_7Q5gwnI z7)PUS94w|~C3(ycgpe?kbMI~`CR~NQMnwQ)W-HG$6OnVi5#C9)az8@IY#C?L{0`uz zmyYGipO@k)=l$M7I zC8K!Pl@g}s3GNQpyCw-B6v5HeF-xMyN2ZW3FQFJMb;9Ta$e&m~>T7WKC$SjKmQi%F zUz%Q@Do8-z;RT&QCOG^t2Lvx~qMNSUQq`r!ah!`4*d_r^1zx;L)*!2L~ zUK+$?&d2YR!ol1#v$MZ{FFxqYi*aYgQ&UpX(9n8hyZ?8jv^gkOMqePai4q4YqWf>q z%J4UDgkp43VPRBMP!ReiT11pm&{DFnFgLff)ZhP0R#vU6Hy)xN?4J@d#^%q9SFhfr zid$Mff3A}VHZik2R+Lg%35HTUdlP4dBlj0rL05pjOIAsE78ZWlFBuGY%A+#13Q(ol znDhw13W#BF!85T59m_^URO}q`JcX}p8Gux07!#%u{{CTM%+fhrxAF0epaE+e8(UjP zm4xqZckklk!8DrxTb2Z{XG!@Hf|N8WGBOe&Bk%OirKZ;PT{hT6Q&UDpQ`7&keNV#cCgPwlzongt#RwQvjVhR< zh@7+E^y=!L-l7zMvas0LgoKzJo=+|byp@&{Dc)dLQSfhx1v~(8^Du8^-_ourvz%zC zho8mA$HauD!te8xTTZ}yJFh4wr=_LVLI?91NK%6L6L>~caOC9W)z#&xLqlv1Y#l2A z(To)Kpx=C4w4BV8}me8!RB; ze(j}}54hM<)5TpLoSTon; zfVvW;U-|jp$U(4^=OUC~$%!3c=z8(z>}=IjlCv$WMU$@1L6t-=4Ifh1Q(9)ej}XvP zD%gYC$J*LB&3cz}6Q&s8%I?tq#rJAEa7L$gW)F{i4d1uGOo)Y_pp{-+h;jtnb_*b_ zkw&9@bH`#(3Q!5JGrK_I1)+3@L|49K0#0zxBoOv!REv;E<7FxZy01LW746MHoUiJD< zY@!KubRj5@2y+NN{$*5Y2F`SbWK4{S2|{szf|7D=ZD%pQ4)N_iAcJ2fu!fQ&u<8EGz^v03)3L+cVx5>DYJ9Pb~8Q@4hzUfMPi}Ha9oq z6Tq?CG3XPvu~Ao-@CEGAU##B_lAy3qxJeon5dp-*Yk|532y%rD|DrEtWh*NkQb1=c z1oHCO!zoX{njeEYQ;Gb%j zPJJv5AU926%wEF7^YY3#!85DHo-wQSA=#gLJ)F{t38D#-QRZ7a6avSAym*94m}4fx zR4Qz1D=PZRPu#=9#^yCFQ2Mh&U};T5!85#|ll%+m#bJ2( zH7B~Jz(4*eF{#u1S1@uYh3V<}Odz|O z14oR$&CJhFf1dz{IM>O=#iXpin*9w7=jP^Svw_?&G`w}o&=6*~zrPESl>8cYtv>k= zn3z}#v*ln2fDAl7*y5`wKy>%?Eb zmXMGSfo;fWQb60uDxZ9)_^Rx(fVE{gRG{BXc=umMaGVFMstK8E515%*S()>n2@IMZ z?cNvLUBQwwjOPKw-@oXbX=i5<5nNnEzZP%*z~3ikG&C<>0<*DJs=0ZT1ef!~6`x4( z9UILH2XY`oz2TGQN*Vh_F<&FlE`Q|^Yeq_5V>MM_L1Sa1b>|cr9Vq*D@$_U2*ynbu zAH2COG^$JHrx*Y}zJQ_0Kf48~o)+o*h#MG8B(f*Q$A^T(KHO{~O-W%=1yY?~;LPpE zXDj3_32|}B+IR8}u}ou?>jOE9Z$paMmK!*6Aumi&L+-^;Zq^si{XctCtlAGZmkY6K z0@c#h)s1vhpWPWk9&aX{-2~npBO{ASLYqr8$wjxV&O+qrVp{Bsx@z=kef`;~-z|R< z)UoS7P-En?{iD4lz|?ZV=z9=!q=Z#-*NKU5*!}hr_aS7~oV4{9FEtVnkd7VjNQ5i&JkbMxe$Y5gjuh+ftS~5#cvaK0v&+KYT!QpiVWx3-;q2@{Tx6lS^Kiw`5S6>)1Y6H3-XQ+(?VXl(P-&)- zXW5^LHXEGsa6Q;O*b*rz+1#|XQ>MB#_WI$43iD_757-sbpL@IPpod6JrW!>2xh#Y^ zrTaNMkH?r{{4?D+I9&b&T4iF%vL?wvTxp5(swvMOISf(HLcCf{iX8v zLpEkE5s*KAF+1t%3WUqQ8=Iy*js07PNrhEad3ovSF01doFPW+28`}szv8>KeFICAG zrkA=5J2C(H*Ey=)O--Gvhe@p5mC5&5Wr0VYo&DuZ^Nqt4`9JIzA3ZryDvR@+SjSYbYi zqD-~~Mw`98?e(S6a+i4@lh0n2m089>OzohnYX)-}6fh>tgV4yxu&@|L+chm)TQ|2I zPgbp>hOvJyKysi~26Gi%90zO!U;n=nwSnzMaQ%N9ioyhcxJ{b_Wd+phH863Uffxw7 z#z7t+nkH|s*h8ul{Zt+Zjw+AiR{Ta&WhJ1iRS>`mFCv|ri->@AH3%N1ft{2;WCF$( z*i(5%>W2%Uo&fV>TRY~btke(P6wn}uiCSn7EEPzC^^6k+(1RE0L4%g%<&im4!$oCLT11tY&6B#88=~XqKNe1po8z?`HCGK z(#aGguot+)9hSg(LP_(n{>j!49*B#7c85xGgz^jH#B{ei;WJEus zZ$j2HnBAlO9f~a zy<7#w(ozm~QvoNaQ3IF@MJ~R+OG~Y-uh0stXV1y4p z6flN{ufk`Bz1s*fuqVlCYY>X_vPgzs#b_@n%pr2YFvWv-9KCDsM(ZV`N=o|rgkNC{ zYS=Iy_Ur50-0bT^k9FA|2|^VhD(Z#8QUxYhH!}m<+rxgP#$ln;)52dxRn9KKrEAde z*D!{eneIeo7*tF$@n3?@c2ogC8{OcCnBc*)1U@p-=7p<}CU8p{jiRqY+QtZaAb?{8 z#AK3Wr1(5_0L8IUfrD_}2Wl`oS!i~;siOlpC;)_m52>k{nV+fC3L>kjKvBnkZ!WZi zY%RzpVosMIc=ws-=>Q|V?lHV>e0Da6@tZd=l+>66!~m$^$r*SK$E(8_L>@i_wEGY~ zwwgZ{S0Vb%ELu<9HQ?C=;a9WD%IfOM!pFFApn~pVeNvYAXNo7!6jU-ns3RJVTgN&4 z{k_yL_2(Z*E7$u{%pp-@1&liNS|=(&%3DnqAT)Lsw`BJQj!&kh!fUms0GyO6D3p}; z_ltBTA>Ta~fX^+Yy#s4QQkE|m)6xhba}kPs2>>;}I&8DO2D1dTWJ=TE z;vWCG3=O6i6{V+ViyN$tYS(`M4yeeh#s@}vBd3FXXKVs*wJs;57Nne8VKr5hl6e_| zjzNJqRP_OC6pHlq+1j#mzz5cnFg0~_6vYEx;cruL_}^FC+8#b^YZt*7{For2P`Yu2D9anU?xWOziJ&0h6b%1$7 zD~(Q)%Y1+S4sn`1ka1wnB3)59oZ$CJ=eS>X$}J6GLyZWYRdk_?j-k29*L=9hX+4q| zgtNR(G%5=Y9rH<9v}(eGlmGXo4D5}RnMVB24T1Co`}*zdN=t+rPI>r_eW>6qEd!2$ zhH@|lkFddH-4nA9-tf7PHkPu?hCFs6fE^E0FHHFE@cHp5_}IdTq0D`HN}P4U|6x0vNjBtnasTkxkjp>aQy~+tYu=Vq}bSS z+D(+|>(_akM9{n#IKPn!)(8f91tyh8S}mq1$offUZSL_5ReMx-603raj=a210naUH z80Orf;B#k&NML`hw#i@p4xp@-?S`jLYZxm%G(0@fV<{A^1nJcRkTrgHToH9qR?i^b(-7fs*m@4i%?)fqQ~Jl#%q>YQm>Tgk{684-#& zUMD~XK}~^FNb5gCy1A;~PLJ)@NC3)WCY$Fw4%TYvBuxuI<~bV@q3%G+kk}+rwOcUE%!-x)8TsU+D39e02P_kfA7f8?;`$&_7{P1LtM)0@@ z=6yJ{dkXtr=i3M5GZ z9S*0#@CXzHI{SHp2G1J~h1r9w`;w!FWMrQ1*=i+9@znEF9pJMu0EQ}4 zB~~=k*kVKbEz5<1n=K$u^puzP*)!f;nM;l2!0uF0QTo!6aCEYlTUK*zu0`N&{h2-b8zTv{2ypgkuCypY{~uAVbxPPY5Gr{D_R3O|4f^8sx5 zi>5!5S!;hjj^^>>h3>6O{9c|;vn>Z(46=TFTOABZb*DAr3JNZV8Itg7k3Nk-7rX+P zu>Rag2^|j)yTZ@b*52M;`E;`(E(;#Vo4uSQ1plmjGLc78d5+h+ZW)C_rNHo-jWP1JB? zx$!3CiJ9Ztc$vh{w|O2QmN$Hf1<)RExzl=Um=B>Z-_gnkT&p^2TWsn8Fh~a@*KS|N z$A3byk_r;1tu!9pg!Gc4!5BvttVKr4cl`U?m&?RrVwb=b!lGRz3>9&C#!sK^j!n6h z>b-~a58vk6;ieF!eo&>&Wzlh)9#r+5Di-Qc@YpZYZ;qM;@v%D~z61qjWkp4SMNi{k zF0SJXArjOoN0)XB6W)DlH@_$kh!S!j0Bru-({#AV!<~pI#H4=>dA89k@|j?H#I6wn zZ+!V;K0!`V?^@Y1-6VKg<>uV^l@c({9l#Tf4om%6F1?MUI+yX?HYGynrOBU|j;lF5 zY!twTmA*VuD{@>N()C{%v6~hKNg5>x!h|WRsDUS1RaNcFcQBhRe}lpA>m6>r#`-zKfkBZI4!6&($X)g%)2x_2jsbM0m-TbH2UMzB-&C{-89qV2MS(Bb`U<#}UcYioCR zM>*kYb{y7-%kBQ_c~;*N$?QY4KB--b0HsUwn1JO2IAw2a9Mah6>I_RmuVnVbYPbfzsM<8caN2zR*G#ws0hTWTCaLjL>#b`kzQ&?K-9 zNS3j)x3>lv3NoJU-La!R(ipZB7lQYWs~|@Ss%!{%(QuW)%#6rjp6{2-1h+=2w-$Td zkAxpGRxK+9mbD3SkYZ=nw-S``~UZb{^4 zkh#l43rYp#%g~gJ1ou)qmIR|OMZi|;R8^JLsA{Z&v6%27*U!8Dm`4_OrF^H;> z_QGKu6r;RqjAf-0TFLFW+K%SWAK2*jTN##jH<~K@LIn&*Oe&zCoZE7^2t;(gdDl9v zkEzn$OLpC>UAw;2mmwWTNbvw~Mft@GjnO858e-zdkB|2J%|~qatqsz}v5{JMKWcaY zG>t3=n4GE~{_URpu>>c-?X`&<#UM(WiP|Y!(0f92o^(;17L9;&Us3*dc}d{-Zwm-7 z$u|E~lAR0kyDlNw-r5R$;Opl(_(r6SgM*`^4KYYeTa0xjh#YP&jUVieiC7}*-H!^i zMKmfc`_i55j0m%hVoQ>u)!gX;eusy{7|ievn*+70tPjh?O-!7f)nm|Q0RIFx0ra&l z5e30fu)>Cq$@*d;1=_i+bc=)e(s42g2vUaay+W4jTH4B4*Y!3}pdZ)|mXrY2kb_W~C?zGq9Y0PGHJ6|HMdRTCf?*~K zrC(B_RK4*iDKQa6aD($E1msHH;%`CK!O_mLKPJWb^h;H3EeL+8DvCel0a;HTzpCo$ zs^g=MfBMn85fRGD5fM)=uBE2-^-aGCK_JjaqYH4-gv1YoFG)#newm%SMM82@$ul8| zH8f6BE*RWGLgL~Qm_ZaRE-r*R6iy?C$e(P1u1+x!A~>4Q<^j9@x3#quL|&`sky)|~ zn7rIAU=h;cf!6mS_QjWYcke2c{!@RkX0lAGQUJjNp;oXV?h_$+h!C(k-^yda6Wz`<>I-zxGN> ziQ*FGeYFqNFLhptvng?fjf__8zeCwzeg-g^G6`yJ-q`pY`ps}TmxPv99Q0)sgUYkp zm>1Z*r+ytAp3U)xO$k2k{g;B`?%mPcDz!u3G$|ucn+9Lz-?d958YHCyg6FPNXJmYy z@|2?$Tok0E#Ws3EP)D-(hn>H%HXhPAY+{pKD*!Du`_xw`+EDrPX__O{YBq=mX zZIM?zwJ~#EKk5}{T%~xp#1KevbYj+jcDoiGgoZlZGPy_RlN*QDESqwU2q0 z$`hc!;cGCrPS-gyOtbT;?$OLeyfb!YkFV7H+5*9{2H~J4p%L@aZytlz%LkeoqXmlo zqL#QoljYra6FZ5Mc93$R=Q{;;fI{+VLM z`q*Cc`dKxU4Vt}|x{0vV=aBSAdlhH(Q@!ii_KBcJ+J4=sipYF}`=KKlb&Rqy!uujT zgI6v`zzkE}MB7f0_ocJ_Ex}um{3>XB2p9vM}&ubQgY{@bFHSZ>X|Bj?Y-Q%*Sh#5wuE&AOt;0j2Zt|7eCbT@aWd9z#r}26Q|X7pp@FN>B7Ai zBrPu0Zt8@KSq8t~UqtL-De5@pv_dNQD=-o*t`QPmX>G6Vo55FgPtE!4Q+C8{rBeSA zGwE++^dqskh1{UALLEq4c^y&o*BW(%0RVI=C1|p*Wu)ysUZ6eJZ!((vF=t6XlAggV z$YfnYZEGIk^e<+P+FeYuce?g{lGU+&BF{6>!Fd=#>474Hkb>WEEYj_-U6HQ`why?3 zgk@32Z_dl9t#-&ffmxqu?FelbMY>(KvF_?Y(OQzub2s&{&{( z@w4Igj=iPR^>F@y^h`r8KCLqkmGr;GG|NmzP2lgZOm!KvsCggs>^|?SSHgF>$q%RY z=?=KHAoDSjyLYdLMllq&y|b9BaX1cNYtnf?69uh?EK|GR$9?xxXKGUzAHf z3!O~K-CakHWYsQRXcXQf0o9fVTgp_xCIgrG``vJzytAzzM4dZspo1i~OWL#Pne(bf zOa_vsbb30Rf*SK)o;tS)GxPM!9oCk2-^-^CcjjF*N^e2bm`hoCRDAVgBJyoW3FyxU zm)EU+qPOcUug`n3Y?5b5|Ol-5NZmao~UgNphfx{4t`e`JeU^HZB;wTko zO?b<9ROlIVwA85st#l^3I_jT&xNzR*UhbHT*+Tt}Ny@8{ona%{v3 zNoh@MU3g`6w5{l@trOhy72p57NhVJ|@@p_MBjeWg!<`;dx80ZwF3{zpXSNrlYQCzY zlzt-OD!qQzD@MW#J1fAve9SNWqAbR4UJc!W^ab5RIfj8>vdps-!3Ojf>#(F z`7CqfGr?v?-F>I8WN8nv#8&?NO!Uo8&|pEpw(iY_9DO}K3oGOx3(kK3XRD}}pT%_E zJ)e3cYND%>c|L$d8QVQ+KeQgjTv(U-@N>xpJ>wyf`$L}ge?EMZUQaplJT6Au#R4a^ zL9@%Guq);Uw&i_e17Z7s36dHgHR)s$bl=l@|9(5%f|8Ke3x1K)pvwfW}Cvt7GA}_UjLR-RaZNz2`*5 zlYVZt(9pO93okzJ?IoiY2-Chl^s$c?--LGMJ;lx;W5eE$AdL?KS zQbmPy3)C(Y)71|-@i@si#G4sD!S!lgBLOOV1!IVo`Xvci?OC29hs(6a95u8u7FFFdhI%rOQdMrQ zmUr0Y@)~+5l&H;NE8U-YXyoG`m?S3F{@?dZFkDh(tIq!7G%`{pCX@BnlurGw5$j}C zvF9s4v>JDtsWG627ULV=>C-dF=$B$_9l!s>7)8dO3qnpTFx7cQkl!;l5R>qI&b>I= zVKbBG{@ZFgna8V|_NLhCGtJ=mm8pu#V*SBtyX)x*Ls0=#ti;p#7JYdhnOK~C|1cZh z(D8P_^2zCgDZXRtpFaLImQs^7$_|h0ieoM`(msCt5WOB$(@JeW-kg;)+YpG_W$sMk@X$JA(ldgs5I~&cg3ZXAC9OLqq z{I+u3*PC|Vpj!CLDl5I!76&9Ha|sWW!Z?)mn3r>0>)EdfPKoa?-i z$5|JKa(;XsS#F&AX=w}{%X{(F(zAg;n|s|4GsDMsxL-wn4VQWOIU6fWs$`^_WRogg zomyS{(rkZb%V*wbMCj)jRdPV54_*kw{d-+OO)XlEtE0+g;DNISOWSv5&GQa>t@~70 z!Z>)HOa+z7?cCjG#qmzhe90iyfA0lJdR5h+_qc9P{PuZbmM(l;9+4*Y-!}~|ik;xP zugVIdBnP^b96c6O{tk=W|DoUBrqqlui^j3W%|zXEzmr~FQviA)4C{^+$2Lcd&wI)d zOY5y~PEJ{o0~JSQjMj;4@86%D{lfQJ=3HFM32Wx#)&IO(6PbQC69&Ey#Pc?c;3lXy z06vxp_A?^86@xmbr(L|nC30M^Ky8uQ;D-yp@o-n%3-s)_HfDC%e(09h-C{BbvJXwM zw8XCImEX(lFFzU;K{n3mX>zzfLtOHAE`YUACm(z`;!np8f!`uB{adEw@*r8WiNI+1 zpC1|6@4+Zz#gF+fvp&(Q3JUm^wn+kf_q^Vpb-u;}-`sc*4=6%=<`-d5D<{LM4^>sb zJ$S3*$@Pam>}K7RGfK@4gcS5r!}WUK(v!&TzV8^xL(%G9|44X&FoJ>8*0$R^zkAU? zi{8ea3fQI4byr-Hr7jb)9M>GzW`-zciDKfps!G{`hD;BjN9O;v(x(^p5R9rOc@_kO z+H98B)?$f2S*y4Vg=Bqn(`tkzMKN<_wC-(x<|X8rj>99(CUS_R!tCU`WL$}AUNFg_ zri(3z2Wjbq;U4XsBetzm3|%Pl`>=X}R4d8Q!RQukAMqIo~zk^6S<|5Oc6RDCLxeSb}0w=cam zsJd3wp4C5xd$E&yC!w$7-P&9f9fnA>5U`t*yvC8Rw1-k`G8`!hbuIhummH-VXo`Gm z@Xj2xD7@PZypSA7{_gGJ&tlf?Wn=Mv?QMtl$kyg*>Iq(_b5(PiUV1mYxhrW%U?>fL zCQHgUxT!>khDNFRyXSO=Ek*fJ*QZ==w-PJe`j}0`GkA(v!|&=k^A+y^vQ0 zYi=}Xi@vQs+ZUUcTYnIR(ynFyTR0Ytqi2Xm=|XS3a*bJ&Jki0NC8^=8VIYiQ=ZIb! zO9o!6{hk9{Z9h^{eieV7QD!pg7%nw_ztAn%+?3Px$?PSL-kg;GWpwTKYnrVEv%+?f zgCbWa>_-lUy)<{o4*iY0-acm^EV7>OxQuMd;>tUkBSjTk>sPJDM@Vu~)>-c^fBZO! zn_q9qq*jslw1dAEoiU(v@S;|mYj1_ob+)96^KCNbQ(h2LF!t*^uSnf}=lA<08*rDp zt3FG`T;Kh<`Gsl-uBoK7B;T*rLJ!Ke+7O7f%~^MOD4>nTe!1KjIK+T zL`Me)hxRHiRvizPMFC)^elG-a%Dr5x9(hE(^kv#}FT9}5kSC>a$BwG=0RR91FoSN= zT3W=%>u2?|BADsR zj=W&J?bTOhq1>%a=+{qmIsgCw0M}rq%#ufs9MKDOcNz2Pr-n&BUir?eRz*mVL`Y?& z>UdC#0s#Nhkj7JA8`G?Re?3#i%c#kd_3c`Qlm-UMI}$FJ>(x`84*&oFuuE^r2x(Yc ztb52l`C!c&X>1&0d?h$I(tkf zU(w>8dP?TYhw^RDo~rW!0000s=_SdRW2aB+>*Yax{I}m$FV?qgS@mLlIXSB1!8%X? zz$^wn|J?hdqI>oCm#J>VyCQ0Duy&O0@J!O43HiZ1E2Xsrp978;aMg+5Om}lJ(C-MX8Pl+e85XB?b(5 zKJUK!Zn?$aR{4@c>(QenRBJ6?j~b=z)nAe3g9gdl+KbY~*;#c?0002sM(EpRgk)A# z$!?jYFNujU4AD+#JwrpaAu`=CZqufnI~Oiozy8RP-yW^2&I|iM0f0JX`0y!H0s}og zE8ljSSGsTbaATHXw>B&wK>M5CO8;oU06jxX(#Cb`rVW#DY3$&jIxheK0PK@#fUixK zRpNF0xIRuE7N5O)wfD3%ZRL&~C9CwN`3SS!${b5Fhc3kC%5fCy73LR$ni%+KH1V4gPvNZPxMQ<+oMLGfX~yeBbe<_x$v6 zMuzIxfH_eB03s3~k4u2Kx*DHv_;;@^ZQ5u*RgSN$@b}jeWvTYB)~$_|+SA&rzP{Qb zi7?Le_SR;~5$#cLZ)1U+m66`w+H-n>4D|MviIO5cy}eD(tKXrml|WBV?PKXE&lwD| zQ_ktnJ2^=`xu88%zrO1I1^@t1rdZnQ-xn6j5b2;TsHo6ilC!eJXw(kt4@gK}-tUiF zEzRUecDBAm^OxP(*@gzvQNpsbMYKZcmX)QyX&kQyXJ>2g%5E(`GgHKnAulB)h!__e v7bhf$6y8zTD?`ma5)uK&Af&0!C7NC;q2N00000NkvXXu0mjf%E@#F literal 0 HcmV?d00001 diff --git a/public/icons/maskable-512.png b/public/icons/maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..89664873a2e6e02423c593ccf51b4bfcbf74dd23 GIT binary patch literal 3003 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 { + event.waitUntil( + caches + .open(SHELL_CACHE) + .then((cache) => cache.addAll(SHELL)) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((key) => key.startsWith('essen-shell-') && key !== SHELL_CACHE) + .map((key) => caches.delete(key)) + ) + ) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + if (event.request.method !== 'GET' || url.origin !== self.location.origin) { + return; + } + + // Navigations: erst Netzwerk (frische Deploys!), dann Cache (Offline). + if (event.request.mode === 'navigate') { + event.respondWith( + fetch(event.request) + .then((response) => { + const kopie = response.clone(); + caches.open(SHELL_CACHE).then((cache) => cache.put(event.request, kopie)); + return response; + }) + .catch(() => + caches.match(event.request).then((treffer) => treffer || caches.match('/')) + ) + ); + return; + } + + // Statische Assets: Cache-first (unveränderlich pro Version). + if ( + url.pathname.startsWith('/js/') || + url.pathname.startsWith('/icons/') || + url.pathname === '/manifest.webmanifest' + ) { + event.respondWith( + caches.match(event.request).then( + (treffer) => + treffer || + fetch(event.request).then((response) => { + const kopie = response.clone(); + caches.open(SHELL_CACHE).then((cache) => cache.put(event.request, kopie)); + return response; + }) + ) + ); + } +}); diff --git a/src/UI/Http/Auth/OidcAuthController.php b/src/UI/Http/Auth/OidcAuthController.php index 2ac7fd3..6c413c6 100644 --- a/src/UI/Http/Auth/OidcAuthController.php +++ b/src/UI/Http/Auth/OidcAuthController.php @@ -65,7 +65,8 @@ final class OidcAuthController try { $tokens = $this->oidc->exchangeCode($code); $claims = $this->oidc->verifyIdToken($tokens['id_token']); - } catch (\Throwable) { + } catch (\Throwable $e) { + error_log('OIDC-Callback fehlgeschlagen: ' . $e->getMessage()); return new RedirectResponse($this->router->generate('login')); } diff --git a/src/UI/Http/Auth/OidcClient.php b/src/UI/Http/Auth/OidcClient.php index 7f125c7..0a6c87f 100644 --- a/src/UI/Http/Auth/OidcClient.php +++ b/src/UI/Http/Auth/OidcClient.php @@ -41,7 +41,6 @@ final class OidcClient 'client_id' => $this->clientId, 'redirect_uri' => $this->redirectUri, 'scope' => implode(' ', $this->scopes), - 'prompt' => 'login', ]; if ($state !== null && $state !== '') { $q['state'] = $state; @@ -71,7 +70,8 @@ final class OidcClient } $resp = $this->httpClient->request('POST', $this->issuer . '/protocol/openid-connect/token', [ - 'json' => $body, + 'body' => http_build_query($body), + 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'], 'timeout' => 10, ]); $sc = $resp->getStatusCode(); @@ -198,10 +198,9 @@ final class OidcClient $mod = $this->derInteger($n); $exp = $this->derInteger($e); - $rsakey = - "\x30" . $this->derLen(strlen($mod) + strlen($exp)) - . "\x02" . $this->derLen(strlen($mod)) . $mod - . "\x02" . $this->derLen(strlen($exp)) . $exp; + $modTLV = "\x02" . $this->derLen(strlen($mod)) . $mod; + $expTLV = "\x02" . $this->derLen(strlen($exp)) . $exp; + $rsakey = "\x30" . $this->derLen(strlen($modTLV) + strlen($expTLV)) . $modTLV . $expTLV; // rsaEncryption OID 1.2.840.113549.1.1.1 → DER: 2A 86 48 86 F7 0D 01 01 01 $algId = "\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01"; diff --git a/src/UI/Http/Essen/MahlzeitController.php b/src/UI/Http/Essen/MahlzeitController.php index 864e49e..dbee106 100644 --- a/src/UI/Http/Essen/MahlzeitController.php +++ b/src/UI/Http/Essen/MahlzeitController.php @@ -78,9 +78,18 @@ final class MahlzeitController extends AbstractController return $this->render('mahlzeiten/tage.html.twig', [ 'tage' => $this->gruppierenNachTag($mahlzeiten), 'total' => \count($mahlzeiten), + 'heute' => $this->heutigerTag(), ]); } + /** Aktueller Wochentag (Europe/Berlin) als `Wochentag`-Name (z. B. "Di"). */ + private function heutigerTag(): string + { + $iso = (int) (new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin')))->format('N'); + + return Wochentag::cases()[$iso - 1]->name; + } + /** * POST /mahlzeiten/reorder — neue Reihenfolge der Planungsliste (Drag & Drop) * persistieren. Erwartet JSON `{"ids": [3, 1, 2]}` oder das Formularfeld diff --git a/src/UI/Http/ExceptionSubscriber.php b/src/UI/Http/ExceptionSubscriber.php index d4adef1..3aa1b27 100644 --- a/src/UI/Http/ExceptionSubscriber.php +++ b/src/UI/Http/ExceptionSubscriber.php @@ -47,7 +47,7 @@ final class ExceptionSubscriber implements EventSubscriberInterface return; } - if ($e instanceof HttpExceptionInterface && $api) { + if ($e instanceof HttpExceptionInterface) { $event->setResponse( $this->render($request, $e->getStatusCode(), $this->shortName(get_class($e)), $e->getMessage(), $wantsJson) ); diff --git a/templates/home/index.html.twig b/templates/home/index.html.twig index f88958b..ef792c1 100644 --- a/templates/home/index.html.twig +++ b/templates/home/index.html.twig @@ -3,11 +3,15 @@ {% block title %}Start · svc-essensplanung{% endblock %} {% block body %} -

{{ name }}

+

Willkommen, {{ name }}!

+

Deine Woche auf einen Blick – was steht an und was schmeckt mal wieder?

+
{% endblock %} diff --git a/templates/layout/base.html.twig b/templates/layout/base.html.twig index 51c60f6..53423a1 100644 --- a/templates/layout/base.html.twig +++ b/templates/layout/base.html.twig @@ -2,102 +2,423 @@ - + {% block title %}svc-essensplanung{% endblock %} + + + + + + + {% block stylesheets %}{% endblock %}
- essen.ljh31.de + + + Essensplanung +
+ {% block fab %} + + + + {% endblock %}
{% for label, messages in app.flashes %} {% for message in messages %} @@ -108,5 +429,12 @@
{% block javascripts %}{% endblock %} + diff --git a/templates/mahlzeiten/_mahlzeit.html.twig b/templates/mahlzeiten/_mahlzeit.html.twig index ab4ea54..e9a9cd2 100644 --- a/templates/mahlzeiten/_mahlzeit.html.twig +++ b/templates/mahlzeiten/_mahlzeit.html.twig @@ -3,49 +3,98 @@ eingebunden und als htmx-Fragment von den CheckOff/Uncheck-Endpunkten zurückgeliefert (htmx ersetzt per hx-swap="outerHTML" genau diese
  • ). - @param MahlzeitResponse m - @param bool sortierbar optional: zeigt Drag-Handle + data-id (Planungsliste) -#} -
  • -
    +
    {% if sortierbar is defined and sortierbar %} - ⋮ + + + + + {% endif %} - {{ m.name }} - {% if m.notiz != '' %} -
    {{ m.notiz }}
    - {% endif %} - {% if m.wochentage|length > 0 %} -
    - {% for tag in m.wochentage %} - {{ tag.name }} - {% endfor %} -
    - {% endif %} - - {{ m.status.value == 'gegessen' ? 'gegessen' : 'geplant' }} - {% if m.status.value == 'gegessen' and m.gegessenAm %} - · {{ m.gegessenAm|date('d.m.Y H:i') }} +
    + {{ m.name }} + {% if m.notiz != '' %} +
    {{ m.notiz }}
    {% endif %} - + {% if m.wochentage|length > 0 %} +
    + {% for tag in m.wochentage %} + {{ tag.name }} + {% endfor %} +
    + {% endif %} + + {{ m.status.value == 'gegessen' ? 'gegessen' : 'geplant' }} + {% if m.status.value == 'gegessen' and m.gegessenAm %} + · {{ m.gegessenAm|date('d.m.Y H:i') }} + {% endif %} + +
    - {% if m.status.value == 'geplant' %} - - {% else %} - - {% endif %} - Bearbeiten -
    - -
    +
    + {% if m.status.value == 'geplant' %} + + {% else %} + + {% endif %} +
    +
    + + + Bearbeiten + +
    + +
    +
  • diff --git a/templates/mahlzeiten/archiv.html.twig b/templates/mahlzeiten/archiv.html.twig index f650cce..c776aaa 100644 --- a/templates/mahlzeiten/archiv.html.twig +++ b/templates/mahlzeiten/archiv.html.twig @@ -4,18 +4,19 @@ {% block body %}
    -
    -

    Archiv

    +
    +
    +

    Archiv

    +
    Alle abgehakten (gegessenen) Mahlzeiten. „Aufheben" stellt sie wieder auf „geplant" um.
    +
    -

    Alle abgehakten (gegessenen) Mahlzeiten. „Aufheben" stellt sie wieder auf „geplant" um.

    - {% if mahlzeiten|length == 0 %} -

    Noch nichts gegessen – das Archiv ist leer.

    +

    Noch nichts gegessen – das Archiv ist leer.

    {% else %}
      {% for m in mahlzeiten %} diff --git a/templates/mahlzeiten/form.html.twig b/templates/mahlzeiten/form.html.twig index 3518721..99358ef 100644 --- a/templates/mahlzeiten/form.html.twig +++ b/templates/mahlzeiten/form.html.twig @@ -4,9 +4,16 @@ {% block title %}{{ titel }} · svc-essensplanung{% endblock %} +{% block fab %}{% endblock %} + {% block body %}
      -

      {{ titel }}

      +
      +
      +

      {{ titel }}

      +
      {{ mahlzeit ? 'Ändere Name, Notiz oder Wochentage.' : 'Ein Gericht, das wieder auf den Plan soll.' }}
      +
      +
      {% if fehler %}
      {{ fehler }}
      @@ -17,12 +24,14 @@
      - +
      - +
      @@ -39,7 +48,7 @@
      -
      +
      Abbrechen
      diff --git a/templates/mahlzeiten/index.html.twig b/templates/mahlzeiten/index.html.twig index 8e74bfd..197efab 100644 --- a/templates/mahlzeiten/index.html.twig +++ b/templates/mahlzeiten/index.html.twig @@ -4,21 +4,23 @@ {% block body %}
      -
      +
      -

      Mahlzeiten

      -
      Planungsliste – per Drag & Drop sortieren
      +

      Mahlzeiten

      +
      Planungsliste – per Drag & Drop sortieren
      {% if total == 0 %} -

      Noch keine Mahlzeiten in der Planung.

      +

      Noch keine Mahlzeiten in der Planung.

      {% else %}
        {% for m in mahlzeiten %} @@ -34,26 +36,46 @@ {% endblock %} diff --git a/templates/mahlzeiten/inspiration.html.twig b/templates/mahlzeiten/inspiration.html.twig index b36afa4..0c54cc9 100644 --- a/templates/mahlzeiten/inspiration.html.twig +++ b/templates/mahlzeiten/inspiration.html.twig @@ -4,10 +4,10 @@ {% block body %}
        -
        +
        -

        Inspiration

        -
        Gerichte aus dem Archiv – was kochst du mal wieder?
        +

        Inspiration

        +
        Gerichte aus dem Archiv – was kochst du mal wieder?
        ← Mahlzeiten @@ -18,40 +18,45 @@ {% set alleAusschluss = ausschluss + gezeigte %} {% if total == 0 %} -

        +

        Noch keine Inspiration: Hake Mahlzeiten ab – dann erscheinen sie hier als Vorschlag.

        {% else %}
          {% for v in vorschlaege %}
        • -
          +
          {{ v.name }} {% if v.notiz != '' %} -
          {{ v.notiz }}
          +
          {{ v.notiz }}
          {% endif %} - +
          {% if v.gegessenAm %} zuletzt gegessen · {{ v.gegessenAm|date('d.m.Y') }} {% else %} aus dem Archiv {% endif %} - · {{ v.auftraete }}× in der Küche - + · {{ v.auftraete }}× in der Küche +
          {% for id in alleAusschluss %} {% endfor %} - +
        • {% endfor %}
        {% if mehrVerfuegbar %} -

        +

        Weitere 5 Vorschläge

        diff --git a/templates/mahlzeiten/tage.html.twig b/templates/mahlzeiten/tage.html.twig index 6722cb3..a231a65 100644 --- a/templates/mahlzeiten/tage.html.twig +++ b/templates/mahlzeiten/tage.html.twig @@ -4,10 +4,10 @@ {% block body %}
        -
        +
        -

        Tagesansicht

        -
        Alle Mahlzeiten nach Wochentag
        +

        Tagesansicht

        +
        Alle Mahlzeiten nach Wochentag
        ← Mahlzeiten @@ -16,10 +16,10 @@
        {% if total == 0 %} -

        Noch keine Mahlzeiten angelegt.

        +

        Noch keine Mahlzeiten angelegt.

        {% else %} {% for tag, mahlzeiten in tage %} -

        {{ tag }}

        +

        {{ tag }}

          {% for m in mahlzeiten %} {{ include('mahlzeiten/_mahlzeit.html.twig', { m: m }) }} diff --git a/tests/Functional/UI/Essen/PwaSmokeTest.php b/tests/Functional/UI/Essen/PwaSmokeTest.php new file mode 100644 index 0000000..d5d0d06 --- /dev/null +++ b/tests/Functional/UI/Essen/PwaSmokeTest.php @@ -0,0 +1,75 @@ +followRedirects(false); + $em = self::getContainer()->get('doctrine')->getManager(); + $schemaTool = new SchemaTool($em); + $schemaTool->dropSchema($em->getMetadataFactory()->getAllMetadata()); + $schemaTool->createSchema($em->getMetadataFactory()->getAllMetadata()); + $session = self::getContainer()->get('session.factory')->createSession(); + $session->start(); + foreach (['auth_sub' => 'test-sub', 'auth_name' => 'Jens', 'auth_email' => 'jens@ljh31.de'] as $k => $v) { + $session->set($k, $v); + } + $session->save(); + $client->getCookieJar()->set(new Cookie($session->getName(), $session->getId())); + + return $client; + } + + public function testIndexHasPwaAndMobileMarkers(): void + { + $client = self::authedClient(); + $client->request('GET', '/mahlzeiten'); + + self::assertResponseIsSuccessful(); + $html = (string) $client->getResponse()->getContent(); + self::assertStringContainsString('manifest.webmanifest', $html); + self::assertStringContainsString('class="fab"', $html); + self::assertStringContainsString("register('/sw.js')", $html); + self::assertStringContainsString('href="/mahlzeiten/tage"', $html); + } + + public function testTageHighlightsHeutigerTag(): void + { + $client = self::authedClient(); + $client->request('POST', '/mahlzeiten', [ + 'name' => 'Heutiges Gericht', + 'notiz' => '', + 'wochentage' => [self::heuteWert()], + ]); + $client->request('GET', '/mahlzeiten/tage'); + + self::assertResponseIsSuccessful(); + $h2 = $client->getCrawler()->filterXPath('//h2[@data-heute="1"]'); + self::assertCount(1, $h2, 'Genau ein

          trägt das data-heute-Attribut.'); + self::assertSame(self::heuteName(), trim($h2->text())); + } + + private static function heuteName(): string + { + $iso = (int) (new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin')))->format('N'); + + return ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'][$iso - 1]; + } + + private static function heuteWert(): string + { + $iso = (int) (new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin')))->format('N'); + + return ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so'][$iso - 1]; + } +} diff --git a/tests/Unit/UI/Http/Auth/OidcClientTest.php b/tests/Unit/UI/Http/Auth/OidcClientTest.php new file mode 100644 index 0000000..e8f75aa --- /dev/null +++ b/tests/Unit/UI/Http/Auth/OidcClientTest.php @@ -0,0 +1,174 @@ +key = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + if ($this->key === false) { + self::fail('konnte RSA-Schlüssel generieren.'); + } + $details = openssl_pkey_get_details($this->key); + $n = $details['n'] ?? $details['rsa']['n']; + $e = $details['e'] ?? $details['rsa']['e']; + $this->jwk = [ + 'n' => self::b64url($n), + 'e' => self::b64url($e), + ]; + } + + public function testVerifyIdTokenAkzeptiertGueltigSigniertesToken(): void + { + $jwt = $this->signJwt(['sub' => 'jens@ljh31.de', 'email' => 'jens@ljh31.de']); + + $claims = $this->oidcClient()->verifyIdToken($jwt); + + self::assertSame('jens@ljh31.de', $claims['sub']); + self::assertSame('jens@ljh31.de', $claims['email']); + self::assertSame(self::ISSUER, $claims['iss']); + } + + public function testVerifyIdTokenAkzeptiertAudienceAlsArray(): void + { + $jwt = $this->signJwt(['sub' => 'x'], ['aud' => ['essen', 'anderer-client']]); + + $claims = $this->oidcClient()->verifyIdToken($jwt); + + self::assertSame('x', $claims['sub']); + } + + public function testVerifyIdTokenVerwirftManipuliertesPayload(): void + { + $jwt = $this->signJwt(['sub' => 'jens@ljh31.de']); + $tampered = $this->tamper($jwt, 'jens@ljh31.de', 'admin@ljh31.de'); + + $this->expectException(\RuntimeException::class); + $this->oidcClient()->verifyIdToken($tampered); + } + + public function testVerifyIdTokenVerwirftFalscheIssuer(): void + { + $jwt = $this->signJwt(['sub' => 'x'], ['iss' => 'https://anderer.issuer/realms/family']); + + $this->expectException(\RuntimeException::class); + $this->oidcClient()->verifyIdToken($jwt); + } + + public function testVerifyIdTokenVerwirftFalscheAudience(): void + { + $jwt = $this->signJwt(['sub' => 'x'], ['aud' => 'falscher-client']); + + $this->expectException(\RuntimeException::class); + $this->oidcClient()->verifyIdToken($jwt); + } + + public function testVerifyIdTokenVerwirftAbgelaufenesToken(): void + { + $jwt = $this->signJwt(['sub' => 'x'], ['exp' => time() - 3600]); + + $this->expectException(\RuntimeException::class); + $this->oidcClient()->verifyIdToken($jwt); + } + + public function testVerifyIdTokenWähltRichtigenKeyBeiMehrfachJwks(): void + { + // Zusätzliches RSA-OAEP-Enc-Key (wie in der echten Keycloak-Realm): + // muss ignoriert werden, obwohl es denselben Modulus teilt. + $jwt = $this->signJwt(['sub' => 'x']); + + $claims = $this->oidcClient(extraKey: [ + 'kty' => 'RSA', + 'alg' => 'RSA-OAEP', + 'use' => 'enc', + 'kid' => 'test-kid-enc', + 'n' => $this->jwk['n'], + 'e' => $this->jwk['e'], + ])->verifyIdToken($jwt); + + self::assertSame('x', $claims['sub']); + } + + private function signJwt(array $claims, array $overrides = []): string + { + $header = ['alg' => 'RS256', 'typ' => 'JWT', 'kid' => self::KID]; + $payload = array_merge([ + 'iss' => self::ISSUER, + 'aud' => self::CLIENT_ID, + 'exp' => time() + 300, + 'iat' => time(), + ], $overrides, $claims); + + $h64 = self::b64url((string) json_encode($header)); + $p64 = self::b64url((string) json_encode($payload)); + if (openssl_sign("$h64.$p64", $sig, $this->key, OPENSSL_ALGO_SHA256) !== true) { + self::fail('konnte Test-JWT signieren.'); + } + + return "$h64.$p64." . self::b64url($sig); + } + + private function tamper(string $jwt, string $from, string $to): string + { + $parts = explode('.', $jwt); + $payload = json_decode((string) base64_decode(strtr($parts[1], '-_', '+/')), true); + $payload['sub'] = $to; + $parts[1] = self::b64url((string) json_encode($payload)); + return implode('.', $parts); + } + + private function oidcClient(?array $extraKey = null): OidcClient + { + $keys = [[ + 'kty' => 'RSA', + 'alg' => 'RS256', + 'use' => 'sig', + 'kid' => self::KID, + 'n' => $this->jwk['n'], + 'e' => $this->jwk['e'], + ]]; + if ($extraKey !== null) { + array_unshift($keys, $extraKey); + } + + $httpClient = new MockHttpClient(new MockResponse((string) json_encode(['keys' => $keys]))); + + return new OidcClient( + httpClient: $httpClient, + issuer: self::ISSUER, + clientId: self::CLIENT_ID, + clientSecret: null, + redirectUri: 'https://app.test/auth/callback', + ); + } + + private static function b64url(string $binary): string + { + return rtrim(strtr(base64_encode($binary), '+/', '-_'), '='); + } +}