From 78fca8ebc210e3ff5c58bf5525845098fb9cb844 Mon Sep 17 00:00:00 2001 From: Ettore <=> Date: Wed, 6 May 2026 01:51:22 +0200 Subject: [PATCH] First commit --- .env.example | 12 + requirements.txt | 9 + src/__pycache__/auth.cpython-313.pyc | Bin 0 -> 2937 bytes src/__pycache__/database.cpython-313.pyc | Bin 0 -> 4467 bytes src/__pycache__/dependencies.cpython-313.pyc | Bin 0 -> 2326 bytes src/__pycache__/main.cpython-313.pyc | Bin 0 -> 5064 bytes src/__pycache__/schemas.cpython-313.pyc | Bin 0 -> 4754 bytes src/core/__init__.py | 0 src/core/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 148 bytes src/core/__pycache__/auth.cpython-313.pyc | Bin 0 -> 2942 bytes src/core/__pycache__/config.cpython-313.pyc | Bin 0 -> 389 bytes src/core/__pycache__/database.cpython-313.pyc | Bin 0 -> 4561 bytes .../__pycache__/dependencies.cpython-313.pyc | Bin 0 -> 3110 bytes src/core/__pycache__/schemas.cpython-313.pyc | Bin 0 -> 5206 bytes src/core/auth.py | 53 ++ src/core/config.py | 3 + src/core/database.py | 91 +++ src/core/dependencies.py | 50 ++ src/core/schemas.py | 123 ++++ src/main.py | 98 +++ src/models/__init__.py | 9 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 311 bytes .../__pycache__/credential.cpython-313.pyc | Bin 0 -> 647 bytes src/models/__pycache__/gate.cpython-313.pyc | Bin 0 -> 894 bytes src/models/__pycache__/status.cpython-313.pyc | Bin 0 -> 461 bytes src/models/credential.py | 5 + src/models/gate.py | 9 + src/models/status.py | 5 + src/routers/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 151 bytes .../__pycache__/admins.cpython-313.pyc | Bin 0 -> 3770 bytes src/routers/__pycache__/auth.cpython-313.pyc | Bin 0 -> 3195 bytes .../__pycache__/credentials.cpython-313.pyc | Bin 0 -> 2272 bytes src/routers/__pycache__/gates.cpython-313.pyc | Bin 0 -> 9710 bytes .../__pycache__/keypasses.cpython-313.pyc | Bin 0 -> 5936 bytes src/routers/__pycache__/stats.cpython-313.pyc | Bin 0 -> 1146 bytes src/routers/admins.py | 55 ++ src/routers/auth.py | 45 ++ src/routers/credentials.py | 40 ++ src/routers/gates.py | 189 ++++++ src/routers/keypasses.py | 89 +++ src/routers/stats.py | 21 + src/services/__init__.py | 9 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 339 bytes .../__pycache__/avconnect.cpython-313.pyc | Bin 0 -> 3572 bytes .../__pycache__/gates.cpython-313.pyc | Bin 0 -> 2593 bytes src/services/avconnect.py | 53 ++ src/services/gates.py | 38 ++ src/static/admin.html | 384 +++++++++++ src/static/admin.js | 601 ++++++++++++++++++ src/static/app.js | 168 +++++ src/static/index.html | 136 ++++ src/static/logo.svg | 53 ++ src/static/manifest.json | 18 + src/static/style.css | 191 ++++++ src/static/sw.js | 26 + 56 files changed, 2584 insertions(+) create mode 100644 .env.example create mode 100644 requirements.txt create mode 100644 src/__pycache__/auth.cpython-313.pyc create mode 100644 src/__pycache__/database.cpython-313.pyc create mode 100644 src/__pycache__/dependencies.cpython-313.pyc create mode 100644 src/__pycache__/main.cpython-313.pyc create mode 100644 src/__pycache__/schemas.cpython-313.pyc create mode 100644 src/core/__init__.py create mode 100644 src/core/__pycache__/__init__.cpython-313.pyc create mode 100644 src/core/__pycache__/auth.cpython-313.pyc create mode 100644 src/core/__pycache__/config.cpython-313.pyc create mode 100644 src/core/__pycache__/database.cpython-313.pyc create mode 100644 src/core/__pycache__/dependencies.cpython-313.pyc create mode 100644 src/core/__pycache__/schemas.cpython-313.pyc create mode 100644 src/core/auth.py create mode 100644 src/core/config.py create mode 100644 src/core/database.py create mode 100644 src/core/dependencies.py create mode 100644 src/core/schemas.py create mode 100644 src/main.py create mode 100644 src/models/__init__.py create mode 100644 src/models/__pycache__/__init__.cpython-313.pyc create mode 100644 src/models/__pycache__/credential.cpython-313.pyc create mode 100644 src/models/__pycache__/gate.cpython-313.pyc create mode 100644 src/models/__pycache__/status.cpython-313.pyc create mode 100644 src/models/credential.py create mode 100644 src/models/gate.py create mode 100644 src/models/status.py create mode 100644 src/routers/__init__.py create mode 100644 src/routers/__pycache__/__init__.cpython-313.pyc create mode 100644 src/routers/__pycache__/admins.cpython-313.pyc create mode 100644 src/routers/__pycache__/auth.cpython-313.pyc create mode 100644 src/routers/__pycache__/credentials.cpython-313.pyc create mode 100644 src/routers/__pycache__/gates.cpython-313.pyc create mode 100644 src/routers/__pycache__/keypasses.cpython-313.pyc create mode 100644 src/routers/__pycache__/stats.cpython-313.pyc create mode 100644 src/routers/admins.py create mode 100644 src/routers/auth.py create mode 100644 src/routers/credentials.py create mode 100644 src/routers/gates.py create mode 100644 src/routers/keypasses.py create mode 100644 src/routers/stats.py create mode 100644 src/services/__init__.py create mode 100644 src/services/__pycache__/__init__.cpython-313.pyc create mode 100644 src/services/__pycache__/avconnect.cpython-313.pyc create mode 100644 src/services/__pycache__/gates.cpython-313.pyc create mode 100644 src/services/avconnect.py create mode 100644 src/services/gates.py create mode 100644 src/static/admin.html create mode 100644 src/static/admin.js create mode 100644 src/static/app.js create mode 100644 src/static/index.html create mode 100644 src/static/logo.svg create mode 100644 src/static/manifest.json create mode 100644 src/static/style.css create mode 100644 src/static/sw.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ee6b63a --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Admin credentials (required on first run to seed the admin user) +ADMIN_USERNAME=admin +ADMIN_PASSWORD=changeme123 + +# JWT signing secret – generate with: python -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY=replace-with-a-random-64-char-hex-string + +# Set to true to skip real AVConnect calls (for testing) +# MOCK_AVCONNECT=true + +# Database path (default: ./data/gates.db relative to project root) +# DATABASE_URL=sqlite:///./data/gates.db diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c87dabe --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.111.0 +uvicorn[standard]>=0.29.0 +sqlalchemy>=2.0.0 +python-jose[cryptography]>=3.3.0 +bcrypt>=4.0.0 +cryptography>=42.0.0 +requests>=2.31.0 +fake-useragent>=1.5.0 +python-multipart>=0.0.9 diff --git a/src/__pycache__/auth.cpython-313.pyc b/src/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7df92b3a31e8b744b3415b6aa0e0c80ee296a5e7 GIT binary patch literal 2937 zcmd5;&2JM&6rc63KR0$hY|`>&HXkH70=s|{DFKqw5JHmzk`1a*ELj_SVlQ#L*6fCm z93s;TEr$jU2qP*Hs!ApO5LB(y9(w4Xkl;hg64V2|aI+LDRh;@}){Bz}AtWvx$@AX6 zc{B6gZ+`Q}>t3%1LG$&#RF<6xJ)<91b5xb}W*b5`(Owisac&e5Q*wl3-!@`nk|#V! z+o&*NCw9s7aS#XC_)+JGi@2Cg7Mut6G9S7@UfufRgq|pIJm^4MZLB zI%eG#_a>UFem-V-FJ8}l?1^SOtON5KnEm~C%mL7a92&^>1d2s#@mpFeN^r3r>#HWg zzCG4k`?j?1s$%6HJ7SI0t8~56OBP{wiw{HwY8%2C`M;&X;!`)W?Ssu;Ms~S zzwYYVG53bys%rVPqb2T4$Zj~sQ)ITF8;-23WeYQg1Lsp}8XL|G&TDcG2GW=sLL8xY zO{2+4Mpq=OPT>gFb(P>qM$scVIipU=1P{wP)*>27MdWEc8!pTmUb=F!AZyx;O41|% z3jptt7J{yb*2KnzW7m$Aw%y%zd)J-d56usP-v!H|-m=)YDjr`EkC(;4Wnpj~oRJwM zPNz0v_X^=Gc)TmZ=ealGZ{gaGh!-{s@x(`?a;CC4HCdP;4b*63IFV-1H5#WR5kK{+ zzl4c0F*d=a_PGy-*zrrU1k?{p(x9BnVIoaUYq}(-(~_K* zI`QQ~r<8_s)|DwN>8hk>u~ZE$4EM(^=Q1i$^z2kfAan}`3~_;GxQ~A_d^Yy+xl^Zs zP_%TKgfbzjOA!pqCUtcZ=Ls!}yQl}vy@>u6eG7fp`mSfbn)~I#ia4+=44A0cnfvQ8 z8HcGiQSdG9-Rn?^a3(G-Xp9EWniI)@wuJVejBNrPz7RtHl)3$~zpo-qF-7v>E+1Uij3VN6-Xc$Wrc6Yr>XOM7!&v+8yX-teuhRD_CmOZ6Ha(@#)SSS*pzS8#maX-jMHbC&~nT2 z3AIAemo?lIHJr3G=ag~7rfP=ML`X9n`fNeTX9ztk=u4;zJh3stDgr86X7{WzW z!;Fw7Qz<{`!CX>^nm70i)(tn~doqhJnN0(GwPcb#3x+VRsyRXn zqamb~l+J1Zp-eV}B#~!~#%Yq%8d~|5=kB`cShKruh2g_)<;^4RwT*+TS zR4)2oFj;YN+>z2@YCLkEXX2qihmKV0Y}|)jsl9@zTs-`O$%@C$1xnxuV$pR!aL@OG zs+9n8d9Kb~nY%iFW&V1u>}*?g9$cI&3V-?ntG7(25#A-2+~txKe?(alE!(mkD`9Gj{!=0cX$r}bY*VrvaA~)N1#DO3irR?e z%5Rs7WfTD$1Bun2NG@tvIfX!bkZ(S;K#o23DAtEauPK_MheEfsQh}l<(9XQ&%9Jgm z4$vhy@6F8no%v>Vb~_xFIe3mw{L9D%IqomWxR2EAY-c8nPY2WR= zVB`^3|FoTo4)6}=wj}m~+57Di+np7E!K_!G?&bET;)-toEQ1e8z`WO;ek;wMYUP~1 z;#b6PuuDRb++EtZa~`)(I;R51xT#Jhpvb3rC3wL@x~4+MI7L?EsV*gS!ArWkIN-xc zPHCG84tSm2Ns>t;AdOBXccllJGzQXmi?oMH+d-OWkse~w4v-#bksfB!PLOuBNRKGp zNsja?2bE-!dru%oi+rkQ3y(6H;;B^7Y~41>dP=0?uxXZbtwQ}5%~G{oq0$H_bD*T+ zm5Qwwbwd4$O^iyB3OW6@O~ZMj!w^-k6pf0W^3ceLo-b)cvyBCPShI8*vUJNb%t~3C zgV_S?rqR&IP;O{=NEubHXD9B+)~%9Z>u;sg={r&pR(wKmZV4 z;fP1!iC6Iuq3BHsRKNpo;ebbA8XuKZwSwELR94lpS*VtsL%rcc`jm!}as3W`m`st8gDxC7rP9HXeocr)SNwp4M&KBzn4N*y)m1G|L*%FX7$O z7Rjg4O4D$d{`p1H0h1WPgIPWxA8}NW2nG?8@42TRr1)(A0Lb(Aobmx+{fZZU0>D#b zyrlRPKY%x|hm-ek!6qjv5{wKf0TM3CsUY>^p+|^3dQsG2lvz>YTnv z!{`{J0#GUI6?^OvfUZ(4m9*&+;7YGxTL7V0&}X!2$!4~iOtleQlXN4&WJM0*7GYHd z)0AKqI)Z0Q6QKtsemdPz@w=4;=E4d+JcLkvX&>NY80p+eN?qb56(y5IOaTz zyYu`*MyHX^5Lmp9wHSNqlm8trFl z6PvNlRsUL|5$nI3aV(5$?Qh|qg65?y>}65mZAOI*oCwHw;vm%I!YnF65KX9SV_XDu z7$ea=x)|f+=Eyk2Pdkeraa>Y0Lg0DZvJt^!VXeU#N zZ{{N*Zx(bCz^#G>-O3YV-iF*vFl$iR$>jw|TvXO?&l?0Xmu3?Il_Jp>%sIV4}kBlre{Q@m?60pS$yuZ^RBCGd6Z zOfBQ|&o^SHq2HER`EFfrw4JP7+9OXSSI@7#(nzH5UUl$*yYG*OUxVhQ@z9zh{R|r( z;^fF+lM68)yPSg!ccg<2R~KoHbRyM3N(``pSvbyg?aeh4jB|Az5Yq>on8v(2l+VM< zb;2xe4Z=-ml;u2)3DuHWbV1Y_)SWL47>0=eRtAtO%kxxjViV(-1$@XAAgE}&%eCN& zitI#?Z=H0^vV&~Qt6HG|;IbSf%9uD*tqAFB(MS%i3!M(Es*Sc&0ISxlzMEzo7z%Z} z5j*L^usJ>yUpZfYr4f4d?x+JjJmCJ&L(NM=Z*MBX*EOaaS&>Mv7)Z%9FjOcTmFr-} zt;?`34Qj}%XxCi~ybX$0AcS5T2@gfBq2uu1wpphajCa$m={P(bJ{9gTcb!|{9`L=; zX1z?fhqxnqxgvM;e~y{qzU@82&G2t}XZRF93FU1_ajMw~Gq07XFJCe(9m-NqVcNpe z^b)KfsK2P&YGIm80rM{Wt=E8TaW(GCK*vuG*M`6Lczx{~ZP6b~UrF+^v?TpZ{^vc9 z)A_fRfWaRI2l-EgWSTprtF+)cC} zdlX*rT=4XAH~p?8iw=I~$p)Vh0DCj!$`tPf9xa#>CUbCmNxyAawrb9$c+w7I;7h_| zT2#W4zW~KC4X9Y~t0PyktY8I=ZfopIp!v>G;YC9I&PT*EiovX@!_wb_zjXo# z*i!I+JpSSMF9P+E-wUZP1?l6fKf3yb(9;lle$}@w^nj2BA$ZMCbh}C_lc{4wg}DR` z2X)D38jk7h@qc6~Q{Ei-yCr)IBR%TeJyD2j=OOF$L8U=XuHy?`9jhD#Ta zd>=^%l3paNRKnL8%X}=%7{&;i=2^Q*Z#$nX?+^l7M5py1AYXfVp8o?E_#=1duiT9< zxEl@b#-F%B1fsYkE=QIktDeuqPsPt7pGMZCCtYWrNM~!ljcEL#cwc-NxgV+fo<#d< z;f+w+@};Fq%M(iztB2P7PeSQh;HyY%`OeavUmscY9smFU literal 0 HcmV?d00001 diff --git a/src/__pycache__/dependencies.cpython-313.pyc b/src/__pycache__/dependencies.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a6d2416cab1804f6e49ecca419b485768055c7af GIT binary patch literal 2326 zcmcH)OKclOboS%jwH>eXX+qn$XjS!tp^a(epc0jeLO-buZdu1FkW|)Ycj9bg@4B<= zwzgC$6^9CO00b4SREZ)X0XKw@5I2q-9qplEl+q%EDsG7aJ#b=Xy<5jsg_HwN+Ier@ zd-LYa`|U;|5d#E%to&o52mrs+O$cH@Y_th+0~A4#$%7m~K%q37XL2lJeVWU2IUe!3 z2#VwcB=|f&FXkj9<)SF+?<4tGE{@_pE#&)h36$_@F)!zmD4FX={R)_k?gkUVwP;|L z-wkF5CYTN*%M_(qsTiFd%<{$9PH=(0z@VXC9e>yV_yp^ph|k8!sbp3t_64W5RT63W zPii*JV9C&2=$bW1=`(fLv@NZg=CF7i)}duMI59OnefGo^9r`jXIIiY4oHUD5RB)`} zF4)LyY4qUh2pZ6GO|9zS7?r*TH3Si{EEb{T5Dh~18Bn(kSa$72Xc04F9yaTm<6xlz z-Lf%ba)mrnf}AN;8D+2Y5GclLQB>95au zBiU7X^!CZSsl&^u!|lnpJXvi^>PmcYsjxvT0L&V5`sVD=XZcy2C)_P$ku9-3S!Dm; zEUF3WeJ{+=!1xf>iaHQ-y11*Q4tBXAHA5<}10}Y|(?SewO-^-b^_ftsi^JJqaV0K{ zgt3(>jclq?PNj){0z#`>s(qoyD)Hw@n0tlv9H%}-GLs5*)n`Mhi@VcslEiUKK8c%s zBK;^85KR;o7dz9YHGJB-q*YBr2^fk$k1*#n=2`>6NK!1@Rp#x6Wwf@3tOdZ+thx|k)-*6@S}u;v zo5*po7~Ftk9ji`Ra}n(uEHqr*vM;B3e+D2LS2RKe>|))aGg2XdXaV75*xSJ~(SE|E zE;y@XSpt7aAUXP9{r!$N^3v1#`$6j2&!=upeR$E~m1qQu4#t`>{{;oBQwV zy){OL!?Kj#;MiTlLm_?*zE@d`fQa}=0DW?g!8bj*(3T1-V)T0JdaFk??a8IKRHCA* z*RC#$${qEq^Ix3*{_P*i-<7?IliuEwJ^E)n`D|M{yAn?3>w4(;Vn0717y&qvg;L<_%MK{n6@Y;2Yq(*U6D|q-Gn9Dm;n~kyG@a<~U?o;~0jy z2X@^9qYuIOG8n%PPTT{#{{Xx0gFV0XjePLNQgmGaFTcwC6!i}0)&b89wRcRcQT(&} xH%06H;DzyT)86w(ei__xbKfVK^~eAtuf+GSaRfh*l52FoCIBJvNFZ91 literal 0 HcmV?d00001 diff --git a/src/__pycache__/main.cpython-313.pyc b/src/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a8001f59eefb207381f1856f478e0c5620a3542 GIT binary patch literal 5064 zcmd57&-6`tMY&+rB-wJpwVDDFx>%Dda$Ry) zon6TmQ3P_*!WPm(GLSYl5WoshAofj1A9`rfOOFyLz{19A4WvEfMuR;xIrYshhcs0w z1`0Ij5Ighc&G+WLc{B6ou~<{%M^J|J@6`DaLSK^(EqsY6{?vugZ;^;ZW*lLsWiVs+ zS=MWrH|`$uV2@3^#=T=a=55+N?i=%CzksHhcoS7oY2&RGno=T~M!RD7 zQ$(fR19_l=pwx8u6ywn4mUa5Jou2X9u{vC5)86s=u?UWgHQJJ9qIv1Wh~17c`^0~~7*qag2VmC$`F;gK zsp%H#-#rog-iT;>BclC{2;q&0jw*ymS5RriIuXqt`BQ0YnwC%;4N6c39>#G~Z&q?i=b{nj@GFwxCe-b=D! z4!t)DWN7$$T)d_xlNsf6ea=*HE_78+=~>VtT$W9kjirivASWoW@R&Nxo+vI zW(6ei#?Xz?VQFME{uC97306-mt!I^(VuBNuSV}cxnbMxvTM{#HB9@g^Ejqhkg(TS3 zc||I@3wOc@Fq51|bqQ3{4U-15A?~->pxD@#v<$d>F8~5L^aLoly)EJfZKgt3RACYO1Ws)mHfg z?AzNL;gUD#1ULrE8~I+J#PWRK`r!LJePVeb9Ak5Cw(4Mz@b@u%#gkC&ywCI<7tj<3 z!*;3gQck#ICc0TnQe}u+hN2`T=U@uJdF_hwQb{`|SMw@-Mi?r4Q(b{yj=pe(e|7C! z!wx%5Elib-B;XSL#XmyWhTsjPQ{u$>9VJde>W%`rBuhiJ_TR{JG$v`xBtvc*DM_#< z<(eu#5!5pp+m0sLxX(eoUQgNoC*2xY;fovL zi_gQCH^P^B8i41<#^JbfmoLFo`~8%^#Z)PHz2WdrFC$SYgWf5 z$;mRhoCN2S12WNl(s4IuEEtw|j%0LLv%E8q+sd;lCT_BtOTB2eoZ?0SW?&K@tU5Z1 zOX;rRyQpNvPh-TZ4CVZ9%bdrj^&UPYVKiKvQW%0TuDf3{l9q;XxlXDs6U7UjXNP=#hh4^0J7>4-@`MyH_uTf|l?cYXy+o)$79V>VchF6%CzR&s}^nW(^U~skX%l57hZLbY=sc*+$WA zbb1?|*hc5#@ZYL`<>spZU4=3(FC{zv_f2OkYSIi25s zaxI+?4K4<@5O>da*LT1B!PJwp8;$+X(u=+gbonLXo}>B=RKMk_--$E9W+mQ0}cqj%=c%&3^5O{77=L)vddu=cnWP< zog~m>{|W8g8%ngdm9+y97-jA2zSbx9obNbh{-O*#=>S7SciY{z`)ZzPDY7h6Pb_OK t5bB@)AQLIHBUkW#F3+`YxdQjbKN?^0uePo=JWJ+X!vzkx_SxyjzX2|s6MO&w literal 0 HcmV?d00001 diff --git a/src/__pycache__/schemas.cpython-313.pyc b/src/__pycache__/schemas.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4d6b22348261b5259e4202a62bbad8a54f024dcf GIT binary patch literal 4754 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 literal 0 HcmV?d00001 diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/__pycache__/__init__.cpython-313.pyc b/src/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b15afc89e8bcc6819717e55734306786a08e6170 GIT binary patch literal 148 zcmey&%ge<81jbpvGC}lX5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~icenx(7s(xxo zNq$kPetKq!eokU~er{q>s(WHdYO#KCQL=tANGd))GcU6wK3=b&@)n0pZhlH>PO4oI VE6@y(9mOEVM`lJw#v*1Q3jkk(BL4sY literal 0 HcmV?d00001 diff --git a/src/core/__pycache__/auth.cpython-313.pyc b/src/core/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1d5800885e585fb9a4f2eb5b436cabcf86363284 GIT binary patch literal 2942 zcmd5;&2JM&6rc63KR0nc?4%7J&JswB5!fYhkVr@<4IwlsAlaY_VaeLq6MGZKYt3#5 z@d24$XmcoVKp0VpP*p1QLr}F+d+4EmLV^z|3#bQr;btjRsyOw{tQRK{LP%UXlIOjB z^JeC~-~8r{*BTo=2wLCtOJ&)K&@=jBHAhWZYqueE9d)BPigOc)n35wL`?e7qlRV); z+9rf?JF!cqkApbC#!ooMUBty~!i0O=Lp`G!*N%lCGJkNM1{C#3z`;)k6A=!^+z4? z24-!GHzwL^eqLsIFW$s_?1^?etON6#nf?8D%mL7a9GXc-0>z^B_${pqCAioD>#Huo z-Vr-k|F*RLnquW18?;91anuKI*^%Kw{^xYjlOfJ< zohs;xnwN8+JC0$J$9l+SxW+#{JxYj541RiEH|)pbz5NFaPkeMFHhMZaF?#m71oI7h zAtx(&!$GhN9Uk7TMTg9+`7;i6r(dXH7nnBvn_V7^Xx*F zUw>`wn0wuDO|^V>p?loPklk=hrpQ7;Hyl}6%NFJh2hOL|G&YqM$scVIit?X1dqu&)*>27MN)J~p3}48!h+FAmroXCO`BIq zn)qQAAV1PV(3Q{=vE|Cp;!wHm_O_ebZUug5zaRK6PzfEZhy$zQkrnYsMI2ccM%KU? znUUgjYCV9jV9tWayTW{id($CU;O;`C5jG3)#7m=crm{FSQN_rMcwa~(FiQIB7qY_2W&V~d+w_v~!7ifn2$R}f`VjrJA zegarU>!-;o6QYI`!LV#nS7&gZ(8{=tdeGcU=x@<`WngjOYUZorFK1W8!^^^96BRpi ze?2CXF!eeLyv5~v9V!vd#AOFMM}ue0iDW=qLY*jMn?gf;Cz|3yeDPR}6#xc>d27-Z zBzq;5NGH`imXs+r&f=96O;Ymb<(!h1A`(EV5S*syd0wHX!6h~EqxXg3N9glHDCGe@ z%9{=FnH$&O>w?i8)FTXC2~`E&?tjwMdZoBnEO&pi<@b)?#FeI_%dVsUJ`sIuh_m}2 zHTkMM$d_6JO!+LZ1Ksh2`5(D3Z`dKwIlKX5$|A7slMNwBhv2`oL*hp@(b0aZga=W8{5|+|WNoTc4rEr>N&I z6sS5-qrc<^;cL6G=h~hd;cMZgZI#yWYU|j8;A-#4!`_iUKC1MNl{}x1RM1$}!Er~q zvagD$T=KnOvg+cv!Ezrp4&LRNcp%WB!D@qz`;aT|sv;_v`d%PIfm<>IB{<%^dtUd>gUJ6E0imx?9fPp^N~+w;)dvo!zh#jh_uNdG$X)6CDa zmEh6U;KWLBq7pn&@t!P+k37v+w^lscOHLYRw7ips5?$I!Ly6KL>!yFGiBaf3Cq*=P literal 0 HcmV?d00001 diff --git a/src/core/__pycache__/config.cpython-313.pyc b/src/core/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..05ad2ddba2b3d141e179d3f2f863ce3c601084b7 GIT binary patch literal 389 zcmYL?%}T>S5XX0ujZvX3o`Mgc(u+%xdZ`E{NDsvn67kYPh;@yTBrBV((jJPBU=Pw~ z@C|&7mI}h)LGa{F=*^QGXz0Mq|2N;uQfV70JG%ReLZ#n%QW#mmQYd&qd&ol`ZXQ6baHZdK;d0pg6=;<0tPrP(JV2ZNGVGcO2); zz5rC!0oQ;?F<_t%SynYc;~AJNeTW24K@XV(Bj%4x2wj~3a>aQJ72glil*>RyJk5On z6)p1zP@}gzVU$V91*2gkX&i(+2?T2el4Ud#ecI1keV*P#;omQ17 BT;Tu! literal 0 HcmV?d00001 diff --git a/src/core/__pycache__/database.cpython-313.pyc b/src/core/__pycache__/database.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d8440f3f5ac3352225abfac74afc32395c1ac1f0 GIT binary patch literal 4561 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 ZeY+Q3WV|N@7b(3c0KxxABr|sDzX5ugcCY{d literal 0 HcmV?d00001 diff --git a/src/core/__pycache__/schemas.cpython-313.pyc b/src/core/__pycache__/schemas.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ed78e67482e5d0733fdac15a09f6cabf6a01c505 GIT binary patch literal 5206 zcmbtY&2QVt6(33zNl~Ac{Gr&f<2X(mF19hciQBqu7HynvYCCbZR?=+|0R$~E3KLn> z4(Z+cX23u;IjwzI#3%~1$Iai-zaTK+Rj19VC<@xviqI^e16^pkm@8?;d?|fhE)I^eCo<1azai*1+|@5j=m?;NE_8sOzOBl5yz(X<9ZUp;av3_MJ5w%l8_- zMQoA>$c|~B%xhs>z>W))|xFe zob7X$S6;E@SE|rV6JgU7x@oStmGv5ylcxFUx>XBXGN$>+;htY}>UQ0Q=BVd$0jO24 zyS{}=dVmg@riZn%Y56{PR@Z$SV4i~ckKmt)4`v>_Yxa!o`!2Ug01G{R*Y8qYvKo<)JH*WVHij0&+u=crI+Di zgoBSLDa4mr3_L`HYk%|!Hv%Uv8M6Da<(D5HT!L=P{5v2y zk%Cm5#v%12=8lgWeb^zA8#n~IxxPp6eQZF1GkYI^017W&-dx%)>|WX#-;*!*QaODE zsC>Nb@6PXRyiQ--m#+k86bCuMATA5j(Q^>J43=cL10%)goWz00k{X=Nk_IK>he~t~ zOL4$dR0>XQn_Z3S322F9si?#GJ#I}8fs|MJ8mtW97)u{a2F6JmT(J zmunRfrxF}Kq*>@?GJp#7pv@5~w?6~dPyqu7OK1RDt5$PAw<~7H5YRsxnZ7ol;J`_^ z73`W>rduo6D)Jk&rL>VqrI1%$x5j?}#b8hSS;P#sqClOzB@kb*zvRciuDs4)+q~1% z5?i;yd2TI)R+263$%SUN5ZcL9GlhP?$99HCcdzX@`|^cgpK){%dD;*PxBYcqFGD=k zNqBwWijM{vwL#Xp3Sk)XZ&7Trav`_y?te&V@x5v#dY#WN)SV;IcC^-OZSR7L)25EGfcYl7dV z%Z~pHT5x{vDTqz>x5Q{Ok@|A>#cVT``SOz&pEL)DUoP(~H}gX;Z|vM?XW86KeMfKC z=6>?c+yealVepTmzaRb6&2Of!zn;FncW(Ay1M#GC$l@{OFv~ucR-|Kqj#YopaUWLl ze|Qe`CV}U`68Vf`oX5cnBt#M;qK*r=1ry!p-v>!#3z;IhIGHfs^de_{2KHIE%f7j0 zmAMNyoKP?rTldI6FB|$h4V?c7$0ZkhY}fs)_a^p3fy?pG>Ab^wGr6thW?^hk9&08u z&z1ti#1Buc-~( zNf~^?f5%*T|Iymc1&{>CnJZ6s}(YLYwh>KJ#4~_2v$Vz1#}r41%B6#sY7DnZaaQC zV4wc$M{_-8{U_KZBZ*Obp}-qLg?KqbFGSQKw3Np%-Z9l8X5kBx(|N+hpKT)4wV#P# zY|2sK6drC1oTBjU2FhkfAkMwCFOLNLV8;_0%K%_H+6D2p#%Dxm7`63AXov-|8Afds zn{~xyI`Y(258^b8;wVDvYDp2JX`ZZRj4Zsl%Vpd1;PKcw6%81v+hND?Cjs!sYYm~d zFE?<}$#xlEnL-UiG7&g-^6|RUFs(`jZb47z_=Y#FDp*&Y#B9!89y+sBUuTBV zO0ScTB1RD$vdrgD^xMJ`HlRR_JUr2F`~1wvo;=db=C|%Ob3=RbP&1X?x)XRoh~4r# zsXcidqqwj{L0lrgJ^$jyzFY|Q9%nE{_=k|uBTFc7tvU57J-gs1i;ao}uCC1SjlxkM zk-{GeuEMV^)++q6!c}aDqo5Z#Jzb}<`aTK_3Mqh~>x;ZLNB2dJgUpeRDJj}xu|oT4 z@T+%$XZ%N zy0E=~_=Q$l4$6?V(oxCSo@+74TFJOHx$U$VWQPS^Qg&}2GRVHpv@mN;C{h-?Le|R3 w()sOkEe2UD2Xk%zHk=a68$lU{PUBJ_YbB#n0f&aHl}$+JcH{qMkP-F&1@0v9VE_OC literal 0 HcmV?d00001 diff --git a/src/core/auth.py b/src/core/auth.py new file mode 100644 index 0000000..eab4f51 --- /dev/null +++ b/src/core/auth.py @@ -0,0 +1,53 @@ +import base64 +import hashlib +import os +import secrets +from typing import Optional + +import bcrypt +from cryptography.fernet import Fernet +from jose import JWTError, jwt + +# ── Password hashing ────────────────────────────────────────────────────────── +ALGORITHM = "HS256" + +# Loaded once at import time; changing SECRET_KEY invalidates all existing tokens. +SECRET_KEY: str = os.environ.get("SECRET_KEY") or secrets.token_hex(32) + + +def hash_password(plain: str) -> str: + return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode() + + +def verify_password(plain: str, hashed: str) -> bool: + return bcrypt.checkpw(plain.encode(), hashed.encode()) + + +# ── JWT ─────────────────────────────────────────────────────────────────────── + +def create_token(payload: dict) -> str: + """Encode a JWT. Caller must add an 'exp' datetime to the payload.""" + return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) + + +def decode_token(token: str) -> Optional[dict]: + """Return the decoded payload, or None if the token is invalid / expired.""" + try: + return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + except JWTError: + return None + + +# ── Symmetric encryption for AVConnect passwords ───────────────────────────── +# Derive a stable 32-byte Fernet key from SECRET_KEY so only one env var is needed. +_raw = os.environ.get("SECRET_KEY") or SECRET_KEY +_fernet_key = base64.urlsafe_b64encode(hashlib.sha256(_raw.encode()).digest()) +_fernet = Fernet(_fernet_key) + + +def encrypt_secret(value: str) -> str: + return _fernet.encrypt(value.encode()).decode() + + +def decrypt_secret(value: str) -> str: + return _fernet.decrypt(value.encode()).decode() diff --git a/src/core/config.py b/src/core/config.py new file mode 100644 index 0000000..d6af125 --- /dev/null +++ b/src/core/config.py @@ -0,0 +1,3 @@ +import os + +MOCK_AVCONNECT: bool = os.environ.get("MOCK_AVCONNECT", "").lower() in ("1", "true", "yes") diff --git a/src/core/database.py b/src/core/database.py new file mode 100644 index 0000000..4a2d515 --- /dev/null +++ b/src/core/database.py @@ -0,0 +1,91 @@ +import os +from datetime import datetime + +from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text, create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_SRC_DIR = os.path.dirname(_HERE) +_PROJECT_ROOT = os.path.dirname(_SRC_DIR) +_DATA_DIR = os.path.join(_PROJECT_ROOT, "data") + +DATABASE_URL = os.environ.get( + "DATABASE_URL", + f"sqlite:///{os.path.join(_DATA_DIR, 'gates.db')}", +) + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +class GateDB(Base): + __tablename__ = "gates" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String, nullable=False) + gate_type = Column(String, nullable=False) # 'car' | 'pedestrian' + avconnect_macro_id = Column(String, nullable=False) # AVConnect macro ID + status = Column(String, default="enabled") # 'enabled' | 'disabled' + + +class ApiCredential(Base): + __tablename__ = "api_credentials" + + id = Column(Integer, primary_key=True, autoincrement=True) + username = Column(String, nullable=False) + password_enc = Column(String, nullable=False) # Fernet-encrypted + session_id = Column(String, nullable=True) + + +class Keypass(Base): + __tablename__ = "keypasses" + + id = Column(Integer, primary_key=True, autoincrement=True) + code = Column(String, unique=True, nullable=False) + description = Column(Text, nullable=False) + created_at = Column(DateTime, nullable=False) + expires_at = Column(DateTime, nullable=True) # NULL = never expires + revoked = Column(Boolean, default=False) + revoked_at = Column(DateTime, nullable=True) + allowed_gates = Column(Text, nullable=True) # JSON list of gate IDs; NULL = all gates + + +class GateAccessLog(Base): + __tablename__ = "gate_access_logs" + + id = Column(Integer, primary_key=True, autoincrement=True) + timestamp = Column(DateTime, nullable=False) + keypass_id = Column(Integer, nullable=False) + keypass_code = Column(String, nullable=False) + gate_id = Column(Integer, nullable=False) + gate_name = Column(String, nullable=False) + ip_address = Column(String, nullable=True) + user_agent = Column(Text, nullable=True) + success = Column(Boolean, nullable=False) + error = Column(Text, nullable=True) + + +class AdminUser(Base): + __tablename__ = "admin_users" + + id = Column(Integer, primary_key=True, autoincrement=True) + username = Column(String, unique=True, nullable=False) + password_hash = Column(String, nullable=False) + role = Column(String, nullable=False, default="admin") # 'admin' | 'manager' + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db() -> None: + os.makedirs(_DATA_DIR, exist_ok=True) + Base.metadata.create_all(bind=engine) diff --git a/src/core/dependencies.py b/src/core/dependencies.py new file mode 100644 index 0000000..22f02a8 --- /dev/null +++ b/src/core/dependencies.py @@ -0,0 +1,50 @@ +from datetime import datetime +from typing import Optional + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session + +from core.auth import decode_token +from core.database import Keypass, get_db + +_security = HTTPBearer() + + +def require_admin(creds: HTTPAuthorizationCredentials = Depends(_security)) -> dict: + payload = decode_token(creds.credentials) + if not payload or payload.get("role") != "admin": + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Admin access required") + if payload.get("scope") != "admin": + raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient permissions") + return payload + + +def require_manager(creds: HTTPAuthorizationCredentials = Depends(_security)) -> dict: + """Accepts both admins and managers.""" + payload = decode_token(creds.credentials) + if not payload or payload.get("role") != "admin": + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Admin access required") + if payload.get("scope") not in ("admin", "manager"): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient permissions") + return payload + + +def require_keypass( + creds: HTTPAuthorizationCredentials = Depends(_security), + db: Session = Depends(get_db), +) -> Keypass: + payload = decode_token(creds.credentials) + if not payload or payload.get("role") != "keypass": + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid keypass token") + + kp: Optional[Keypass] = db.query(Keypass).filter( + Keypass.id == int(payload["sub"]) + ).first() + if not kp: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass not found") + if kp.revoked: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has been revoked") + if kp.expires_at is not None and kp.expires_at < datetime.utcnow(): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Keypass has expired") + return kp diff --git a/src/core/schemas.py b/src/core/schemas.py new file mode 100644 index 0000000..c60731a --- /dev/null +++ b/src/core/schemas.py @@ -0,0 +1,123 @@ +import json +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict + +from core.database import Keypass + + +# ── Auth ────────────────────────────────────────────────────────────────────── + +class AdminLoginRequest(BaseModel): + username: str + password: str + + +class KeypassLoginRequest(BaseModel): + code: str + + +class TokenResponse(BaseModel): + token: str + token_type: str = "bearer" + + +# ── Keypasses ───────────────────────────────────────────────────────────────── + +class KeypassCreate(BaseModel): + description: str + expires_at: Optional[datetime] = None # None = never expires + gate_ids: list[int] = [] # empty = all gates + code: Optional[str] = None # None = auto-generate + + +class KeypassPatch(BaseModel): + description: Optional[str] = None + expires_at: Optional[datetime] = None # None = never expires + gate_ids: Optional[list[int]] = None # None = keep unchanged; [] = all gates + + +class KeypassResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + code: str + description: str + created_at: datetime + expires_at: Optional[datetime] + revoked: bool + revoked_at: Optional[datetime] = None + allowed_gate_ids: list[int] # empty = all gates + + +def keypass_to_response(kp: Keypass) -> KeypassResponse: + return KeypassResponse( + id=kp.id, + code=kp.code, + description=kp.description, + created_at=kp.created_at, + expires_at=kp.expires_at, + revoked=kp.revoked, + revoked_at=kp.revoked_at, + allowed_gate_ids=json.loads(kp.allowed_gates) if kp.allowed_gates else [], + ) + + +# ── Gates ───────────────────────────────────────────────────────────────────── + +class GateResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + name: str + gate_type: str + avconnect_macro_id: str + status: str + + +class GateCreate(BaseModel): + name: str + gate_type: str # 'car' | 'pedestrian' + avconnect_macro_id: str + status: str = "enabled" + + +# ── AVConnect Credentials ───────────────────────────────────────────────────── + +class CredentialRead(BaseModel): + id: int + username: str + + +class CredentialUpsert(BaseModel): + username: str + password: str + + +# ── Admin users ─────────────────────────────────────────────────────────────── + +class AdminUserResponse(BaseModel): + id: int + username: str + role: str # 'admin' | 'manager' + + +class AdminUserCreate(BaseModel): + username: str + password: str + role: str = "admin" # 'admin' | 'manager' + + +# ── Statistics ──────────────────────────────────────────────────────────────── + +class AccessLogResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + timestamp: datetime + keypass_id: int + keypass_code: str + gate_id: int + gate_name: str + ip_address: Optional[str] + user_agent: Optional[str] + success: bool + error: Optional[str] diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..2da09de --- /dev/null +++ b/src/main.py @@ -0,0 +1,98 @@ +import os +import sys + +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +# Ensure src/ root is importable for models/services/routers +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from core.auth import hash_password +from core.database import AdminUser, SessionLocal, init_db +from routers.auth import router as auth_router +from routers.keypasses import router as keypasses_router +from routers.gates import router as gates_router +from routers.credentials import router as credentials_router +from routers.admins import router as admins_router +from routers.stats import router as stats_router + +# ── App ─────────────────────────────────────────────────────────────────────── +app = FastAPI(title="Lagomare Gates", docs_url=None, redoc_url=None) + +_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") +app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +# ── Routers (Controllers) ───────────────────────────────────────────────────── +app.include_router(auth_router) +app.include_router(keypasses_router) +app.include_router(gates_router) +app.include_router(credentials_router) +app.include_router(admins_router) +app.include_router(stats_router) + +# ── Static / frontend ───────────────────────────────────────────────────────── +@app.get("/favicon.ico", include_in_schema=False) +async def _serve_favicon() -> FileResponse: + return FileResponse( + os.path.join(_STATIC_DIR, "logo.svg"), media_type="image/svg+xml" + ) + + +@app.get("/", include_in_schema=False) +async def _serve_index() -> FileResponse: + return FileResponse(os.path.join(_STATIC_DIR, "index.html")) + + +@app.get("/admin", include_in_schema=False) +async def _serve_admin() -> FileResponse: + return FileResponse(os.path.join(_STATIC_DIR, "admin.html")) + + +@app.get("/sw.js", include_in_schema=False) +async def _serve_sw() -> FileResponse: + return FileResponse( + os.path.join(_STATIC_DIR, "sw.js"), media_type="application/javascript" + ) + + +@app.get("/manifest.json", include_in_schema=False) +async def _serve_manifest() -> FileResponse: + return FileResponse( + os.path.join(_STATIC_DIR, "manifest.json"), media_type="application/json" + ) + + +# ── Startup ─────────────────────────────────────────────────────────────────── +@app.on_event("startup") +async def _startup() -> None: + init_db() + _seed_admin() + + +def _seed_admin() -> None: + """Create the initial admin user from env vars if it doesn't exist yet.""" + username = os.environ.get("ADMIN_USERNAME", "admin") + password = os.environ.get("ADMIN_PASSWORD") + if not password: + return + db = SessionLocal() + try: + if not db.query(AdminUser).filter_by(username=username).first(): + db.add(AdminUser(username=username, password_hash=hash_password(password))) + db.commit() + finally: + db.close() + + +if __name__ == "__main__": + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..0358a52 --- /dev/null +++ b/src/models/__init__.py @@ -0,0 +1,9 @@ +from .credential import Credential +from .gate import Gate +from .status import Status + +__all__ = [ + "Credential", + "Gate", + "Status" +] \ No newline at end of file diff --git a/src/models/__pycache__/__init__.cpython-313.pyc b/src/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3cfc8ba5dad26ada843b28e870d1bd43f7f75666 GIT binary patch literal 311 zcmX|5u};J=40YPHMLl(df$7B7h1|l1SQwC62?_NBqUbIvk@jX;MrhPg~w( zn5{uT#~&ExeOBk6jBGUvi$u9?qO;-Q_ literal 0 HcmV?d00001 diff --git a/src/models/__pycache__/credential.cpython-313.pyc b/src/models/__pycache__/credential.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e98fce6904d94b43c9d8a3a4c8d94dda4634901c GIT binary patch literal 647 zcmYLHv5wO~5FLA+U;^Qgawmn8I9U`(rZ@`;M1yE3=+an7S43JZ&YoGcv5(n}PIOd= z0?PDsh>p)e+fva*q&SI+nZ(IW@n&Xc=k1%Z*Tdle$POR>GvCSo=459{7p%t=EMW`} zpoC*6qo?t6h~sCM_+?|U9nvVk_+rn4f_P)rqj-uwlT9@gv4okeqD6TpJiEiR=jQJy3pRYsEgY8!m7HRE7Y%) z*RF6_74vqctt+bi7^fE>LMo+cjF&QuuID@59kMJpBKQcu#@UzQ7Vh2uoP52rreh$b zED}=&);yt25ht&OI0sVFs|(BVZ7JU`lU)7qvSB-*U?rj`x{8zN4e-6n=9%w|BXiL?vpB&5}k}FdH#R5rSY$5E8k7OCXSgVYAsgH|*`{%$#6n zB^C+T1S~DJv#_yJ&_7^h7))iAE_y}4%J+6}W5|Q~=6f@5-+SMi?F|jp0By7RH@?gO zevwlc?~~pXl0ERjXDhJGBy)(DR!Ym3v>X_N3Gl6P@cB$B|3dOs*|iTbAGplez7BL( zA*h|I7q@gGRI2a=`AAJg9b?EgDm+>S`GdvYw&b9z%=tq4coVH@dlr^{qi_$ z^KB+8zAdY*id!}OL7<~Ul3+^lmf>m8j*P9cR>DFVWhHLm>S=^MSU;cOp4+;UkcP}Q z$^~1qHtd2Iif{A}0+g~om7L^n!A2nt)~uO+wlz2%;j+40mQ~Ck2M|mxR&k1rreP$h zF-Ze(x#22Gn*ZanV?~u5G;`Z~+-XN%q;*F|UMtpK60|z)Kt?nOl*^je7oVoAL-0SA<;5Ylr zv#-Oi`0ij2L&3Gqv{rWqr~=I$E7XL*>fMC+IU$~QgQR~mB1AKmN+)p|r5zzM&8D(c zzqjykY0+3pOF4oC%q1b{`QlInTFZE|i?W1x5Ap)u%3%U}mkeku>K4LF*q>kcT>n)6 z4OV%Ty}5Ig?Qg?;|1f);`MH;8q9pVpH1@oiT6pr`O3Iwqxk~J$wyDnN0`JOectJ0% Zt{@y)jIje4KY)p2&eY5S_h`J*a?F82l6BQ zN8!NADxGlJSUHh!Xd>kEmp@~l5(HO6gY4;giD5O7q(yz zxUmh~9J3>{szW2NTzjsC%L=QXB{`w%k~g^5g3O4s|N z)3d97uW))7ykcD;GI9wg!dD8z+g!;^2z1D(qzlEN_mIZYlS-v1y&zOxt#6c$X`Z87vmm0KBHdR0`%xvli&?zYwef8tiAqvj_a%R$TbT~(vDCUR&Cn4s^}0a hoa5?uC%8dUZz0bCgfOU))*TFl%MPXYy><+Ne{`tWlYFW0xD_xSgiAH(n><5X5b*ms-}Sli2h|oTb|D=n)ZGUCqpa%Br~b(j0OPiB~_nKE)XlM z-YlfBI-#8fSx%WCRcFs$TLVd`P^+M18f`e?5Kj609j#ng() z>&lX5lD!15?LME$CDpXaCKpU*KEWP2M-Rw9hW|qzZu~ToATwuB%)b32c4)5R>XH**ULcI>R1^3adWHqB_EApgMCT znHlD$+3)ou405f{Ir$ ziQW|9hE+Vju!>vAlvC1a><0Ol%tEM-d(sV0jH2exck=<$Q(Cwjhv@Zgk%rNEH zyT#bMJ9EiWOfAQ<#aMPHw^GjCD&}sLVz>VZ70!&Y2iEYM3=`}jLcR$54{P$f>pQ;A zd%*|I_nRLvpVxg>_c-{ZWxHj^H%Xl)h%Z?01I0zy7vKbw7aG~+hhyA0VzylaV>0=FzwBREnB+#ESp+F(EA0&1EQ&41agILu}F876k3#to)3yyGh& zupsGN)0~*8{$!03rbiLxIvo8BA3Np9)e%;!lZphZ$K8=A% zjY{i&U|cDQMKHmuh>Z9F}@*i{6I}t=_#~ z66n?nw2}r|POeJo3Y4G*Wlhe+^gBF1PgtK;JSN8(s<-qmO~I+4_PJ0Bj2Pm`Q*ZOu@^3PaC!Sp0zFKb&A~fYiFjq>%AF}9L^qLLkFU;EH z02-^wtjtwQ6AI-RYMLX6v^gaegI8f~n?W)UmSsE=$qmNe%Y1ELW%hHXabfYBAu1CJ`*4tjMmjVNZ zIPh}8Nku^_3ElUUlF)10T`mPC4RO-0oz3K(w{7t!Q+9of_l<{{YoSx7TVKtmvP+nx zt|=_Sals=KlkH)W^z^LEfOa#jq!Y_(X0<9fKpO`g1YOg@=P==8~B z^7$N)B_T0xB6!EZDy2N}B0P5sY4y8!64pp|Xsyuia}2{gLmhuZLq#<7 z40Zn%o&FM?eTL3{iAKKiG#c%FkA7$jMv9(G>+XFwV#T}d8||CpTe{IRRQ$#p>+J?} z?yJU=TW5YAe)#=IKPdH09B>>HVRyy)eM>m=+H)!#a)>>@&l2Unj{tRSbZo{9={vvG z);kPl;=qlX+6m5|0w+Golma~mLIZQ1+4Z#Tb42(vAi9VEVTiuxR7o%+%sxw(&k)eg zjn2&vK0UqOX)yg&L;xNwHH8iYKhv-q2<~$*Qal&|&L4bEGz2Ytz=DJ?!vXypG|UFc literal 0 HcmV?d00001 diff --git a/src/routers/__pycache__/auth.cpython-313.pyc b/src/routers/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6f550f018398f303de051847389a3f4cc0b89e57 GIT binary patch literal 3195 zcmcImO>7&-6`mz`m!za6^fk2be|40sd2>AWO%%*U^hpzDNUYaAaw!G15evA_`TUZ+Xfe3D7{K znKnmSXp5usOtnVZXj|kEJrrrD?JAj<`iSn;eIY?_7;(|V^M|z%cdiUF1#iO(9&JBJ{|&SZe-OONVNfTE1babGbFxwvBd! zF6K=bwrMBRCi7vRFjKY>6qt7=XD726BNY^x@3q;pS~hQ+lzGmWIWv>6m@+vxH#>G^ z(R7wE*|H5gZ^1^7Zdw-X1k+86)CA*kJA2X0u)t-LCYM&@Im5Ev%F;y84TclxWF~5% z;VE+!XPCTf+VR8!%(ghQ=d#PmjAmZSo0iSmYO4+?l{vJpnN}{FvCM)mWaN?|BX6%T z5qoe%&QWtId4-9#v1}FOkb!>-zWQlxQ#_r8gEKcZFELkQVbN8)bR00_-iF`quOLeO zM1|an3R*2WHBE_9<#2kAB7#b-h&683TmG8u?nV z*Tm)e7`WKhNH4nL^L>E|U;j^(;E@_9jR3CL0-)#!94z7p)dDdy%pg+Rb)W;b7 zU$tpHT&j&( zBW)Ji&SowfsboThL`;|&J87gWCOOgHM)zQ`w35wJ3zFPz$$5J*lYNVdsKMX_MT$31(RU`|F=;=D z7A{bsv16f?Y}yQ&wwy>V=Yep{&lYo%v&9 z;Fs6$Sof~py}B{C8J;MGCpJ4KOC6Js{N107Z}yxl^_(oeI#c$~7QM5NnvQG*+Sae# zy0+EavDMQ4Vdwjuj{+U**WbNf4yd1wY#b}~pC|=R?D(WEd6!6vXKn0h6KU%FC|_zE zD2fArw=nH~cDnJjA{4x#i&gSCfLpw62e-d~tp9)E_TOPv+rub$QfoZJ9lT>EeWUUD z{djj!8sH0K&0Ny<@X9rSl6@!z3_b(L9Qy`h|G%~Bu45mp9N&HVf?VS`*`E1{20;Yh8{slXfRf@x+{YF>sa zmniTG=*$2?C4t$J$066Zk$bk0JhqV|wtom6^VAFm-Sj#5u5t@fC+&kS*h*hO839kB zs1S-T6`((xCUZ>6=W;+WFQOi?z1C=egUHA5VVz({j(z&7R>> z&+wMgyng=H`Smw$y-`+rHv%alULF`fu)aoa zTiw04M{ke5)3<)~-J_qcY}mik9<)Bt9=1Nz%7e3Or-A$)1zO)b_F?$_@Xrfd&7E6; z)*X+CL?=2#*YK}IHztjFgy~>c^_G$EnQX7-l9^?S7sxzI2E4!^8!&SzJdO9ojQOog zDI>MGVy0IIvouZdMZz!ikSO3ihg&Q(WLOk= zh~neHi7=C^b%lw!EJPmb2*u!iMs}em)!=$h!8o#;1w;%|Q$xC08 z?k8llOh&&ZeNV{o$7HBPh8~mY5}E$9ul1wv-~Y+|$&xR;CRb$Q^4{#e(f!W(JL$FV zqA>K;snZX}znIw}4Z@4X?w2b#ZhZGC4!dsR3RhepRl4}L3-=Y=cH*laPdt==AJ`$y Yu&wu%3XU7gPjSF)!#ggJ=s5WJPvf+aZ~y=R literal 0 HcmV?d00001 diff --git a/src/routers/__pycache__/credentials.cpython-313.pyc b/src/routers/__pycache__/credentials.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..36e367d707999ec4e503dd4d78a153328d32b255 GIT binary patch literal 2272 zcmbtVO>7fK6rTO@dL92JCL}Qtb-{#~idZU%id#TRLRGLu0B=)N;>cONn`Dvym|3e0 z5>|sI4k)j>3hW`evPtt%w6spR_aY zy?OJ^&->=>rq|nsK)$;CuhHc|=uc+YB5x_10~|tMAO$I05+yjyg_+JL`GkOlgos7U z7LtyHge6NClg@;UWlMJ?U5Pf_mT+UYWlKp4*lOFw`hV<+CNzC>GRQl&7xYbEi|q zvZ-V0JgJxTf<_?1spy122&g{2ki~N)Geh(&)=ik=9WEK8Sl9G|X{b3W&FE%Ey9A+L ztY0Y`SkI_h-Y7t%uNjuoRgDIk_IQct*u1(srk0GD6(0lmElhH?-EtjnaRHoE!uquF zJ{3)MhER4AAJ$2!SRi^PU)1y*<+V$c%VhbM>tl?$3E$?A(CkNHV9hv6ZDpq=4oz&& zva^+9DcvDQm8j!NS;upjtwRMhm&0z*a3^baLlYHn zJJUO$p-(1*ie8jsBu^Lk7-%_7!gGlm(AdCzBTWhFCAYmxQR@x!sLT-C66E1rYVxN;-n@BKc8%aT6 zZBym!(TQ!e=dbsoYcL|g3Ahx?`wkfw?45)WLA>d9|vk%gcio4yv0)LDXE0KSHxO|RheB79ra=c-ypVQ+Q7uLLNU8CD%4LSvEKgAN@0_S840zMHH?-nbtbu11Dyk3(MxpwaT@s`}nGO{25^v zeq*$Aw2K>yc2mimD;b3u%&0}3(<(8c%3^j@%t{*dkt;bhmz~w~a|1=3$80urWuYVt zsAY4uao1kU)>Koyq(a?cl?F4u)Aq)I^|OGq89Kp?`_yCk34>KhBJ65{1sq3fZAkwx?CvFb}l=0mI8;Ny;- z#lFvDl?&fb)(($u2m+^a>$1PzQ2Gu%wUkW(@k4c<4c0wPoDUepz=ni^AqMiBuk+J< m%@^5lc5o-wT|IRH`ab~70X?iQd!Dip1{T}kLBoS^hW`L{R^(d% literal 0 HcmV?d00001 diff --git a/src/routers/__pycache__/gates.cpython-313.pyc b/src/routers/__pycache__/gates.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b8f86235d34ac0891a28132102de16b747c0abea GIT binary patch literal 9710 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 bPW6IZ(+fGCae!XHp~x+3Cg8|zu(tmO(5E4m literal 0 HcmV?d00001 diff --git a/src/routers/__pycache__/keypasses.cpython-313.pyc b/src/routers/__pycache__/keypasses.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7ede39550aaac8829a7abfd0d45ba817a7ffab33 GIT binary patch literal 5936 zcmd5=Z)_9i8GrB2_Stdl{BaT>aT=2ZiVN7RBn{9K8uEw4ZWHFi>qNp_$G#*N9NT@* zDIue3(lQaGX{?M2I!$VjDp90Kr4>@DX&>4T(>93@c8!+o3R|m9s`zGNz zv8^2j!QM!w4bu$WHr5d6)D}@Jg{rZpu{t9NgzAIrn7NgV**lrMUTsxttqxYG8M6wt zV@^PIO)+(CdUcq5dHE9mox1wK}><*iwSt0_Z&&X0a5u0{VB)dK>IT z!AL40jzOhR)BcH1Q5Zh3%^!)`_~N5d&GCC!Lf zA4#U9L_8dGbBd+s_)s7*n-ZzQ_lZd{9+ef_;E5B*`{yE}+6@Nqf+)){!p$nYUp$)( z%d%pc6jPz-IB2V=_|mLI#n4PR9-b7b+o0GAy?Ut#uT?4w&Ew%zWJ;+jv;v}>OvGhT zah%R$hEjAHx)=;V=WG{$j7=a1!-_!+}4n|<% zJj|mOO&cVdJv)eIy_%$*xVd44Y?>^-az8xl$06%x+K3CBK1dv7u!)d3vte>&rrt_U zup0QwⅈwG4z%rKs%EJk3PVACQ~2`M6iIk2963KLSy;aGAiJPtbC zFPCdU>GDn`W<;-;N+qc1os?4ESa>oq6Q<%ZIAPf<(}%%ac5-Se}^_ADDJzEQgcMhdG#sB4 z-7Kwzq11`5!EJLrOYv=ree=xd4iMI0r+tupwLtDw)vs3V$W-n4t?516Oae>xry-CNrMNyB;ZP zo&Lllz02OMY*}XiQrVXANEz#N+A#g-6YKON8LjEHo|>M0%*T%Up52CzcbTF7;Ljii zTYDS|#VV)5saaXIc!dM1jG#+t2`_c^hKlfVdUXTgg^W>mNHAP4(`$61m_>oh?CEvY26>QoM+x0WcODIjX&H|6@= zTrf9eBQ*sc(`v=C70;^-g697t82A|un&Jl7^X&gsVP@#N|Iej_pWdZ+&X&JCC-7D$^&kb zVnDfKMHi=%8f?%#fUZ=)eE|W5j0C__@#Vy6F{;>r0}^L|6(;e*ao5qOu&WXIKdBg1 z;#YZ_J^}JPrI;q97)Cb635h~KG*sG!8M=yMnoULGi8J&7Hq3|`k|6pqOv?$lFru?F zNg0SI8ingIF*75nQ<;ESP34)$Nb!0u(vdnJ>IIpMob19>prt&YX~aYaK#kXq{5fPQ zEme|TyKl_hn7h@xGWh=B%JBQcs~vrrj=rV+mkwM!aH;!Zch=gpYTcf(Zog}7yW`3F zPA>GV@sSG`${)_~hws^&uJxw3 z`?7X_+U&n)sa~_ytkrF~I`YQIJx9~kBbSe49qnt5hO6Cgbgxy{uRYnhvS($_mFU&! z%hSt_8_w&_yA7U?`X1W&3e&>TTs1NB7X}swmYzKCTQit03@r|w_hZGk=v$)ahwoV* zyGFCt*0iDZk&JiHYyFM=PG;Wh{psl>?D8v$Q7t2QlWR%6)uFNeT#*ae|J5xRB%65T zTkb^gEQ98+QREVxGd6Qc&=3G^Y0^f9H;?25Ly?8RFX0|7P2(-V)nzpSoz044G%pSs zVmcqg=rW83J@nN=51rqNSmWRkKw^9N3zZzE*$-v)mET{j;y^iZSF)enZGb!5cX&718MWX8eehY)x}pc zeCyga=i4oBwXAOQWVU&-+x9K7Ker=)48JkFaP<81*zw%rIiR4mu0t#5R?hvVK7THNK1-T+)eBb%N z7(LnpCct>VBo%!-|4r)Vhv^XXp+3x1zijJQ}Jd&;3p*n5;;{KVxa7FjF_O>%_<^kSf?)E%!ZG}2@VU`b>QfHI!wM}vD z3O^BsZyN~IFx;Vd%d1qV*yWdE;aFr!oH^@B&>3pR&bA2rH}C+1g~#FRTn*1C8i-Pp zSE#?9#CSv!Wr`Q9QmM*igbB+O=S1il-C~;yX3`Q#klBr5Nx_ z5I!EINJ{+*R1C=k^uWkVF(&ah6h#7szXW1Tgb)#r>n5Q)5nTNvV15R^*Jop5HysBV zPz{kULZ0IohWV6OKPC20iS0gVzE7IIAe|Y~d7rd?LEQJr-oKNRS#t7oOKrMwU;4mM zy33cb_!mqMO)S<-%=`SiZ~sOD%I1gf(AiTx?uAdM&j)l4x7Z^Q2>}(35fPxcQFe7CPmNnp>=r+uCiqGA;zCdL_I3QX*RsR^#UP?PN1$AdG8l!1@8VsW4o| zd6@K(P`vJ?tl|3@_#x>Dy~omSu!fv6u#L?SSU2|iUXKBWW~aP1N8-$J* z#9`mzsh9HYd?fc5nOu=Vg4p`~7r3xZWjF#fq4CjpCu608Z@^-+fD($M=AztJCP z7Dx?c_=&W~qnNVL`FZ-+WHfC#zYo?sR(m4udyKaA9vHimVNR>&Kjye}nk{~uom5yP zm(+D?D>uT|cz@dvxs6Q*TOcnY%me}k-L)-YrD4o)X7NC10pq^NMPVGKLiM7^R-lAz zD5K%B;aW|6<=jeQ=CCwPfH}P|bt12q#2zrICwDmb4rIEcFLkL8@DM3(hTt@okv;JS znwMmU3=71{KQQ;q$7c`C^T*cg-s#87&nxyp#eQC?A5`jxl`jshFORL#-rO#~Up=-8 zd!O8&8(O)X{)WqL?l#UfYV?LZD~xpyPZ@-uVi=^DqQo~MFY<4(c&na33{oXg^zrib zz)QU~kF#tfV93PmNg{i%tnY`6gDK;MEY4*akAu`r=qFtY6?+p}6mqmd=%-nSzmXAg z2z)A;=4E_un-RMLiwNao;vt%$N+~@iOGjk>U*a5*yAKuXn2Z^wNvUnWsySoWDwQ50k^v(yN?B^*_z>P{sXkBmY|B PkxG str: + alphabet = string.ascii_uppercase + string.digits + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +@router.get("", response_model=list[KeypassResponse]) +async def list_keypasses( + db: Session = Depends(get_db), _: dict = Depends(require_manager) +): + return [keypass_to_response(kp) for kp in db.query(Keypass).order_by(Keypass.created_at.desc()).all()] + + +@router.post("", response_model=KeypassResponse, status_code=201) +async def create_keypass( + req: KeypassCreate, + db: Session = Depends(get_db), + _: dict = Depends(require_manager), +): + code = req.code.strip().upper() if req.code and req.code.strip() else _generate_code() + if db.query(Keypass).filter(Keypass.code == code).first(): + raise HTTPException(409, "A keypass with this code already exists") + kp = Keypass( + code=code, + description=req.description, + created_at=datetime.utcnow(), + expires_at=req.expires_at, + revoked=False, + allowed_gates=json.dumps(req.gate_ids) if req.gate_ids else None, + ) + db.add(kp) + db.commit() + db.refresh(kp) + return keypass_to_response(kp) + + +@router.patch("/{kp_id}", response_model=KeypassResponse) +async def update_keypass( + kp_id: int, + req: KeypassPatch, + db: Session = Depends(get_db), + _: dict = Depends(require_manager), +): + kp: Optional[Keypass] = db.query(Keypass).filter(Keypass.id == kp_id).first() + if not kp: + raise HTTPException(404, "Keypass not found") + if kp.revoked: + raise HTTPException(409, "Revoked keypasses cannot be edited") + if req.description is not None: + kp.description = req.description.strip() + kp.expires_at = req.expires_at + if req.gate_ids is not None: + kp.allowed_gates = json.dumps(req.gate_ids) if req.gate_ids else None + db.commit() + db.refresh(kp) + return keypass_to_response(kp) + + +@router.delete("/{kp_id}", status_code=204) +async def revoke_keypass( + kp_id: int, + db: Session = Depends(get_db), + _: dict = Depends(require_manager), +): + kp: Optional[Keypass] = db.query(Keypass).filter(Keypass.id == kp_id).first() + if not kp: + raise HTTPException(404, "Keypass not found") + if kp.expires_at is not None and kp.expires_at < datetime.utcnow(): + raise HTTPException(409, "Expired keypasses cannot be revoked") + if kp.revoked: + raise HTTPException(409, "Keypass is already revoked") + kp.revoked = True + kp.revoked_at = datetime.utcnow() + db.commit() diff --git a/src/routers/stats.py b/src/routers/stats.py new file mode 100644 index 0000000..f0b04f3 --- /dev/null +++ b/src/routers/stats.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from core.database import GateAccessLog, get_db +from core.dependencies import require_manager +from core.schemas import AccessLogResponse + +router = APIRouter(prefix="/api/admin/stats", tags=["admin-stats"]) + + +@router.get("", response_model=list[AccessLogResponse]) +async def get_stats( + db: Session = Depends(get_db), + _: dict = Depends(require_manager), +): + return ( + db.query(GateAccessLog) + .order_by(GateAccessLog.timestamp.desc()) + .limit(500) + .all() + ) diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..aafca4e --- /dev/null +++ b/src/services/__init__.py @@ -0,0 +1,9 @@ +from .avconnect import AVConnectAPI +from .gates import GatesService, OpenResult, call_open_gate + +__all__ = [ + "AVConnectAPI", + "GatesService", + "OpenResult", + "call_open_gate", +] \ No newline at end of file diff --git a/src/services/__pycache__/__init__.cpython-313.pyc b/src/services/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..73df0bbc53ba66448c3ed0eaadb869c53f60b329 GIT binary patch literal 339 zcmZ8cJxc^J5KT78t=`?**&kRfh$$^jgu_DI1HTq*!?K1oklh@U+<~P(!XM$E@ITlF z#KOu>+|J76P8+9q^JeDFW2VotAqw%XzSVQgvG|MRm-PFAJfaenc#aATcoKOsPYMF0 zpg;=-Y=Sr{$rXk{Lv!|l!%B|*cy@DQx=z+^c6pW)KR&ITw55cVs-+)ZEM#{r?NU3x zQ&(CG6B?oknw$a&7CTRlTuXD?E#G`%maoA)=pnLVegn{*d39R&wc_ z0m&QXxUL%0Rv_0GaSJuKo3ktzLUqasaj>}eTSu+AU21s@S@4N(+1&u21Y`V)_TJF+ P1x>#w8zm1ZjKbsx7$8^? literal 0 HcmV?d00001 diff --git a/src/services/__pycache__/avconnect.cpython-313.pyc b/src/services/__pycache__/avconnect.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0ac08da0ff429e8047482775f8fc5550fb95f1d4 GIT binary patch literal 3572 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 literal 0 HcmV?d00001 diff --git a/src/services/avconnect.py b/src/services/avconnect.py new file mode 100644 index 0000000..108d48d --- /dev/null +++ b/src/services/avconnect.py @@ -0,0 +1,53 @@ +import requests +from fake_useragent import UserAgent +from models import Credential + +class AVConnectAPI: + _BASE_URL = "https://www.avconnect.it" + + def __init__(self, credentials: Credential): + self._ua = UserAgent(browsers=["Chrome Mobile"], os=["Android"], platforms=["mobile"]).random + self._credentials = credentials + self._session = requests.Session() + self._authenticated = False + + if credentials.sessionid: + self._session.cookies.set("PHPSESSID", credentials.sessionid) + self._authenticated = True + + def _authenticate(self) -> bool: + login_url = f"{self._BASE_URL}/loginone.php" + headers = { + "User-Agent": self._ua, + "Content-Type": "application/x-www-form-urlencoded" + } + payload = f"userid={self._credentials.username}&password={self._credentials.password}&entra=Login" + response = self._session.post(login_url, data=payload, headers=headers) + if response.ok and "PHPSESSID" in self._session.cookies: + self._authenticated = True + print("Authenticated") + return True + return False + + def _check_sessionid(self) -> bool: + if not self._authenticated or not self._credentials.sessionid: + return False + exec_url = f"{self._BASE_URL}/exemacrocom.php" + headers = { + "User-Agent": self._ua, + } + response = self._session.get(exec_url, headers=headers) + print(response.ok) + return response.ok + + def exec_gate_macro(self, id_macro) -> bool: + if (not self._authenticated or not self._check_sessionid()) and not self._authenticate(): + raise Exception("Authentication failed.") + exec_url = f"{self._BASE_URL}/exemacrocom.php" + headers = { + "User-Agent": self._ua, + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" + } + payload = f"idmacrocom={id_macro}&nome=16" + response = self._session.post(exec_url, data=payload, headers=headers) + return response.ok \ No newline at end of file diff --git a/src/services/gates.py b/src/services/gates.py new file mode 100644 index 0000000..e3b6e36 --- /dev/null +++ b/src/services/gates.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass +from typing import Optional + +from models import Credential, Status, Gate +from .avconnect import AVConnectAPI + + +@dataclass +class OpenResult: + success: bool + error: Optional[str] = None + new_session_id: Optional[str] = None + + +class GatesService: + def open_gate(self, gate: Gate, credentials: Credential) -> OpenResult: + if gate.status == Status.DISABLED: + return OpenResult(success=False, error="Gate is disabled") + try: + api = AVConnectAPI(credentials) + ok = api.exec_gate_macro(gate.id) + new_sid = api._session.cookies.get("PHPSESSID") + if not ok: + return OpenResult(success=False, error="Gate did not confirm open", new_session_id=new_sid) + return OpenResult(success=True, new_session_id=new_sid) + except Exception as e: + return OpenResult(success=False, error=str(e)) + + +def call_open_gate(gate: Gate, credentials: Credential) -> tuple[bool, Optional[str], Optional[str]]: + """Attempt to open a gate. Returns (success, error_msg, new_session_id). + Respects the MOCK_AVCONNECT environment variable. + """ + from core.config import MOCK_AVCONNECT + if MOCK_AVCONNECT: + return True, None, None + result = GatesService().open_gate(gate, credentials) + return result.success, result.error, result.new_session_id diff --git a/src/static/admin.html b/src/static/admin.html new file mode 100644 index 0000000..e6d4c82 --- /dev/null +++ b/src/static/admin.html @@ -0,0 +1,384 @@ + + + + + + + Lagomare Gates – Admin + + + + + + + + +
+
🔐
+

Admin Panel

+

Lagomare Gates

+
+
+
+ + +
+
+ + +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/static/admin.js b/src/static/admin.js new file mode 100644 index 0000000..2efdbb1 --- /dev/null +++ b/src/static/admin.js @@ -0,0 +1,601 @@ +/* admin.js – Lagomare Gates admin panel */ + +// ── Token helpers ───────────────────────────────────────────────────────────── +const TOKEN_KEY = "lg_admin_token"; +const saveToken = t => localStorage.setItem(TOKEN_KEY, t); +const clearToken = () => localStorage.removeItem(TOKEN_KEY); +const getToken = () => localStorage.getItem(TOKEN_KEY); + +function tokenValid(t) { + if (!t) return false; + try { + const p = JSON.parse(atob(t.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))); + return p.exp * 1000 > Date.now(); + } catch { return false; } +} + +// ── API helper ──────────────────────────────────────────────────────────────── +async function api(method, path, body) { + const token = getToken(); + const headers = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; + const res = await fetch(path, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (res.status === 401) { + clearToken(); + showLogin(); + throw new Error("Session expired."); + } + if (!res.ok) { + const j = await res.json().catch(() => null); + throw new Error((j && j.detail) || `Error ${res.status}`); + } + if (res.status === 204) return null; + return res.json(); +} + +// ── Views ───────────────────────────────────────────────────────────────────── +function _tokenPayload() { + try { + const t = getToken(); + return JSON.parse(atob(t.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))); + } catch { return {}; } +} + +function showLogin() { + document.getElementById("login-view").classList.remove("hidden"); + document.getElementById("admin-view").classList.add("hidden"); +} + +function showAdmin() { + document.getElementById("login-view").classList.add("hidden"); + document.getElementById("admin-view").classList.remove("hidden"); + const isAdmin = _tokenPayload().scope === "admin"; + document.querySelectorAll(".admin-only").forEach(el => { + el.style.display = isAdmin ? "" : "none"; + }); + loadAllData(); +} + +// ── Login ───────────────────────────────────────────────────────────────────── +document.getElementById("login-form").addEventListener("submit", async e => { + e.preventDefault(); + const username = document.getElementById("admin-username").value.trim(); + const password = document.getElementById("admin-password").value; + const errEl = document.getElementById("login-error"); + const btn = e.target.querySelector("button[type=submit]"); + btn.disabled = true; + errEl.classList.add("hidden"); + try { + const data = await api("POST", "/api/auth/admin", { username, password }); + saveToken(data.token); + showAdmin(); + } catch (e) { + errEl.textContent = e.message; + errEl.classList.remove("hidden"); + } finally { + btn.disabled = false; + } +}); + +document.getElementById("logout-btn").addEventListener("click", () => { + clearToken(); + showLogin(); +}); + +// ── Tabs ────────────────────────────────────────────────────────────────────── +document.querySelectorAll(".tab-btn").forEach(btn => { + btn.addEventListener("click", () => { + document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); + document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active")); + btn.classList.add("active"); + document.getElementById(`tab-${btn.dataset.tab}`).classList.add("active"); + }); +}); + +// ── Toast ───────────────────────────────────────────────────────────────────── +let _timer; +function showToast(msg, isError = false) { + const el = document.getElementById("toast"); + clearTimeout(_timer); + el.textContent = msg; + el.className = `toast ${isError ? "error" : "success"}`; + _timer = setTimeout(() => el.classList.add("fade"), 2600); + setTimeout(() => { el.className = "toast hidden"; }, 3000); +} + +// ── Keypasses ───────────────────────────────────────────────────────────────── +async function loadKeypasses() { + const rows = await api("GET", "/api/admin/keypasses"); + const tbody = document.getElementById("keypasses-body"); + tbody.innerHTML = ""; + if (!rows.length) { + tbody.innerHTML = 'No keypasses yet'; + return; + } + for (const kp of rows) { + const now = Date.now(); + const expMs = kp.expires_at ? new Date(kp.expires_at + "Z").getTime() : Infinity; + let badge; + if (kp.revoked) badge = 'Revoked'; + else if (expMs < now) badge = 'Expired'; + else badge = 'Active'; + + const gatesCell = kp.allowed_gate_ids && kp.allowed_gate_ids.length + ? `${kp.allowed_gate_ids.length} gate${kp.allowed_gate_ids.length > 1 ? "s" : ""}` + : 'All'; + + const expiresCell = kp.expires_at + ? `${fmtDate(kp.expires_at)}` + : 'Never'; + + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${esc(kp.code)} + ${esc(kp.description)} + ${gatesCell} + ${expiresCell} + ${badge} + + ${!kp.revoked ? `` : ""} + ${!kp.revoked && expMs >= now ? `` : ""} + `; + tbody.appendChild(tr); + } + + tbody.querySelectorAll("[data-edit-kp]").forEach(btn => { + btn.addEventListener("click", () => { + const kp = JSON.parse(btn.dataset.editKp); + document.getElementById("kp-edit-id").value = kp.id; + document.getElementById("kp-edit-desc").value = kp.description; + // Expiry + const neverCb = document.getElementById("kp-edit-never"); + const expInput = document.getElementById("kp-edit-expires"); + if (kp.expires_at) { + neverCb.checked = false; + expInput.disabled = false; + expInput.style.opacity = ""; + expInput.value = toLocalDatetimeInput(new Date(kp.expires_at + "Z")); + } else { + neverCb.checked = true; + expInput.disabled = true; + expInput.style.opacity = ".4"; + expInput.value = ""; + } + // Gates + const checksContainer = document.getElementById("kp-edit-gate-checks"); + checksContainer.innerHTML = _allGates.length + ? "" + : 'No gates configured yet'; + const allowedIds = kp.allowed_gate_ids && kp.allowed_gate_ids.length ? kp.allowed_gate_ids : null; + for (const g of _allGates) { + const lbl = document.createElement("label"); + lbl.style.cssText = "display:flex;align-items:center;gap:.75rem;cursor:pointer;padding:.5rem .75rem;background:var(--surface2);border-radius:6px;border:1px solid var(--border);margin:0"; + const checked = allowedIds && allowedIds.includes(g.id) ? "checked" : ""; + lbl.innerHTML = ` ${esc(g.name)} ${g.gate_type === 'car' ? '🚘 Car' : '🚶 Pedestrian'}`; + checksContainer.appendChild(lbl); + } + const allGatesCb = document.getElementById("kp-edit-all-gates"); + allGatesCb.checked = !allowedIds; + checksContainer.style.display = allowedIds ? "flex" : "none"; + document.getElementById("kp-edit-error").classList.add("hidden"); + document.getElementById("kp-edit-modal").classList.remove("hidden"); + }); + }); + tbody.querySelectorAll("[data-kp-id]").forEach(btn => { + btn.addEventListener("click", async () => { + if (!confirm("Revoke this keypass?")) return; + try { + await api("DELETE", `/api/admin/keypasses/${btn.dataset.kpId}`); + showToast("Keypass revoked"); + loadKeypasses(); + } catch (e) { showToast(e.message, true); } + }); + }); +} + +// New keypass modal +let _allGates = []; + +document.getElementById("btn-new-keypass").addEventListener("click", () => { + document.getElementById("kp-desc").value = ""; + document.getElementById("kp-code").value = ""; + // Reset never-expires + const neverCb = document.getElementById("kp-never-expires"); + neverCb.checked = false; + const kpExpInput = document.getElementById("kp-expires"); + kpExpInput.disabled = false; + kpExpInput.required = true; + kpExpInput.style.opacity = ""; + const d = new Date(Date.now() + 7 * 86400_000); + kpExpInput.value = toLocalDatetimeInput(d); + // Render individual gate checkboxes + const checksContainer = document.getElementById("kp-gate-checks"); + checksContainer.innerHTML = _allGates.length + ? "" + : 'No gates configured yet'; + for (const g of _allGates) { + const lbl = document.createElement("label"); + lbl.style.cssText = "display:flex;align-items:center;gap:.75rem;cursor:pointer;padding:.5rem .75rem;background:var(--surface2);border-radius:6px;border:1px solid var(--border);margin:0"; + lbl.innerHTML = ` ${esc(g.name)} ${g.gate_type === 'car' ? '🚘 Car' : '🚶 Pedestrian'}`; + checksContainer.appendChild(lbl); + } + // Reset All gates checkbox + document.getElementById("kp-all-gates").checked = true; + checksContainer.style.display = "none"; + document.getElementById("kp-error").classList.add("hidden"); + document.getElementById("keypass-modal").classList.remove("hidden"); +}); + +// Never expires toggle +document.getElementById("kp-never-expires").addEventListener("change", e => { + const kpExpInput = document.getElementById("kp-expires"); + kpExpInput.disabled = e.target.checked; + kpExpInput.required = !e.target.checked; + kpExpInput.style.opacity = e.target.checked ? ".4" : ""; +}); + +// All gates toggle +document.getElementById("kp-all-gates").addEventListener("change", e => { + const checksContainer = document.getElementById("kp-gate-checks"); + if (e.target.checked) { + // Uncheck all individual gates and hide them + checksContainer.querySelectorAll("input[name='kp-gate']").forEach(cb => cb.checked = false); + checksContainer.style.display = "none"; + } else { + checksContainer.style.display = "flex"; + } +}); + +// Individual gate checkbox — uncheck "All gates" when any individual gate is checked +document.getElementById("kp-gate-checks").addEventListener("change", () => { + const anyChecked = document.getElementById("kp-gate-checks").querySelectorAll("input[name='kp-gate']:checked").length > 0; + if (anyChecked) document.getElementById("kp-all-gates").checked = false; +}); +document.getElementById("kp-cancel").addEventListener("click", () => { + document.getElementById("keypass-modal").classList.add("hidden"); +}); + +// Keypass edit modal +document.getElementById("kp-edit-never").addEventListener("change", e => { + const expInput = document.getElementById("kp-edit-expires"); + expInput.disabled = e.target.checked; + expInput.style.opacity = e.target.checked ? ".4" : ""; +}); +document.getElementById("kp-edit-all-gates").addEventListener("change", e => { + const checksContainer = document.getElementById("kp-edit-gate-checks"); + if (e.target.checked) { + checksContainer.querySelectorAll("input[name='kp-edit-gate']").forEach(cb => cb.checked = false); + checksContainer.style.display = "none"; + } else { + checksContainer.style.display = "flex"; + } +}); +document.getElementById("kp-edit-gate-checks").addEventListener("change", () => { + const anyChecked = document.getElementById("kp-edit-gate-checks").querySelectorAll("input[name='kp-edit-gate']:checked").length > 0; + if (anyChecked) document.getElementById("kp-edit-all-gates").checked = false; +}); +document.getElementById("kp-edit-cancel").addEventListener("click", () => { + document.getElementById("kp-edit-modal").classList.add("hidden"); +}); +document.getElementById("kp-edit-form").addEventListener("submit", async e => { + e.preventDefault(); + const id = document.getElementById("kp-edit-id").value; + const description = document.getElementById("kp-edit-desc").value.trim(); + const never = document.getElementById("kp-edit-never").checked; + const expires_at = never ? null : new Date(document.getElementById("kp-edit-expires").value).toISOString(); + const allGates = document.getElementById("kp-edit-all-gates").checked; + const gate_ids = allGates ? [] : Array.from(document.querySelectorAll('input[name="kp-edit-gate"]:checked')).map(cb => parseInt(cb.value)); + const errEl = document.getElementById("kp-edit-error"); + errEl.classList.add("hidden"); + try { + await api("PATCH", `/api/admin/keypasses/${id}`, { description, expires_at, gate_ids }); + document.getElementById("kp-edit-modal").classList.add("hidden"); + showToast("Keypass updated"); + loadKeypasses(); + } catch (e) { + errEl.textContent = e.message; + errEl.classList.remove("hidden"); + } +}); +document.getElementById("keypass-form").addEventListener("submit", async e => { + e.preventDefault(); + const desc = document.getElementById("kp-desc").value.trim(); + const code = document.getElementById("kp-code").value.trim() || null; + const neverExpires = document.getElementById("kp-never-expires").checked; + const expires_at = neverExpires ? null : new Date(document.getElementById("kp-expires").value).toISOString(); + const allGates = document.getElementById("kp-all-gates").checked; + const gate_ids = allGates ? [] : Array.from(document.querySelectorAll('input[name="kp-gate"]:checked')).map(cb => parseInt(cb.value)); + const errEl = document.getElementById("kp-error"); + errEl.classList.add("hidden"); + try { + await api("POST", "/api/admin/keypasses", { description: desc, expires_at, gate_ids, code }); + document.getElementById("keypass-modal").classList.add("hidden"); + showToast("Keypass created"); + loadKeypasses(); + } catch (e) { + errEl.textContent = e.message; + errEl.classList.remove("hidden"); + } +}); + +// ── Gates ───────────────────────────────────────────────────────────────────── +async function loadGates() { + const rows = await api("GET", "/api/admin/gates"); + _allGates = rows; // cache for keypass modal + const isAdmin = _tokenPayload().scope === "admin"; + const tbody = document.getElementById("gates-body"); + tbody.innerHTML = ""; + if (!rows.length) { + tbody.innerHTML = 'No gates yet'; + return; + } + for (const g of rows) { + const badge = g.status === "enabled" + ? 'Enabled' + : 'Disabled'; + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${g.id} + ${esc(g.name)} + ${g.gate_type === "car" ? "🚘 Car" : "🚶 Pedestrian"} + ${esc(g.avconnect_macro_id)} + ${badge} + + ${g.status === 'enabled' ? `` : ''} + ${isAdmin ? ` + ` : ''} + `; + tbody.appendChild(tr); + } + + tbody.querySelectorAll("[data-open-id]").forEach(btn => { + btn.addEventListener("click", async () => { + btn.disabled = true; + const orig = btn.textContent; + btn.textContent = "…"; + try { + const res = await api("POST", `/api/admin/gates/${btn.dataset.openId}/open`); + showToast(`${res.gate} opened ✓`); + } catch (e) { showToast(e.message, true); } + finally { btn.disabled = false; btn.textContent = orig; } + }); + }); + tbody.querySelectorAll("[data-edit-id]").forEach(btn => { + btn.addEventListener("click", () => openGateModal(JSON.parse(btn.dataset.gate))); + }); + tbody.querySelectorAll("[data-del-id]").forEach(btn => { + btn.addEventListener("click", async () => { + if (!confirm("Delete this gate?")) return; + try { + await api("DELETE", `/api/admin/gates/${encodeURIComponent(btn.dataset.delId)}`); + showToast("Gate deleted"); + loadGates(); + } catch (e) { showToast(e.message, true); } + }); + }); +} + +function openGateModal(gate = null) { + document.getElementById("gate-modal-title").textContent = gate ? "Edit Gate" : "Add Gate"; + document.getElementById("gate-edit-id").value = gate ? gate.id : ""; + document.getElementById("gate-name").value = gate ? gate.name : ""; + document.getElementById("gate-type").value = gate ? gate.gate_type : "car"; + document.getElementById("gate-avconnect-macro-id").value = gate ? gate.avconnect_macro_id : ""; + document.getElementById("gate-status").value = gate ? gate.status : "enabled"; + document.getElementById("gate-error").classList.add("hidden"); + document.getElementById("gate-modal").classList.remove("hidden"); +} + +document.getElementById("btn-new-gate").addEventListener("click", () => openGateModal()); +document.getElementById("gate-cancel").addEventListener("click", () => { + document.getElementById("gate-modal").classList.add("hidden"); +}); +document.getElementById("gate-form").addEventListener("submit", async e => { + e.preventDefault(); + const editId = document.getElementById("gate-edit-id").value; + const payload = { + name: document.getElementById("gate-name").value.trim(), + gate_type: document.getElementById("gate-type").value, + avconnect_macro_id: document.getElementById("gate-avconnect-macro-id").value.trim(), + status: document.getElementById("gate-status").value, + }; + const errEl = document.getElementById("gate-error"); + errEl.classList.add("hidden"); + try { + if (editId) { + await api("PUT", `/api/admin/gates/${encodeURIComponent(editId)}`, payload); + } else { + await api("POST", "/api/admin/gates", payload); + } + document.getElementById("gate-modal").classList.add("hidden"); + showToast("Gate saved"); + loadGates(); + } catch (e) { + errEl.textContent = e.message; + errEl.classList.remove("hidden"); + } +}); + +// ── Credentials ─────────────────────────────────────────────────────────────── +async function loadCredentials() { + try { + const list = await api("GET", "/api/admin/credentials"); + if (list.length) { + document.getElementById("cred-username").value = list[0].username; + } + } catch { /* no creds yet */ } +} + +document.getElementById("credentials-form").addEventListener("submit", async e => { + e.preventDefault(); + const username = document.getElementById("cred-username").value.trim(); + const password = document.getElementById("cred-password").value; + const errEl = document.getElementById("cred-error"); + errEl.classList.add("hidden"); + if (!password) { + errEl.textContent = "Password is required."; + errEl.classList.remove("hidden"); + return; + } + try { + await api("PUT", "/api/admin/credentials", { username, password }); + document.getElementById("cred-password").value = ""; + showToast("Credentials saved"); + } catch (e) { + errEl.textContent = e.message; + errEl.classList.remove("hidden"); + } +}); + +// ── Statistics ─────────────────────────────────────────────────────────────── +async function loadStats() { + try { + const rows = await api("GET", "/api/admin/stats"); + const tbody = document.getElementById("stats-body"); + tbody.innerHTML = ""; + if (!rows.length) { + tbody.innerHTML = 'No access logs yet'; + return; + } + for (const r of rows) { + const badge = r.success + ? 'OK' + : `Fail`; + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${fmtDate(r.timestamp.replace(' ', 'T'))} + ${esc(r.keypass_code)} + ${esc(r.gate_name)} + ${esc(r.ip_address || '–')} + ${esc(r.user_agent || '–')} + ${badge}`; + tbody.appendChild(tr); + } + } catch (e) { showToast(e.message, true); } +} + +document.getElementById("btn-refresh-stats").addEventListener("click", loadStats); + +document.getElementById("btn-refresh-stats").addEventListener("click", loadStats); + +// ── Admin users ─────────────────────────────────────────────────────────────── +async function loadAdmins() { + const rows = await api("GET", "/api/admin/admins"); + const tbody = document.getElementById("admins-body"); + tbody.innerHTML = ""; + if (!rows.length) { + tbody.innerHTML = 'No admins'; + return; + } + const me = _tokenPayload().sub; + for (const u of rows) { + const roleBadge = u.role === "admin" + ? 'admin' + : 'manager'; + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${esc(u.username)}${u.username === me ? ' you' : ""} ${roleBadge} + + ${u.username !== me ? `` : ""} + `; + tbody.appendChild(tr); + } + tbody.querySelectorAll("[data-del-admin]").forEach(btn => { + btn.addEventListener("click", async () => { + if (!confirm(`Delete admin "${btn.dataset.delAdmin}"?`)) return; + try { + await api("DELETE", `/api/admin/admins/${encodeURIComponent(btn.dataset.delAdmin)}`); + showToast("Admin deleted"); + loadAdmins(); + } catch (e) { showToast(e.message, true); } + }); + }); +} + +document.getElementById("btn-new-admin").addEventListener("click", () => { + document.getElementById("admin-new-username").value = ""; + document.getElementById("admin-new-password").value = ""; + document.getElementById("admin-new-role").value = "admin"; + document.getElementById("admin-modal-error").classList.add("hidden"); + document.getElementById("admin-modal").classList.remove("hidden"); +}); +document.getElementById("admin-cancel").addEventListener("click", () => { + document.getElementById("admin-modal").classList.add("hidden"); +}); +document.getElementById("admin-form").addEventListener("submit", async e => { + e.preventDefault(); + const username = document.getElementById("admin-new-username").value.trim(); + const password = document.getElementById("admin-new-password").value; + const role = document.getElementById("admin-new-role").value; + const errEl = document.getElementById("admin-modal-error"); + errEl.classList.add("hidden"); + try { + await api("POST", "/api/admin/admins", { username, password, role }); + document.getElementById("admin-modal").classList.add("hidden"); + showToast("Admin created"); + loadAdmins(); + } catch (e) { + errEl.textContent = e.message; + errEl.classList.remove("hidden"); + } +}); + +// ── Load all data ───────────────────────────────────────────────────────────── +function loadAllData() { + const isAdmin = _tokenPayload().scope === "admin"; + loadKeypasses(); + loadGates(); + loadStats(); + if (isAdmin) { + loadCredentials(); + loadAdmins(); + } +} + +// ── Utilities ───────────────────────────────────────────────────────────────── +function esc(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function fmtDate(iso) { + const d = new Date(iso + "Z"); + const pad = n => String(n).padStart(2, "0"); + return `${pad(d.getDate())}/${pad(d.getMonth()+1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function toLocalDatetimeInput(date) { + const pad = n => String(n).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +/** Parse "dd/mm/yyyy hh:mm" as local time and return a UTC ISO string, or null on error. */ +function parseLocalDdMmYyyy(str) { + const m = str.trim().match(/^(\d{2})\/(\d{2})\/(\d{4})\s+(\d{2}):(\d{2})$/); + if (!m) return null; + const d = new Date(+m[3], +m[2] - 1, +m[1], +m[4], +m[5]); + return isNaN(d.getTime()) ? null : d.toISOString(); +} + +// ── Init ────────────────────────────────────────────────────────────────────── +(function init() { + const t = getToken(); + if (tokenValid(t)) { + showAdmin(); + } else { + clearToken(); + showLogin(); + } +})(); diff --git a/src/static/app.js b/src/static/app.js new file mode 100644 index 0000000..5b35fa7 --- /dev/null +++ b/src/static/app.js @@ -0,0 +1,168 @@ +/* app.js – Lagomare Gates frontend */ + +// ── Token helpers ───────────────────────────────────────────────────────────── +const TOKEN_KEY = "lg_keypass_token"; + +function saveToken(t) { localStorage.setItem(TOKEN_KEY, t); } +function clearToken() { localStorage.removeItem(TOKEN_KEY); } +function getToken() { return localStorage.getItem(TOKEN_KEY); } + +function tokenValid(t) { + if (!t) return false; + try { + const payload = JSON.parse(atob(t.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))); + return payload.exp * 1000 > Date.now(); + } catch { return false; } +} + +// ── API helper ──────────────────────────────────────────────────────────────── +async function apiFetch(method, path, body) { + const token = getToken(); + const headers = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; + const res = await fetch(path, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (res.status === 401) { + clearToken(); + showLogin(); + throw new Error("Session expired – please log in again."); + } + if (!res.ok) { + const json = await res.json().catch(() => null); + throw new Error((json && json.detail) || `Error ${res.status}`); + } + if (res.status === 204) return null; + return res.json(); +} + +// ── Views ───────────────────────────────────────────────────────────────────── +function showLogin() { + document.getElementById("login-view").classList.remove("hidden"); + document.getElementById("gates-view").classList.add("hidden"); +} + +function showGatesView() { + document.getElementById("login-view").classList.add("hidden"); + document.getElementById("gates-view").classList.remove("hidden"); +} + +// ── Gate rendering ──────────────────────────────────────────────────────────── +function renderGates(gates) { + const grid = document.getElementById("gates-grid"); + const loading = document.getElementById("loading-gates"); + grid.innerHTML = ""; + + if (!gates.length) { + loading.textContent = "No gates configured."; + loading.classList.remove("hidden"); + grid.classList.add("hidden"); + return; + } + loading.classList.add("hidden"); + grid.classList.remove("hidden"); + + for (const gate of gates) { + const icon = gate.gate_type === "car" ? "🚘" : "🚶"; + const label = gate.gate_type === "car" ? "Car" : "Pedestrian"; + const btn = document.createElement("button"); + btn.className = `gate-btn ${gate.gate_type}`; + btn.dataset.gateId = gate.id; + btn.innerHTML = `${icon}${gate.name}`; + btn.addEventListener("click", () => handleOpenGate(btn, gate.id)); + grid.appendChild(btn); + } +} + +async function loadGates() { + try { + const gates = await apiFetch("GET", "/api/gates"); + renderGates(gates); + } catch (e) { + document.getElementById("loading-gates").textContent = e.message; + document.getElementById("loading-gates").classList.remove("hidden"); + } +} + +// ── Open gate action ────────────────────────────────────────────────────────── +async function handleOpenGate(btn, gateId) { + btn.disabled = true; + btn.classList.add("loading"); + btn.classList.remove("ok", "fail"); + + try { + await apiFetch("POST", `/api/gates/${encodeURIComponent(gateId)}/open`); + btn.classList.remove("loading"); + btn.classList.add("ok"); + showToast("Gate opened ✓", false); + setTimeout(() => btn.classList.remove("ok"), 2000); + } catch (e) { + btn.classList.remove("loading"); + btn.classList.add("fail"); + showToast(e.message, true); + setTimeout(() => btn.classList.remove("fail"), 2000); + } finally { + btn.disabled = false; + } +} + +// ── Toast ───────────────────────────────────────────────────────────────────── +let _toastTimer; +function showToast(msg, isError = false) { + const el = document.getElementById("toast"); + clearTimeout(_toastTimer); + el.textContent = msg; + el.className = `toast ${isError ? "error" : "success"}`; + _toastTimer = setTimeout(() => el.classList.add("fade"), 2600); + setTimeout(() => { el.className = "toast hidden"; }, 3000); +} + +// ── Login form ──────────────────────────────────────────────────────────────── +document.getElementById("login-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const code = document.getElementById("keypass-input").value.trim(); + if (!code) return; + + const btn = document.getElementById("login-btn"); + const errEl = document.getElementById("login-error"); + btn.disabled = true; + errEl.classList.add("hidden"); + + try { + const data = await apiFetch("POST", "/api/auth/keypass", { code }); + saveToken(data.token); + document.getElementById("keypass-input").value = ""; + showGatesView(); + loadGates(); + } catch (e) { + errEl.textContent = e.message; + errEl.classList.remove("hidden"); + } finally { + btn.disabled = false; + } +}); + +// ── Logout ──────────────────────────────────────────────────────────────────── +document.getElementById("logout-btn").addEventListener("click", () => { + clearToken(); + showLogin(); +}); + +// ── Init ────────────────────────────────────────────────────────────────────── +(function init() { + const t = getToken(); + if (tokenValid(t)) { + showGatesView(); + loadGates(); + } else { + clearToken(); + showLogin(); + } +})(); + +// ── Service worker registration ─────────────────────────────────────────────── +if ("serviceWorker" in navigator) { + navigator.serviceWorker.register("/sw.js").catch(() => {}); +} diff --git a/src/static/index.html b/src/static/index.html new file mode 100644 index 0000000..752ee10 --- /dev/null +++ b/src/static/index.html @@ -0,0 +1,136 @@ + + + + + + + + + Lagomare Gates + + + + + + + + + + +
+ Lagomare +

Lagomare Gates

+

Enter your keypass to continue

+
+
+
+ + +
+ + +
+
+
+ + + + + + + + + + diff --git a/src/static/logo.svg b/src/static/logo.svg new file mode 100644 index 0000000..7a3f970 --- /dev/null +++ b/src/static/logo.svg @@ -0,0 +1,53 @@ + + + + diff --git a/src/static/manifest.json b/src/static/manifest.json new file mode 100644 index 0000000..04185f0 --- /dev/null +++ b/src/static/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "Lagomare Gates", + "short_name": "Lagomare Gates", + "description": "Gates control panel for Lagomare residential complex", + "start_url": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#0f0f1a", + "theme_color": "#0f0f1a", + "icons": [ + { + "src": "/static/logo.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/src/static/style.css b/src/static/style.css new file mode 100644 index 0000000..fec7c13 --- /dev/null +++ b/src/static/style.css @@ -0,0 +1,191 @@ +/* ── Reset & base ──────────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0f0f1a; + --surface: #1c1c2e; + --surface2: #252540; + --border: #2e2e50; + --primary: #4f8ef7; + --primary-dk: #3a72d6; + --green: #27ae6e; + --green-dk: #1e9057; + --red: #e05260; + --yellow: #f0a843; + --text: #e2e2f0; + --text-muted: #8888aa; + --radius: 12px; + --shadow: 0 4px 24px rgba(0,0,0,.45); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100dvh; + -webkit-font-smoothing: antialiased; +} + +.hidden { display: none !important; } + +/* ── Layout helpers ────────────────────────────────────────────────────────── */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2rem; + box-shadow: var(--shadow); +} + +/* ── Form elements ─────────────────────────────────────────────────────────── */ +input, select, textarea { + width: 100%; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + font-size: 1rem; + padding: .65rem 1rem; + outline: none; + transition: border-color .2s; +} +input:focus, select:focus, textarea:focus { + border-color: var(--primary); +} +label { + display: block; + font-size: .85rem; + color: var(--text-muted); + margin-bottom: .35rem; +} +.field { margin-bottom: 1rem; } + +/* ── Buttons ───────────────────────────────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: .5rem; + font-size: .95rem; + font-weight: 600; + border: none; + border-radius: 8px; + padding: .65rem 1.4rem; + cursor: pointer; + transition: opacity .15s, transform .1s; + white-space: nowrap; +} +.btn:active { transform: scale(.97); } +.btn:disabled { opacity: .55; cursor: not-allowed; transform: none; } + +.btn-primary { background: var(--primary); color: #fff; } +.btn-primary:not(:disabled):hover { background: var(--primary-dk); } +.btn-danger { background: var(--red); color: #fff; } +.btn-danger:not(:disabled):hover { opacity: .85; } +.btn-ghost { background: var(--surface2); color: var(--text); border: 1px solid var(--border); } +.btn-ghost:not(:disabled):hover { background: var(--border); } +.btn-full { width: 100%; } + +/* ── Gate buttons ──────────────────────────────────────────────────────────── */ +.gate-btn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: .4rem; + padding: 1.25rem .75rem; + border: none; + border-radius: var(--radius); + font-size: .9rem; + font-weight: 700; + cursor: pointer; + transition: transform .12s, box-shadow .12s, opacity .15s; + min-height: 96px; + position: relative; + overflow: hidden; +} +.gate-btn .icon { font-size: 1.8rem; line-height: 1; } +.gate-btn.car { background: var(--primary); color: #fff; } +.gate-btn.pedestrian { background: var(--green); color: #fff; } +.gate-btn:not(:disabled):active { transform: scale(.94); } +.gate-btn:disabled { opacity: .55; cursor: not-allowed; } + +.gate-btn.loading::after { + content: ""; + position: absolute; + inset: 0; + background: rgba(255,255,255,.15); + animation: pulse 1s infinite; +} +.gate-btn.ok { box-shadow: 0 0 0 3px #27ae6e; } +.gate-btn.fail { box-shadow: 0 0 0 3px var(--red); } + +@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} } + +/* ── Toast ─────────────────────────────────────────────────────────────────── */ +.toast { + position: fixed; + bottom: 1.5rem; + left: 50%; + transform: translateX(-50%); + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 8px; + padding: .75rem 1.5rem; + font-size: .9rem; + font-weight: 600; + box-shadow: var(--shadow); + z-index: 9999; + transition: opacity .3s; + pointer-events: none; +} +.toast.success { border-color: var(--green); color: var(--green); } +.toast.error { border-color: var(--red); color: var(--red); } +.toast.fade { opacity: 0; } + +/* ── Tables ────────────────────────────────────────────────────────────────── */ +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: .9rem; } +th { background: var(--surface2); color: var(--text-muted); font-weight: 600; + text-align: left; padding: .65rem 1rem; border-bottom: 1px solid var(--border); } +td { padding: .65rem 1rem; border-bottom: 1px solid var(--border); vertical-align: middle; } +tr:last-child td { border-bottom: none; } + +/* ── Badges ────────────────────────────────────────────────────────────────── */ +.badge { + display: inline-block; + font-size: .75rem; + font-weight: 700; + border-radius: 99px; + padding: .2rem .7rem; +} +.badge-green { background: rgba(39,174,110,.2); color: var(--green); } +.badge-red { background: rgba(224,82,96,.2); color: var(--red); } +.badge-yellow { background: rgba(240,168,67,.2); color: var(--yellow); } +.badge-muted { background: var(--surface2); color: var(--text-muted); } + +/* ── Modal ─────────────────────────────────────────────────────────────────── */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} +.modal { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.75rem; + width: 100%; + max-width: 440px; + box-shadow: var(--shadow); +} +.modal h3 { margin-bottom: 1.25rem; font-size: 1.1rem; } +.modal-actions { display: flex; gap: .75rem; justify-content: flex-end; margin-top: 1.25rem; } + +/* ── Error text ────────────────────────────────────────────────────────────── */ +.error-msg { color: var(--red); font-size: .85rem; margin-top: .5rem; } diff --git a/src/static/sw.js b/src/static/sw.js new file mode 100644 index 0000000..9c52ba4 --- /dev/null +++ b/src/static/sw.js @@ -0,0 +1,26 @@ +/* Service worker – Lagomare Gates */ +const CACHE = "lagomare-gates-v1.1"; +const PRECACHE = ["/", "/static/style.css", "/static/app.js", "/static/logo.svg", "/manifest.json"]; + +self.addEventListener("install", event => { + event.waitUntil( + caches.open(CACHE).then(c => c.addAll(PRECACHE)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener("activate", event => { + event.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", event => { + // Let API calls always go to the network + if (event.request.url.includes("/api/")) return; + + event.respondWith( + caches.match(event.request).then(cached => cached || fetch(event.request)) + ); +});