OF+=VIoiZ^^)1JS1P8K7ee|FY$`W@%; z(v18jsTNtRR-EtUX*S~TR>bKjpGk5&zP=YDzkp}=YE zjSn1X@J^v3^NsD1mMw0ux0&9wpSKz3y^~a@&FNS?;NGo4G3V)}pw;6O`jbMM-AMM^ z3cj7-+FxQj73{@6XeVpqiwkiXicoZi%q9RPqE{-naYf+g*3!?79*eB^cYuqubD8|C zx+3VRE1nhTN~@N<9IuIk9ix${gRvQ-r=26{$}0fC|Jl>g$i&9XmBiT0!pdF%c-7eh zB(X9T0BUl`Gs`=Qnps*&c{`h_dMl`zc-xrpm;yfw!t;Cbf&lEyT#ZOP?QHE`cs&Jx z|KRe1p8pmz0ZINbakUWuYRM~+h&ni%k+3teGcq%Xds?}(0tMko_?=D7c~!(D{tW^8 zCjhi`b#>%rV)F3tVDw;Pba1v{V&UQ8VPa-wVr69jSunVG*}EEfGT6J2{e}1!hM1X) ziL;fXtCfR2$zMz(V+S`^0U!`mPx2q*vvZV}|4(>(mw&SW;)BW4$dQSKk(tTPj_JR) zaB&rP2Z8*1K>ybkE^45g!AvS m7U{1tw3b|H%nJ5^Z!HEe;eE1nt!JAUk3te{-3!2 z&H6v){*N)pN?x8<%)!L%@9?C>1b~10=QVXOu`=cT=h2wU+=SEI*qFiCgw2S7orjg1 z!I%xSD6BjzCgv<`9L6lhM*js$+TO+0$lk>4FBAxz(F%lPYHr4DWX#3Rz{+OE&cM!U zY|g;V#ly+K%4)>I#=-^aft&rmKqxv}fmCT^`(H=(7s?a_#lpdA%EH2I%3x+<0)jGS zXJg=IHRc43%hUu^%+6}c!|@N4sR^%ygR`9xXgRIyj4aHU9PKUssrXAcudtG|0FafD z`TvwC*&4Z;gBl0`<*e-8JpWIHnw6cIs;kjoHd#2iSlK}guyL@maPn~etD}GLG|Ze` zKuY|J$->OY_AlJO%fbtq42W2xzd8j0`~wS`3$LiNnUSl5vzmj0tpM;Z1j*l)|Hzw! z|6hwDW#t00@cOIxe`{XV%;{hM{ cqv!-OTi#jzI1HRb^sn zWN%>x+TZ^c)PIy){a+S~g`JhdoZF0n}wU*oQ0W{-H6Nh zzoWZ2n7euyIhzSvfMyDs4M;%$%!Y*eA1Z17yS0a<* _JhG z2PmESgLSYBN`>GYrL tgM!&7&u`Qs0dMWsa1&+1FYiS*5EM8>v|1|;{B9dh3RDEmN2bP?EHj})Pu(k%cd zDhWAh=ugCS{L8I$pFX{GJC3R+TzQ`3w+|eATU7H$t@+qrcY-tiBGo*0Ip2T&cwDcN z(k-23pHkD*%urTQN!HiUNLK-UwKO!aG(lf=eSLc-28L!VY;63Ai3ti<-G-B`&ep7~ ztTP2^X{XZSV)l~a;>EJ!Vl^r%D$U~3(sXlE({C+W^oJSecOIt+Jsll7g5LM#X6MT_ zN12(KtFf`MUGwwvyRVNI_>Jxt>w-&5OPp0p)~V!U`P9*U`!}PE8M5x}y2%Ttsk)3A z#njQYP@2qglo%RS>fd#0vWAS6r_8@wvwl_6PRCSNPdhp?cd)m&zgTTBLAgI)aq!U9 z)$QwWJ9qcE`nkp8`|Rl9?%whx-AtY;P+@Q^g{regtD*3 Jc zlclq?^cZ!*$bt)~fJR+i{RO*%%yM*U>fz|<2&-MUcHhg(%cidLvLkJ6ZOw;VTEz{# zjr~r4z1^j3$d 6R!As%|r8oODV`N=`%q?%5)YKtMc^Kvt9SP;?)1#D4nB=Ra@5m~ 8>8;H!lfRDR8 z)tH!=hvpyg6GRJTigjOE&Bpo0#>N6VFIqO@rIQc3_d@I4&X #4lFJn?}?cq9#0 zJN ?AFf}da^}0RSLlJ!5={`Fw7z9CM_+k|9*HTO8kK&o!kV6LAluTb~_QU*5PJjok1R$AoybW*yVMHOioEz z`69a%AvieF8w9cS`OoheY`dRbJe?EN-KADXAiI;xoBffzIfFvMdnYS0US6H{*UL_> zx-YHvVyddDmRFfMyl%9&7p>d=j{Tu o#4naTH#;FHms&r(A0XuyMA)=y8yZ;7 zrnt7>hMKGvD(|sNQT&?zv>oUAJl5#8JC~MPl_skAw{8&rUjE`B;PkMhxpK-RL;P}* zX~dbm GFuNB9TPJE($rL*zTd-AbH`d8il6IS+n G!mS&t@^b_V#@_ZvPJFf+T$}bc?}@xNOtMh2_(AUh@oh8xK>y_U2gUK zdFuHi(An0un%@IW$Qv9H9StuE`uvHEjASGBf8MP{{*hT~74GrV>v(Q)QLWWJ7^KsJ z6b0Wcx0M{-M$@t9%1YV-JYos3!;Ex>FPx_jD+U3@)zy>5E_KL+1z_sxYxPE=<{-fl zC&a}GK&Li$q8LtPacpVS>i)jY74SiSaO(jXhJl T|=2=WM%D$eXsk- zMiPh}@ UO3f#oRcJ8g6@LUYA_am2}c5*JMAtOp^O@YydB7YSNvN? zN5^ZP&m|8H3HWNKN8K*SwQ~L1Y V9VpM>W2b=PgmOkS3wXk zjGY1j+QEy|QEjkro$A10#-to0O0sD=mDniD>>$UO&?%q^OOSLr^Jq6zdSP&(HMans zLaRiKY)OlBNoC)}sWrC2r%p@DaPtfayA%oSkZ{eV&k}?=2@GFa1`m&q|KL!gQUX%O zT(@PJez5O2qmQclAsv`zO2u)^lg)|fbbSq#f?(N2mk5E(QXG5l`~5!N)6`H5+XcVa zO0C9OnY!3UU3;sfyML(E5YZg*<3+QgXkm79lfDL|>h-_A?ysKD-zWn?{`DS)*w^vr zAxWtJ7%Nu-oP}X&*djN^+%7b5V49ZSk+Jkt#W=g^?qh&NR5vu}SqkEPL5`Z1omU-C z=hO6}*P74xQ2)i$FWUJja6-a_!otF5(AMy)Yie@!v9_)pU;@`_vYh*6G7|4Lh9!<> zev(VhR@nOWG=yw)jM=1h90v*g*6T7c9@(7V!(ThtGFz@mo>JiFU}CO@eLHQkC 0iUGR+ zZsX=&pKTcc2M5Por^i(;?imUj$I9sBt~(`C6NXI&Q+5WqEUJAH?$XA1peq|bGOt!! zO_HL=**Q32MtDaDFVE-Ce>mkA1QW@n?NR`8lnbd`KRt_lMr-c|c23ul &kI0!&1K=Z{Z0%8qm|Fg2nC zQBhIQAdy=hj;HZ;8-b-}WIW-44(Cjz&eKac^zA&<=}l#2axvV*+Foo67HQKm9nafR zNM|;DFB?C$*XRdsa6;qNA3m*iC$k0n);2bOF!1p=e8IqMSL= @ZRqB3uj^od^lez>)5)CHQd(KX&Rg{mt=MEghbQ8 z^_%bE{)P3GZ$uJ}*ytbu;hd&&nWA*RQ&~fU(;XDbLJKAJX+E2PykJFNR|Sz1dO`qG zGQOmQ@k-jZL+DpibHwj=of~^n3Cj#%gxhhTZ4?Izm%f9Kg3kR?;Sgvfe&++J+1QJZ zG1i_e{3#?dU_;hJg4QSVqQPQa{F!&^TqA)8j+FSAqWpZ4LeSwP3U&b6;9u3%r{I>S zOsQnwob3@;kiMHFx*hg>>ghcZG~(yvix0Bmg*$2EmXVPGT^jRwy5vYpO*Ms?`1R*> z(vlLf{&ajBnR%3%k}lR@6Gv92m{F0?PIQ6&6ckK^fE^4EOPqtw4 &-SWfKk;_xzl!#H&S5w{r+7WZIY1i z-OP-L@S>9-lXGww#OJ(xD YcM4p9lR Uv 7J )dYOz zhi@?iTQCO`12SVWEKb`^mdS;#W*4xVZYz4l#l^nISI{U#lMeoO*@ xREegF0YrS%=KUz)BJ*Zm1KKG~cz?t-ue3C@U z`o7nnCwuFaE^_k1W-1LNosV2NG&_bvsU$LA^Os$>sXL74yCfkc-Q^3QlST*m5YRN1 zYrbGFzQ)JLFDIv@w2oR#e>=VQGE&N#tWI(_?ibxe ZMFu^i|%PHvB}`F6%vOPt2~e z-BF{t)NrC3%f*+ovJ+})A^6CtfH&K6?TLwh=*lr^ST^U6C*J*~`QFGv5sj8om5sfR zb}KYQ bQKtC< zM{ACfrY0y+(Wss?Wo7Ysv<*il#A|xBx3WqCZPeIXP*Tb_xqwNG{G<>?3am!R%*nzq zk+aXn3T$mhpZfBSgwSgk<1nD3q=b)>`+yQkWQ+CnE7`Au;xiVUi=g0Oa8PJA2Pv=t zO6&Nqdm)57ua=iJ6uxnjdYov!gxJ{LE3$dSf#<4Vl$O(z|IRk=T-QxFs^Pn8Wo;CI zmTqa12gvimsh8!G3+I`#tss9lUfcr9hy^8P-~Hdcb-sKFOUPk10)N_cQB=C>84wH- zK4ryl?r->VxZ`&UE<>&D4b;A{BP!x0$Zz`gWP%Z1-o`mo#GVmU#!~!w-SPWmQ=js^ zVmm8tzGj_%EG}DkRb6)$$OWR|;(AFR`@G$Z@OWP5Uppx)D{uP0UJMQnLds(e$It($ z`aXpyg_69ln9NeT8A{rSU0L*L$q9VP3w+86wo4%q2w;;6XTsEnKM%A0DCHBo<5kLv zp24C>oo;{%KjCy&;6U=Y@F!s{#uS2RT$r3Bure`_zQZhf=FE=sx%!bIokBVH!^b05 zf(-Nuf`HpG@a@g__U;Zs@ab;m^UT}tyQt{f09E;PGiPV#Hc*P9U2hn4%?dPy>m?s} z7$~aOF3yL1ml}5LZ>$|ycHyt=L|a-4J##PY(503i^0ax{uh~ve>BN|Fg0BZgJczaU zx`>LowdOYwyyNw)F^fOht2TEv!wA9qQDqAUuaS{GzmA*J;xPpr&Ww)kj8WzN^_JMb z)-24-SgaSRDHU_&?Clw61Yd21jt!bD(Lf7y3`)Q-*D)g#5-eWsPY;fdRn^p7y-<5= zOz07%xL}y}W>N$Du)w57kEl)WysE3siYhSX)G*nRmX-p`K42~38!0+K>Iyrf=Izws zeHA)_l|!=|QJ~?F6lQgYE0Kz3cb++r%Kdo0H3t;UjsEqTIUYZwmaOgWu0Yx19w_)9 zGG)E#+ HTKf9>R5IUO3J?7~2#bi= z0#zzlYB`!HIV*Ml_75wmPVSWlaV8a9?gs>h`kf;;n2C=Z82tuktyv!(rJ{&~tW*RK ztk4~L=P@D=N#z>Ch6UKE1OysJAr J}hMMv@Ej=hz)B)P#ckRbFsgfQIJll2b?9 zMcMY1$U;#T_kzub5y`T1T55e&rZEyNr=7G-Iqx@ *|g>hz~yt16ZZQ6h+Ko1Elaf5ghH a60M+LU{^lu?Idxt2=mXKAUFKUI3o|kAf^KEW%@q7#;#)e+xFvhL_r|EOe!ZNf zD=s6#>X4N_fm1J!bKvtpoDliYESA%y-#aque5z_X7;Z`;L3U->u R1zOHHQ;_4HRbA!#uAd>=sYHz%HtTP?<~yr`yERw-)oXe z7n_ GysP4|sqB1XnCgOmqTxCnyi@yBsY;e0amE*DpJL$TVW8C6_yle4PE2 zux3S_J>IAx#ZQ}+acjLZCie)a+~}0N&HR~2IgjuJK9Tk2n5e6Y(_sL=+h@w*}WA zZeUAj6hd-BLT}sk>hRs=0W&M>e;^9@-0c 8{;2E4F){Spf8a&4&^W5q&+)t{i3e z6~Fy_Xa9Fnb?quAtN#XL(MkDB!J~+0;wajdGWYU4(~a!8R((M4$(ErWm<(o0M98Vl z`BE}z@HUs xGQiw}C)v)>lLJIAOvuoQrtsqU*);=mw|P>+exozx7vV zVx|SQzInxE0smI?5+6(SA2x_#Gz<(OH#eTu)zv8g858yTm($vs5f_KsF|MzpBWG*- zS~?UHgxHw(sX;;TtKiu1=YA^r^($4_qkMvIT%Q?0h{w)oImXCTuZ=cy?S6!zU$#O6 z36#eVxETLk9PF@pugN=z&CVLYzMC-O^He6 pvVblK|ao7C1J+u+hGO-Ox!Lxmo|qrIN-Q z4zOuU?o{>?DqUlD0sv)F0n}1zCtDmUHacqYD&|8?Dgc?)2cgcG-@kt^HEPl*vISKz zU<9~9vKZCyu((C%8&{UnBH@lQL>xig-EsM&p%{O3%ZaH7UWuhl{wa8?QBSG3cu9ha z_cMGq8@J*l;@7=_%Q++d^idE(6JPOsKpy>@?RUp)H&;$@?>KghyzH^&+dclnhUEVG zWT!VAPimmAzn|NBB8R^$Cx+Jlr!`zb`8%mK;^@g^Qv|pCJ#?n8RqM7aWdr;-8G%pb zN{4$A)}E{1x#?un70@m8Dis)veSAbf8XxhFqHTD4vj ynrP vQJN0izTWA1+vHM+wc|X{)+fr39S- zzP2uWZe<9DLL@KFN37Kr{1sP9;m@Vc`eh(}+aQMt?CM pDj05mX;2j*JyZ4iHM1AfAG2MD}t7e4l4O{nV|qE zD#*Y9h^X-#`6dX`Mu{yS4gyQO($o#qa1?rT$dbbBFV|GGNWMWdi+06vbcBk|$nxyo zE5;4}{=JzSN`d?4_~m4nDb|!4Mv5QmE!D4q^{aPSsz(`sSIH6)szsriVlc09`>w)m zSqfc(mi&5Ew@uug==+`}X;qh6@rk9j@cf;EHg Un$I_Je>5K7?6cb<=1PBuSB2>HP2ySSBOb+VfCxd-f5SL3)wLcOcf3NKk< zZiI!c&5(Phb};QlD=wf{9y%LkuYbZt@SyeB##JtSmETS0cd!5`K(~$rNUK1BinEC0 zdDcf1oEfmOv9bJVfE>mU0;+YX0U3FD59_^C<)`xzIh1&=88!%kP)UpF*)t@L3te-O zHF9W >RDs1Eo*@MaDLTM$9ICzw+ zb%#9wg2n?_U~-B^XPBCcD;87%mNmDgF_!=#?d}pq3=C*(ZS{X7U8;A$t1 {mj*t`>Kf`}=-ZZg4C?qZfCq*cDWo2#3 K+;!!!$ -GKX3F*sT&uo`Wd|*K2n`I#bUa$0G+1w+I_ypiGk)L1 SL5C+6Wa1>aD z$e#CAc*y<}CyyV$bz-lw6$z9SQ)Cp*sdpS-3to$1^ahnS8i?jr6@&cm1eN)3Q_KqG z!#a}G!FeDO*3|qwlUpVLPGb@i=^s==G08Xo8;X|_CjZ=$35d`MI*2N*Cx*m>@8D^` zpk&4$t%~>%wrBb2yXP*$D;ov0k-gb@Z*JZ+_)>$qZvg*6A-8N;3ck43Ei;fcHqM;j zVrFLU4p-QDr==A%z1I;5jtNi&!swHct3gT%azl9GtlL8<7@p%^(S-)AHU9#?sm^RLc&ca;A@3y?>9A zIs%sA7Fd~5bKwyYv^+dKIol_INC`$>C);q7(L1Hb^Uu4JW5V;Nra>F}FMICK7h53= zmcNsg`#UogU()W}$EO<~vunwo(LIictd-1uv%Ko5yi8M?;Tty_^~HA%NlC~l`aRmA zGxw>HhS$VeRTZP<^AYaqueZv|w2X}MyD2XL1pz@ZuD8;>CkXX>R?o+5UpWVdvUm6v zAT 6Eg^}$ ??(4~ci<<+)cE-p(2GVVgy` zc+DI^obdl&3&2d~Jyw>&?SxAe9Tfl%n1QFN>a1sb=iuc7z)@R_XNgJ`*Mg7%vP#s^ zl$V(+VTX@NO=Vt2{zn!?F3eyBGk^41LqkI<7Q$<^I&lf3&` J zaVWLUt?%uu4Lo=-NVEEk-LYf;qYmUlyw4GiRe2?u7~NA=RhbjsQGvXY1$hb4QMuz| zV}!r4;Z0Tik-NLQQ#j3~B*tr9chsn11+P`8 adYdG`o>oY=~%cEZ}RY+pW%t1QRuss=kI8{|i;V{+loVX+4bapp8ktgQGYI8=(b zD=i*ayI%lFTU3H;_=bUzF_Z#^tW3+s?KQO_mPB=5{lf1S)tAE^jhEk)J4frJ?~7OF zd?-menwz7TKRg+$&GuW$IdzQ{g4aTw-w0TOG9B`4T1H1lXJ%%QaU^mKT}w((8=m*W zjQy1vAPq<*B={oK3%VNJ*zvp1tgFlL`}v2HES|41!`mRP@Ns u$}#jWZrW| zSy$5BoEo4NQoosPxdnK6kxgkC803mDyL1Ut7>uZy7#R9b=-g;-9-n1`V6}UJ?$XX%J8%DaBWz!$m)Xe*ad= z%?}x}LKT#0C@obvyomexxdL~Y9scx*UqH32iwc6l%o>uCdLQ1(pIwR5(LZCy9x|^^ zyeWqxbSxVg`;-WyyHaMItN@9l# Xabpz?2FZf}|VPP>DdexL&WJ{(ap&D(Z zl^#VY_yqOjuIKyb3`z(BLJX<__fT}9CaQHxJCC9gB@o%z*w|!bZmy$L7~-8zRV*!k zyer6>w8r0m@)iTdE@o)YiN ~y|@%7e eRYXeZGaoLH z8@+JW_!`84T~uTkdWME9_Ap^4TV&t`IqT$DZzZVF`cH4a4*n>DzVEpA)6D}#`X9Cm zm*~=tJp)TY3Pp;A7lfxHV?*16_S+VENnzjIx$-5=D+gkGMdF?oV-3ZV*|<^b@=eV6 zciKO?*TgEeFGMZGLT+SkE}uIa&&;@Q9}+^bdd_}y(f@DJU)~A@D=I5r2*;g{r1Er| z4}iST4QLX8fF$e|+G>(ZhNKv`*rsdNf+G=63K-8 i<6_WL4V}p`$qvT55)k42d zK3hoH8F |RS zV0r`lJR^Pd1NFAZr_CVeYlUPU4`%f*Rz`gG xSkO|v8ZG!$;{$e0V*xw-eO74S#oB#Axx5kp(eCG)QbdlF6B6Tg~`Oxlx7&YL@~ zi|`+oo{f;SW*2P2Zw#us3ZX*V%4;hY4))@0m70|ITt0Ln|1<{&TPr`8 R=1ON# zM9UAtauTCU$%>6JfB1;wzgvfnD7}jd={y%@GZOAqv(RG1-$)o~m&7m1;m?wD5D8AE z_YbkE4P?d{N{FO>9iK8ZUcb^LuQ~2_Nv>!>k`yQ~;Xl35Q(t(&!esyZ($F`&v zHfnYje%exKzTW9Xs8%ir=k&-}i!9;R&x=YJBr4BMqZ7h3WK<7dd50GKSZJ&=!iAWN zdM!kMm8zIFhp=kFJD~W}$;Ko9OC~SnEwQGXY2+2}8g94MSt7PwD1A_Ym%8!y#^0Z( zs<&)r3Y13#ZV0smUO#+MC&)e_y3YZUyJ%>L`j3r+*?hXL*|K*ggm2)1;w}b(YPh;` zDvTY*O5f3A`e7-#-M|_T(%C{vO8kcry@gL}cHJ?OOUU60;gpwfWBHalYF5>(npQrt zGvgB+P9XGjl-EL4R?}D^B;sCB%LDFP4Pt&V>RVN=a!oY#*J23F1=6LFh@rnhZg;jp z$hG}eAt+lEHNH^FF#hKc1fAfRTj PK!WN6q@4;8vM<)wM zHMppjv+*je+hGt}x-1@||L4GpFpA=W$dofnDIeiZOWI+R=WSSGvwSDg_?|8TVaUFD zLg`&=(U=ae2YFfxY~3ZifgN*uXYM@a7v4g{t(qg%)+U|OqA zH?4$&6_(~zEX`)l_Z!&J?Ox4RNP_j6%F|`E%=9AXi7NS(o(Td!`RDUpC`nLsn`_qi z-nE1I;Wya(>z-T=sc47{ft;2QTopuS3vUcZf@;q8ty{|HW5NCR9 j=20I zY^A#51)}@se3@=enOXv;vRuK}UDG$)&+?iaWSPX#>vo$P|LHn?`?x#uNb&H%754V# zYHen&r@d`rp4b51tj-{q4T1x!psb9Sl?X= {n ;-E`YH#3 zJe!<+kk3Wq8%+FY*^v_1;8i=Nf6?3|C z7gr2b{|=l iRR1O=5$5)117cKa?j3$GP@tO2)83rp z%T(Wk6r!oA$quLUiYD=zpKp?+G%X+wq`y_8KdDEz<=XrqTIgIWi*AIr-_YvLv_6I% z8cuFbd480JLrV%zP2b?Z$u3-PK3G~kzVAOjB4&LM TUR zMAi{LH;YUE_XV=S-oF;CmO3W3wAy{TN?3mU5lzZDCi0gRH8CkyA3At-aEko?=&OH~ zPG|fx+|-7-`s wscU<71*{TL z8W(|VZo3KV5;r%u#vtppSDTbr$OR55DT@q$fEF$$3ap@3`T1m$oR>RKJnr-mLv6u2 z+bx;XP+`uWz{w~wA$(~qajlPd3OgPc=N4i6t5*@_!ep(ok%$NbnGcsWh)VF8ehn)y z;(pkhTervLuTV-@%#ZdbH2va-=u{vS(V;*pjL8U5k2}Mup<8ZJnC1m_c?$&$q_w3_ zwc 58YWuO6Kw)}VgnbIRi2Urp|6~_ zHChvgCHSd__+yUDBBd@85VzIPSVTZZ7Q!~rSFb6*(=`r-@x8x;Moy!6U%_{r4m6}b z=Vf{V+3XikYCb;sDk*()zGrJ#8U^BCSQ>u-Kj&Hk-SvD26go%mFDD?9Z7Q!+8XNE` ziRuRKfxlkD{YSB54#jd;D( uD?NXCjfWVTKxRWnNyk zw&rPjZk!#On(gzN#$r8>Dhs-!irsPYZgaiBN2ncX1t 3 zbbXyC=ejnp&WYW|_WMy@G!jMe#-)Z8B8Aku5Jb -O zOz3*kyqM%~@Fy 0ELF2#*x}J3~WK=U($cT!=& %&^v1ofo&NU{a;s ~`lWD1r2wkalKP2k3{HezF3(LEt6qGXO=q5CH)}_jm%pCqN(w)`&7R zFaWYin` 9EFLwMi2W zG9mP(`k;cyzay!%WRG(*GriUChlA$+%X8=j8hN<1*!#-^q6r@NUP3B27p6*Nv9*)@ z(t;3ShWT3)6a3G=+!h3p`7olAnms?+kPnpf5<5P~m}+MyBq(=aHIODjDbl$2XUj-y zK8)gGJ_-5UaAy1ML;2!?4sHqrl3aMI *os-+HpUlp)(gOlgsl{^n^L@)Q;LHJ|qI_Y3~_pQ>1K@wxu00C8hQ6bJCh} zF9Ik)4Sgm2o(zDRTuXqJDVoMcGUrrP#bye7po+(LndLaGwM)&0 vUqtx-~ zsj8do0d6@ s(&fNB-0DjNUb71vq_@y_Z!^u- z=Q*-Z>2HJTEcsj-mWb{tzrk~Y7~^$K=;{I_eqfLbwKnk+pImpw2#!ZI!DkOpBI$6s z17fBdMZ&P&?U3+$EPJFeqxMy%t(Cnv678K{s#vdDwiTY7J}NW!y#D-eQ}JJTB|D76 zr;n5%&xZFXYF~;gIY)SIG>|^YR7>Mkod4%!ZEbdYzAY8&viAIRJ@orGuFhb-ax&sS zqneN71%UIE8lPQW2Gefm2HQy7N>VEH-}}dn4<`E4BgU59DPnYSS&DLp=G-5ov4wH1 z-)Ze_{24z8!t*Im34U%Sn&Md$P>`R_`AM9_fi`-b3Q*A-5fUKw+TKn!y}C~U0k7a~ zwwjSq#zY+m7;s}x=^|fTB>qCZiD23ek=K=Z-UWs*=v6Kcece`bB(t&bfs*>hv;AaD z35({u*~hlc)LpD;R@^YPJGik=p3j3yU_NXn#|Rfa(*9d^G+n4j+n?|T0qmN{ 4cMghgVz_-Okz9 kkOT0o&6R!@k>h_8^rde61= zMJBnJ4{^g1+su>Q-c-I+fH(k%0o4-|8%yox1UOpzwRYpi5#r7mYH2{_;hM}Iu61;* z+(V4G;GHp%l(aDunz?>s+kCR%Q;Zzpj+>j4( Uha+>ln^Ks^RwMHe#U+ zK8AN%@3b&v5h_YHE^QNLy>71;y~O5e{+=nTd}V~fZ87*)1vY=9oX0Y32>IKhp`94d z_?8=xryYK#Mn)q8lu*_gZtoWpRECh|{P;U{^{$WE;Y6yH24ye0kH&dxSy==?`W`u_ znqI-5=2u8u(vxn?=rykNbRU_zyS_?){}b9A-pfQg$)7sN`N}&ba+HQz85a?bn9h`5 zR~rt9<~@B4y)he GwQo0d%A~d{1i35wWc1RHV_qjvE!1mqjiwI8qZzGhFfsar7aOuozq4ATY1dI>}a4Oki#y4J}Sd+Bt7v(xQ#al}-|pYKTP19Lcco^Wn|pTJ zQPZnK-M8yHf(`9SrovZg1>P7i=(JB;{l33KP1Ny|I84WYQeGZ#9cw#T0!}6phMy7> z6I)Na(O^W!qy7HTZfu7PY@rM;BI751EYtUeHz)pX-LM>W)rV7Cpi}!ncOV-4BgbeE z2v?Rz=&(aki$# cD1O*jBn3q)-f`@bqs#VUFM5H>VCjF z^&>C4$?_Mj#WyhUII&!(|2a{6m^uF-9yfK->!G5@(3tiel>&RQ^PL^b%#73|il7^| z4qK?Gch?p|bjxEhW?5O;15kuLSL+q_Y#=wX>1b);1CLb8X?sUWiT!e8EZC?hrZpfG zy#)HkF*8L)6c*j`8I*Mwc^A}&HCIlT1ikz6F**drb3WoKJt}TgTwH1vv;x&oBq%H& z-#Z@?O!BWQEji_gC3Zdma=}M4rj^ajcX7g-Agn1jR%73kJeBKJNi`B?(<#2FEiy95 zHdaD_Tg6T93@Z~N1s@)MI?rJ~g>U+$qWtScGA1G%9pgP}eB@Z5@JZ03K~Z_gH&oNm zjm>H28&01Gt|Cb{c9Z`xwtWP$FVgvki|l9ob`v)@UY-vl*vIeR85@rRPMMj7Wg3j4 zzm>96wc)FT@nB4F!EH#_ZVfhWe9kk(v;wAZS|ID;`}k|U8q}wUfw7JO35^J4W#!vp z( 0sy<^ENK4i!ua07 zheBXeAJ@L@kk=y;bGbi1UZW*HIyyLP1Igt{%oCI-u=JPJj`^|b>4m)H1d+Rb=RrW7 z-B(wK$q*8-zBYiW2yNFOTJ(xoIs_f3UQjLbO BHme3G z@b;NKF@YOk^F_$Hf8*+S)Zw knzE1^)JL5|63L6r7dU{sF`pDah9V8>@Nvu3Y?%Qm!P53Vlg15J~ zQg!ISyh@Ye4!7(g9JEx?`mv(qtFFn{>ov6R_cq`FetNhqHSNf1Z55G=B!=k(^8~DE zaPm=PD1z5dfZF~5#ChH3{c45ri7dL+AW-BC$ujz5X26u0iy@^9TxiRh9-;1fEiR*< zbQ~C7hvI@!A|N1u@~w7BsKx!DzpP>PwbAjx^5OH%Ry2|}0|=SeyckJrh+4bZaH3vP zYpe=yJ6eZ24}#x{2QWgvyl>qBPRJMHeA%EiKR>wR@yJP%=MjtoJ|VeB{CVU4 d?FCxSpF4`7pxtrrD5?|6qAM6z>; zW)0%GbC`4z6wdi*KrGVx@s*71J #MOcZ5@%c`B8UWzmI^!d4q z*u!a$e>2(vyz?snQB>SrY#SqdvCQ#sGF_G!H)+Q<2+Xy^?y9mpo$I)%zb~h2TyYek zh+f<1cWg+nvzz;D-NKOxX{M^TKgWpZ+hI{qy@>5h5g``A*U;!NcpYRQ(v|AsdBr^V zeE8ej2~3Ec`ez>t!Xe%Z@UhGn;(!r_EO!)`S1 z2lHc6`1eJuQ$nw`x0%_3e=C`yaM RGL)+6vaWm)svNgnn}GbOa>1TgK%mC^+R+4fbJdE^C?S8r+etOs zCLvCoJA((yz*1P`32nY!JK39Pz( g$31?RUR2vxqAG1RGZgu6<0>PX&!z2FZFCo3+pU_c)YGMRAY5Os3$Os{)4m^CC zX!9OOB66}1@>mNIcq$&gcL}6Gp6QE?M_Y*lKcVWF?9_6W9KByX%LcATQ2ZK+uCSXU zN~Rs`ZnyD7xmP`lA|XFW%} #L+AnjbBj zR@Nh$WMDntg|eGwLfnFQ bm3$^&~`%-gEf_ z2AJ#gbPZx}msPXdw)p4XxlT*@0QNbSI0#4{w2aY>(WFD9ET;0a3-h`QAF|nI-h1VD z+4IL|Wt9&B%+}GE%or)|Kjo+J=$6_GUf#~<$5&6F7OGGP3JtXbC6x& YVZVg<$1U_ZU36}rd$v99k4eWZoe3|cQ<$6ghgLplA!ZSjc Gb6b6b7C>LdO3=+7AeaPGFqM^kK{xBK2lVF6|IuL!z-Ty80PH%^*(t(TOZ$IosH zdxB7ln6Pi(Y&la^wq1a3@B;Amm+vLQXlbXDA9tKCO_R~YOc5`#uj|gIa<|B=^gjUW zw>+KVR3tb<&(cQ!_DDDx^X5jhay=0}YY{NbqehFN6C^ety7>e`YJpSZ2qvbcx_X%~ zLiQRXRIvHlUr)RnN^iD<>3Oc(e133~gd(LqG%~V`LIrw2cwd3Ck&%r}HCZK$h8m~( zE?_kJ+%y#Pe*gApF@IQ9era%Q?6WpVupooL^05dW_U(6KE-5TzRCPLf$ _zKsB4RvqFCz#F0a5|_0*?NDj9-jdO&Z3 zW@%wj%Ym|f$ZZxlur-N7)r(DjakSBqhDa)Kvwur)fF?-p2e>5i6keODlH zIemHsBA~V;WWtEg*NiVgOCb~rOz1`Fz@&??LcvjlYTvMQ@o_XQ|6@cbvWAB5Mh9X~ z@UzJ$#HV@)A;;AFpFQHl-{q=nYOug$TZ}j0`VVXsgJu`YxW-1g0aaT*k`g-&&$hN- zlL m8> ym32TlJ= zzg=Pf-#wrQ$1?ze{+J{{AR$ Z*m{`d~ U4nBh(DBzTBsV6kss zDtt>>#f}|8$fneBP~M^jn JT7=HYyZ z0 Q3IK@fQ33`wWb~b1#3Ynd-iDCJ<#MjJWHee5y2ix$a@oVIiCH za(B|Q4`|6(r<$}wqb5Q|KWM)6%IF4yWVEI45YRzjdw;3n#1;5JpaFku{|txhP2hY5 z?9g16-`(C^TwL6$-F3WO8bb$AKsLOX{`s1V^Raw`W x)-0e#+_92Cl= z7`N{%C{|WpPSFGs*HG=Y<62B`lme}534kG=Y=A|aQTU&fauRE}C-4jq$;rs_F(RLM7V51SrqiSdOtM=^K>Eyw0s(BMK*0hX*SyEiSkw ~FKZAq+1B;~CgB!}XS(;+;p%|wz zr-$(2`})FCHT!Vf=Q5+i>v$gul#F}P9dEy7CrnGrCeVT;Ls3N~hz{~qomW&;)CDXf zB*5AXRq$km>pnuV#|Lcdyy}1c{EsDHV=uIi2qJ@(srGSneEeC13Y+X$@1IwjjIOS( zH0Z3rZ)JfkV$@{BK6`Na77-C~Y;1Hix5z3RxDaP%XJ>!@u@#_VqVn|Izx3K!Utg~) z;*d;AN{U(oL5ehAqX{7ZrmMl}{rhd6nuQ0P{*>WwzG1`LFA}yB? fhK9qr zJvVmX0q;5be4+17T-DsHv;tbo2!EHWLFt0+5~xrsG~`NEagSA?%SvR710_Q~@cea* z4Gjf=Av#Tf%#S{PNGq7@uj~8>NP{;4l9dkVz4KL>Ctq z7SRKNf>oHR(*p(u2JLL@>{mGOFad~N&=ypa*BGSeh@lJi<=bR@;89jjc6XWV?Coo% zDkV?fPftx ~H zwiH<$-6sc!qfJow7|775-v^@+@kjaeaP|M}vuM)M)VyH9#|$_rNoF@b2aN+nQ-G(( zt;tA6EAAaH9!wACW>gerV9&-)ZA}f2Fg9{$Erjd~@EJC^y1G)@)-BEgv7vg wP{)>dpuS6 KF3o1vMRnMkO? z7UC={5d=JBELiJgm;=l|CiTD^C>0hKj;pDtoaU0kRKX_?Zzm)rd4aUbrp{o+SV!jr z= 058tmt+||uZYx8qUt~{(0IauS_nVD_9TW5rUUF$DJm1Z)BsUICSa% ;D1d8n=@G literal 0 HcmV?d00001 diff --git a/static/js/index.js b/static/js/index.js new file mode 100644 index 0000000..880a555 --- /dev/null +++ b/static/js/index.js @@ -0,0 +1,456 @@ +Vue.component(VueQrcode.name, VueQrcode) + +const mapCards = obj => { + obj.date = Quasar.utils.date.formatDate( + new Date(obj.time * 1000), + 'YYYY-MM-DD HH:mm' + ) + + return obj +} + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data: function () { + return { + toggleAdvanced: false, + nfcTagReading: false, + lnurlLink: `${window.location.host}/boltcards/api/v1/scan/`, + cards: [], + hits: [], + refunds: [], + cardDialog: { + show: false, + data: { + counter: 1, + k0: '', + k1: '', + k2: '', + uid: '', + card_name: '' + }, + temp: {} + }, + cardsTable: { + columns: [ + { + name: 'card_name', + align: 'left', + label: 'Card name', + field: 'card_name' + }, + { + name: 'counter', + align: 'left', + label: 'Counter', + field: 'counter' + }, + { + name: 'wallet', + align: 'left', + label: 'Wallet', + field: 'wallet' + }, + { + name: 'tx_limit', + align: 'left', + label: 'Max tx', + field: 'tx_limit' + }, + { + name: 'daily_limit', + align: 'left', + label: 'Daily tx limit', + field: 'daily_limit' + } + ], + pagination: { + rowsPerPage: 10 + } + }, + refundsTable: { + columns: [ + { + name: 'hit_id', + align: 'left', + label: 'Hit ID', + field: 'hit_id' + }, + { + name: 'refund_amount', + align: 'left', + label: 'Refund Amount', + field: 'refund_amount' + }, + { + name: 'date', + align: 'left', + label: 'Time', + field: 'date' + } + ], + pagination: { + rowsPerPage: 10, + sortBy: 'date', + descending: true + } + }, + hitsTable: { + columns: [ + { + name: 'card_name', + align: 'left', + label: 'Card name', + field: 'card_name' + }, + { + name: 'amount', + align: 'left', + label: 'Amount', + field: 'amount' + }, + { + name: 'old_ctr', + align: 'left', + label: 'Old counter', + field: 'old_ctr' + }, + { + name: 'new_ctr', + align: 'left', + label: 'New counter', + field: 'new_ctr' + }, + { + name: 'date', + align: 'left', + label: 'Time', + field: 'date' + }, + { + name: 'ip', + align: 'left', + label: 'IP', + field: 'ip' + }, + { + name: 'useragent', + align: 'left', + label: 'User agent', + field: 'useragent' + } + ], + pagination: { + rowsPerPage: 10, + sortBy: 'date', + descending: true + } + }, + qrCodeDialog: { + show: false, + wipe: false, + data: null + } + } + }, + methods: { + readNfcTag: function () { + try { + const self = this + + if (typeof NDEFReader == 'undefined') { + throw { + toString: function () { + return 'NFC not supported on this device or browser.' + } + } + } + + const ndef = new NDEFReader() + + const readerAbortController = new AbortController() + readerAbortController.signal.onabort = event => { + console.log('All NFC Read operations have been aborted.') + } + + this.nfcTagReading = true + this.$q.notify({ + message: 'Tap your NFC tag to copy its UID here.' + }) + + return ndef.scan({signal: readerAbortController.signal}).then(() => { + ndef.onreadingerror = () => { + self.nfcTagReading = false + + this.$q.notify({ + type: 'negative', + message: 'There was an error reading this NFC tag.' + }) + + readerAbortController.abort() + } + + ndef.onreading = ({message, serialNumber}) => { + //Decode NDEF data from tag + var self = this + self.cardDialog.data.uid = serialNumber + .toUpperCase() + .replaceAll(':', '') + this.$q.notify({ + type: 'positive', + message: 'NFC tag read successfully.' + }) + } + }) + } catch (error) { + this.nfcTagReading = false + this.$q.notify({ + type: 'negative', + message: error + ? error.toString() + : 'An unexpected error has occurred.' + }) + } + }, + getCards: function () { + var self = this + + LNbits.api + .request( + 'GET', + '/boltcards/api/v1/cards?all_wallets=true', + this.g.user.wallets[0].inkey + ) + .then(function (response) { + self.cards = response.data.map(function (obj) { + return mapCards(obj) + }) + }) + .then(function () { + self.getHits() + }) + }, + getHits: function () { + var self = this + LNbits.api + .request( + 'GET', + '/boltcards/api/v1/hits?all_wallets=true', + this.g.user.wallets[0].inkey + ) + .then(function (response) { + self.hits = response.data.map(function (obj) { + obj.card_name = self.cards.find(d => d.id == obj.card_id).card_name + return mapCards(obj) + }) + }) + }, + getRefunds: function () { + var self = this + LNbits.api + .request( + 'GET', + '/boltcards/api/v1/refunds?all_wallets=true', + this.g.user.wallets[0].inkey + ) + .then(function (response) { + self.refunds = response.data.map(function (obj) { + return mapCards(obj) + }) + }) + }, + openQrCodeDialog(cardId, wipe) { + var card = _.findWhere(this.cards, {id: cardId}) + this.qrCodeDialog.data = { + id: card.id, + link: window.location.origin + '/boltcards/api/v1/auth?a=' + card.otp, + name: card.card_name, + uid: card.uid, + external_id: card.external_id, + k0: card.k0, + k1: card.k1, + k2: card.k2, + k3: card.k1, + k4: card.k2 + } + this.qrCodeDialog.data_wipe = JSON.stringify({ + action: 'wipe', + k0: card.k0, + k1: card.k1, + k2: card.k2, + k3: card.k1, + k4: card.k2, + uid: card.uid, + version: 1 + }) + this.qrCodeDialog.wipe = wipe + this.qrCodeDialog.show = true + }, + addCardOpen: function () { + this.cardDialog.show = true + this.generateKeys() + }, + generateKeys: function () { + var self = this + const genRanHex = size => + [...Array(size)] + .map(() => Math.floor(Math.random() * 16).toString(16)) + .join('') + + debugcard = + typeof this.cardDialog.data.card_name === 'string' && + this.cardDialog.data.card_name.search('debug') > -1 + + self.cardDialog.data.k0 = debugcard + ? '11111111111111111111111111111111' + : genRanHex(32) + + self.cardDialog.data.k1 = debugcard + ? '22222222222222222222222222222222' + : genRanHex(32) + + self.cardDialog.data.k2 = debugcard + ? '33333333333333333333333333333333' + : genRanHex(32) + }, + closeFormDialog: function () { + this.cardDialog.data = {} + }, + sendFormData: function () { + let wallet = _.findWhere(this.g.user.wallets, { + id: this.cardDialog.data.wallet + }) + let data = this.cardDialog.data + if (data.id) { + this.updateCard(wallet, data) + } else { + this.createCard(wallet, data) + } + }, + createCard: function (wallet, data) { + var self = this + + LNbits.api + .request('POST', '/boltcards/api/v1/cards', wallet.adminkey, data) + .then(function (response) { + self.cards.push(mapCards(response.data)) + self.cardDialog.show = false + self.cardDialog.data = {} + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }, + updateCardDialog: function (formId) { + var card = _.findWhere(this.cards, {id: formId}) + this.cardDialog.data = _.clone(card) + + this.cardDialog.temp.k0 = this.cardDialog.data.k0 + this.cardDialog.temp.k1 = this.cardDialog.data.k1 + this.cardDialog.temp.k2 = this.cardDialog.data.k2 + + this.cardDialog.show = true + }, + updateCard: function (wallet, data) { + var self = this + + if ( + this.cardDialog.temp.k0 != data.k0 || + this.cardDialog.temp.k1 != data.k1 || + this.cardDialog.temp.k2 != data.k2 + ) { + data.prev_k0 = this.cardDialog.temp.k0 + data.prev_k1 = this.cardDialog.temp.k1 + data.prev_k2 = this.cardDialog.temp.k2 + } + + LNbits.api + .request( + 'PUT', + '/boltcards/api/v1/cards/' + data.id, + wallet.adminkey, + data + ) + .then(function (response) { + self.cards = _.reject(self.cards, function (obj) { + return obj.id == data.id + }) + self.cards.push(mapCards(response.data)) + self.cardDialog.show = false + self.cardDialog.data = {} + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }, + enableCard: function (wallet, card_id, enable) { + var self = this + let fullWallet = _.findWhere(self.g.user.wallets, { + id: wallet + }) + LNbits.api + .request( + 'GET', + '/boltcards/api/v1/cards/enable/' + card_id + '/' + enable, + fullWallet.adminkey + ) + .then(function (response) { + console.log(response.data) + self.cards = _.reject(self.cards, function (obj) { + return obj.id == response.data.id + }) + self.cards.push(mapCards(response.data)) + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }, + deleteCard: function (cardId) { + let self = this + let cards = _.findWhere(this.cards, {id: cardId}) + + Quasar.utils.exportFile( + cards.card_name + '.json', + this.qrCodeDialog.data_wipe, + 'application/json' + ) + + LNbits.utils + .confirmDialog( + "Are you sure you want to delete this card? Without access to the card keys you won't be able to reset them in the future!" + ) + .onOk(function () { + LNbits.api + .request( + 'DELETE', + '/boltcards/api/v1/cards/' + cardId, + _.findWhere(self.g.user.wallets, {id: cards.wallet}).adminkey + ) + .then(function (response) { + self.cards = _.reject(self.cards, function (obj) { + return obj.id == cardId + }) + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }) + }, + exportCardsCSV: function () { + LNbits.utils.exportCSV(this.cardsTable.columns, this.cards) + }, + exportHitsCSV: function () { + LNbits.utils.exportCSV(this.hitsTable.columns, this.hits) + }, + exportRefundsCSV: function () { + LNbits.utils.exportCSV(this.refundsTable.columns, this.refunds) + } + }, + created: function () { + if (this.g.user.wallets.length) { + this.getCards() + this.getRefunds() + } + } +}) diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..375e700 --- /dev/null +++ b/tasks.py @@ -0,0 +1,47 @@ +import asyncio +import json + +from lnbits.core import db as core_db +from lnbits.core.models import Payment +from lnbits.helpers import get_current_extension_name +from lnbits.tasks import register_invoice_listener + +from .crud import create_refund, get_hit + + +async def wait_for_paid_invoices(): + invoice_queue = asyncio.Queue() + register_invoice_listener(invoice_queue, get_current_extension_name()) + + while True: + payment = await invoice_queue.get() + await on_invoice_paid(payment) + + +async def on_invoice_paid(payment: Payment) -> None: + + if not payment.extra.get("refund"): + return + + if payment.extra.get("wh_status"): + # this webhook has already been sent + return + + hit = await get_hit(str(payment.extra.get("refund"))) + + if hit: + await create_refund(hit_id=hit.id, refund_amount=(payment.amount / 1000)) + await mark_webhook_sent(payment, 1) + + +async def mark_webhook_sent(payment: Payment, status: int) -> None: + + payment.extra["wh_status"] = status + + await core_db.execute( + """ + UPDATE apipayments SET extra = ? + WHERE hash = ? + """, + (json.dumps(payment.extra), payment.payment_hash), + ) diff --git a/templates/boltcards/_api_docs.html b/templates/boltcards/_api_docs.html new file mode 100644 index 0000000..ec5ed57 --- /dev/null +++ b/templates/boltcards/_api_docs.html @@ -0,0 +1,22 @@ + + diff --git a/templates/boltcards/index.html b/templates/boltcards/index.html new file mode 100644 index 0000000..2091718 --- /dev/null +++ b/templates/boltcards/index.html @@ -0,0 +1,474 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} + ++ ++ +Be your own card association
++ Manage your Bolt Cards self custodian way
+
+ + More details +
+++ +{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/views.py b/views.py new file mode 100644 index 0000000..273cfcb --- /dev/null +++ b/views.py @@ -0,0 +1,17 @@ +from fastapi import Depends, Request +from fastapi.templating import Jinja2Templates +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import boltcards_ext, boltcards_renderer + +templates = Jinja2Templates(directory="templates") + + +@boltcards_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return boltcards_renderer().TemplateResponse( + "boltcards/index.html", {"request": request, "user": user.dict()} + ) diff --git a/views_api.py b/views_api.py new file mode 100644 index 0000000..d242783 --- /dev/null +++ b/views_api.py @@ -0,0 +1,165 @@ +from http import HTTPStatus + +from fastapi import Depends, HTTPException, Query + +from lnbits.core.crud import get_user +from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key + +from . import boltcards_ext +from .crud import ( + create_card, + delete_card, + enable_disable_card, + get_card, + get_card_by_uid, + get_cards, + get_hits, + get_refunds, + update_card, +) +from .models import CreateCardData + + +@boltcards_ext.get("/api/v1/cards") +async def api_cards( + g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False) +): + wallet_ids = [g.wallet.id] + + if all_wallets: + user = await get_user(g.wallet.user) + wallet_ids = user.wallet_ids if user else [] + + return [card.dict() for card in await get_cards(wallet_ids)] + + +@boltcards_ext.post("/api/v1/cards", status_code=HTTPStatus.CREATED) +@boltcards_ext.put("/api/v1/cards/{card_id}", status_code=HTTPStatus.OK) +async def api_card_create_or_update( + data: CreateCardData, + card_id: str = Query(None), + wallet: WalletTypeInfo = Depends(require_admin_key), +): + try: + if len(bytes.fromhex(data.uid)) != 7: + raise HTTPException( + detail="Invalid bytes for card uid.", status_code=HTTPStatus.BAD_REQUEST + ) + + if len(bytes.fromhex(data.k0)) != 16: + raise HTTPException( + detail="Invalid bytes for k0.", status_code=HTTPStatus.BAD_REQUEST + ) + + if len(bytes.fromhex(data.k1)) != 16: + raise HTTPException( + detail="Invalid bytes for k1.", status_code=HTTPStatus.BAD_REQUEST + ) + + if len(bytes.fromhex(data.k2)) != 16: + raise HTTPException( + detail="Invalid bytes for k2.", status_code=HTTPStatus.BAD_REQUEST + ) + except: + raise HTTPException( + detail="Invalid byte data provided.", status_code=HTTPStatus.BAD_REQUEST + ) + if card_id: + card = await get_card(card_id) + if not card: + raise HTTPException( + detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND + ) + if card.wallet != wallet.wallet.id: + raise HTTPException( + detail="Not your card.", status_code=HTTPStatus.FORBIDDEN + ) + checkUid = await get_card_by_uid(data.uid) + if checkUid and checkUid.id != card_id: + raise HTTPException( + detail="UID already registered. Delete registered card and try again.", + status_code=HTTPStatus.BAD_REQUEST, + ) + card = await update_card(card_id, **data.dict()) + else: + checkUid = await get_card_by_uid(data.uid) + if checkUid: + raise HTTPException( + detail="UID already registered. Delete registered card and try again.", + status_code=HTTPStatus.BAD_REQUEST, + ) + card = await create_card(wallet_id=wallet.wallet.id, data=data) + assert card + return card.dict() + + +@boltcards_ext.get("/api/v1/cards/enable/{card_id}/{enable}", status_code=HTTPStatus.OK) +async def enable_card( + card_id, + enable, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + card = await get_card(card_id) + if not card: + raise HTTPException(detail="No card found.", status_code=HTTPStatus.NOT_FOUND) + if card.wallet != wallet.wallet.id: + raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN) + card = await enable_disable_card(enable=enable, id=card_id) + assert card + return card.dict() + + +@boltcards_ext.delete("/api/v1/cards/{card_id}") +async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admin_key)): + card = await get_card(card_id) + + if not card: + raise HTTPException( + detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND + ) + + if card.wallet != wallet.wallet.id: + raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN) + + await delete_card(card_id) + return "", HTTPStatus.NO_CONTENT + + +@boltcards_ext.get("/api/v1/hits") +async def api_hits( + g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False) +): + wallet_ids = [g.wallet.id] + + if all_wallets: + user = await get_user(g.wallet.user) + wallet_ids = user.wallet_ids if user else [] + + cards = await get_cards(wallet_ids) + cards_ids = [] + for card in cards: + cards_ids.append(card.id) + + return [hit.dict() for hit in await get_hits(cards_ids)] + + +@boltcards_ext.get("/api/v1/refunds") +async def api_refunds( + g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False) +): + wallet_ids = [g.wallet.id] + + if all_wallets: + user = await get_user(g.wallet.user) + wallet_ids = user.wallet_ids if user else [] + + cards = await get_cards(wallet_ids) + cards_ids = [] + for card in cards: + cards_ids.append(card.id) + hits = await get_hits(cards_ids) + hits_ids = [] + for hit in hits: + hits_ids.append(hit.id) + + return [refund.dict() for refund in await get_refunds(hits_ids)]+++ ++ +++++++++Cards
++++ +Add card +++Export to CSV ++ {% raw %} + + ++ + + ++ + {{ col.label }} + ++ + + + + + {% endraw %} ++ ++ +Card key credentials ++ {{ col.value }} + ++ +DISABLE +ENABLE + ++ ++ +Edit card ++ ++ +Deleting card will also delete all records ++ ++ +++++Hits
+++Export to CSV ++ {% raw %} + + ++ + + ++ {{ col.label }} + ++ + + {% endraw %} ++ {{ col.value }} + ++ ++ +++++Refunds
+++Export to CSV ++ {% raw %} + + ++ + + ++ {{ col.label }} + ++ + + {% endraw %} ++ {{ col.value }} + ++++ ++ ++ {{SITE_TITLE}} Bolt Cards extension +
++ ++ {% include "boltcards/_api_docs.html" %} ++ + ++ ++ ++ ++++++ +++ + +++ ++++ +Get from the card you'll use, using an NFC app ++++ +Tap card to scan UID ++ +++ ++ + ++ +Zero if you don't know. +Generate keys +++Update Card +Create Card + +Cancel ++ ++ {% raw %} + ++++ ++ + (QR for create the card in + Boltcard NFC Card Creator) +
++ ++ + (QR for wipe the card in + Boltcard NFC Card Creator) +
++++ + Name: {{ qrCodeDialog.data.name }}
+
+ UID: {{ qrCodeDialog.data.uid }}
+ External ID: {{ qrCodeDialog.data.external_id }}
+ Lock key (K0): {{ qrCodeDialog.data.k0 }}
+ Meta key (K1 & K3): {{ qrCodeDialog.data.k1 }}
+ File key (K2 & K4): {{ qrCodeDialog.data.k2 }}
++ Always backup all keys that you're trying to write on the card. Without + them you may not be able to change them in the future! +
++ +Click to copy, then paste to NFC Card Creator ++ +Click to copy, then paste to NFC Card Creator ++ + {% endraw %} +Backup the keys, or wipe the card first! +++Close +