9daM86TRQ)H
gjc3ha{RzEIdOuUuic}kTzm
zagb3_aZqt^P%&{ZG4XLQFmUiOad63T3Gj&VaY;YEkdu*7P}7j(P;-L8!9o2a4F`uo
zii3<#Mu3P*OGblFMnOh~$3R9yN`^^JhEGh4k57P0K!#62L5_?^L5jnShmS)>N==MI
zLHFJdpuvJkgC#yqgeUMxkN(2Tiwd_9Dn4=72({i;SF5*hhGvKu@?8Pjh0My`8hHWU9lIgjQK5
zlvw31scnjXE!&|r#e+Mx((+9wv=xGGnMxx~x$K+Yv#E`%g8^pJ(OXda(2@M|gky}t
zvQ{m6={AQLys|L2u&-&Z-)`iRnb^>35+A?i|Q;*{H03UJMdMo^7uq!
zZ=FTw@1}XhHcaa?H45J706ci=AI?W^FZ=3Mp^@9Wfq#4pFnW4)^8S?n!V&e^eeu4d
zp7jBJg-8z@-X@lRU5v$AN<3|6^k$c@*WU;k$O|GM^
zJx|@UJ!S;NPM6;mmAN>sj%g;Peq2$RSVo}Vp7GR$x)SxDsRBU3|2bpSRo0jHxL&kV
zG(-@xW%B(>oaf^_Ma>#*GD^i_MX*HHN{qvgP|9o3A+2@vAp?eCp}_%FVXG{sywSb%HF4IN+x=*J$cyJ80-}<4OnZ2q^LjeliKLtC(r0UCXfICgsxS&aPH;Rr0UtfrU8~Y7YH@Pf7+TZ
z81uL&2fqu-X(;TQ$YPED@x*yc6L#{k!^_s7o+h`#9rw+Pa>AR~pR8Xs%rz`%Q#1Rp
za%^?w$J$oQ%n<0`Go#~e%nFbud2y7OHj|@I;Cct78s>fIIU<>fJPyPB1I)Nfs@5
zNAnnPD#(j54#hZsdW;ieI2TS&KlZ`r{9`5TORI;Ql^*DS#Qymt(}Ahj@pcY~{j2HG
z`u*xxJkN*OpnGB&r?gtja=qUF%Zu;gbhdUlL8MANcYpT|j4yxO-hi`%Hi#p^qCKRH
zUF82TgNe5JT#9J@Rv6+gm7sG81ORw7r(u&*0>RE*ng7hd|8TQDF=@_UI@mCuSu|#w
zE5^WnkRgr}_@U`bo8luoTC^koog4o!W76HsL+>5GnnrJSW}2}3;l#V`jmS;a2I*5Jj
zFTZL@E4yvRJ36-d7B@=KT0Q&RT$d)6W@GH+k3Y?z$K+z+{-YZJRD<|$)0%m`-72Y;
zePrL_HUhh-q4!RpXlh?FEBUhUrsHy#A7ni!a51C~Lo4mOcppnkZLXh@#
z$sQVBZr~EL?M7yGZ++!r-1|a3#cvY2nm$)Xbu$@q{^vQ0>NSY06YBnZ>L|9?)mCAX
zJ2ubgZh56D*EiUA>MQh*6dTLF^C-oVV(GfeNyF;~cGfc8y*CadKDtZZ9_E>C3qhT`
z>fO<)?w2_E=QFTT`=;{YZykTsN~5fSx>Zs&Gz>YN#M1A+gc
z|Ii*2)j{>Y=
z%KOre&(H>b_S%1R8=R2!O?ht`8gI*n)7|WR2?kz4+e=mV^u<$l_gV-2b;4*zXfe%4
zCBMhmvf{KPo6rlDm94Y1-~~*8DEUb-DaMF&&uwZP|d};7x%4Qf7^n24QcgoqfGZ+
z+$V3gvHIXiuX1T$83`M!cObI&K{V3>BYPBH>Y_04KC_>D6vGP9%5)`5*PZsKoIHCI
zi7L>GzcD_WQ$J~-c8qV`IJ^)*YW#6vX6|rgHu#%#M>|+7dU&SoGjiTFD(eVqa~9XL
zliXS47^(bra(s^3x>~iBr=4^G%JjTz^}0S8l&et&KZtZ#Cb-#t3%cFmqM5
z`u*sFUJxWHU~1>kIjmNRECG_zV%{#uor-%`P0EAAwe(d
z-jx_Z(B@gCv9`I!H+w#`AL!+rAIl#(uUL;=-a517fUj{`x29VWv)Cnk>!7RExc)kJ
zPPd*Cmr|+8D6T9fB5zld`o(68PtlGtI^9OPV_2i1!MR-ZuNge!UE7&HEe1h_AemLi
z>aXiH6zb)2gU8R0>Z$eiEweDkR}`R~@@mg=3UhC7>{aac)i+TT}nCr$LnKjXN@)Z;R3
zxWo|J5jK?zZm#Tv%()a3Qa0Zv(tSt7I8x>_s%Z|3*oPfbWW@{qR8n!7l71rbC(59w^m7T4W;9FXZ#=er|gV}7M^T7gCJvL
zBn0Q62Bm4e$?G24hRqC&b*+}FvnJ>yZk2m0ktoI^z9D*NUTnh?Hh*!b=gRubqa1X#
z861<
zHKQ9QH*%J2i=4j(R>AmTlCb6<$l}OdU7m-_0h=bUD>wSL^ESlw_PkmSA9fL*Brp|`
zh6uVzN?`{4c_toBz`K=su-jC_33bO8?+aS+kk`beR_@O`i$=i`W#yr{@ZGgbx#FbQ
zk}#6OcFZbwGZB^ytl{tU=PHXP2)|uydF!9}7P3qR;W@nAleN#MXhBY*WD2ww+)7Q#
zi^QG06YF09%HGjQx;afk%$TDZIBC{Onr^-$k@EL(w)owM;nnQ3#OU^khec1b~BH%B}~|C6(T(s3{{bBO?JG|Kb1OcH*q4XM3MRfH$FhOc(set
zzsRxIp2UUK(z5s?k6~-&j`@}9EADARm8rG9X^#y+oPGol`8;?-Kiz?po7NJrAf{ou=1)144%sy_V_epBV*K#RCf*QREfn@AT3^WW
zc_&V)K1^)VN!1=*%3uxiC(^h?a)hJm+~S|jK@CBeY0K2tIjaOgt_b4YJL-2J=Gk?|
zE1F8-G*`YE{`yvyl=djj0Ab+3o9|-*tO=#e9VR`d$yqM8AdR4QHG(E3W0IXx(v^|8m+n>DTT#P33Br4GYk
zVU{@5lW4D>+Cb9XKkOvhl&v1df}Bp}1o&>BIZ!m1lb~{N`bmUox3*Jgl!GH#vKy#16_ao?>YJb?uy_JSg$cD$j
z{8)F(UDq*uZ?57vOCVc7J+&6rK0xObji!&DDvvaYo`0cJfAl75SU1+5<7Uu}e0=JV
za`W)>vV){wC`o<&&Y$gNw~;=LA$}Lh2TFcRE^lAjdsw4Kp}HHt
zuU~WC0l#L5i)flh7t5u>P1aSUoM>HSLuk*|#if?N1_b`Ejq$i$2^Wfw($+`xZC)e0
z2b_pCxt4{Cmo|f!mS@}?!U+NvFPes}9Bwa8N;ZxYyFx8F7*(0o9jZ1)ukCL1nQ4z6
z8#Z@}t&AJVM1J0VMBdP!JJzj|HjE}Q=~35uU>DzFDcIR19Os6{=mcAdJmYGGeq#JA
zA&hsCcENBF@a&jL6((T2r);uVW6E3caEycZr_Uf+j^133*8h0VE;7;n>l$u-M!-
zHi%FeZobFUiHT@d%LElxyJlrPuUa{-z4$ZV&_+M1Y%CL~5(qW%P7i+`U~UfxDFc7uf5m}h=_3eL{W2$ceYVfbChChS`yT_n$FyztC7}Ar(@0WDUS+-T|VbzkVM*g0o=Z
z*qczWoittlo_2_nKjIRr{9@CvVau#Sju}YT#UrKWDs@=qH-ajk(>^EJ#i;ro{wgEv
zQMqk#w%6R88JK3LqUfCAw2Sqn*bT#srgL;>lUu*?Gor?c97)*RRSW-J$b5^yEPn6uIX$tVF8(h0qMAn3tgHE#f#C;|at)$K(^z&>
zQft>H>9a;$a51P(
z?Z>NZ`Dt)00P}PHn{w}tW0Hxf8#NpcJV}oDl<<;xGBs;&T(~oDo=BYRw4tEIxbp1+
zZGRX!?v5*Kyt&||%VHv|0rQ73!l)my1ULjD90bINN&kix9~moFQjtUmRCE@QvawT8
zLH#TV8Jnm|Qs3^CQt-7&VFRS9Gda7MQUBb3;|w@q@S$%u`d^$h!2{P9j>4ha2^%DN
zWdqgDK}8C-Ucq08m{TD4UHK@F4lqEg7*^5rMh4my$VUF&bgprg5fxA~G(x^y%sNH>QB=PwP=)aJgS5^?X?jexVtUrV!(;bxoMn0n*#0`lo9)Ak;6NQvCy)z1-3Se{
z&>X{2FO>hoR^|}iSesN-D%MztniGDnS&DaLm>XgwS4c|u`CPtVv7ZRX8*9-cbk^MG
z2zDnHqgitO7;S4eGC@smoitBslvG|G%UX4V)0B#NrFDCv(aw0f?2eXI3a;)#F4j88
z>L6Cg)YIg<0oB)J>RTD&y}>p9CPdpo?cW?{CzY^
zr+D@6fZH!Q6J6|7a+)i;?zZyOg-WxB33gy|^R?npJxFF&Wmb3*S{Iha{!u@L
zk~@ygTEr#GyJ2t8riA~tkH{sV5PMhALy&I6DgQX^Zf}r1R+D^dAcawmPd_xO<|9O?
zgJpVjuPe#T_M87{hPyUlq|`v5=+ox1y&KPJ+Z&b|GJ(1_isgadl~mc#J21eb#9cE1
zgg#s=SIzx}1Lh)adD*(01Ozw`I5v<@5o6zf>(sxvEVQNFrs&F+SiTP>pRpk~6a
zku&`@BkQuT?bDmVrNd1?i;Xr$JTUX`b|xmo+Ap0-_$rK>L9M~Z4G)5N%M)(fay*^uNwAYFtM+~O}mb&?Mb{QO-|ZVpVw)WH!uyK<1sChJv2B-OC3^$Mo%|gzl;jBPToLA?
ztFe#$%0(ysO5hIKR=EgJI!jhZ2LFa+8eL
zvyF$iV+ZKf^oGaOdXkboUrfyxxg4M2R&`GG20x3mH=gO2+Rs%Czp-71`l^$M|J_)w
z6J>JEEcYpKMxHd*)*9!#oTYd|xd>&^?b^as*Oj?)Ubb3_Y-Fy!vUA>A0U6@Hzn-q>ZDHfe;M5g9R{o1BhGGpBzwUT=h
z5cLRm!MDRp#kK9`65Rewvr=Rd886>!r`V9{!lcD5=zn^CY-!sKZiKI>cO!LFZdiPT
zP_YesWe}>V5`%MvE;o%HGx2x9I33?18so$}Dp~!Q7if
zF4jFSU&k$x(udN+uR$eeov0CEVIa;@pA8;-mCqeJdGuY%Ahegad@&v5x!OgJrDmxP
zDBj*Q!F-CPrivptNs1#-3Xxtwjoh|&2b-VND|*h7g2$R^6bto;lfEFEqKRkg^PCe!
zBzEztj)O$swu4tlEc-i=yUo_Y9YFE04tew^_{}ua;gVoM;AbYp#FjOY)1=X#V8+{z
zk>rdc!v!fZOV}o-W__rN6Ra8f{AK~2Y(^(Td~n}+|m5Aa?$2+c&McU
zsq@BiafNkjZRG8QyH?Sx~8-FiLFmc-6|*eTbCnqo!@bX+M5vnCKH
zqOYiedC#>UFhchMBM{(_Q2&dHU;r8^tH=j_Bz^!$-xVqgXmY4Um!5BgE8;XXq$ViD{D_9FO6Xuk;`)m@1ZJo^TP~V$
zVI7;q#J#E<3nWkFr0~;@y7k&(4koqF5o?sp8%J2M`vAm9@~GxMjNK6JJ8-x-gwH9{
z1A8kiHsINT8iNLg9+6*Y6w0Vt_ss{Ypo>&a1wnYh>Ep^PVWLcYTgYGcwRRi{4+oTB
z!MOi6V9O3l`Ktb0oTixfLCqy=O6Qq(MIEP~p3%F73#^}sVU|h`-J{tjs(1#%OeQlK
zMp3mS52Eb-)D%LD+xlW~W|O)21g^}u?6U#gK|;07Qw|y%Y7sXU#^}tV;LlO*i5e1+
zU=3(+hu|k7usF_?lU4PXdAQ~8KqFPJgknlx`Oc+(^hE+CcwZW|ic$=lWbEG3+dPRI
zOyZ!X-(CYtPovLxLKx4$8qGF$QyG0CDIhAqHwq(eS@gJ;%%rSB2Q_$qRlEG@vmdy(7
z1VY%UWG6tnt{t+@>6t!dy+y;8DXa;p({PJg-Ybs=8b=FaulP?sh;5RB6&4^4Lp;Q=
ziz(#Clfs>tG~Zx|BxX18im1*cixWCzT6zb&;Ehh=EMciYs35|Lq@9b(U&0VxDxhd2fmlO*&8r3@;I&ttjHs+M
z+ScEN`DrKcv0pYQQtK>#o~*f#sL$Nm8f+&5_u$0QC>pllJ`j^T4b%XJ9E-?}X_*bK
zeOMymOS{W4~
z52X4NIWDVCCy9RIuP_`1B;5GSSu>CV_kze#XyqUdfkg{#Lkl;djDw}93;^4{%Vemr
zfW3e!i($MSD7Fh92>w?&X5*7+9x)*9h)a?wx(H6n=969kQZk}R8qH1;=*Y;vU>c5Y
zZ{Q+2ap`9h0{|mlp9DQ*9PJt8Vu-@EAg!gottR8T_;52v@Zw(az5-P7%tf?!0D`99
zQT4H~St-L9v%@8!k2BmDIvo|Q4=+98n0rYE=xeEMvKG^x!*#Tg^f2vH@t*`tB@
z1)nf>S6ckXly{&425tS^7q|9fMUb{iB7zV=_#xvqbauss&fKUtegx^ZL2s`USAZ$
zsw0Jj<5g}be4VBRK^LNr6Am1sCZ=FC3{ngkKCmvZn|{=E;Zt?*i@hy?Oj`wqT`Y))
zH8JeGpWb(s;Z0MjHW3Be@B@v23Dx*h0SJpGP%fN7v@qi;^td1zA3&_sRCUu}RDQ4vhF|Lnsrgp7hQKfyx%qZ7e{rRgvE$sCq!5&D|+}s}XV*TwqQ={92Tsxi|G7j%dDLX(m2YlpNUK!II`5o--l!m1{4NKOMOv(<+xG
zJ*3IPJwiOF!EF~V>Px&-V1u`b#(Aa-7tcnDn5r^>7DF`QYC^sf%wvuwvY7hvnG;4`
zgb1JBr~VnFW{tA?85<`D?tJh0F5SoETVAo7aVE53ncQG5qHOJ0?%b^ELR3bc$CDW!
zggG|a>rS*=RaDQ_OkeP|Tks@`QBCmlE
zy*{j!iwliD6Ba5FPt^wTj=%3t2FwQCJ9yF{FXuy=8BR?-cF*fbHc_ipttp%p!QKJP
zZ;M7DDT+&SH){-=L+~kOfMdK_MG^KU1H;=p5DjBCy&<>ff?aNr6M{@b#$Yi8fbr+%
zZulR-EMUl8In_ReB84V{8c!m_rlf;V!QqL~C!&o|A67BR4YIOG0J3*rrvosisA}9C
zjhH?S+0k-P!QnMRgSVZqUx{cV^H+d7-+bXJePZ1XlgJm(b%Q!ciot3nawsGj*?_@L
zn~pAOb6PmNjD%!*CL0NBB^wBsd+yA7y9)%_5V7LGB{o(=ti+os}MHU=l>s025|z5=|h2l!mYi
z<|#AwGKexNUP;As1)X#f;`kk)DAjub=)PtDNSFFHyZfNLr#sLqu$i)?k-)XTrCiJ^
z6xZ~`YR&7&5ojCOc2d%t?@>RxjN}iu2+_cR8XwMK)P+7cH!={!7Gssf<#d**mD;3$
zas&%f1m`)e&$gVv;5F@-FL==|a{l&H99k(zI?{zd#a~Z%4R4bqg+)4qeMB4}nz?fa
zZ(-8s0j<9lLW(Dd1xxpZ1{6HxTK-*BqCi1S_tou4Wu1Cp48eVbl*!{Zf>^}qAso2_
z=^%b}7y0sM+q+OYnFdNi3b(s}KS62sJ2PG=+V--rbk+j)6D1_x3UCVTIw46U67HG8
zVw*Zi)HaB^8y1VNz6eAGHhc=E}f5XmqX@yfaYiM2vFKxmzj3wL*I%L)E`iG4D^B
zdTJLjUX|uMMb_<7N|G|zyC#o9S@LZzD3K`*`w_3JeW|1lQsRQeA9x3V7jSS&Gs?~;
zRIyWnLbco35Hk=M5=SOUj`A#DOT`z${02@@xH8Xvmfew*Aw76n513~<7e_KVJ1l>o
z7u+S%*{9U9;&V_UBE*?^%I!{W1X%o4HR@GE4-+yL?iQMV*jWCNEG%-K?HMQ
zX=ks*sboV%JA@hR22@A_7&5279K7df=8sfcBBMOyBoY32$-G|mtv!@ed_H9F3Ux`u
zFDN0TCDN|#{%&dV%f>
z#5yPso55l5<7UE!a5K(>n3xf9ENPBHsRb!4LpAkm%)e!aAowJ@tWIl-iFn?Q%
ze9h%gsOq%s60pKYoJe3fk)VVg5XZ=j#l}~;GT6*UlLOLK`cSTf|BcQ*s>nWamJpEt
zEg$%(MEh@a7L-`<5t`NaktogXUS0ox(HZJXEVau9^-*DqfBZO(fkV8bf=3DI>k@Xb
ztLog!qRu*6M3NnCxGVc510(c1fM?;l<8j4n8ns!RL>0{ZVmw{=%FTSyl!KJhe!fb;
zl=eJ-W73#Qa~u1z`-`5EsUl~wwH+R%3I(jW`7W+nN=_A|E89|2+XyZnmTFhx`ZMW5
zSxKWJ9a#n+&$DGNRnSgks0z=xLe`bUlGbnRdV4H1)=!JNTA75(cG#2qNfVjold^@&
zI}rGt>X2QaDYSQaNZ1z2n7vNKJTQEAui4=98M*)Jq95)HBYEbLYnYn;%*FVYB6B`Z
zO}Sp$E*tl!PBEN{W)JKkHM~wOMROa_qOOyuB%N2eC<4=rS-m8r79!L!lTiZ_qtRkJ
zj=Stcmpc?K_=rmHaf1n?ENdhD3_qxsk7g%2W~H+1Km%k1S#4AK#Sy+;#(DOeX?`C|
znAxAYVPwtyG^nud)pvl8p$**KKjg%4Rpt*DOZW
zuouFB*o4-+-e6&%+EkSo%bm=f3mHq3)mqlv-vcB5cODkI0vBlp=njJhuQn#L6>F-CszecI%s%H$ldZ+Bv|@==dWAS#>*HdBCEBgqYSj-RkWNYV{9jUuiRy0
z^%z1sxv7FZ8})P#Jr}79J^?BV%)fPdVAN+6wQ17%Yi=VteRe1`5nwN{o0%C$%6~!3
zAZu1m8v!^d4LwG?$B`Z+$Z*7K{Ln`{u3ts>Cwb-s)Y!;CG~{)RMFjS!3J@!0F^JP9
z`mW>~nBmMMMn=nlf;m^Ox-TWU5z78VTZR!_1QN*ZuCnq%tHd=z@O-)EgCSYUaC4i&sFDs^XU3`FU-1Z
zDlc6g@n=(y(9T(mavZ%~YoxO$gh)A7Xpwm+&JdWe*_sCp*zBm#P}fpc@d`}NRjs`w
z;QGx2Dst1oGa5D4zM~9lhsZO;SPrfZ8oF5q>vx%Q^W+>aTVSWbgLeLxMh_aJpjI^S
zf()D-)^1!jH#DGz8JcbX?y;z9EDC~DhZ$@tQUdWE_*4ICTAqBmHq5mHdxCTZUt!DM
zw~6h{o(Z@34m|opLl63`ld9<_nwQ_kmAmjl<$)C!@JgvLHb$%SOMdkPGZnwuV;N>$
zM*AA3RJr+#4H^<8Xbbn=l|Uk_YXU)r<%DN{s`>@OgVf2N{&qdc7z>Q7g^#B!R&gaQ
zWhl=&GO9f{ddowR>>uvXR9Ml*@2z>zT3k+>o5A4g5LGp+;81$~)Cr#GysyEM09^+i
z?NE^K7lm631#l?!PxkVxyZBFI>ZF&5VEOPUqQrhoO5vYI^TwjVP(vc~8QtvHtu_n|
zr2W+i`?-TR-Icl4=6pG(&4wVDv%*(O%>!uGq5I^589eDb@#yPy6Cq9UMEuoakP6M6
zbqf)JDkxvXP#y2Acfd)f6IMrgj&kWMJgL!Vn#wA$1gJYmum!xk#rmjy6~{Xz#_JUJ
zJikqpfGkITXy9Y2awtD&kWdr5S*
z_MkUQX#m04JE#Q~N?H@dbbo{?yE#rUofr@aQ9X=mjy(2ko4VUzooA=gOoRWp3Pv4CjCiQ51xr!lPT^jLD!+vteb?;4Llyj*)VXci0Y2tvw@cOONx+TgEGad}
z&^fdcv>WUt?qZo$=dLu-^+V)IL~@Ak(U(~I$s++O5oEQvR5m8zS4+lQcVO|pWnsF`^
z->S%&UZG}T?Zozo^zQeDiz2;3qwn9dNfdu%)vMZDNRV8owXiF@mn`
zNzFIqZE0*74WUAr?=xTD9AqmmYFqPNyRZe#Vr=hLUpR;hDyPp-dX-}x7G&8KQciak%>SdRg^3*
zjKH9p80uslmMN?)qULJjrlKFZ-*ezrp3eq%%SI8EMaugFs<_?l!hOgH`J)C4h`vsT0N$pvU
z&;7Hw{l!~1(RrLGE}Je*-ZKW$bie4=srNu$(~xG~}!C54up
z!%&R*{4ZxBDzSsdOmM|(#VhP0%FwfXVY^rRaYw@0Nr_4A&%dj}Q5$
zm@j|c(x31}oLC&~UQ~|xlSw)q2K>Z9v3fP-!aIhI@E-9Z?;U`V?%0uoJVQ;yB)n01
zC~0pCQAUeQg}fqjmNO^1>mvA4v|AHYx+`>g3T)%Ro)GHO8XML1
zdPWRF&-PO~ZHAC$p_Y2fU30%Zxw3mRf%yR1kCx-%2hDxGD_2JSLgJNmZkLwyV=
zc=c`^nXgwynS9k_Li;FKL~DiT+bZ*Kr%^AQlE3a)iJ{t61VAirqsq12GxLY3-Od+A
ztA(pKgSE`Yjm~ZMNNu&j4N|Km!KnPbaQ&sVClL?kbm2q@71I#XzpmJIF91udK$m1^QCQ;W)0
zFhjQ>FzxZuh&sw*Kp$&3Wn$=#dsh$6;-JnMd|?KSep8~(-yfRk^BHQbn33;@Wq7((
zZ~B*jilFF5tsZpP<3PlF}`b4ZkEc%x9l8#54=kxcIqE31{{PH%*-|rms
zW10Ip7OENAPPCo2*pfybc0@GECwtp`;BB+N6O@xpfijEUxVHMafv
z0qZD%g|EyrKF1Ivww{)9ulk^|AxFwna_q75&&qcofbG?H@=O)dXgUt5Ci72b`c=DR
zF1gZUeQq+3@JEGsHoNlJ@;V*u83N{AJ+7O>Y@B8vDXI=pA=Fq%fNr^&2zp6}|9xod
z>*BeQ72BkLgI*2JMJvs_IU#JUxjn-ImLju1i^&p3!HDtLd%$w(Jjc49qr~x
zBL?xX4?RAqYYo9Yuu^|mYy3nlTH2Vx082?7AMXJPZ8xP2BeHLEAvL@CVuq(85|WJA
zF$us>J#^w#yfohG#dzVG+;*o(?tqx-c;JNaK_pO2(=TOQ8<{%nHYH+56r|iA3@V(i8wqOt*67^b4Jc_NAB;UGiJdD@3~vw0^lG#GAvPWae<
zrw#qB$2`J#7as<&e#?|O#gpjP@aSi#UqwditTpz?k1b1TJkmPzH3(%q$VAWS
zk)yN|bTEPo?ghagh<*ppw3ntYTKw4~A56yIWNhVX+`!XF(~TZ0cUiv1^yIid8=`i(
zKpZMp=HbK77pvw`@~wg3J73ve<J9gi$iIia_Nz0`>5ArP;c$wUKU~1Y`G2Gb!i;9Ad(LF5=TQ;7n19o$
zNs>F69nh*ZS&Bc@=dyaW`^$BQa|OtdTglUV2$Ac*GS-olY)d?TRXCdEwH+u9Q|a`g_3WY(OP^v~7q
zg599Qx%_DM0b-NLcHb7$#|BH}lm$hNQm|-Joxh7>oK%EX{#B2?^$^K>f-7VOC%P6#
zG3KS*FS|8hbG$pZ@?pRb52%oNX{OUWe2W~)Tlpvdg#S@7_kU^;|E)4pa&!V2{hMzB
z2ZIv^I$DCK={gALP(x`@{+0MBUHsVN^1lrXAaMjKIsGdE@sGr&ft|S*q>+iZl-K*}
F{{vMZFWUeB
literal 0
HcmV?d00001
diff --git a/xxxthegame/build.gradle.kts b/xxxthegame/build.gradle.kts
index df07ce0..c40335c 100644
--- a/xxxthegame/build.gradle.kts
+++ b/xxxthegame/build.gradle.kts
@@ -27,6 +27,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-mail")
+ implementation("commons-codec:commons-codec:1.16.0")
runtimeOnly("com.mysql:mysql-connector-j")
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/admin/AdminController.java b/xxxthegame/src/main/java/de/oaa/xxx/admin/AdminController.java
index 0f59cea..b68ecb8 100644
--- a/xxxthegame/src/main/java/de/oaa/xxx/admin/AdminController.java
+++ b/xxxthegame/src/main/java/de/oaa/xxx/admin/AdminController.java
@@ -1,5 +1,25 @@
package de.oaa.xxx.admin;
+import java.security.Principal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Sort;
+import org.springframework.http.ResponseEntity;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
import de.oaa.xxx.aufgaben.AufgabenGruppe;
import de.oaa.xxx.aufgaben.Toy;
import de.oaa.xxx.aufgaben.entity.AufgabenGruppeEntity;
@@ -11,21 +31,16 @@ import de.oaa.xxx.aufgaben.repository.GruppenAboRepository;
import de.oaa.xxx.aufgaben.repository.SperreRepository;
import de.oaa.xxx.aufgaben.repository.StrafeRepository;
import de.oaa.xxx.aufgaben.repository.ToyRepository;
+import de.oaa.xxx.games.chastity.ttlock.TTLockConfigEntity;
+import de.oaa.xxx.games.chastity.ttlock.TTLockConfigRepository;
import de.oaa.xxx.meldung.MeldungEntity;
import de.oaa.xxx.meldung.MeldungRepository;
import de.oaa.xxx.meldung.MeldungStatus;
+import de.oaa.xxx.subscription.SubscriptionType;
+import de.oaa.xxx.subscription.UserSubscriptionEntity;
+import de.oaa.xxx.subscription.UserSubscriptionRepository;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
-import org.springframework.data.domain.PageRequest;
-import org.springframework.data.domain.Sort;
-import org.springframework.http.ResponseEntity;
-import org.springframework.transaction.annotation.Transactional;
-import org.springframework.web.bind.annotation.*;
-
-import java.security.Principal;
-import java.time.LocalDateTime;
-import java.util.List;
-import java.util.UUID;
@RestController
@RequestMapping("/admin")
@@ -42,6 +57,8 @@ public class AdminController {
private final FinisherRepository finisherRepository;
private final GruppenAboRepository gruppenAboRepository;
private final ToyRepository toyRepository;
+ private final TTLockConfigRepository ttLockConfigRepository;
+ private final UserSubscriptionRepository userSubscriptionRepository;
public AdminController(AdminRepository adminRepository, UserRepository userRepository,
MeldungRepository meldungRepository,
@@ -51,7 +68,9 @@ public class AdminController {
SperreRepository sperreRepository,
FinisherRepository finisherRepository,
GruppenAboRepository gruppenAboRepository,
- ToyRepository toyRepository) {
+ ToyRepository toyRepository,
+ TTLockConfigRepository ttLockConfigRepository,
+ UserSubscriptionRepository userSubscriptionRepository) {
this.adminRepository = adminRepository;
this.userRepository = userRepository;
this.meldungRepository = meldungRepository;
@@ -62,12 +81,18 @@ public class AdminController {
this.finisherRepository = finisherRepository;
this.gruppenAboRepository = gruppenAboRepository;
this.toyRepository = toyRepository;
+ this.ttLockConfigRepository = ttLockConfigRepository;
+ this.userSubscriptionRepository = userSubscriptionRepository;
}
// ── DTOs ─────────────────────────────────────────────────────────────────
record AdminDto(UUID adminId, UUID userId, String userName, AdminRolle rolle, LocalDateTime createdAt) {}
+ record TtlockConfigDto(String clientId, String clientSecret, String baseUrl) {}
+
+ record TtlockConfigRequest(String clientId, String clientSecret, String baseUrl) {}
+
record MeldungDto(UUID meldungId, UUID melderId, String melderName,
de.oaa.xxx.meldung.MeldungZielTyp zielTyp, UUID zielId,
String grund, LocalDateTime gemeldetAt,
@@ -79,6 +104,11 @@ public class AdminController {
record UserSearchDto(UUID userId, String name) {}
+ record GiftSubscriptionRequest(UUID userId) {}
+
+ record SubscriptionStatusDto(UUID userId, String userName, String subscriptionType,
+ LocalDate subscribedAt, LocalDate validUntil) {}
+
// ── Hilfsmethoden ────────────────────────────────────────────────────────
private AdminEntity requireAdmin(Principal principal) {
@@ -274,6 +304,18 @@ public class AdminController {
.toList());
}
+ @GetMapping("/users/search/all")
+ public ResponseEntity> searchAllUsers(
+ @RequestParam String q, Principal principal) {
+ requireSuperAdmin(principal);
+ if (q == null || q.isBlank()) return ResponseEntity.ok(List.of());
+ List users = userRepository.findByNameContainingIgnoreCase(q.trim());
+ return ResponseEntity.ok(users.stream()
+ .limit(20)
+ .map(u -> new UserSearchDto(u.getUserId(), u.getName()))
+ .toList());
+ }
+
// ── Admin-Verwaltung (nur SUPERADMIN) ────────────────────────────────────
@GetMapping("/admins")
@@ -309,4 +351,91 @@ public class AdminController {
adminRepository.delete(entity);
return ResponseEntity.noContent().build();
}
+
+ // ── Abonnement verschenken (nur SUPERADMIN) ──────────────────────────────
+
+ @GetMapping("/subscriptions")
+ public ResponseEntity> getAllSubscriptions(Principal principal) {
+ requireSuperAdmin(principal);
+ var activeSubscriptions = userSubscriptionRepository
+ .findByValidUntilGreaterThanEqualOrderByValidUntilDesc(LocalDate.now());
+ return ResponseEntity.ok(activeSubscriptions.stream().map(sub -> {
+ String name = userRepository.findById(sub.getUserId()).map(UserEntity::getName).orElse("?");
+ return new SubscriptionStatusDto(sub.getUserId(), name,
+ sub.getSubscriptionType().name(), sub.getSubscribedAt(), sub.getValidUntil());
+ }).toList());
+ }
+
+ @GetMapping("/subscriptions/user/{userId}")
+ public ResponseEntity getSubscriptionStatus(
+ @PathVariable UUID userId, Principal principal) {
+ requireSuperAdmin(principal);
+ UserEntity user = userRepository.findById(userId).orElse(null);
+ if (user == null) return ResponseEntity.notFound().build();
+ var sub = userSubscriptionRepository
+ .findTopByUserIdAndValidUntilGreaterThanEqualOrderByValidUntilDesc(userId, LocalDate.now())
+ .orElse(null);
+ return ResponseEntity.ok(new SubscriptionStatusDto(
+ userId, user.getName(),
+ sub != null ? sub.getSubscriptionType().name() : "STANDARD",
+ sub != null ? sub.getSubscribedAt() : null,
+ sub != null ? sub.getValidUntil() : null
+ ));
+ }
+
+ @PostMapping("/subscriptions/gift")
+ public ResponseEntity giftSubscription(
+ @RequestBody GiftSubscriptionRequest request, Principal principal) {
+ requireSuperAdmin(principal);
+ UserEntity user = userRepository.findById(request.userId()).orElse(null);
+ if (user == null) return ResponseEntity.notFound().build();
+
+ LocalDate today = LocalDate.now();
+ var existing = userSubscriptionRepository
+ .findTopByUserIdAndValidUntilGreaterThanEqualOrderByValidUntilDesc(request.userId(), today)
+ .orElse(null);
+
+ UserSubscriptionEntity sub = new UserSubscriptionEntity();
+ sub.setUserId(request.userId());
+ sub.setSubscriptionType(SubscriptionType.PREMIUM);
+ sub.setSubscribedAt(today);
+ // Hat der User bereits ein aktives Abo: Laufzeit um 1 Monat verlängern
+ sub.setValidUntil(existing != null
+ ? existing.getValidUntil().plusMonths(1)
+ : today.plusMonths(1));
+ sub.setCancellableFrom(null); // Geschenk, kein Vertrag
+ userSubscriptionRepository.save(sub);
+
+ return ResponseEntity.ok(new SubscriptionStatusDto(
+ request.userId(), user.getName(),
+ sub.getSubscriptionType().name(),
+ sub.getSubscribedAt(), sub.getValidUntil()
+ ));
+ }
+
+ // ── TTLock-Konfiguration (nur SUPERADMIN) ─────────────────────────────────
+
+ @GetMapping("/ttlock")
+ public ResponseEntity getTtlockConfig(Principal principal) {
+ requireSuperAdmin(principal);
+ TTLockConfigEntity cfg = ttLockConfigRepository.findById(1L)
+ .orElse(new TTLockConfigEntity());
+ return ResponseEntity.ok(new TtlockConfigDto(
+ cfg.getClientId(),
+ cfg.getClientSecret(),
+ cfg.getBaseUrl()
+ ));
+ }
+
+ @PutMapping("/ttlock")
+ public ResponseEntity saveTtlockConfig(@RequestBody TtlockConfigRequest body, Principal principal) {
+ requireSuperAdmin(principal);
+ TTLockConfigEntity cfg = ttLockConfigRepository.findById(1L)
+ .orElseGet(TTLockConfigEntity::new);
+ cfg.setClientId(body.clientId());
+ cfg.setClientSecret(body.clientSecret());
+ cfg.setBaseUrl(body.baseUrl());
+ ttLockConfigRepository.save(cfg);
+ return ResponseEntity.noContent().build();
+ }
}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/config/SecurityConfig.java b/xxxthegame/src/main/java/de/oaa/xxx/config/SecurityConfig.java
index 6a2c5d1..a3508ac 100644
--- a/xxxthegame/src/main/java/de/oaa/xxx/config/SecurityConfig.java
+++ b/xxxthegame/src/main/java/de/oaa/xxx/config/SecurityConfig.java
@@ -35,6 +35,7 @@ public class SecurityConfig {
.dispatcherTypeMatchers(DispatcherType.ASYNC, DispatcherType.ERROR).permitAll()
.requestMatchers("/").permitAll()
.requestMatchers("/error").permitAll()
+ .requestMatchers("/api").permitAll()
.requestMatchers("/userhome.html").authenticated()
.requestMatchers("/toys.html").authenticated()
.requestMatchers("/aufgaben.html").authenticated()
@@ -86,6 +87,7 @@ public class SecurityConfig {
.requestMatchers("/*.svg").permitAll()
.requestMatchers("/*.webp").permitAll()
.requestMatchers(HttpMethod.GET, "/login").permitAll()
+ .requestMatchers(HttpMethod.GET, "/ttlock").permitAll()
.requestMatchers(HttpMethod.POST, "/login").permitAll()
.requestMatchers(HttpMethod.GET, "/login/publickey").permitAll()
.requestMatchers(HttpMethod.GET, "/login/logout").permitAll()
@@ -99,6 +101,8 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/email-change/**").permitAll()
.requestMatchers(HttpMethod.GET, "/keyholder/invitation/**").permitAll()
.requestMatchers(HttpMethod.POST, "/filler").permitAll()
+ .requestMatchers(HttpMethod.POST, "/api/ttlock/callback").permitAll()
+ .requestMatchers(HttpMethod.GET, "/api/ttlock/callback").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockController.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockController.java
index d8f8995..82594cf 100644
--- a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockController.java
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockController.java
@@ -46,6 +46,8 @@ import de.oaa.xxx.games.chastity.keyholder.KeyholderInvitationEntity;
import de.oaa.xxx.games.chastity.keyholder.KeyholderInvitationRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderNotificationRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderTaskChoiceRepository;
+import de.oaa.xxx.games.chastity.lockcontroll.LockControlFactory;
+import de.oaa.xxx.games.chastity.lockcontroll.LockControllType;
import de.oaa.xxx.games.chastity.lockee.LockeeInvitationEntity;
import de.oaa.xxx.games.chastity.lockee.LockeeInvitationRepository;
import de.oaa.xxx.games.chastity.tasks.AssignedTaskEntity;
@@ -56,6 +58,7 @@ import de.oaa.xxx.games.chastity.unlock.TempOpeningReason;
import de.oaa.xxx.games.chastity.unlock.UnlockCodeHistoryRepository;
import de.oaa.xxx.games.chastity.unlock.UnlockCodeHistoryService;
import de.oaa.xxx.social.SystemMessageService;
+import de.oaa.xxx.subscription.SubscriptionLimitService;
import de.oaa.xxx.user.UserRepository;
@RestController
@@ -76,6 +79,7 @@ public class CardLockController {
private final UnlockCodeHistoryService unlockCodeHistoryService;
private final SystemMessageService systemMessageService;
private final CardLockServiceFactory cardLockServiceFactory;
+ private final SubscriptionLimitService subscriptionLimitService;
@Value("${app.base-url:http://localhost:8080}")
private String baseUrl;
@@ -90,10 +94,12 @@ public class CardLockController {
AssignedTaskRepository assignedTaskRepository,
KeyholderTaskChoiceRepository keyholderTaskChoiceRepository,
CommunityTaskVoteRepository communityTaskVoteRepository,
- UnlockCodeHistoryRepository unlockCodeHistoryRepository,
+ UnlockCodeHistoryRepository unlockCodeHistoryRepository,
UnlockCodeHistoryService unlockCodeHistoryService,
- SystemMessageService systemMessageService,
- CardLockServiceFactory cardLockServiceFactory) {
+ SystemMessageService systemMessageService,
+ CardLockServiceFactory cardLockServiceFactory,
+ LockControlFactory lockControlFactory,
+ SubscriptionLimitService subscriptionLimitService) {
this.cardlockRepository = cardlockRepository;
this.userRepository = userRepository;
this.invitationRepository = invitationRepository;
@@ -108,13 +114,14 @@ public class CardLockController {
this.unlockCodeHistoryService = unlockCodeHistoryService;
this.systemMessageService = systemMessageService;
this.cardLockServiceFactory = cardLockServiceFactory;
+ this.subscriptionLimitService = subscriptionLimitService;
}
record CreateCardLockRequest(String name, UUID keyholder, UUID lockeeUserId, boolean lockeeDetailsVisible,
List initialCards, Integer pickEveryMinute, boolean accumulatePicks, boolean showRemainingCards,
LocalDateTime latestOpeningtime, Integer hygineOpeningDurationMinutes, Integer hygineOpeningEveryMinites,
List tasks, boolean requiresVerification, boolean testLock, Integer unlockCodeLines,
- TaskMode taskMode) {
+ TaskMode taskMode, LockControllType controllType) {
}
private static final SecureRandom RNG = new SecureRandom();
@@ -190,8 +197,12 @@ public class CardLockController {
if (cardlockRepository.existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(myId))
return ResponseEntity.status(409).body(Map.of("error", "active_lock_exists"));
+ LockControllType controllType = req.controllType() != null ? req.controllType() : LockControllType.UNLOCK_CODE;
+ if (controllType == LockControllType.TTLOCK && !subscriptionLimitService.hasActivePaidSubscription(myId)) {
+ return ResponseEntity.status(403).body(Map.of("error", "subscription_required"));
+ }
+
int codeLines = (req.unlockCodeLines() != null && req.unlockCodeLines() >= 1) ? req.unlockCodeLines() : 5;
- String unlockCode = generateUnlockCode(codeLines);
CardLockEntity lock = new CardLockEntity();
lock.setName(req.name());
@@ -209,7 +220,7 @@ public class CardLockController {
lock.setTestLock(req.testLock());
lock.setTaskMode(req.taskMode() != null ? req.taskMode() : TaskMode.RANDOM);
lock.setUnlockCodeLength(codeLines);
- lock.setUnlockCode(unlockCode);
+ lock.setControllType(controllType);
LocalDateTime now = LocalDateTime.now();
lock.setStartTime(now);
@@ -219,8 +230,17 @@ public class CardLockController {
if (req.hygineOpeningEveryMinites() != null) {
lock.setLastHygineOpening(now);
}
+ cardlockRepository.save(lock); // erst speichern, damit Lock-ID vorhanden ist
- cardlockRepository.save(lock);
+ // Initialen Unlock-Code / TTLock-PIN via LockControl setzen
+ CardLockService initService = cardLockServiceFactory.create(lock);
+ if (initService.getLockControl() != null) {
+ initService.getLockControl().lock();
+ } else {
+ // Fallback: direkte Code-Generierung (UNLOCK_CODE ohne Factory)
+ lock.setUnlockCode(generateUnlockCode(codeLines));
+ cardlockRepository.save(lock);
+ }
boolean keyholderPending = false;
if (req.keyholder() != null) {
@@ -246,7 +266,7 @@ public class CardLockController {
}
}
- return ResponseEntity.ok(Map.of("lockId", lock.getLockId().toString(), "unlockCode", unlockCode,
+ return ResponseEntity.ok(Map.of("lockId", lock.getLockId().toString(), "unlockCode", lock.getUnlockCode(),
"keyholderPending", keyholderPending));
}
@@ -350,6 +370,24 @@ public class CardLockController {
return ResponseEntity.noContent().build();
}
+ @PostMapping("/cardlock/{lockId}/relock")
+ @Transactional
+ public ResponseEntity relock(@PathVariable UUID lockId, Principal principal) {
+ var meOpt = userRepository.findByEmail(principal.getName());
+ if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
+ UUID myId = meOpt.get().getUserId();
+
+ var lockOpt = cardlockRepository.findById(lockId);
+ if (lockOpt.isEmpty()) return ResponseEntity.notFound().build();
+ var l = lockOpt.get();
+ if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build();
+ if (l.getControllType() != LockControllType.TTLOCK) return ResponseEntity.status(409).build();
+
+ var lc = cardLockServiceFactory.create(l).getLockControl();
+ if (lc != null) lc.lock();
+ return ResponseEntity.noContent().build();
+ }
+
@PostMapping("/cardlock/{lockId}/green/keep")
@Transactional
public ResponseEntity greenKeep(@PathVariable UUID lockId, Principal principal) {
@@ -916,7 +954,8 @@ public class CardLockController {
var notification = keyholderNotificationRepository.findByLockId(lockId).stream()
.sorted((a, b) -> b.getViolationTime().compareTo(a.getViolationTime())).limit(5)
- .map(v -> Map.of("time", v.getViolationTime().toString(), "overtimeMinutes", v.getOvertimeMinutes()))
+ .map(v -> Map.of("time", v.getViolationTime().toString(), "overtimeMinutes", v.getOvertimeMinutes(),
+ "openingReason", v.getOpeningReason() != null ? v.getOpeningReason().name() : "HYGIENE"))
.toList();
Map result = new HashMap<>();
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockRepository.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockRepository.java
index ce1b2ad..e379070 100644
--- a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockRepository.java
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockRepository.java
@@ -3,7 +3,12 @@ package de.oaa.xxx.games.chastity.cardlock;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
public interface CardLockRepository extends JpaRepository {
+ @Modifying
+ @Query("DELETE FROM CardLockEntity c WHERE c.lockId = :lockId")
+ void deleteByLockId(UUID lockId);
}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockService.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockService.java
index 01d73eb..1596549 100644
--- a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockService.java
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockService.java
@@ -18,6 +18,8 @@ import de.oaa.xxx.games.chastity.community.CommunityVerificationVoteRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderNotificationRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderTaskChoiceRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderVerificationRepository;
+import de.oaa.xxx.games.chastity.lockcontroll.LockControlCallback;
+import de.oaa.xxx.games.chastity.lockcontroll.LockControlFactory;
import de.oaa.xxx.games.chastity.tasks.AssignedTaskEntity;
import de.oaa.xxx.games.chastity.unlock.TempOpeningReason;
import de.oaa.xxx.games.chastity.unlock.UnlockCodeHistoryService;
@@ -26,13 +28,13 @@ import de.oaa.xxx.games.history.GameType;
import de.oaa.xxx.social.SystemMessageService;
import de.oaa.xxx.user.UserRepository;
-public class CardLockService extends BaseLockService {
+public class CardLockService extends BaseLockService implements LockControlCallback {
private static final Logger LOGGER = LoggerFactory.getLogger(CardLockService.class);
private final CardLockEntity lock;
private final CardLockRepository cardLockRepository;
private String pendingTaskMode;
-
+
public CardLockService(
CardLockEntity lock,
CommunityVerificationVoteRepository communityVerificationVoteRepository,
@@ -45,12 +47,37 @@ public class CardLockService extends BaseLockService {
UnlockCodeHistoryService unlockCodeHistoryService,
KeyholderTaskChoiceRepository keyholderTaskChoiceRepository,
CommunityTaskVoteRepository communityTaskVoteRepository,
- CardLockRepository cardLockRepository) {
+ CardLockRepository cardLockRepository,
+ LockControlFactory lockControlFactory) {
super(communityVerificationVoteRepository, communityVerificationRepository, keyholderVerificationRepository,
gameHistoryRepository, userRepository, keyholderNotificationRepository, systemMessageService,
unlockCodeHistoryService, keyholderTaskChoiceRepository, communityTaskVoteRepository);
this.lock = lock;
this.cardLockRepository = cardLockRepository;
+ // lockControl aus Entity-Typ wiederherstellen (für bereits laufende Locks)
+ if (lock.getControllType() != null) {
+ this.lockControl = lockControlFactory.create(lock.getControllType(), this, lock.getLockee());
+ }
+ }
+
+ // ── LockControl Setup ─────────────────────────────────────────────────────
+
+ /** Wird von CardLockServiceFactory gesetzt (package-private). */
+ void initLockControl(de.oaa.xxx.games.chastity.lockcontroll.LockControl lc) {
+ this.lockControl = lc;
+ }
+
+ // ── LockControlCallback ───────────────────────────────────────────────────
+
+ @Override
+ public void setUnlockCode(String code) {
+ lock.setUnlockCode(code);
+ cardLockRepository.save(lock);
+ }
+
+ @Override
+ public int getUnlockcodeLenght() {
+ return lock.getUnlockCodeLength() != null ? lock.getUnlockCodeLength() : 5;
}
// ── Abstract method implementations ──────────────────────────────────────
@@ -206,6 +233,11 @@ public class CardLockService extends BaseLockService {
// ── Hygiene opening ───────────────────────────────────────────────────────
+ @Override
+ protected void afterHygieneClosing() {
+ if (lockControl != null) lockControl.lock();
+ }
+
public void startHygieneOpening() {
startTempOpening(TempOpeningReason.HYGIENE, lock.getHygineOpeningDurationMinutes());
}
@@ -232,7 +264,13 @@ public class CardLockService extends BaseLockService {
lock.setTempOpeningTime(null);
lock.setTempOpeningReason(null);
- var code = CodeCreator.createNumeric(lock.getUnlockCodeLength());
+ if (lockControl != null
+ && lock.getControllType() != de.oaa.xxx.games.chastity.lockcontroll.LockControllType.UNLOCK_CODE) {
+ lockControl.lock();
+ cardLockRepository.save(lock);
+ return lock.getUnlockCode() != null ? lock.getUnlockCode() : "";
+ }
+ var code = CodeCreator.createNumeric(lock.getUnlockCodeLength() != null ? lock.getUnlockCodeLength() : 5);
lock.setUnlockCode(code);
cardLockRepository.save(lock);
return code;
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockServiceFactory.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockServiceFactory.java
index 6c15b66..3881ca2 100644
--- a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockServiceFactory.java
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockServiceFactory.java
@@ -7,6 +7,7 @@ import de.oaa.xxx.games.chastity.community.CommunityVerificationVoteRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderNotificationRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderTaskChoiceRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderVerificationRepository;
+import de.oaa.xxx.games.chastity.lockcontroll.LockControlFactory;
import de.oaa.xxx.games.chastity.unlock.UnlockCodeHistoryService;
import de.oaa.xxx.social.SystemMessageService;
import de.oaa.xxx.user.UserRepository;
@@ -33,6 +34,7 @@ public class CardLockServiceFactory {
private final SystemMessageService systemMessageService;
private final KeyholderTaskChoiceRepository keyholderTaskChoiceRepository;
private final CommunityTaskVoteRepository communityTaskVoteRepository;
+ private final LockControlFactory lockControlFactory;
public CardLockServiceFactory(
CommunityVerificationRepository communityVerificationRepository,
@@ -45,7 +47,8 @@ public class CardLockServiceFactory {
UnlockCodeHistoryService unlockCodeHistoryService,
SystemMessageService systemMessageService,
KeyholderTaskChoiceRepository keyholderTaskChoiceRepository,
- CommunityTaskVoteRepository communityTaskVoteRepository) {
+ CommunityTaskVoteRepository communityTaskVoteRepository,
+ LockControlFactory lockControlFactory) {
this.cardLockRepository = cardLockRepository;
this.communityVerificationRepository = communityVerificationRepository;
this.communityVerificationVoteRepository = communityVerificationVoteRepository;
@@ -57,15 +60,19 @@ public class CardLockServiceFactory {
this.systemMessageService = systemMessageService;
this.keyholderTaskChoiceRepository = keyholderTaskChoiceRepository;
this.communityTaskVoteRepository = communityTaskVoteRepository;
+ this.lockControlFactory = lockControlFactory;
}
/**
* Erstellt eine neue CardLockService-Instanz für das gegebene Lock.
+ * Setzt den lockControl anhand des gespeicherten controllType.
*/
public CardLockService create(CardLockEntity lock) {
- return new CardLockService(lock, communityVerificationVoteRepository, communityVerificationRepository,
- keyholderVerificationRepository, gameHistoryRepository, userRepository,
- keyholderNotificationRepository, systemMessageService, unlockCodeHistoryService,
- keyholderTaskChoiceRepository, communityTaskVoteRepository, cardLockRepository);
+ CardLockService service = new CardLockService(lock, communityVerificationVoteRepository,
+ communityVerificationRepository, keyholderVerificationRepository, gameHistoryRepository,
+ userRepository, keyholderNotificationRepository, systemMessageService, unlockCodeHistoryService,
+ keyholderTaskChoiceRepository, communityTaskVoteRepository, cardLockRepository, lockControlFactory);
+
+ return service;
}
}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockEntity.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockEntity.java
index 889b788..381da7a 100644
--- a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockEntity.java
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockEntity.java
@@ -4,6 +4,7 @@ import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
+import de.oaa.xxx.games.chastity.lockcontroll.LockControllType;
import de.oaa.xxx.games.chastity.tasks.Task;
import de.oaa.xxx.games.chastity.tasks.TaskListConverter;
import de.oaa.xxx.games.chastity.tasks.TaskMode;
@@ -51,6 +52,9 @@ public class BaseLockEntity {
private Integer unlockCodeLength;
@Column
private String unlockCode;
+ @Enumerated(EnumType.STRING)
+ @Column(length = 20)
+ private LockControllType controllType;
// --- Timing & Hygiene ---
@Column
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockRepository.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockRepository.java
index 63a3b60..e6e2598 100644
--- a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockRepository.java
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockRepository.java
@@ -1,9 +1,11 @@
package de.oaa.xxx.games.chastity.common;
+import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BaseLockRepository extends JpaRepository{
-
+
+ Optional findByLockee(UUID userId);
}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockService.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockService.java
index dd30ab7..1031acf 100644
--- a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockService.java
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockService.java
@@ -46,6 +46,13 @@ public abstract class BaseLockService {
protected final KeyholderTaskChoiceRepository keyholderTaskChoiceRepository;
protected final CommunityTaskVoteRepository communityTaskVoteRepository;
+ /** Wird von Subklassen gesetzt; steuert wie das physische Schloss (neu) verriegelt wird. */
+ protected de.oaa.xxx.games.chastity.lockcontroll.LockControl lockControl;
+
+ public de.oaa.xxx.games.chastity.lockcontroll.LockControl getLockControl() {
+ return lockControl;
+ }
+
// ── Abstrakte Methoden ────────────────────────────────────────────────────
protected abstract BaseLockEntity getLock();
@@ -106,7 +113,13 @@ public abstract class BaseLockService {
notification.setKeyholderUserId(lock.getKeyholder());
notification.setViolationTime(LocalDateTime.now());
notification.setOvertimeMinutes(overtime);
+ notification.setOpeningReason(de.oaa.xxx.games.chastity.unlock.TempOpeningReason.HYGIENE);
keyholderNotificationRepository.save(notification);
+ userRepository.findById(lock.getKeyholder()).ifPresent(kh ->
+ sendMessage(lock.getLockee(), kh.getUserId(),
+ "Deine Lockee hat die Hygiene-Öffnung um " + overtime + " Minuten überschritten.",
+ "/keyholder.html?lockId=" + lock.getLockId(),
+ de.oaa.xxx.social.entity.MessageCause.GAME_STATE));
}
protected void sendMessage(UUID senderId, UUID receiverId, String text, String targetUrl,
@@ -193,7 +206,16 @@ public abstract class BaseLockService {
lock.setLastHygineOpening(now);
lock.setTempOpeningDuration(null);
lock.setTempOpeningTime(null);
- String code = CodeCreator.createNumeric(lock.getUnlockCodeLength());
+ if (lockControl != null
+ && lock.getControllType() != de.oaa.xxx.games.chastity.lockcontroll.LockControllType.UNLOCK_CODE) {
+ // TTLock/Trust: lockControl.lock() setzt PIN am Gerät (oder tut nichts bei Trust).
+ // Kein Software-Code notwendig.
+ lockControl.lock();
+ saveLock();
+ return lock.getUnlockCode() != null ? lock.getUnlockCode() : "";
+ }
+ // UNLOCK_CODE (oder kein lockControl): neuen numerischen Code generieren
+ String code = CodeCreator.createNumeric(lock.getUnlockCodeLength() != null ? lock.getUnlockCodeLength() : 5);
lock.setUnlockCode(code);
saveLock();
return code;
@@ -240,6 +262,10 @@ public abstract class BaseLockService {
LOGGER.debug("Unlocked at {}", lock.getUnlockTime());
saveLock();
+ if (lockControl != null) {
+ lockControl.cleanup();
+ }
+
if (valid) {
long durationMinutes = Duration.between(lock.getStartTime(), lock.getUnlockTime()).toMinutes();
GameHistoryEntity entry = new GameHistoryEntity();
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderNotificationEntity.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderNotificationEntity.java
index eefb43a..ee635a8 100644
--- a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderNotificationEntity.java
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderNotificationEntity.java
@@ -37,6 +37,7 @@ public class KeyholderNotificationEntity {
@Column(nullable = false)
private boolean notifiedKeyholder = false;
+ @Enumerated(EnumType.STRING)
@Column(nullable = false)
private TempOpeningReason openingReason;
}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/LockControl.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/LockControl.java
index 7efc575..1db9495 100644
--- a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/LockControl.java
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/LockControl.java
@@ -13,4 +13,6 @@ public abstract class LockControl {
public abstract boolean unlock();
public abstract boolean lock();
+
+ public abstract boolean cleanup();
}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/LockControlFactory.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/LockControlFactory.java
new file mode 100644
index 0000000..144ef63
--- /dev/null
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/LockControlFactory.java
@@ -0,0 +1,42 @@
+package de.oaa.xxx.games.chastity.lockcontroll;
+
+import de.oaa.xxx.games.chastity.ttlock.TTLockConfigRepository;
+import de.oaa.xxx.games.chastity.ttlock.TTLockUserConfigRepository;
+import de.oaa.xxx.games.chastity.ttlock.TTAuthService;
+import de.oaa.xxx.games.chastity.ttlock.TTLockService;
+import org.springframework.stereotype.Component;
+
+import java.util.UUID;
+
+@Component
+public class LockControlFactory {
+
+ private final TTAuthService ttAuthService;
+ private final TTLockService ttLockService;
+ private final TTLockConfigRepository ttLockConfigRepository;
+ private final TTLockUserConfigRepository ttLockUserConfigRepository;
+
+ public LockControlFactory(TTAuthService ttAuthService,
+ TTLockService ttLockService,
+ TTLockConfigRepository ttLockConfigRepository,
+ TTLockUserConfigRepository ttLockUserConfigRepository) {
+ this.ttAuthService = ttAuthService;
+ this.ttLockService = ttLockService;
+ this.ttLockConfigRepository = ttLockConfigRepository;
+ this.ttLockUserConfigRepository = ttLockUserConfigRepository;
+ }
+
+ public LockControl create(LockControllType type, LockControlCallback callback, UUID lockeeId) {
+ return switch (type != null ? type : LockControllType.UNLOCK_CODE) {
+ case TRUST -> new TrustLockControl();
+ case TTLOCK -> new TTLockControl(
+ ttAuthService,
+ ttLockService,
+ ttLockConfigRepository.findById(1L).orElse(null),
+ ttLockUserConfigRepository.findById(lockeeId).orElse(null),
+ ttLockUserConfigRepository,
+ callback);
+ case UNLOCK_CODE -> new UnlockcodeLockControl(callback);
+ };
+ }
+}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/TTLockControl.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/TTLockControl.java
index 43a08a6..740a378 100644
--- a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/TTLockControl.java
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/TTLockControl.java
@@ -1,29 +1,142 @@
package de.oaa.xxx.games.chastity.lockcontroll;
+import de.oaa.xxx.games.chastity.common.CodeCreator;
+import de.oaa.xxx.games.chastity.ttlock.TTAuthService;
+import de.oaa.xxx.games.chastity.ttlock.TTLockConfigEntity;
+import de.oaa.xxx.games.chastity.ttlock.TTLockService;
+import de.oaa.xxx.games.chastity.ttlock.TTLockUserConfigEntity;
+import de.oaa.xxx.games.chastity.ttlock.TTLockUserConfigRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
public class TTLockControl extends LockControl {
- // private static final String BASE_URL = "https://euapi.ttlock.com/";
+ private static final Logger LOGGER = LoggerFactory.getLogger(TTLockControl.class);
- public TTLockControl() {
- super(new NoInteractionCallback());
- }
+ private final TTAuthService ttAuthService;
+ private final TTLockService ttLockService;
+ private final TTLockConfigEntity adminConfig;
+ private final TTLockUserConfigEntity userConfig;
+ private final TTLockUserConfigRepository userConfigRepository;
+
+ public TTLockControl(TTAuthService ttAuthService,
+ TTLockService ttLockService,
+ TTLockConfigEntity adminConfig,
+ TTLockUserConfigEntity userConfig,
+ TTLockUserConfigRepository userConfigRepository,
+ LockControlCallback callback) {
+ super(callback);
+ this.ttAuthService = ttAuthService;
+ this.ttLockService = ttLockService;
+ this.adminConfig = adminConfig;
+ this.userConfig = userConfig;
+ this.userConfigRepository = userConfigRepository;
+ }
+
+ @Override
+ public boolean init() {
+ return true;
+ }
+
+ @Override
+ public boolean lock() {
+ if (!isConfigValid()) {
+ LOGGER.warn("TTLock-Konfiguration unvollständig – lock() übersprungen");
+ return false;
+ }
+ try {
+ String token = getToken();
+ if (token == null) {
+ LOGGER.error("TTLock: Kein Access Token erhalten");
+ return false;
+ }
+
+ // Neuen PIN erstellen – Länge aus Callback, mindestens 4, maximal 9 (TTLock-Limit)
+ int pinLength = Math.min(9, Math.max(4, callback.getUnlockcodeLenght()));
+ String newPin = CodeCreator.createNumeric(pinLength);
+ Integer newPwdId = ttLockService.addCustomPasscode(
+ adminConfig.getClientId(), token,
+ userConfig.getLockId(), newPin);
+
+ if (newPwdId == null) {
+ LOGGER.error("TTLock: Neuer PIN konnte nicht erstellt werden – alter PIN bleibt erhalten");
+ return false;
+ }
+ callback.setUnlockCode(newPin);
+
+ // Neuen PIN-ID speichern, dann alten PIN löschen
+ Integer oldPwdId = userConfig.getCurrentKeyboardPwdId();
+ userConfig.setCurrentKeyboardPwdId(newPwdId);
+ userConfigRepository.save(userConfig);
+ LOGGER.info("TTLock: Neuer PIN gesetzt (pwdId={})", newPwdId);
+
+
+ if (oldPwdId != null) {
+ ttLockService.deleteCustomPasscode(
+ adminConfig.getClientId(), token,
+ userConfig.getLockId(), oldPwdId);
+ LOGGER.debug("TTLock: Alter PIN {} gelöscht", oldPwdId);
+ }
+
+ return true;
+ } catch (Exception e) {
+ LOGGER.error("TTLock lock() fehlgeschlagen: {}", e.getMessage(), e);
+ return false;
+ }
+ }
+
+ /**
+ * Löscht den aktuellen PIN vom Schloss, sodass es entsperrt bleibt.
+ */
+ @Override
+ public boolean unlock() {
+ if (!isConfigValid() || userConfig.getCurrentKeyboardPwdId() == null) {
+ return true; // Kein PIN gesetzt – nichts zu tun
+ }
+ try {
+ String token = getToken();
+ if (token == null) return false;
+
+ ttLockService.deleteCustomPasscode(
+ adminConfig.getClientId(), token,
+ userConfig.getLockId(), userConfig.getCurrentKeyboardPwdId());
+
+ userConfig.setCurrentKeyboardPwdId(null);
+ userConfigRepository.save(userConfig);
+ LOGGER.info("TTLock: PIN gelöscht (Entsperrung)");
+ return true;
+ } catch (Exception e) {
+ LOGGER.error("TTLock unlock() fehlgeschlagen: {}", e.getMessage(), e);
+ return false;
+ }
+ }
+
+ private String getToken() {
+ return ttAuthService.getAccessToken(
+ adminConfig.getClientId(),
+ adminConfig.getClientSecret(),
+ userConfig.getUsername(),
+ userConfig.getPasswordMd5());
+ }
+
+ private boolean isConfigValid() {
+ return adminConfig != null
+ && adminConfig.getClientId() != null
+ && adminConfig.getClientSecret() != null
+ && userConfig != null
+ && userConfig.getUsername() != null
+ && userConfig.getPasswordMd5() != null
+ && userConfig.getLockId() != null;
+ }
@Override
- public boolean init() {
- // TODO Auto-generated method stub
- return false;
+ public boolean cleanup() {
+ String token = getToken();
+ if (token == null) {
+ LOGGER.error("TTLock: Kein Access Token erhalten");
+ return false;
+ }
+ ttLockService.findAndDeleteLocksByName(adminConfig.getClientId(), token, userConfig.getLockId());
+ return true;
}
-
- @Override
- public boolean unlock() {
- // TODO Auto-generated method stub
- return false;
- }
-
- @Override
- public boolean lock() {
- // TODO Auto-generated method stub
- return false;
- }
-
}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/TrustLockControl.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/TrustLockControl.java
index 583bb8c..944e908 100644
--- a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/TrustLockControl.java
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/TrustLockControl.java
@@ -19,5 +19,10 @@ public class TrustLockControl extends LockControl {
@Override
public boolean lock() {
return true;
+ }
+
+ @Override
+ public boolean cleanup() {
+ return true;
}
}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/UnlockcodeLockControl.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/UnlockcodeLockControl.java
index e052505..82ff99f 100644
--- a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/UnlockcodeLockControl.java
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/UnlockcodeLockControl.java
@@ -24,4 +24,9 @@ public class UnlockcodeLockControl extends LockControl {
callback.setUnlockCode(code);
return true;
}
+
+ @Override
+ public boolean cleanup() {
+ return true;
+ }
}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockController.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockController.java
index 9b4e38a..9a3b05e 100644
--- a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockController.java
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockController.java
@@ -37,6 +37,7 @@ import de.oaa.xxx.games.chastity.community.CommunityVerificationVoteRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderInvitationEntity;
import de.oaa.xxx.games.chastity.keyholder.KeyholderInvitationRepository;
import de.oaa.xxx.games.chastity.lockcontroll.LockControllType;
+import de.oaa.xxx.subscription.SubscriptionLimitService;
import de.oaa.xxx.games.chastity.lockee.LockeeInvitationEntity;
import de.oaa.xxx.games.chastity.lockee.LockeeInvitationRepository;
import de.oaa.xxx.games.chastity.spinningwheel.SpinningWheelEntry;
@@ -57,6 +58,7 @@ public class TimeLockController {
private final TimeLockServiceFactory timeLockServiceFactory;
private final CommunityVerificationRepository verificationRepository;
private final CommunityVerificationVoteRepository verificationVoteRepository;
+ private final SubscriptionLimitService subscriptionLimitService;
public TimeLockController(TimeLockRepository timeLockRepository,
TimeLockTemplateRepository templateRepository,
@@ -66,7 +68,8 @@ public class TimeLockController {
SystemMessageService systemMessageService,
TimeLockServiceFactory timeLockServiceFactory,
CommunityVerificationRepository verificationRepository,
- CommunityVerificationVoteRepository verificationVoteRepository) {
+ CommunityVerificationVoteRepository verificationVoteRepository,
+ SubscriptionLimitService subscriptionLimitService) {
this.timeLockRepository = timeLockRepository;
this.templateRepository = templateRepository;
this.userRepository = userRepository;
@@ -76,6 +79,7 @@ public class TimeLockController {
this.timeLockServiceFactory = timeLockServiceFactory;
this.verificationRepository = verificationRepository;
this.verificationVoteRepository = verificationVoteRepository;
+ this.subscriptionLimitService = subscriptionLimitService;
}
// ── Erstellen ────────────────────────────────────────────────────────────────
@@ -140,6 +144,11 @@ public class TimeLockController {
if (timeLockRepository.existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(myId))
return ResponseEntity.status(409).body(Map.of("error", "active_lock_exists"));
+ LockControllType controllType = req.controllType() != null ? req.controllType() : LockControllType.UNLOCK_CODE;
+ if (controllType == LockControllType.TTLOCK && !subscriptionLimitService.hasActivePaidSubscription(myId)) {
+ return ResponseEntity.status(403).body(Map.of("error", "subscription_required"));
+ }
+
TimeLockAdditionalSettings settings = new TimeLockAdditionalSettings(
req.controllType() != null ? req.controllType() : LockControllType.UNLOCK_CODE,
myId, req.keyholder(), req.testLock(), codeLen);
@@ -410,6 +419,24 @@ public class TimeLockController {
// ── Aufgabe erledigt ──────────────────────────────────────────────────────────
+ @PostMapping("/timelock/{lockId}/relock")
+ @Transactional
+ public ResponseEntity relock(@PathVariable UUID lockId, Principal principal) {
+ var meOpt = userRepository.findByEmail(principal.getName());
+ if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
+ UUID myId = meOpt.get().getUserId();
+
+ var lockOpt = timeLockRepository.findById(lockId);
+ if (lockOpt.isEmpty()) return ResponseEntity.notFound().build();
+ var l = lockOpt.get();
+ if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build();
+ if (l.getControllType() != LockControllType.TTLOCK) return ResponseEntity.status(409).build();
+
+ var lc = timeLockServiceFactory.create(l).getLockControl();
+ if (lc != null) lc.lock();
+ return ResponseEntity.noContent().build();
+ }
+
@PostMapping("/timelock/{lockId}/task/done")
@Transactional
public ResponseEntity taskDone(@PathVariable UUID lockId, Principal principal) {
@@ -564,7 +591,7 @@ public class TimeLockController {
verifications.forEach(v -> verificationVoteRepository.deleteAllByVerificationId(v.getDisplayId()));
verificationRepository.deleteAll(verifications);
invitationRepository.deleteByLockId(lockId);
- timeLockRepository.deleteById(lockId);
+ timeLockRepository.deleteByLockId(lockId);
return ResponseEntity.noContent().build();
}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockRepository.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockRepository.java
index 5d40b59..b6f7cde 100644
--- a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockRepository.java
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockRepository.java
@@ -3,9 +3,14 @@ package de.oaa.xxx.games.chastity.timelock;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
public interface TimeLockRepository extends JpaRepository {
boolean existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(UUID lockee);
+ @Modifying
+ @Query("DELETE FROM TimeLockEntity t WHERE t.lockId = :lockId")
+ void deleteByLockId(UUID lockId);
}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockService.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockService.java
index ed5055f..a41daa7 100644
--- a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockService.java
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockService.java
@@ -24,11 +24,8 @@ import de.oaa.xxx.games.chastity.keyholder.KeyholderNotificationRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderTaskChoiceRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderVerificationEntity;
import de.oaa.xxx.games.chastity.keyholder.KeyholderVerificationRepository;
-import de.oaa.xxx.games.chastity.lockcontroll.LockControl;
import de.oaa.xxx.games.chastity.lockcontroll.LockControlCallback;
-import de.oaa.xxx.games.chastity.lockcontroll.TTLockControl;
-import de.oaa.xxx.games.chastity.lockcontroll.TrustLockControl;
-import de.oaa.xxx.games.chastity.lockcontroll.UnlockcodeLockControl;
+import de.oaa.xxx.games.chastity.lockcontroll.LockControlFactory;
import de.oaa.xxx.games.chastity.spinningwheel.SpinningWheelEntry;
import de.oaa.xxx.games.chastity.tasks.TaskMode;
import de.oaa.xxx.games.chastity.unlock.TempOpeningReason;
@@ -44,8 +41,9 @@ public class TimeLockService extends BaseLockService implements LockControlCallb
private final TimeLockEntity lock;
private final TimeLockRepository timeLockRepository;
private final CommunityPilloryRepository pilloryRepository;
+ private final LockControlFactory lockControlFactory;
- private LockControl lockControl;
+ // lockControl ist in BaseLockService als protected-Feld definiert
public TimeLockService(TimeLockEntity lock,
CommunityVerificationRepository verificationRepository,
@@ -59,7 +57,8 @@ public class TimeLockService extends BaseLockService implements LockControlCallb
CommunityTaskVoteRepository communityTaskVoteRepository,
CommunityPilloryRepository pilloryRepository,
UnlockCodeHistoryService unlockCodeHistoryService,
- SystemMessageService systemMessageService) {
+ SystemMessageService systemMessageService,
+ LockControlFactory lockControlFactory) {
super(verificationVoteRepository, verificationRepository, keyholderVerificationRepository,
gameHistoryRepository, userRepository, keyholderNotificationRepository,
systemMessageService, unlockCodeHistoryService,
@@ -67,6 +66,11 @@ public class TimeLockService extends BaseLockService implements LockControlCallb
this.lock = lock;
this.timeLockRepository = timeLockRepository;
this.pilloryRepository = pilloryRepository;
+ this.lockControlFactory = lockControlFactory;
+ // lockControl aus Entity-Typ wiederherstellen (für bereits laufende Locks)
+ if (lock.getControllType() != null) {
+ this.lockControl = lockControlFactory.create(lock.getControllType(), this, lock.getLockee());
+ }
}
// ── Abstract method implementations ──────────────────────────────────────
@@ -111,11 +115,11 @@ public class TimeLockService extends BaseLockService implements LockControlCallb
* generiert und das Lock bereits persistiert.
*/
public void init(TimeLockTemplateEntity template, TimeLockAdditionalSettings settings) {
- switch (settings.controllType()) {
- case TTLOCK -> lockControl = new TTLockControl();
- case TRUST -> lockControl = new TrustLockControl();
- case UNLOCK_CODE -> lockControl = new UnlockcodeLockControl(this);
- }
+ de.oaa.xxx.games.chastity.lockcontroll.LockControllType type =
+ settings.controllType() != null ? settings.controllType()
+ : de.oaa.xxx.games.chastity.lockcontroll.LockControllType.UNLOCK_CODE;
+ lock.setControllType(type);
+ lockControl = lockControlFactory.create(type, this, settings.lockee());
LocalDateTime now = LocalDateTime.now();
lock.setStartTime(now);
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockServiceFactory.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockServiceFactory.java
index f8f7e9f..7498303 100644
--- a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockServiceFactory.java
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockServiceFactory.java
@@ -9,6 +9,7 @@ import de.oaa.xxx.games.chastity.community.CommunityVerificationVoteRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderNotificationRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderTaskChoiceRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderVerificationRepository;
+import de.oaa.xxx.games.chastity.lockcontroll.LockControlFactory;
import de.oaa.xxx.games.chastity.unlock.UnlockCodeHistoryService;
import de.oaa.xxx.games.history.GameHistoryRepository;
import de.oaa.xxx.social.SystemMessageService;
@@ -30,6 +31,7 @@ public class TimeLockServiceFactory {
private final UnlockCodeHistoryService unlockCodeHistoryService;
private final SystemMessageService systemMessageService;
private CommunityVerificationVoteRepository communityVerificationVoteRepository;
+ private final LockControlFactory lockControlFactory;
public TimeLockServiceFactory(CommunityVerificationRepository verificationRepository,
CommunityVerificationVoteRepository verificationVoteRepository, TimeLockRepository timeLockRepository,
@@ -38,7 +40,8 @@ public class TimeLockServiceFactory {
KeyholderTaskChoiceRepository keyholderTaskChoiceRepository,
KeyholderVerificationRepository keyholderVerificationRepository,
CommunityTaskVoteRepository communityTaskVoteRepository, CommunityPilloryRepository pilloryRepository,
- UnlockCodeHistoryService unlockCodeHistoryService, SystemMessageService systemMessageService) {
+ UnlockCodeHistoryService unlockCodeHistoryService, SystemMessageService systemMessageService,
+ LockControlFactory lockControlFactory) {
this.communityVerificationVoteRepository = verificationVoteRepository;
this.timeLockRepository = timeLockRepository;
this.communityVerificationRepository = verificationRepository;
@@ -51,15 +54,16 @@ public class TimeLockServiceFactory {
this.keyholderTaskChoiceRepository = keyholderTaskChoiceRepository;
this.communityTaskVoteRepository = communityTaskVoteRepository;
this.keyholderVerificationRepository = keyholderVerificationRepository;
+ this.lockControlFactory = lockControlFactory;
}
/**
- * Erstellt eine neue CardLockService-Instanz für das gegebene Lock.
+ * Erstellt eine neue TimeLockService-Instanz für das gegebene Lock.
*/
public TimeLockService create(TimeLockEntity lock) {
return new TimeLockService(lock, communityVerificationRepository, communityVerificationVoteRepository,
timeLockRepository, gameHistoryRepository, userRepository, keyholderNotificationRepository,
keyholderTaskChoiceRepository, keyholderVerificationRepository, communityTaskVoteRepository,
- pilloryRepository, unlockCodeHistoryService, systemMessageService);
+ pilloryRepository, unlockCodeHistoryService, systemMessageService, lockControlFactory);
}
}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTAuthService.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTAuthService.java
new file mode 100644
index 0000000..aaafdf9
--- /dev/null
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTAuthService.java
@@ -0,0 +1,38 @@
+package de.oaa.xxx.games.chastity.ttlock;
+
+import java.util.Map;
+
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Service;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestTemplate;
+
+@Service
+public class TTAuthService {
+
+ private final String AUTH_URL = "https://euapi.ttlock.com/oauth2/token";
+
+ @SuppressWarnings("unchecked")
+ public String getAccessToken(String clientId, String clientSecret, String username, String md5Password) {
+ RestTemplate restTemplate = new RestTemplate();
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+
+ MultiValueMap map = new LinkedMultiValueMap<>();
+ map.add("client_id", clientId);
+ map.add("client_secret", clientSecret);
+ map.add("username", username);
+ map.add("password", md5Password); // MD5 Hash des TTLock-Passworts
+ map.add("grant_type", "password");
+
+ HttpEntity> request = new HttpEntity<>(map, headers);
+
+ // Response parsen und access_token extrahieren
+ Map response = restTemplate.postForObject(AUTH_URL, request, Map.class);
+ return (String) response.get("access_token");
+ }
+}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockCallback.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockCallback.java
new file mode 100644
index 0000000..565ac91
--- /dev/null
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockCallback.java
@@ -0,0 +1,225 @@
+package de.oaa.xxx.games.chastity.ttlock;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.Map;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.MediaType;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import de.oaa.xxx.games.chastity.common.BaseLockEntity;
+import de.oaa.xxx.games.chastity.common.BaseLockRepository;
+import de.oaa.xxx.games.chastity.keyholder.KeyholderNotificationEntity;
+import de.oaa.xxx.games.chastity.keyholder.KeyholderNotificationRepository;
+import de.oaa.xxx.games.chastity.unlock.TempOpeningReason;
+import de.oaa.xxx.social.SystemMessageService;
+import de.oaa.xxx.social.entity.MessageCause;
+import de.oaa.xxx.user.UserRepository;
+import lombok.Data;
+
+@RestController
+@RequestMapping("/api/ttlock/callback")
+public class TTLockCallback {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(TTLockCallback.class);
+
+ private static final int START_WINDOW_MINUTES = 5;
+
+ private final TTLockUserConfigRepository ttLockUserConfigRepository;
+ private final BaseLockRepository baseLockRepository;
+ private final KeyholderNotificationRepository keyholderNotificationRepository;
+ private final UserRepository userRepository;
+ private final SystemMessageService systemMessageService;
+
+ public TTLockCallback(TTLockUserConfigRepository ttLockUserConfigRepository,
+ BaseLockRepository baseLockRepository,
+ KeyholderNotificationRepository keyholderNotificationRepository,
+ UserRepository userRepository,
+ SystemMessageService systemMessageService) {
+ this.ttLockUserConfigRepository = ttLockUserConfigRepository;
+ this.baseLockRepository = baseLockRepository;
+ this.keyholderNotificationRepository = keyholderNotificationRepository;
+ this.userRepository = userRepository;
+ this.systemMessageService = systemMessageService;
+ }
+
+ @GetMapping
+ public String test() {
+ return "OK";
+ }
+
+ @PostMapping(consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
+ public String handleCallback(@RequestParam Map allRequestParams) {
+ LOGGER.debug("Callback von TTLock erhalten, verarbeite...");
+ try {
+ var wrapper = parse(allRequestParams);
+ if (Integer.valueOf(1).equals(wrapper.getNotifyType())) {
+ LOGGER.info("Lock {} wurde aufgeschlossen", wrapper.getLockId());
+ checkUser(wrapper);
+ } else {
+ if (LOGGER.isDebugEnabled()) {
+ LOGGER.debug("Uninteressantes ereignis: {}", wrapper);
+ }
+ }
+ return "1";
+ } catch (Exception e) {
+ LOGGER.error("Fehler beim Verarbeiten des Callbacks", e);
+ return "0";
+ }
+ }
+
+ @Transactional
+ void checkUser(TTLockCallbackWrapper wrapper) {
+ var userOpt = ttLockUserConfigRepository.findByLockId(wrapper.getLockId());
+ if (userOpt.isEmpty()) {
+ LOGGER.warn("TTLock-Öffnung für unbekanntes Lock {} – nicht in XXX-Sphere registriert", wrapper.getLockId());
+ return;
+ }
+
+ var lockOpt = baseLockRepository.findByLockee(userOpt.get().getUserId());
+ if (lockOpt.isEmpty()) {
+ LOGGER.debug("Kein aktives Lock für Benutzer {} gefunden", userOpt.get().getUserId());
+ return;
+ }
+
+ var lock = lockOpt.get();
+
+ if (lock.getKeyholder() == null) {
+ LOGGER.debug("Lock {} hat keinen Keyholder – keine Berechtigungsprüfung notwendig", lock.getLockId());
+ return;
+ }
+
+ // Nur erfolgreiche Öffnungen prüfen
+ LockRecord record = wrapper.getRecords() != null && !wrapper.getRecords().isEmpty()
+ ? wrapper.getRecords().get(0) : null;
+ if (record != null && !Integer.valueOf(1).equals(record.getSuccess())) {
+ LOGGER.debug("Öffnungsversuch an Lock {} war nicht erfolgreich – ignoriere", lock.getLockId());
+ return;
+ }
+
+ if (isOpeningAuthorized(lock)) {
+ LOGGER.debug("Öffnung von Lock {} ist berechtigt", lock.getLockId());
+ } else {
+ LOGGER.warn("Unerlaubte Öffnung von Lock {} erkannt – benachrichtige Keyholder {}",
+ lock.getLockId(), lock.getKeyholder());
+ notifyKeyholder(lock);
+ }
+ }
+
+ /**
+ * Prüft, ob eine Öffnung des Schlosses zu diesem Zeitpunkt berechtigt ist.
+ * Berechtigt sind:
+ *
+ * - Das Spiel ist bereits beendet (unlockTime gesetzt)
+ * - Der Keyholder hat die Entsperrung genehmigt (keyholderRequestedUnlock)
+ * - Es läuft gerade eine temporäre Öffnung (Hygiene, Karte, Aufgabe)
+ * - Das Lock wurde vor Kurzem gestartet – der Anwender hat den Startcode erhalten
+ * und die Übergabe an das physische Schloss ist noch nicht abgeschlossen
+ *
+ */
+ private boolean isOpeningAuthorized(BaseLockEntity lock) {
+ // Spiel beendet
+ if (lock.getUnlockTime() != null) return true;
+
+ // Keyholder hat Entsperrung genehmigt
+ if (lock.isKeyholderRequestedUnlock()) return true;
+
+ // Aktive temporäre Öffnung (Hygiene, Karte, Aufgabe)
+ if (lock.getTempOpeningTime() != null) return true;
+
+ // Start-Fenster: Anwender hat beim Lock-Start den Code erhalten und
+ // hat das Schloss physisch noch nicht übergeben (Relock läuft gerade)
+ if (lock.getStartTime() != null
+ && ChronoUnit.MINUTES.between(lock.getStartTime(), LocalDateTime.now()) <= START_WINDOW_MINUTES) {
+ return true;
+ }
+
+ return false;
+ }
+
+ private void notifyKeyholder(BaseLockEntity lock) {
+ KeyholderNotificationEntity notification = new KeyholderNotificationEntity();
+ notification.setLockId(lock.getLockId());
+ notification.setLockeeId(lock.getLockee());
+ notification.setKeyholderUserId(lock.getKeyholder());
+ notification.setViolationTime(LocalDateTime.now());
+ notification.setOvertimeMinutes(0);
+ notification.setNotifiedKeyholder(false);
+ notification.setOpeningReason(TempOpeningReason.TTLOCK_UNAUTHORIZED);
+ keyholderNotificationRepository.save(notification);
+ userRepository.findById(lock.getKeyholder()).ifPresent(kh ->
+ systemMessageService.send(lock.getLockee(), kh.getUserId(),
+ "Deine Lockee hat ihr Schloss unerlaubt geöffnet!",
+ "/keyholder.html?lockId=" + lock.getLockId(),
+ MessageCause.GAME_STATE));
+ }
+
+ private TTLockCallbackWrapper parse(Map params) {
+ ObjectMapper mapper = new ObjectMapper();
+ TTLockCallbackWrapper wrapper = new TTLockCallbackWrapper();
+
+ try {
+ if (params.containsKey("lockId"))
+ wrapper.setLockId(Integer.parseInt(params.get("lockId")));
+ if (params.containsKey("notifyType"))
+ wrapper.setNotifyType(Integer.parseInt(params.get("notifyType")));
+ wrapper.setLockMac(params.get("lockMac"));
+
+ String recordsJson = params.get("records");
+ if (recordsJson != null && !recordsJson.isEmpty()) {
+ List recordList = mapper.readValue(recordsJson, new TypeReference>() {
+ });
+ wrapper.setRecords(recordList);
+ }
+ } catch (Exception e) {
+ System.err.println("Fehler beim Parsen des TTLock Callbacks: " + e.getMessage());
+ }
+
+ return wrapper;
+ }
+
+ @Data
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public class TTLockCallbackWrapper {
+ private Integer lockId;
+ private Integer notifyType;
+ private String lockMac;
+ private List records;
+ }
+
+ @Data
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class LockRecord {
+ private Integer lockId;
+ private Integer electricQuantity;
+ private Long serverDate;
+ private Integer recordType;
+ private Integer success;
+ private String lockMac;
+ private String keyboardPwd;
+ private Long lockDate;
+ private String username;
+
+ public LocalDateTime getLockDateTime() {
+ if (this.lockDate == null || this.lockDate == 0) return null;
+ return LocalDateTime.ofInstant(
+ Instant.ofEpochMilli(this.lockDate),
+ ZoneId.systemDefault()
+ );
+ }
+ }
+}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockConfigEntity.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockConfigEntity.java
new file mode 100644
index 0000000..d000d2b
--- /dev/null
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockConfigEntity.java
@@ -0,0 +1,26 @@
+package de.oaa.xxx.games.chastity.ttlock;
+
+import jakarta.persistence.*;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@Entity
+@Table(name = "ttlock_config")
+public class TTLockConfigEntity {
+
+ /** Singleton-Zeile – immer ID 1 */
+ @Id
+ @Column
+ private Long id = 1L;
+
+ @Column(length = 100)
+ private String clientId;
+
+ @Column(length = 100)
+ private String clientSecret;
+
+ @Column(length = 200)
+ private String baseUrl;
+}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockConfigRepository.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockConfigRepository.java
new file mode 100644
index 0000000..84e7f4f
--- /dev/null
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockConfigRepository.java
@@ -0,0 +1,5 @@
+package de.oaa.xxx.games.chastity.ttlock;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface TTLockConfigRepository extends JpaRepository {}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockService.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockService.java
new file mode 100644
index 0000000..ecccc87
--- /dev/null
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockService.java
@@ -0,0 +1,169 @@
+package de.oaa.xxx.games.chastity.ttlock;
+
+import java.util.Collections;
+
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import lombok.Data;
+import lombok.Getter;
+import lombok.Setter;
+
+@Service
+public class TTLockService {
+
+ private final RestTemplate restTemplate;
+
+ private static final String UNLOCK_CODE_NAME = "xxx-unlock-code";
+
+ public TTLockService() {
+ restTemplate = new RestTemplate();
+
+ }
+
+ public TTLockDetailResponse getLockDetail(String clientId, String accessToken, int lockId) {
+ String url = UriComponentsBuilder.fromUriString("https://euapi.ttlock.com/v3/lock/detail")
+ .queryParam("clientId", clientId).queryParam("accessToken", accessToken).queryParam("lockId", lockId)
+ .queryParam("date", System.currentTimeMillis()).toUriString();
+
+ try {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
+ TTLockDetailResponse response = restTemplate.getForObject(url, TTLockDetailResponse.class);
+ System.out.println(response);
+ return response;
+ } catch (Exception e) {
+ System.err.println("Fehler beim Abrufen der Details: " + e.getMessage());
+ return null;
+ }
+ }
+
+ public Integer addCustomPasscode(String clientId, String accessToken, int lockId, String pin) {
+
+ String url = "https://euapi.ttlock.com/v3/keyboardPwd/add";
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+
+ MultiValueMap map = new LinkedMultiValueMap<>();
+ map.add("clientId", clientId);
+ map.add("accessToken", accessToken);
+ map.add("lockId", String.valueOf(lockId));
+ map.add("keyboardPwd", pin); // Der 4-9 stellige PIN
+ map.add("keyboardPwdName", UNLOCK_CODE_NAME);
+ map.add("addType", "2");
+ map.add("keyboardPwdType", "2");
+ map.add("date", String.valueOf(System.currentTimeMillis()));
+
+ HttpEntity> request = new HttpEntity<>(map, headers);
+
+ try {
+ ResponseEntity response = restTemplate.postForEntity(url, request,
+ TTLockAddPasscodeResponse.class);
+ if (response.getBody() != null && response.getBody().isSuccess()) {
+ return response.getBody().getKeyboardPwdId();
+ } else {
+ System.out.println("Fehler von TTLock: " + response.getBody().getErrmsg());
+ return null;
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ return null;
+ }
+ }
+
+ public String deleteCustomPasscode(String clientId, String accessToken, int lockId, int keyboardPwdId) {
+ String url = "https://euapi.ttlock.com/v3/keyboardPwd/delete";
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+
+ MultiValueMap map = new LinkedMultiValueMap<>();
+ map.add("clientId", clientId);
+ map.add("accessToken", accessToken);
+ map.add("lockId", String.valueOf(lockId));
+ map.add("keyboardPwdId", String.valueOf(keyboardPwdId));
+ map.add("deleteType", "2");
+ map.add("date", String.valueOf(System.currentTimeMillis()));
+
+ HttpEntity> request = new HttpEntity<>(map, headers);
+
+ try {
+ ResponseEntity response = restTemplate.postForEntity(url, request, String.class);
+ return response.getBody();
+ } catch (Exception e) {
+ return "{\"errcode\":-1, \"errmsg\":\"" + e.getMessage() + "\"}";
+ }
+ }
+
+ public void findAndDeleteLocksByName(String clientId, String accessToken, int lockId) {
+ ObjectMapper mapper = new ObjectMapper();
+
+ String listUrl = UriComponentsBuilder.fromUriString("https://euapi.ttlock.com/v3/lock/listKeyboardPwd")
+ .queryParam("clientId", clientId)
+ .queryParam("accessToken", accessToken)
+ .queryParam("lockId", lockId)
+ .queryParam("pageNo", 1)
+ .queryParam("pageSize", 100)
+ .queryParam("date", System.currentTimeMillis()).toUriString();
+
+ try {
+ String response = restTemplate.getForObject(listUrl, String.class);
+ JsonNode root = mapper.readTree(response);
+ JsonNode list = root.get("list");
+
+ if (list != null && list.isArray()) {
+ for (JsonNode unlockcode : list) {
+ String name = unlockcode.get("keyboardPwdName").asText();
+ int passwordId = unlockcode.get("keyboardPwdId").asInt();
+
+ if (name.equalsIgnoreCase(UNLOCK_CODE_NAME)) {
+ deleteCustomPasscode(clientId, accessToken, lockId, passwordId);
+ }
+ }
+ }
+ } catch (Exception e) {
+ System.err.println("Fehler beim Massenlöschen: " + e.getMessage());
+ }
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ @Getter
+ @Setter
+ @Data
+ public static class TTLockDetailResponse {
+ private int errcode;
+ private String errmsg;
+ private String lockName;
+ private String lockAlias;
+ private int lockId;
+ private int electricQuantity;
+ private String modelNum;
+ private String featureValue;
+ private String adminPwd;
+ private String state;
+ }
+
+ @Data
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class TTLockAddPasscodeResponse {
+ private int errcode;
+ private String errmsg;
+ private Integer keyboardPwdId; // Die ID des neuen Pins in der Cloud
+
+ public boolean isSuccess() {
+ return errcode == 0;
+ }
+ }
+}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockTest.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockTest.java
new file mode 100644
index 0000000..da137aa
--- /dev/null
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockTest.java
@@ -0,0 +1,56 @@
+package de.oaa.xxx.games.chastity.ttlock;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import de.oaa.xxx.games.chastity.ttlock.TTLockService.TTLockDetailResponse;
+
+@RestController
+@RequestMapping("/ttlock")
+public class TTLockTest {
+
+ private final TTAuthService auth;
+ private final TTLockService lock;
+
+ private String clientId = "6e5077a84b6a4e1ba0fb6a8da21c6417";
+ private String clientSecret = "a2c1d68c7905d52584fc29028937db11";
+ private String username= "mario.stoermer@proton.me";
+ private String password = "knall666.Halla";
+ private int lockId = 30158446;
+
+ public TTLockTest(TTAuthService auth, TTLockService lock) {
+ this.auth = auth;
+ this.lock = lock;
+ }
+
+ @GetMapping("/details")
+ public ResponseEntity details() {
+ String md5Hex = org.apache.commons.codec.digest.DigestUtils.md5Hex(password).toLowerCase();
+ String token = auth.getAccessToken(clientId, clientSecret, username, md5Hex);
+ return ResponseEntity.ok(lock.getLockDetail(clientId, token, lockId));
+ }
+
+ @GetMapping("/add/{pin}")
+ public ResponseEntity add(@PathVariable String pin) {
+ String md5Hex = org.apache.commons.codec.digest.DigestUtils.md5Hex(password).toLowerCase();
+ String token = auth.getAccessToken(clientId, clientSecret, username, md5Hex);
+ return ResponseEntity.ok(lock.addCustomPasscode(clientId, token, lockId, pin));
+ }
+
+ @GetMapping("/delete/{id}")
+ public ResponseEntity remove(@PathVariable Integer id) {
+ String md5Hex = org.apache.commons.codec.digest.DigestUtils.md5Hex(password).toLowerCase();
+ String token = auth.getAccessToken(clientId, clientSecret, username, md5Hex);
+ return ResponseEntity.ok(lock.deleteCustomPasscode(clientId, token, lockId, id));
+ }
+
+ @GetMapping("/delete/all")
+ public void removeAll() {
+ String md5Hex = org.apache.commons.codec.digest.DigestUtils.md5Hex(password).toLowerCase();
+ String token = auth.getAccessToken(clientId, clientSecret, username, md5Hex);
+ lock.findAndDeleteLocksByName(clientId, token, lockId);
+ }
+}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockUserConfigEntity.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockUserConfigEntity.java
new file mode 100644
index 0000000..e00d970
--- /dev/null
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockUserConfigEntity.java
@@ -0,0 +1,32 @@
+package de.oaa.xxx.games.chastity.ttlock;
+
+import jakarta.persistence.*;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.UUID;
+
+@Getter
+@Setter
+@Entity
+@Table(name = "ttlock_user_config")
+public class TTLockUserConfigEntity {
+
+ @Id
+ @Column
+ private UUID userId;
+
+ @Column(length = 200)
+ private String username;
+
+ /** MD5-Hex des TTLock-Passworts (so wie TTAuthService es erwartet) */
+ @Column(length = 32)
+ private String passwordMd5;
+
+ @Column
+ private Integer lockId;
+
+ /** ID des aktuell gesetzten PINs auf dem Schloss – wird zum Löschen beim nächsten lock() benötigt */
+ @Column
+ private Integer currentKeyboardPwdId;
+}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockUserConfigRepository.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockUserConfigRepository.java
new file mode 100644
index 0000000..5fcedb8
--- /dev/null
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockUserConfigRepository.java
@@ -0,0 +1,11 @@
+package de.oaa.xxx.games.chastity.ttlock;
+
+import java.util.Optional;
+import java.util.UUID;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface TTLockUserConfigRepository extends JpaRepository {
+
+ Optional findByLockId(Integer lockId);
+}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/unlocktypes b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/unlocktypes
new file mode 100644
index 0000000..0d5b861
--- /dev/null
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/ttlock/unlocktypes
@@ -0,0 +1,121 @@
+1-unlock by app
+
+4-unlock by passcode
+
+5-Rise the lock (for parking lock)
+
+6-Lower the lock (for parking lock)
+
+7-unlock by IC card
+
+8-unlock by fingerprint
+
+9-unlock by wrist strap
+
+10-unlock by Mechanical key
+
+11-lock by app
+
+12-unlock by gateway
+
+29-apply some force on the Lock
+
+30-Door sensor closed
+
+31-Door sensor open
+
+32-open from inside
+
+33-lock by fingerprint
+
+34-lock by passcode
+
+35-lock by IC card
+
+36-lock by Mechanical key
+
+37-Use APP button to control the lock (rise, fall, stop, lock), mostly used for roller shutter door
+
+42-received new local mail
+
+43-received new other cities' mail
+
+44-Tamper alert
+
+45-Auto Lock
+
+46-unlock by unlock key
+
+47-lock by lock key
+
+48-System locked ( Caused by, for example: Using INVALID Passcode/Fingerprint/Card several times)
+
+49-unlock by hotel card
+
+50-Unlocked due to the high temperature
+
+51-Try to unlock with a deleted card
+
+52-Dead lock with APP
+
+53-Dead lock with passcode
+
+54-The car left (for parking lock)
+
+55-Use remote control lock or unlock lock
+
+57-Unlock with QR code success
+
+58-Unlock with QR code failed, it's expired
+
+59-Double locked
+
+60-Cancel double lock
+
+61-Lock with QR code success
+
+62-Lock with QR code failed, the lock is double locked
+
+63-Auto unlock at passage mode
+
+64-Door unclosed alarm
+
+65-Failed to unlock
+
+66-Failed to lock
+
+67-Face unlock success
+
+68-Face unlock failed - door locked from inside
+
+69-Lock with face
+
+71-Face unlock failed - expired or ineffective
+
+75-Unlocked by App granting
+
+76-Unlocked by remote granting
+
+77-Dual authentication Bluetooth unlock verification success, waiting for second user
+
+78-Dual authentication password unlock verification success, waiting for second user
+
+79-Dual authentication fingerprint unlock verification success, waiting for second user
+
+80-Dual authentication IC card unlock verification success, waiting for second user
+
+81-Dual authentication face card unlock verification success, waiting for second user
+
+82-Dual authentication wireless key unlock verification success, waiting for second user
+
+83-Dual authentication palm vein unlock verification success, waiting for second user
+
+84-Palm vein unlock success
+
+85-Palm vein unlock success
+
+86-Lock with palm vein
+
+88-Palm vein unlock failed - expired or ineffective
+
+92-Administrator password to unlock
\ No newline at end of file
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/unlock/TempOpeningReason.java b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/unlock/TempOpeningReason.java
index 184aecb..f121f78 100644
--- a/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/unlock/TempOpeningReason.java
+++ b/xxxthegame/src/main/java/de/oaa/xxx/games/chastity/unlock/TempOpeningReason.java
@@ -1,5 +1,5 @@
package de.oaa.xxx.games.chastity.unlock;
public enum TempOpeningReason {
- HYGIENE, CARD, TASK;
+ HYGIENE, CARD, TASK, TTLOCK_UNAUTHORIZED;
}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/subscription/UserSubscriptionRepository.java b/xxxthegame/src/main/java/de/oaa/xxx/subscription/UserSubscriptionRepository.java
index c9f2888..19dafab 100644
--- a/xxxthegame/src/main/java/de/oaa/xxx/subscription/UserSubscriptionRepository.java
+++ b/xxxthegame/src/main/java/de/oaa/xxx/subscription/UserSubscriptionRepository.java
@@ -3,6 +3,7 @@ package de.oaa.xxx.subscription;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
+import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -10,4 +11,6 @@ public interface UserSubscriptionRepository extends JpaRepository findTopByUserIdAndValidUntilGreaterThanEqualOrderByValidUntilDesc(
UUID userId, LocalDate today);
+
+ List findByValidUntilGreaterThanEqualOrderByValidUntilDesc(LocalDate today);
}
diff --git a/xxxthegame/src/main/java/de/oaa/xxx/user/UserController.java b/xxxthegame/src/main/java/de/oaa/xxx/user/UserController.java
index 6cc9f8b..31bc3b0 100644
--- a/xxxthegame/src/main/java/de/oaa/xxx/user/UserController.java
+++ b/xxxthegame/src/main/java/de/oaa/xxx/user/UserController.java
@@ -25,11 +25,21 @@ import org.springframework.web.bind.annotation.RestController;
import de.oaa.xxx.games.bdsm.entity.BdsmDefaultsEntity;
import de.oaa.xxx.games.bdsm.repository.BdsmDefaultsRepository;
+import de.oaa.xxx.games.chastity.common.BaseLockRepository;
+import de.oaa.xxx.games.chastity.common.CodeCreator;
+import de.oaa.xxx.games.chastity.ttlock.TTAuthService;
+import de.oaa.xxx.games.chastity.ttlock.TTLockConfigRepository;
+import de.oaa.xxx.games.chastity.ttlock.TTLockService;
+import de.oaa.xxx.games.chastity.ttlock.TTLockUserConfigEntity;
+import de.oaa.xxx.games.chastity.ttlock.TTLockUserConfigRepository;
import de.oaa.xxx.registration.Registration;
import de.oaa.xxx.registration.RegistrationRepository;
import de.oaa.xxx.social.entity.MessageCause;
import de.oaa.xxx.social.entity.NotificationPreferenceEntity;
import de.oaa.xxx.social.repository.NotificationPreferenceRepository;
+import org.springframework.util.DigestUtils;
+
+import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/user")
@@ -42,22 +52,39 @@ public class UserController {
private final NotificationPreferenceRepository notificationPreferenceRepository;
private final BdsmDefaultsRepository bdsmDefaultsRepository;
private final UserService userService;
+ private final TTLockUserConfigRepository ttLockUserConfigRepository;
+ private final TTLockConfigRepository ttLockConfigRepository;
+ private final TTAuthService ttAuthService;
+ private final TTLockService ttLockService;
+ private final BaseLockRepository baseLockRepository;
public UserController(UserRepository userRepository,
RegistrationRepository registrationRepository,
NotificationPreferenceRepository notificationPreferenceRepository,
BdsmDefaultsRepository bdsmDefaultsRepository,
- UserService userService) {
+ UserService userService,
+ TTLockUserConfigRepository ttLockUserConfigRepository,
+ TTLockConfigRepository ttLockConfigRepository,
+ TTAuthService ttAuthService,
+ TTLockService ttLockService,
+ BaseLockRepository baseLockRepository) {
this.userRepository = userRepository;
this.registrationRepository = registrationRepository;
this.notificationPreferenceRepository = notificationPreferenceRepository;
this.bdsmDefaultsRepository = bdsmDefaultsRepository;
this.userService = userService;
+ this.ttLockUserConfigRepository = ttLockUserConfigRepository;
+ this.ttLockConfigRepository = ttLockConfigRepository;
+ this.ttAuthService = ttAuthService;
+ this.ttLockService = ttLockService;
+ this.baseLockRepository = baseLockRepository;
}
record ProfilePictureRequest(String picture, String pictureHq) {}
record NameChangeRequest(String name) {}
record GeburtsdatumChangeRequest(LocalDate geburtsdatum) {}
+ record TtlockUserConfigDto(String username, boolean passwordSet, Integer lockId) {}
+ record TtlockUserConfigRequest(String username, String password, Integer lockId) {}
record ProfileRequest(Integer groesse, Integer gewicht,
Geschlecht geschlecht, Neigung neigung, Beziehungsstatus beziehungsstatus, String beschreibung) {}
record PrivacyRequest(
@@ -270,6 +297,132 @@ public class UserController {
.build();
}
+ // ── TTLock-Account ────────────────────────────────────────────────────────
+
+ @GetMapping("/me/ttlock")
+ public ResponseEntity getTtlockUserConfig(Principal principal) {
+ var userOpt = userRepository.findByEmail(principal.getName());
+ if (userOpt.isEmpty()) return ResponseEntity.status(401).build();
+ TTLockUserConfigEntity cfg = ttLockUserConfigRepository.findById(userOpt.get().getUserId())
+ .orElse(new TTLockUserConfigEntity());
+ return ResponseEntity.ok(new TtlockUserConfigDto(
+ cfg.getUsername(),
+ cfg.getPasswordMd5() != null && !cfg.getPasswordMd5().isBlank(),
+ cfg.getLockId()
+ ));
+ }
+
+ @PutMapping("/me/ttlock")
+ public ResponseEntity saveTtlockUserConfig(@RequestBody TtlockUserConfigRequest body, Principal principal) {
+ var userOpt = userRepository.findByEmail(principal.getName());
+ if (userOpt.isEmpty()) return ResponseEntity.status(401).build();
+ UUID userId = userOpt.get().getUserId();
+ TTLockUserConfigEntity cfg = ttLockUserConfigRepository.findById(userId)
+ .orElseGet(() -> { TTLockUserConfigEntity n = new TTLockUserConfigEntity(); n.setUserId(userId); return n; });
+ cfg.setUsername(body.username());
+ if (body.password() != null && !body.password().isBlank()) {
+ cfg.setPasswordMd5(DigestUtils.md5DigestAsHex(body.password().getBytes(StandardCharsets.UTF_8)));
+ }
+ cfg.setLockId(body.lockId());
+ ttLockUserConfigRepository.save(cfg);
+ return ResponseEntity.ok().build();
+ }
+
+ @GetMapping("/me/ttlock/test")
+ public ResponseEntity