From f24616d3423f2f6858f4ee045849dbf47ed31e4c Mon Sep 17 00:00:00 2001 From: migatu Date: Fri, 24 Jul 2026 19:21:49 +0200 Subject: [PATCH] feat(render): raport PDF przez LaTeX jako osobna usluga (PRE-24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ostatnia z osmiu wskazowek partnerow. Nowa usluga services/render sklada raport: generuje plik posredni .tex i kompiluje go XeLaTeX-em do PDF. OSOBNY komponent, nie czesc prezentacji — ta sama zasada co przy izolacji swissepha (LOG-27). TeX Live wazy setki megabajtow; w obrazie produktu spowalnialby kazdy build, a tak aktualizuje sie niezaleznie i jego awaria nie kladzie aplikacji, tylko przycisk „Pobierz PDF". SZYFROWANIE, o ktore prosiles: lacze prezentacja↔render idzie tak samo jak pozostale, ale z WLASNYM, TRZECIM kluczem (LINK_KEY_PRESENTATION_RENDER). Osobny, bo tym laczem plynie CALY raport — dane urodzeniowe i opisy z baz — wiec przejecie go nie moze otwierac lacza do logiki ani do danych. Fail-closed: bez klucza pod nie wstaje. Test kopii link_crypto obejmuje teraz cztery uslugi. Uklad PDF wg prosby: imie i nazwisko -> wprowadzone dane -> RYSUNEK kosmogramu -> interpretacja natalna -> predykcje okresowe. Test pilnuje kolejnosci. Dwie rzeczy, ktore wyszly dopiero przy skladaniu tego kawalka: 1. KOSMOGRAM NIE NADAWAL SIE DO PDF. Uzywa zmiennych CSS (var(--line)) i klasy .glyph, ktorej font podaje styles.css — a samodzielny konwerter SVG→PDF nie zna naszego arkusza. Wyszlyby czarne kreski BEZ SYMBOLI. Stad wariant „print": konkretne kolory na bialym tle i font glifow wpisany wprost w rysunek. Przy okazji zalatwia to etap 4 planu kola (wariant do druku). 2. UCIECZKA ZNAKOW LATEXA byla zepsuta — i zlapal to moj wlasny test. Zamiana „\” na \textbackslash{} szla w tej samej petli co nawiasy, wiec kolejne podmiany ucieklyby nawiasy dopiero co wstawione: wychodzilo \textbackslash\{\}. Poprawka: ukosnik chowany pod znacznik zastepczy i rozwijany na koncu. To nie kosmetyka — niezauwazony „%” komentuje RESZTE LINII, wiec zdanie od modelu urywaloby sie w polowie, a PDF powstawalby normalnie, tylko krotszy. Test na wrogim tekscie sprawdza tez, ze \end{document} ani \input{} nie wyrwa sie z dokumentu. Usluga nie zapisuje nic poza katalogiem tymczasowym, ktory sprzata po sobie; w manifescie readOnlyRootFilesystem + emptyDir na /tmp. /health raportuje obecnosc xelatex i rsvg-convert, zeby zepsuty obraz bylo widac od razu. Zweryfikowane na zywo (TestClient uslugi render): zadanie bez szyfrowania z POPRAWNYM tokenem -> 400; obcy klucz -> 400 i tajny opis nie wraca; wlasciwy klucz -> zadanie dochodzi do aplikacji, tresci baz NIE MA na kablu, odpowiedz zaszyfrowana. Manifesty przechodza kubectl apply --dry-run=server na zywym klastrze. CZEGO NIE SPRAWDZILEM: samej kompilacji PDF. W tym srodowisku nie ma ani TeX Live, ani dzialajacego runtime'u kontenerow (docker CLI jest, daemon nie) — probowalem zbudowac obraz i sie nie dalo. Sprawdzone jest wszystko dookola: generowanie .tex, ucieczka znakow, szyfrowanie, kontrakt API, samowystarczalnosc SVG. Pierwsze uruchomienie na klastrze trzeba obejrzec — dlatego PRE-24 zostaje jako „W trakcie", nie „Zrobione". Instrukcja wdrozenia: docs/wdrozenie-render-pdf.md (klucz, obraz, merge, weryfikacja, znane ograniczenia). Testy: 15 nowych (render) + 14 (lacze i wariant druku w prezentacji). Calosc: prezentacja 131, logika 265/1 skip, render 15. Co-Authored-By: Claude Opus 4.8 --- docs/astrololo_wymagania.xlsx | Bin 37748 -> 37767 bytes docs/wdrozenie-render-pdf.md | 139 +++++ services/data/app/link_crypto.py | 4 + services/logic/app/link_crypto.py | 4 + services/logic/tests/test_link_crypto.py | 6 +- services/presentation/app/chartwheel.py | 72 ++- .../presentation/app/clients/render_client.py | 52 ++ services/presentation/app/config.py | 2 + services/presentation/app/link_crypto.py | 4 + services/presentation/app/main.py | 59 ++ services/presentation/app/static/compile.js | 64 +++ .../presentation/app/templates/compile.html | 3 + .../presentation/tests/test_render_link.py | 149 +++++ services/render/Dockerfile | 31 ++ services/render/app/__init__.py | 0 services/render/app/compile.py | 77 +++ services/render/app/latex.py | 131 +++++ services/render/app/link_crypto.py | 525 ++++++++++++++++++ services/render/app/main.py | 62 +++ services/render/app/security.py | 50 ++ services/render/requirements.txt | 4 + services/render/tests/test_latex.py | 130 +++++ 22 files changed, 1546 insertions(+), 22 deletions(-) create mode 100644 docs/wdrozenie-render-pdf.md create mode 100644 services/presentation/app/clients/render_client.py create mode 100644 services/presentation/tests/test_render_link.py create mode 100644 services/render/Dockerfile create mode 100644 services/render/app/__init__.py create mode 100644 services/render/app/compile.py create mode 100644 services/render/app/latex.py create mode 100644 services/render/app/link_crypto.py create mode 100644 services/render/app/main.py create mode 100644 services/render/app/security.py create mode 100644 services/render/requirements.txt create mode 100644 services/render/tests/test_latex.py diff --git a/docs/astrololo_wymagania.xlsx b/docs/astrololo_wymagania.xlsx index 053664742592514f50f8eba03eae93d082dd9e99..dff0e3fcb6c31bbf3ed169ce8b134c464e1299ee 100644 GIT binary patch delta 5950 zcmYjVbyO7G(_Xq$VrisfiKRPNxWkpSdVOd{>Hyt)YyBOauS`&;bRRN!Y#U1)0~z$v6?3Ni%Fvcr_Ko zfJ;R*{4InLzm?^*CAI`cSm^7fZq(w6j!U`mbZ%#qw1}}17<^SNt4tsEnvw7#$Zxl!_%xi){yt@FcCo96pA4KkRm6x;RyY zT;gqrY*!{%qu9}ztN^(pWcq5M?4JSut;4nP2jgsd{Kf|aA=G~gVmR0C0~bdJBb>wC z&`GHZ%gSb_@vD~Z>e34&3-P{$iC3S}>~Sr;WHf4^hZmyjNkYWZChUsS%;}I%n7shgB_m1POGSK55?ZyI1d37gbxG)AS zRC33I90k|GAaoTuxJnB#y4D@SG?t|)4~nNKMD-lzj~72pW>i;HYpkVQ9DDx*nqvJ1 zW{R}n!I1IMNY?x#>H>3U_lWw4&jLL=i)|8HIRT{7GS&JXX8CN!=*sov z2nXk7XGLi?7hxG2H5_DCoXs&aOk6`uDDD78JhC12eO_(%eT*$T%{LDzFs85(pw}eu-URErE z1SfK@w-b208VB!rkarQ}sA0%*F$Hap&F1<=YqZD_$X_YP6{Uno;^^aL2M5x>K}T$t zvlkue8c4@wQ}VcRjl+)yUSfWLYw0WCqlY%CRD(9x!YJt+wk(1DikJ*_UHu&1Oy~*e z{bmTlMD6wN8?ye`tn1jz7{9*s^--&CxkqnRpmV712jSUul#XBAx@Fkfg}kq9Se~{G z$W))i90&R(=F}i-=+%igV9(#ep{muOcLr%t%rHyqza@CkB%x;%!1C~mb45=N8QcXT zU0fYiF6_Y@Pgc98Z3>>;nQj+VvONXkwX{;-eHOsut@%JdMelC1@aW2v#c{~8+$%43&p`{Rr{ zIbZ!KWvn~c^k>bYM=bqe7~PzS``2rwo?|97Owca{2^Yu2Js{rs=ecq_u^&8~5j5G2 zqZnEImBV<=o8MmYh(sZx$#B<0YBtX2NMk1=26Bn2 zLtd)j(z9`?8aHgk(9JpT(Trswdbik|hP;Y<2BmXLSL()5q(4yi z>lQRZCsVyZL<1?ENc5osgb33k=8G! z$s3FTPg;PnK-+@m(14n&3nji15}3pO
    Qmz$KAW;TfJ<)u>MrpuZf<5mf#bhp&x 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 F{|}HIHZTAH delta 5926 zcmZWtbyO5?vtAaEmF{j>I;C?#x>B23}SHv9;Au(TD*600_XHo{rrQ;!giN3q0jfj>99*Z zi|0=JO#0}n@DhZho$|9Xicg_G>nk=7@HQ&`q+y_CSD_m**Me2ZW10$5W?;1oZ+%|( z7NZ%XEIKxo0$>^aS>Q|I`(-|`z%qsaT-R&~yymqPoAM$cI4C49J5_^7Ss0TE>O7RC zq*70|t#C5G1pY=mesOdPJRIVSm>b&^?qY5jzcahSf1cgUsF{>YqMvdb@-#4!LVAG6Xew)J7U%HauHH4~ltIez2EwK4f6*QAE;E2lnmLi>AVY|SMFZB$KT2V(tAHV;ow&mZSHzMzhY4VtEk;2GYjJVnK&=v@;YHQVHChi08^?Wuv{K$`e@9@rQ?YYlm*%$cA4p^v4#mo7`C^@ z_u#ey?wZGfM$1XN4654X81MS7xA0>oRM@$gL1-UgRR*M9o-(-UtzP`tTg>Q$U(L5j z#fhRv)V3PJsT02$b^KzGjLr0Xc7pcH%E~IW%1d<6OFufBb?NkoU| z31|v92jY!QSNo~%T1Yo|7#@ob5%|fZ|Kt(13jtgDBT>;wUopQH78>+6UHe3D0`LgQIm#Tj?DO4+QS*VQH3RwAQ&+D5r%UlZIm0Xs-=;_AObRC-+suP5#v{-WW=Uu#} zE*=(^cmA+Y;SvJx8eZ{vh&PjobX&-&YZhL#uV0ORGZnbOra+u1cWkT%^uP#&r@vRL z{2p78epTK5y?{G9nI#B;N?PepXN3x&PEeC|ja(`^8m|v(f%d&C>1&_%9+ix%im_5k z*U8eUZtT7bM}yhxR@Zg9JHRncvo(p9UzHiYf18Wype*K+x&M+pMov!bXsK=PI6*$6 z_@$8Er0R*S_Vyt8eq$~=>4iiiTv9hU2u!J)G%V=r7=)g`?qAZ$rb(KW76ev47xpaA z@Y0Pbv7p~~AviiV$a;16een3&c?-9fz1u3n*2u&&z2lDjq?IF3GhmuRnooUAtWAQW zC-t%*5ZoeOprQ7bFMl3m<#GzM&Z%C%bL=n|fmO9lX_)U!o0DYUQPfiihr(7@^)>x9 z>Z;fFUHkiO*6i!KPrmW+@_OTKA{M{Ob-1k5M;Nt2%1>zZm-4)#BxM}vObp}d?;r*# zP#>LgcirF!-7UjN0ab!O-@`dwlFO{Li+GlrK{lUdz2QMdO$n!klcb*np1e>)7{AQ6B zA^+SVnAd{bHjEQ_yt#Zo2<&tORcApB61w_g0`W_Y7k$Kw%59`EcDSo6*nU3?e^Xd@ z9rwcbjgrGo6|#&B>O&A3S+K9SPr`jSN)_M1&p5|4`o@6LjTX68cgt-j6@`7F)0Dq~gHne&CO!**0aFl zzQ(qU=6hzrVp@kYh0SxO=?{goQdm^jzrZ-$H1H{=M_J2c$@*ko<6S9%HPOfC7 zx^MDbp!ji`U-$Pr>0*)mI3N#8gkWnCQ8%XeP9IUrX4KgGxeRROSq=X@(?g@}*sRaU zYvLZKbv_#4oJ^%$MX>37MzA8^#|*!rtn?r z3JsQGzRHe=-(Ra)X`Rk*S-T>&sA*zpcNpAcS((hCpnnbagkeG1Jre>NTGQHNTc=E}(oTOXKB?tR z^stdcbPXN~FVdk7fOpw1c2P@EAPMXD3&0EQAh221H2Uuc9n_Kqx_zhXNsFZ4RELlM z=WjIqGPB7I3ji=>csitTA)Wv?*COL_uhmhKhb$g$!e&K6x}fn?c36g;ooybI^AW{q zz6{cJYX&sUhs~vf;Fz*0m|%QYYnw-R#zAt*^*6qkc~6)=qgXJZ)>XbFBDnjm_)heF zXm+i)I_$@c>>QeWc=^PxXU%O|KSt=#N=f@W z#aCgWkKd*9j+4M0NzCF)rAd2J#kyRQC5(Fsqd7h2lQtW;wUlh>+I@(+h3PMc}Naxw@z~Nm8an=fj4w zJ2XJ2{0y!y_+d<7KPjdfUz5k5MOdm@D5&ZY0fGBeiyW#glTJvs`1;$=y4N0ACNz79 ztk4udT?P@>4qR<#K9t@pa@<>`xy-eb6O~n!iTL-l6hH$_NoIgSW6S9jj7iU!*+gC8 zg$ipVJ8$%rjZk7>nsA=(53Y=Kdf%gyHHg-Carh?>nj4E|b@eKUSl{tUD%{RK*Dg@Z z=jMbiYn>-a_`s*LG}Z$urDE{g*_71JR>a=K!_Nra~?MX|I|}lnp6%y>`eQ+O)te?S*!R)6^(XGbR^(h zslrtpU|h@!#g2IGa%|YPAd}~d9e3leCAyCZ%`857zMbn0zIi`|i*?Ja13kPTXAp~M z6lgCq6gXaZ`GcmJW`Et`Ca5$cbSUKT zyg!AZ(mP9@6Ua{b(r=OKOylr8R}#te(XWppw0{Z1=`h}W1Dh1#p#BV{EO^ci4*M(G z%&2EmVjS(~06JbA{(`_MrEChL#m?k48t$tspa0D4XV`V3OX932WPuRP8kf=8@JauD zFBtAbNwoQ?4ysNR=}^@9N<0#h3lFT+jV9-?Q29V!IB4E(z-z@2L`YMu*S<-y6Pqg^ zp`JL8r1(X{sVpS?rd}mjAnn?Dz;WrBPRiGXtGO(|alPN-(l^zU*SGVcxR6(gFYaIxpm- zFweCsO9R2r;BR~MEe{^NuB6{B4pP5V;%%i-u~2uvhSJ{ zGquCfl?fb38tyO$vDTVMG8Zmxu(a{$KKNB=*EHfOgarLz9~lI%Dg_AE1Ro3?ruZnh zV$=>Rw$al}$cWdZB!yBb3t7V(I?0lv3nyY{WHm6CUPFd~31bv-v&d1Rszv(=OMpAt zFz1#nV?AJ=4Fcr?+jG4fi-@!uVS2olA_1$5@!x-$>UO>kxj7jjAw6x~o3!s1=EHsa zV4q@27g|1Ulau8jbms1IvuwWR=$SG*?dI<|d}F`Bi<3`?h&MA4^pG6S|0>U@-g2Izyj{?TwChpR4BuzPK*sq;@hHkH zj`#>e2sgfQr-6)koz!*j@vgpB(A_#grLeO>;imwvfkFa#epbQ}Tl`zb8e`euwRZ(= z)q;#R;l^l_WV}-wNPqb0Uj?!vZV7s!;hIU6qT6NJ>g%%-8!5>e4Ak?m^0(x#$n3QMkz?F_U*yEtVM|GMbW>BjsXRPz(J=c@b{!94D^lF|&c=(bf(;wAv=CJvd0lK6GL$1!Y`xYx3ZmRNmzZ@Al3|#! zvU>`#ulDAs@Y+inhs{|$D>VFciUny6z61ik6V6e|Lny!&4s%-8X)R5O`dhW}IA?^% z-fS5On3F2#-I5Ty7^jPAy_#mSNnrzE!~p3HG&Is(mmeqGGyJXx3TNKE7<{1}b}vlZo}durHY$=lnoxK9rNs zi4v*qK+mGr0IVH%%3fKqKT6n zj#*luN#sO83#B;U4x!Z+UwXVg>hB< zfqtQD0{LoEEoiV^O+Fdi-X6rUs7=gn3>@J*h(?=u7OxjyX+@g}_JX#E^}0{QZrK~v zk{YQqB?~We_9J`9)Vz%@kN(^$sQe85y@@ty&`EJ5Oc!HGTZ8Q#lNe~n*mp?Sx+t&g z!?wl9`rfZoQD>N1~DVSb~S6%9ZpRaBQnmvt~S?p5o>~wtjh_6DWP%0B1317$G zoK;ze6pZz4VC z^IU;I8!1{z*h%CCVQ808>3h7r+&@@p!f(8tg&p0SrDxHA?=73PdvE{fQDS_|Qzs-y zfkl1R@^9#*45pj@(vz`GabqVt$On&T=>LuxZm>Z{`r^W{PDR7)D#U%o%)H26jqB~; zehy<)^k9zV`Nm%SL^|jK^La3?vHh<+-F{zwSzvaHrJo6=Gj|BuZmoe`IS1rj}u`v6^%j$?540f!_sw)jDA zX!{ZPAJn=k^bGVwt#f0?QU5`tUEUz1-XYnsTD z%`y%0zr)Y!H0dYXUYh(9aHq@T{0Co0hNX-BH-rB-axj!G`sDkVP7RC;MatrmXbL}3 z1#LAHATjFyjGTX)<^YSKWvBqa79{|H^UomqetI b1Tq~#LaMEX{_M#Q_n%exPd%SfpS1r1b1d@v diff --git a/docs/wdrozenie-render-pdf.md b/docs/wdrozenie-render-pdf.md new file mode 100644 index 0000000..8a1228a --- /dev/null +++ b/docs/wdrozenie-render-pdf.md @@ -0,0 +1,139 @@ +# Wdrożenie usługi `render` — raport PDF (PRE-24) + +Instrukcja krok po kroku dla nowego komponentu. Dotyczy dwóch repozytoriów: + +| Repo | Co wnosi | +|---|---| +| `gitea/astrololo` | usługa `services/render`, klient w prezentacji, przycisk „Pobierz PDF", wariant kosmogramu do druku | +| `gitea/deploy` | `astrololo/render.yaml`, adres i klucz łącza w `presentation.yaml` | + +--- + +## Dlaczego osobna usługa + +TeX Live waży setki megabajtów. W obrazie prezentacji spowalniałby każdy build +i deploy, a przy każdej poprawce w CSS trzeba by go ciągnąć od nowa. Osobno: +obraz produktu zostaje mały, TeX aktualizuje się niezależnie, a **awaria renderu +nie kładzie aplikacji** — przestaje działać wyłącznie przycisk „Pobierz PDF". + +To ta sama zasada, co przy izolacji silnika swisseph (LOG-27). + +--- + +## Krok 1 — klucz łącza (PRZED wdrożeniem) + +Usługa dostaje **cały raport**: dane urodzeniowe i opisy z baz. Łącze jest +szyfrowane AES-256-GCM, własnym, **trzecim** kluczem — przejęcie go nie może +otwierać łącza do logiki ani do danych. + +Sekret `astrololo-link` już istnieje (PRE-16); dokładamy do niego trzeci klucz, +**zachowując dwa dotychczasowe**: + +```bash +kubectl -n astrololo create secret generic astrololo-link \ + --from-literal=LINK_KEY_PRESENTATION_LOGIC="$(kubectl -n astrololo get secret astrololo-link \ + -o jsonpath='{.data.LINK_KEY_PRESENTATION_LOGIC}' | base64 -d)" \ + --from-literal=LINK_KEY_LOGIC_DATA="$(kubectl -n astrololo get secret astrololo-link \ + -o jsonpath='{.data.LINK_KEY_LOGIC_DATA}' | base64 -d)" \ + --from-literal=LINK_KEY_PRESENTATION_RENDER="$(openssl rand -hex 32)" \ + --dry-run=client -o yaml | kubectl apply -f - +``` + +Sprawdzenie — mają być **trzy** klucze: +```bash +kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data}' | tr ',' '\n' +``` + +> **Uwaga:** `LINK_ENCRYPTION_REQUIRED=true` działa fail-closed. Bez klucza pod +> `render` **nie wstanie** — i tak ma być. Usługa, która wstała i po cichu nie +> szyfruje, jest gorsza niż CrashLoop, bo awarii nie widać. + +--- + +## Krok 2 — obraz usługi + +Obraz budowany jest osobnym workflow (jak silnik swisseph — nie wchodzi do +głównego pipeline'u produktu). Pierwszy build trwa dłużej, bo ciągnie TeX Live. + +```bash +kubectl -n astrololo get deploy render -o jsonpath='{.spec.template.spec.containers[0].image}'; echo +``` + +Pakiety w obrazie dobrane **wąsko** (nie `texlive-full`, który ma kilka GB): + +| Pakiet | Po co | +|---|---| +| `texlive-xetex` | silnik **XeLaTeX** — konieczny, bo raport ma polskie znaki i glify astrologiczne; `pdflatex` ich nie złoży | +| `texlive-latex-recommended` | `geometry`, `graphicx` | +| `fonts-dejavu-core` | jeden font na polskie znaki **i** symbole (♄ ♓ ☉) | +| `librsvg2-bin` | `rsvg-convert` — SVG kosmogramu → PDF (LaTeX nie wstawia SVG wprost) | + +Dockerfile sprawdza obecność obu narzędzi **przy budowie**, więc zepsuty obraz +nie dojedzie na produkcję niezauważony. + +--- + +## Krok 3 — merge manifestów + +```bash +tea pr merge --login gitea --repo gitea/deploy +kubectl -n astrololo rollout status deploy/render deploy/presentation +``` + +Wjeżdża naraz: `Deployment` + `Service` renderu (ClusterIP — **bez** Ingressu +i NodePortu, nie ma powodu sięgać do niej z zewnątrz) oraz `RENDER_URL` +i trzeci klucz w prezentacji. + +--- + +## Krok 4 — sprawdzenie + +### Narzędzia są na miejscu +`/health` raportuje obecność `xelatex` i `rsvg-convert`, żeby zepsuty obraz +było widać od razu, a nie dopiero przy pierwszym raporcie: + +```bash +kubectl -n astrololo exec deploy/presentation -- \ + python -c "import httpx; print(httpx.get('http://render:8004/health').text)" +``` +Oczekiwane: `{"status":"ok","layer":"render","tools":{"xelatex":true,"rsvg-convert":true}}`. +Gdy `status` to `degraded` — obraz zbudował się bez któregoś narzędzia. + +### Łącze faktycznie szyfruje +Najmocniejszy test to próba obejścia. Z wnętrza klastra, **z poprawnym tokenem, +ale bez szyfrowania**: + +```bash +kubectl -n astrololo exec deploy/presentation -- python -c " +import httpx, os +r = httpx.post('http://render:8004/pdf', json={'person':'test'}, + headers={'X-Astrololo-Token': os.environ['INTERNAL_TOKEN']}) +print(r.status_code, r.text[:80]) +" +``` +Oczekiwane: **400** i `Łącze międzywarstwowe wymaga szyfrowania.` Sam token już +nie wystarcza — o to chodziło. + +### Raport od końca do końca +W przeglądarce: **Skompiluj** → wypełnij dane → *Złóż podsumowanie* → *Pobierz PDF*. +PDF ma zacząć się od imienia i nazwiska, potem wprowadzone dane, **rysunek +kosmogramu**, a po nim interpretacja natalna i predykcje okresowe. + +--- + +## Czego ta wersja nie załatwia + +- **Kompilacja PDF nie została sprawdzona end-to-end** w środowisku, w którym + powstawała — nie było tam ani TeX Live, ani runtime'u kontenerów. Sprawdzone + jest wszystko dookoła: generowanie źródła `.tex` (w tym ucieczka znaków + specjalnych), szyfrowanie łącza, kontrakt API i samowystarczalność SVG. + **Pierwsze uruchomienie na klastrze trzeba obejrzeć.** +- **Dzielenie wyrazów** jest angielskie — nie wciągamy `polyglossia`, żeby nie + puchł obraz. Tekst składa się poprawnie, tylko przenoszenie bywa nieoptymalne. +- **Brak kolejki**. Długi raport blokuje jedno połączenie na czas kompilacji + (timeout klienta: 180 s). Przy większym ruchu warto dołożyć zadania w tle. +- **Glify w tekście od modelu**. Kosmogram idzie jako obraz (rysuje go + `rsvg-convert` z DejaVu), ale gdyby model wplótł symbole w prozę, złoży je + XeLaTeX — też z DejaVu. Jeśli któregoś zabraknie, LaTeX zgłasza + „Missing character" w logu i **nie drukuje znaku**; warto zerknąć w log + pierwszego raportu. diff --git a/services/data/app/link_crypto.py b/services/data/app/link_crypto.py index afa70bb..89ed3d8 100644 --- a/services/data/app/link_crypto.py +++ b/services/data/app/link_crypto.py @@ -65,6 +65,10 @@ CONTENT_TYPE = "application/vnd.astrololo.enc" ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC" ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA" +# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych — +# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie +# jej klucza nie może otwierać łącza do logiki ani do danych. +ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER" ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED" REQUEST, RESPONSE = b"req", b"res" diff --git a/services/logic/app/link_crypto.py b/services/logic/app/link_crypto.py index afa70bb..89ed3d8 100644 --- a/services/logic/app/link_crypto.py +++ b/services/logic/app/link_crypto.py @@ -65,6 +65,10 @@ CONTENT_TYPE = "application/vnd.astrololo.enc" ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC" ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA" +# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych — +# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie +# jej klucza nie może otwierać łącza do logiki ani do danych. +ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER" ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED" REQUEST, RESPONSE = b"req", b"res" diff --git a/services/logic/tests/test_link_crypto.py b/services/logic/tests/test_link_crypto.py index 3e716f0..45d5153 100644 --- a/services/logic/tests/test_link_crypto.py +++ b/services/logic/tests/test_link_crypto.py @@ -342,13 +342,13 @@ def test_incremental_unframing_handles_split_frames(link): # ----------------------------------------------- trzy kopie muszą być identyczne -def test_all_three_services_share_the_same_module(): - """Moduł jest skopiowany do trzech niezależnych usług (nie mają wspólnej +def test_all_services_share_the_same_module(): + """Moduł jest skopiowany do KAŻDEJ usługi, która szyfruje łącze (nie mają wspólnej biblioteki). Rozjazd między kopiami objawiłby się dopiero na produkcji jako „nie da się odszyfrować" — więc pilnujemy tego testem.""" root = pathlib.Path(__file__).resolve().parents[3] copies = {svc: (root / "services" / svc / "app" / "link_crypto.py") - for svc in ("presentation", "logic", "data")} + for svc in ("presentation", "logic", "data", "render")} missing = [svc for svc, path in copies.items() if not path.is_file()] assert not missing, f"brak modułu w warstwach: {missing}" contents = {svc: path.read_bytes() for svc, path in copies.items()} diff --git a/services/presentation/app/chartwheel.py b/services/presentation/app/chartwheel.py index 0666d54..9199a32 100644 --- a/services/presentation/app/chartwheel.py +++ b/services/presentation/app/chartwheel.py @@ -49,6 +49,22 @@ MIN_SEP = 8.0 # muted kolory żywiołów — czytelne na ciemnym tle, spójne z paletą aplikacji _ELEMENT_COLOR = ["#d98a6a", "#8fae7a", "#cfc06a", "#6aa6c9"] # ogień, ziemia, powietrze, woda +# ── motywy ─────────────────────────────────────────────────────────────── +# „screen" korzysta ze zmiennych CSS strony, więc koło samo dostraja się do +# motywu aplikacji. „print" MUSI mieć konkretne kolory: SVG idzie wtedy do +# osobnego konwertera (rsvg → PDF, PRE-24), który o naszym arkuszu nic nie wie +# i zmiennych `var(--…)` nie rozwiąże — wyszłyby czarne albo żadne kreski. +# Przy okazji odwracamy kontrast: raport drukuje się na białym papierze. +_THEMES = { + "screen": {"line": "var(--line)", "ink": "var(--ink)", "muted": "var(--muted)", + "accent": "var(--accent)", "disc": "#12132a", "retro": "#ff9b6a", + "elements": _ELEMENT_COLOR}, + "print": {"line": "#b9bcc9", "ink": "#111318", "muted": "#5d6270", + "accent": "#5a49c8", "disc": "#ffffff", "retro": "#b2452a", + # ciemniejsze warianty żywiołów — na białym tle jasne byłyby niewidoczne + "elements": ["#a8442a", "#3f6b33", "#8a6d10", "#1f5c80"]}, +} + def _pt(lon: float, asc: float, r: float) -> tuple[float, float]: phi = math.radians(180.0 - (lon - asc)) @@ -61,10 +77,20 @@ def _line(x1: float, y1: float, x2: float, y2: float, stroke: str, w: float = 1. f'stroke="{stroke}" stroke-width="{w}" opacity="{opacity}"/>') -def _text(x: float, y: float, s: str, *, size: float, fill: str, cls: str = "") -> str: +# Stack fontów z glifami astrologicznymi. Na stronie dostarcza go klasa CSS +# `.glyph`, ale w wariancie do druku SVG jedzie do SAMODZIELNEGO konwertera, który +# naszego arkusza nie widzi — wtedy trzeba wpisać font wprost do elementu, inaczej +# rysunek wychodzi BEZ symboli (konwerter bierze domyślny font, a ten ich nie ma). +GLYPH_FONT = ("Apple Symbols, Segoe UI Symbol, Noto Sans Symbols2, " + "DejaVu Sans, sans-serif") + + +def _text(x: float, y: float, s: str, *, size: float, fill: str, cls: str = "", + font: str = "") -> str: c = f' class="{cls}"' if cls else "" + f = f' font-family="{font}"' if font else "" return (f'{escape(s)}') + f' text-anchor="middle" dominant-baseline="central"{c}{f}>{escape(s)}') def spread(lons: list[float], min_sep: float = MIN_SEP, passes: int = 300) -> list[float]: @@ -126,8 +152,16 @@ def _delta(a: float, b: float) -> float: return abs(((a - b + 180.0) % 360.0) - 180.0) -def render(chart: dict) -> str: - """Zwraca SVG koła albo '' gdy brakuje danych (silnik bez osi/domów).""" +def render(chart: dict, theme: str = "screen") -> str: + """Zwraca SVG koła albo '' gdy brakuje danych (silnik bez osi/domów). + + theme='screen' — kolory ze zmiennych CSS aplikacji (dostraja się do motywu). + theme='print' — konkretne kolory na białym tle; konieczne, gdy SVG trafia + do samodzielnego konwertera (PDF), który zmiennych CSS nie rozwiąże. + """ + T = _THEMES.get(theme, _THEMES["screen"]) + # tylko dla druku — na stronie font podaje CSS (.glyph) + glyph_font = GLYPH_FONT if theme == "print" else "" angles = chart.get("angles") cusps = chart.get("cusps") signs = chart.get("sign_glyphs") @@ -138,26 +172,26 @@ def render(chart: dict) -> str: parts: list[str] = [] # tło i okręgi - parts.append(f'') - parts.append(f'') - parts.append(f'') + parts.append(f'') + parts.append(f'') + parts.append(f'') # drobne podziałki co 5°, mocniejsze co 10° (na pasie znaków) for deg in range(0, 360, 5): r_in = R_TICK if deg % 10 else R_TICK - 4 x1, y1 = _pt(deg, asc, R_ZOD) x2, y2 = _pt(deg, asc, r_in) - parts.append(_line(x1, y1, x2, y2, "var(--line)", 0.6, 0.7)) + parts.append(_line(x1, y1, x2, y2, T["line"], 0.6, 0.7)) # pas znaków: granice co 30° + glif znaku w środku sektora, kolorem żywiołu for i, sg in enumerate(signs): b = 30.0 * i x1, y1 = _pt(b, asc, R_ZOD) x2, y2 = _pt(b, asc, R_OUT) - parts.append(_line(x1, y1, x2, y2, "var(--line)", 1.0)) + parts.append(_line(x1, y1, x2, y2, T["line"], 1.0)) gx, gy = _pt(b + 15.0, asc, R_SIGN) parts.append(_text(gx, gy, sg.get("glyph") or "", size=17, - fill=_ELEMENT_COLOR[i % 4], cls="glyph")) + fill=T["elements"][i % 4], cls="glyph", font=glyph_font)) # szprychy domów (od pasa do piasty) + numery domów w środku każdego domu n = len(cusps) @@ -165,24 +199,24 @@ def render(chart: dict) -> str: lon = float(c["decimal"]) x1, y1 = _pt(lon, asc, R_ZOD) x2, y2 = _pt(lon, asc, R_HUB) - parts.append(_line(x1, y1, x2, y2, "var(--line)", 0.8, 0.85)) + parts.append(_line(x1, y1, x2, y2, T["line"], 0.8, 0.85)) nxt = float(cusps[(i + 1) % n]["decimal"]) span = (nxt - lon) % 360.0 or 360.0 mid = lon + span / 2.0 hx, hy = _pt(mid, asc, R_HNUM) - parts.append(_text(hx, hy, str(c["house"]), size=11, fill="var(--muted)")) + parts.append(_text(hx, hy, str(c["house"]), size=11, fill=T["muted"])) # osie: Asc–Dsc i MC–IC przez całe koło, wyróżnione akcentem for a, b, la, lb in (("Asc", "Dsc", "AC", "DC"), ("MC", "IC", "MC", "IC")): lon_a = float(angles[a]["decimal"]) ax, ay = _pt(lon_a, asc, R_ZOD) bx, by = _pt(lon_a + 180.0, asc, R_ZOD) - parts.append(_line(ax, ay, bx, by, "var(--accent)", 1.6, 0.9)) + parts.append(_line(ax, ay, bx, by, T["accent"], 1.6, 0.9)) # etykiety POZA kołem — w środku kolidowały z glifami obiektów lax, lay = _pt(lon_a, asc, R_AXIS_LABEL) lbx, lby = _pt(lon_a + 180.0, asc, R_AXIS_LABEL) - parts.append(_text(lax, lay, la, size=10, fill="var(--accent)")) - parts.append(_text(lbx, lby, lb, size=10, fill="var(--accent)")) + parts.append(_text(lax, lay, la, size=10, fill=T["accent"])) + parts.append(_text(lbx, lby, lb, size=10, fill=T["accent"])) # ── obiekty: glify na pozycjach, stopień w znaku, retrogradacja ───── # Glify rozsuwamy, ale kreska na pasie zostaje w PRAWDZIWEJ pozycji — @@ -197,15 +231,15 @@ def render(chart: dict) -> str: mx1, my1 = _pt(true_lon, asc, R_ZOD) mx2, my2 = _pt(true_lon, asc, R_MARK) - parts.append(_line(mx1, my1, mx2, my2, "var(--ink)", 1.0, 0.75)) + parts.append(_line(mx1, my1, mx2, my2, T["ink"], 1.0, 0.75)) if _delta(draw_lon, true_lon) > 0.4: lx, ly = _pt(draw_lon, asc, R_PLANET + 12) - parts.append(_line(mx2, my2, lx, ly, "var(--line)", 0.7, 0.9)) + parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9)) gx, gy = _pt(draw_lon, asc, R_PLANET) parts.append(_text(gx, gy, obj.get("glyph") or "", size=16, - fill="var(--ink)", cls="glyph")) + fill=T["ink"], cls="glyph", font=glyph_font)) # Sam stopień w znaku, bez „°" — tak robią programy astrologiczne i tylko # tak podpisy mieszczą się obok siebie w skupiskach. Retrogradacja: ℞ oraz @@ -213,7 +247,7 @@ def render(chart: dict) -> str: dx, dy = _pt(draw_lon, asc, R_DEG) label = f"{int(true_lon % 30)}" + ("℞" if retro else "") parts.append(_text(dx, dy, label, size=8, - fill="#ff9b6a" if retro else "var(--muted)")) + fill=T["retro"] if retro else T["muted"])) body = "".join(parts) return (f' dict[str, str]: + """Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona.""" + token = os.getenv("INTERNAL_TOKEN", "") + return {"X-Astrololo-Token": token} if token else {} + + +def _link() -> link_crypto.Link | None: + """Klucz łącza prezentacja↔render. Czytany przy każdym wywołaniu, bo + konfiguracja może się zmienić bez restartu procesu.""" + key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_RENDER) + return link_crypto.Link(key) if key else None + + +class RenderClient: + def __init__(self, base_url: str | None = None) -> None: + self.base_url = (base_url or settings.render_url).rstrip("/") + + def pdf(self, report: dict) -> bytes: + """Raport → bajty PDF. Timeout długi: XeLaTeX na obszernym tekście + potrzebuje chwili, a raport bywa na kilkadziesiąt stron.""" + with httpx.Client(timeout=max(settings.http_timeout, 180.0)) as client: + return link_crypto.call(client, "POST", f"{self.base_url}/pdf", + payload=report, headers=_auth_headers(), + link=_link()) + + def health(self) -> dict: + """/health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza + nie mają, a nie przechodzi tędy nic z raportu.""" + import json + + with httpx.Client(timeout=settings.http_timeout) as client: + r = client.get(f"{self.base_url}/health", headers=_auth_headers()) + r.raise_for_status() + return json.loads(r.content) diff --git a/services/presentation/app/config.py b/services/presentation/app/config.py index 440f32f..116a909 100644 --- a/services/presentation/app/config.py +++ b/services/presentation/app/config.py @@ -11,6 +11,8 @@ from dataclasses import dataclass, field @dataclass class Settings: logic_url: str = field(default_factory=lambda: os.getenv("LOGIC_URL", "http://localhost:8001")) + # Usługa składająca PDF (PRE-24) — osobny komponent, bo dźwiga TeX Live. + render_url: str = field(default_factory=lambda: os.getenv("RENDER_URL", "http://localhost:8004")) http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "10"))) diff --git a/services/presentation/app/link_crypto.py b/services/presentation/app/link_crypto.py index afa70bb..89ed3d8 100644 --- a/services/presentation/app/link_crypto.py +++ b/services/presentation/app/link_crypto.py @@ -65,6 +65,10 @@ CONTENT_TYPE = "application/vnd.astrololo.enc" ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC" ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA" +# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych — +# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie +# jej klucza nie może otwierać łącza do logiki ani do danych. +ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER" ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED" REQUEST, RESPONSE = b"req", b"res" diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index 18d1393..a6eb637 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -149,6 +149,65 @@ def compile_build( return templates.TemplateResponse(request, "compile.html", ctx) +@app.post("/compile/pdf") +def compile_pdf(payload: dict): + """Składa raport PDF (PRE-24) — woła usługę render po szyfrowanym łączu. + + Wejście z przeglądarki, bo części od AI (interpretacja natalna i predykcje) + mieszkają w magazynie lokalnym. Kosmogram i tabele liczymy TU, żeby PDF + zawierał dokładnie to, co widać na stronie. + + Kosmogram idzie w motywie DRUKU: samodzielny konwerter SVG→PDF nie zna + naszego arkusza, więc zmienne CSS i font glifów muszą być w samym rysunku. + """ + from fastapi.responses import Response + + from app.clients.render_client import RenderClient + + data = payload.get("data") or {} + try: + iso_utc, label = _build_utc( + str(data.get("date") or ""), str(data.get("time") or ""), + float(data.get("tz_offset") or 0.0), + ) + except (ValueError, TypeError) as e: + return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422) + + wheel_svg = "" + try: + chart = logic.positions( + when_utc_iso=iso_utc, + lat=float(data.get("lat") or 0.0), lon=float(data.get("lon") or 0.0), + house_system=str(data.get("house_system") or "whole_sign"), + zodiac=str(data.get("zodiac") or "tropical"), + ) + from app import chartwheel + + wheel_svg = chartwheel.render(chart, theme="print") + except httpx.HTTPError as e: + # Brak rysunku nie może zablokować raportu — tekst jest ważniejszy. + log_note = _logic_error(e) + chart, wheel_svg = None, "" + data = {**data, "wheel_error": log_note} + + report = { + "person": payload.get("person") or "", + "data": {**data, "moment_utc": label}, + "wheel_svg": wheel_svg, + "natal": payload.get("natal") or {}, + "predictions": payload.get("predictions") or [], + } + try: + pdf = RenderClient().pdf(report) + except httpx.HTTPError as e: + return JSONResponse( + {"detail": f"Usługa render niedostępna albo nie złożyła PDF-a: {e}"}, + status_code=502, + ) + return Response(content=pdf, media_type="application/pdf", + headers={"Content-Disposition": 'attachment; filename="raport.pdf"'}) + + # ---------------- Sygnifikatory (wyszukiwarka w bazach) ---------------- @app.get("/significators", response_class=HTMLResponse) def significators_form(request: Request): diff --git a/services/presentation/app/static/compile.js b/services/presentation/app/static/compile.js index 8167bf3..d4c131c 100644 --- a/services/presentation/app/static/compile.js +++ b/services/presentation/app/static/compile.js @@ -85,11 +85,75 @@ else document.addEventListener('DOMContentLoaded', fn); } + // ── PDF (PRE-24) ──────────────────────────────────────────────────── + // Części od AI mieszkają w przeglądarce, więc raport składamy TU i wysyłamy + // do serwera, który dokłada kosmogram i woła usługę render. + function formData() { + function v(name) { + var el = document.querySelector('[name=' + name + ']'); + return el ? el.value : ''; + } + return { + date: v('date'), time: v('time'), tz_offset: v('tz_offset'), + lat: v('lat'), lon: v('lon'), + house_system: v('house_system'), zodiac: v('zodiac'), + place: (document.querySelector('#geoSearch') || {}).value || '' + }; + } + + function download(blob) { + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'raport.pdf'; + document.body.appendChild(a); + a.click(); + a.remove(); + // Zwalniamy obiekt dopiero po kliknięciu — wcześniej przeglądarka nie zdąży + // rozpocząć pobierania. + setTimeout(function () { URL.revokeObjectURL(url); }, 4000); + } + + function buildPdf(btn, note) { + var person = (document.querySelector('[name=person]') || {}).value || ''; + var payload = { + person: person, + data: formData(), + natal: readNatal() || {}, + predictions: readPredictions() + }; + btn.disabled = true; + note.textContent = 'Składam PDF — LaTeX potrzebuje chwili przy obszernym tekście…'; + fetch('/compile/pdf', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }).then(function (r) { + if (!r.ok) { + return r.json().catch(function () { return {}; }).then(function (d) { + throw new Error(d.detail || ('HTTP ' + r.status)); + }); + } + return r.blob(); + }).then(function (blob) { + download(blob); + note.textContent = 'Gotowe — PDF pobrany.'; + }).catch(function (e) { + note.textContent = 'Nie udało się złożyć PDF-a: ' + e.message; + }).finally(function () { + btn.disabled = false; + }); + } + ready(function () { if (!document.getElementById('readiness')) return; // nie ta zakładka // Horoskop jest w dokumencie tylko po wysłaniu formularza. renderReadiness(!!document.getElementById('reportPerson')); renderNatal(); renderPredictions(); + + var btn = document.getElementById('pdfBtn'); + var note = document.getElementById('pdfNote'); + if (btn && note) btn.addEventListener('click', function () { buildPdf(btn, note); }); }); })(); diff --git a/services/presentation/app/templates/compile.html b/services/presentation/app/templates/compile.html index 45a18f3..33c60bd 100644 --- a/services/presentation/app/templates/compile.html +++ b/services/presentation/app/templates/compile.html @@ -57,6 +57,9 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
    + +
    diff --git a/services/presentation/tests/test_render_link.py b/services/presentation/tests/test_render_link.py new file mode 100644 index 0000000..63fba4d --- /dev/null +++ b/services/presentation/tests/test_render_link.py @@ -0,0 +1,149 @@ +"""Łącze do usługi render i wariant kosmogramu do druku (PRE-24). + +Przez to łącze leci CAŁY raport: dane urodzeniowe i opisy z baz. Musi być +szyfrowane tak samo jak pozostałe, WŁASNYM kluczem — i musi obowiązywać ten sam +niezmiennik co dla klienta logiki: żadnego wyjścia w dół bez tokenu i klucza. +""" +import pathlib + +import pytest + +APP = pathlib.Path(__file__).resolve().parents[1] / "app" +CLIENT = APP / "clients" / "render_client.py" +SRC = CLIENT.read_text(encoding="utf-8") +WHEEL = (APP / "chartwheel.py").read_text(encoding="utf-8") +MAIN = (APP / "main.py").read_text(encoding="utf-8") +COMPILE_JS = (APP / "static" / "compile.js").read_text(encoding="utf-8") + + +# ─────────────────────────── szyfrowanie łącza ─────────────────────────── + +def test_uses_its_own_third_key(): + """Osobny klucz na parę rozmówców. Przejęcie klucza renderu nie może otwierać + łącza do logiki ani do danych.""" + from app import link_crypto + + assert link_crypto.ENV_PRESENTATION_RENDER == "LINK_KEY_PRESENTATION_RENDER" + assert link_crypto.ENV_PRESENTATION_RENDER != link_crypto.ENV_PRESENTATION_LOGIC + assert link_crypto.ENV_PRESENTATION_RENDER != link_crypto.ENV_LOGIC_DATA + assert "ENV_PRESENTATION_RENDER" in SRC + + +def test_report_goes_through_the_encrypted_link(): + assert "link_crypto.call(" in SRC + assert "link=_link()" in SRC + + +def test_every_outbound_call_carries_token_and_key(): + """Ten sam niezmiennik co przy kliencie logiki — tam brak nagłówka wyszedł + dopiero na produkcji (401).""" + import ast + + tree = ast.parse(SRC) + calls = [n for n in ast.walk(tree) + if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) + and n.func.attr in ("call", "call_json")] + assert calls, "nie znaleziono wywołania przez link_crypto" + for c in calls: + kwargs = {k.arg for k in c.keywords} + assert "headers" in kwargs and "link" in kwargs + + +def test_health_is_the_only_unencrypted_path(): + """/health musi zostać otwarty dla sond k8s — ale nic poza nim.""" + from app import link_crypto + + assert "/health" in link_crypto.PUBLIC_PATHS + raw = [line for line in SRC.splitlines() if "client.get(" in line or "client.post(" in line] + assert all("/health" in line for line in raw), f"surowe wywołania poza /health: {raw}" + + +def test_key_is_read_lazily(): + """Konfiguracja może się zmienić bez restartu procesu (podmiana sekretu).""" + assert "def _link()" in SRC and "key_from_env" in SRC + + +# ──────────────────────── kosmogram w wariancie do druku ───────────────── + +def test_pdf_route_uses_print_theme(): + """Samodzielny konwerter SVG→PDF nie zna naszego arkusza stylów.""" + assert 'chartwheel.render(chart, theme="print")' in MAIN + + +def test_print_theme_has_no_css_variables(): + """`var(--…)` w SVG poza stroną nie rozwiąże się — wyszłyby czarne albo + żadne kreski.""" + from app import chartwheel + + chart = _sample_chart() + printed = chartwheel.render(chart, theme="print") + assert printed and "var(--" not in printed + + +def test_screen_theme_still_uses_css_variables(): + """Na stronie koło ma się dostrajać do motywu aplikacji.""" + from app import chartwheel + + assert "var(--" in chartwheel.render(_sample_chart(), theme="screen") + + +def test_print_theme_embeds_the_glyph_font(): + """Font glifów podaje CSS klasy `.glyph` — poza stroną trzeba go wpisać + W SAM rysunek, inaczej kosmogram w PDF wyszedłby BEZ symboli.""" + from app import chartwheel + + printed = chartwheel.render(_sample_chart(), theme="print") + assert 'font-family=' in printed + assert "DejaVu Sans" in printed # font obecny w obrazie usługi render + + +def test_screen_theme_leaves_font_to_css(): + from app import chartwheel + + assert "font-family=" not in chartwheel.render(_sample_chart(), theme="screen") + + +def test_unknown_theme_falls_back_to_screen(): + from app import chartwheel + + assert chartwheel.render(_sample_chart(), theme="cokolwiek") == \ + chartwheel.render(_sample_chart(), theme="screen") + + +# ─────────────────────────── składanie po stronie klienta ──────────────── + +def test_browser_sends_all_three_parts(): + for field in ("person", "natal", "predictions"): + assert field in COMPILE_JS, f"raport wysyłany bez części: {field}" + + +def test_pdf_button_exists(): + tpl = (APP / "templates" / "compile.html").read_text(encoding="utf-8") + assert 'id="pdfBtn"' in tpl + + +def test_wheel_failure_does_not_block_the_report(): + """Brak rysunku ma dać raport bez kosmogramu, a nie brak raportu — tekst + kosztował wywołanie modelu, rysunek policzymy zawsze.""" + assert "wheel_svg = \"\"" in MAIN or 'wheel_svg, = ""' in MAIN + assert "Brak rysunku nie może zablokować raportu" in MAIN + + +# ─────────────────────────────────── pomocnicze ────────────────────────── + +def _sample_chart(): + signs = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"] + glyphs = ["♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓"] + asc = 128.9 + asc_sign = int(asc // 30) + return { + "angles": {"Asc": {"name": "Asc", "decimal": asc, "sign": signs[asc_sign]}, + "MC": {"name": "MC", "decimal": 5.0}, + "Dsc": {"name": "Dsc", "decimal": (asc + 180) % 360}, + "IC": {"name": "IC", "decimal": 185.0}}, + "cusps": [{"house": i + 1, "sign": signs[(asc_sign + i) % 12], + "decimal": (asc_sign * 30 + i * 30) % 360} for i in range(12)], + "sign_glyphs": [{"sign": s, "glyph": g} for s, g in zip(signs, glyphs)], + "positions": [{"name": "Sun", "decimal": 40.2, "glyph": "☉", "direction": "D"}], + } diff --git a/services/render/Dockerfile b/services/render/Dockerfile new file mode 100644 index 0000000..2159cdc --- /dev/null +++ b/services/render/Dockerfile @@ -0,0 +1,31 @@ +# Usługa render: składa raport PDF (PRE-24). +# +# Obraz jest CIĘŻKI (TeX Live) i właśnie dlatego stoi osobno — patrz app/main.py. +# Dobieramy pakiety wąsko, zamiast brać texlive-full (kilka GB): +# texlive-xetex — silnik XeLaTeX (unicode + fonty systemowe), +# texlive-latex-recommended — geometry, graphicx, +# texlive-fontspec... (w xetex) — wybór fontu systemowego, +# fonts-dejavu-core — polskie znaki ORAZ glify astrologiczne (♄ ♓ ☉), +# librsvg2-bin — rsvg-convert: SVG kosmogramu → PDF. +FROM python:3.12-slim + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + texlive-xetex \ + texlive-latex-recommended \ + fonts-dejavu-core \ + librsvg2-bin \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . + +# Sprawdzenie przy budowie: brak narzędzia ma wyjść TERAZ, nie przy pierwszym +# raporcie na produkcji. +RUN xelatex --version > /dev/null && rsvg-convert --version > /dev/null \ + && echo "xelatex + rsvg-convert OK" + +EXPOSE 8004 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8004"] diff --git a/services/render/app/__init__.py b/services/render/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/render/app/compile.py b/services/render/app/compile.py new file mode 100644 index 0000000..2e1bfc7 --- /dev/null +++ b/services/render/app/compile.py @@ -0,0 +1,77 @@ +"""Kompilacja raportu do PDF (PRE-24). + +Dwa kroki zewnętrzne, oba w katalogu tymczasowym, który po sobie sprzątamy: + 1. SVG kosmogramu → PDF (`rsvg-convert`). LaTeX nie wstawia SVG wprost; + zamiana na PDF zachowuje wektor, więc rysunek zostaje ostry przy dowolnym + powiększeniu. + 2. .tex → PDF (`xelatex`). + +Świadomie NIE logujemy treści raportu — to dane urodzeniowe i opisy z baz. +Do logu idzie wyłącznie ogon komunikatów LaTeXa przy błędzie, bo bez niego +diagnoza jest niemożliwa. +""" +from __future__ import annotations + +import logging +import pathlib +import shutil +import subprocess +import tempfile + +from app.latex import build + +log = logging.getLogger("astrololo.render") + +WHEEL_SVG = "wheel.svg" +WHEEL_PDF = "wheel.pdf" +DOC = "report" +TIMEOUT = 120 # xelatex na dużym tekście potrzebuje chwili + + +class RenderError(Exception): + """Kompilacja się nie udała — z komunikatem nadającym się do pokazania.""" + + +def _run(cmd: list[str], cwd: pathlib.Path) -> subprocess.CompletedProcess: + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=TIMEOUT) + + +def _svg_to_pdf(svg: str, work: pathlib.Path) -> str | None: + """Zwraca nazwę pliku PDF z kosmogramem albo None, gdy nie ma czego rysować.""" + if not svg or not svg.strip(): + return None + (work / WHEEL_SVG).write_text(svg, encoding="utf-8") + proc = _run(["rsvg-convert", "-f", "pdf", "-o", WHEEL_PDF, WHEEL_SVG], work) + if proc.returncode != 0 or not (work / WHEEL_PDF).exists(): + # Brak rysunku nie może wywalić całego raportu — tekst jest ważniejszy. + log.warning("konwersja kosmogramu nie udała się: %s", (proc.stderr or "")[-300:]) + return None + return WHEEL_PDF + + +def to_pdf(report: dict) -> bytes: + """Raport (dict) → bajty PDF. Rzuca RenderError, gdy LaTeX nie zbuduje pliku.""" + with tempfile.TemporaryDirectory(prefix="astrololo-render-") as tmp: + work = pathlib.Path(tmp) + wheel = _svg_to_pdf(report.get("wheel_svg") or "", work) + (work / f"{DOC}.tex").write_text(build(report, wheel), encoding="utf-8") + + proc = _run( + ["xelatex", "-interaction=nonstopmode", "-halt-on-error", f"{DOC}.tex"], + work, + ) + pdf = work / f"{DOC}.pdf" + if not pdf.exists(): + tail = (proc.stdout or "")[-1200:] + log.error("xelatex nie zbudował PDF-a; ogon logu: %s", tail) + raise RenderError("LaTeX nie zbudował dokumentu. Ogon logu: " + tail[-400:]) + return pdf.read_bytes() + + +def toolchain_ready() -> dict: + """Czy narzędzia są na miejscu — do /health, żeby awarię obrazu widzieć od razu, + a nie przy pierwszym raporcie.""" + return { + "xelatex": bool(shutil.which("xelatex")), + "rsvg-convert": bool(shutil.which("rsvg-convert")), + } diff --git a/services/render/app/latex.py b/services/render/app/latex.py new file mode 100644 index 0000000..195d5b3 --- /dev/null +++ b/services/render/app/latex.py @@ -0,0 +1,131 @@ +"""Budowa dokumentu LaTeX raportu (PRE-24). + +Układ wg prośby partnerów: + 1. imię i nazwisko, + 2. wprowadzone dane, + 3. rysunek kosmogramu, + 4. interpretacja natalna, + 5. predykcje okresowe. + +Silnik: XeLaTeX. Nie pdflatex — tekst zawiera polskie znaki ORAZ glify +astrologiczne (♄ ♓ ☉ …), a te wymagają silnika unicode'owego z fontem systemowym. +DejaVu Sans ma jedno i drugie, dlatego jest fontem głównym. +""" +from __future__ import annotations + +# Znaki, które w LaTeXu mają własne znaczenie. +# +# Odwrotnego ukośnika NIE MA na tej liście celowo. Jego zamiennik +# (`\textbackslash{}`) sam zawiera nawiasy klamrowe, więc gdyby szedł w tej samej +# pętli, kolejne podmiany uciekłyby nawiasy, które dopiero co wstawiliśmy — +# i zamiast ukośnika wyszedłby dosłowny tekst `\textbackslash\{\}`. Dlatego +# ukośnik chowamy najpierw pod znacznik zastępczy, a rozwijamy na samym końcu. +_ESCAPES = [ + ("{", r"\{"), + ("}", r"\}"), + ("$", r"\$"), + ("&", r"\&"), + ("#", r"\#"), + ("_", r"\_"), + ("%", r"\%"), + ("~", r"\textasciitilde{}"), + ("^", r"\textasciicircum{}"), +] + +# Znak spoza tekstu użytkownika — nie da się go wpisać, więc nie da się nim +# oszukać ucieczki. +_BACKSLASH_MARK = "\x00astrololo-backslash\x00" + + +def esc(text: object) -> str: + """Ucieka znaki specjalne LaTeXa. + + Najgroźniejszy jest `%`: bez ucieczki komentuje RESZTĘ LINII, więc tekst od + modelu urwałby się w środku zdania i nikt by tego nie zauważył — PDF + powstałby normalnie, tylko krótszy. Dlatego to nie kosmetyka, a warunek + poprawności raportu. + """ + out = "" if text is None else str(text) + out = out.replace("\\", _BACKSLASH_MARK) # schowaj, żeby nie ucierpiał niżej + for ch, rep in _ESCAPES: + out = out.replace(ch, rep) + return out.replace(_BACKSLASH_MARK, r"\textbackslash{}") + + +def _paragraphs(text: str) -> str: + """Tekst od modelu → akapity LaTeXa. + + Model oddziela akapity pustą linią. Zamieniamy je na osobne akapity, a same + złamania wiersza w środku akapitu zostawiamy LaTeXowi do złożenia. + """ + blocks = [b.strip() for b in str(text or "").split("\n\n")] + return "\n\n".join(esc(b).replace("\n", " ") for b in blocks if b) + + +PREAMBLE = r"""\documentclass[11pt,a4paper]{article} +\usepackage{fontspec} +\usepackage[margin=2.4cm]{geometry} +\usepackage{graphicx} +% DejaVu Sans ma i polskie znaki, i glify astrologiczne — jeden font na oba +% zadania oszczędza nam dobierania fontu zapasowego. +\setmainfont{DejaVu Sans} +\setlength{\parindent}{0pt} +\setlength{\parskip}{0.6em} +\pagestyle{plain} +\begin{document} +""" + + +def build(report: dict, wheel_pdf: str | None = None) -> str: + """Składa źródło .tex. `wheel_pdf` to nazwa pliku z kosmogramem (już PDF).""" + person = str(report.get("person") or "").strip() + data = report.get("data") or {} + natal = report.get("natal") or {} + predictions = report.get("predictions") or [] + + parts = [PREAMBLE] + + # ── 1. imię i nazwisko ────────────────────────────────────────────── + parts.append(r"{\Huge\bfseries " + esc(person or "Raport astrologiczny") + r"\par}") + parts.append(r"\vspace{0.3em}") + + # ── 2. wprowadzone dane ───────────────────────────────────────────── + rows = [ + ("Data urodzenia", data.get("date")), + ("Godzina (lokalna)", data.get("time")), + ("Offset względem GMT", data.get("tz_offset")), + ("Szerokość", data.get("lat")), + ("Długość", data.get("lon")), + ("Miejsce", data.get("place")), + ("System domów", data.get("house_system")), + ("Zodiak", data.get("zodiac")), + ] + parts.append(r"\begin{tabular}{@{}ll@{}}") + for label, value in rows: + if value not in (None, ""): + parts.append(esc(label) + r" & " + esc(value) + r"\\") + parts.append(r"\end{tabular}") + parts.append(r"\vspace{1em}") + + # ── 3. kosmogram ──────────────────────────────────────────────────── + if wheel_pdf: + parts.append(r"\begin{center}") + parts.append(r"\includegraphics[width=0.82\textwidth]{" + wheel_pdf + r"}") + parts.append(r"\end{center}") + parts.append(r"\vspace{0.5em}") + + # ── 4. interpretacja natalna ──────────────────────────────────────── + if natal.get("text"): + parts.append(r"\section*{Interpretacja natalna}") + parts.append(_paragraphs(natal["text"])) + + # ── 5. predykcje okresowe ─────────────────────────────────────────── + if predictions: + parts.append(r"\section*{Predykcje okresowe}") + for p in predictions: + span = f"{p.get('from_date', '')} – {p.get('to_date', '')}" + parts.append(r"\subsection*{" + esc(span) + r"}") + parts.append(_paragraphs(p.get("text"))) + + parts.append(r"\end{document}") + return "\n".join(parts) + "\n" diff --git a/services/render/app/link_crypto.py b/services/render/app/link_crypto.py new file mode 100644 index 0000000..89ed3d8 --- /dev/null +++ b/services/render/app/link_crypto.py @@ -0,0 +1,525 @@ +"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33). + +Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token +międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź +— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń +produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu, +zrzut z węzła), miał je w całości. + +Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi. +GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt +nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale +podatne na modyfikację". + +**Dwa niezależne klucze**, po jednym na parę rozmówców: + * ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika, + * ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane. +Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych, +gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony. + +Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF). +Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie +losowej jednorazówki w jedną stronę nie osłabia drugiej. + +Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu): + + [4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik] + +Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu +i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć +po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu. + +Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność +wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa +działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa +regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu. +""" +from __future__ import annotations + +import base64 +import binascii +import logging +import os +import struct +import time +from typing import Iterable, Iterator + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +log = logging.getLogger("astrololo.link") + +MAGIC = b"AL1" +VERSION = "v1" +NONCE_BYTES = 12 +KEY_BYTES = 32 # AES-256 +LENGTH_PREFIX = 4 +MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości +MAX_SKEW_SECONDS = 300.0 + +HEADER_ENC = "X-Astrololo-Enc" +HEADER_TS = "X-Astrololo-Enc-Ts" +CONTENT_TYPE = "application/vnd.astrololo.enc" + +ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC" +ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA" +# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych — +# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie +# jej klucza nie może otwierać łącza do logiki ani do danych. +ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER" +ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED" + +REQUEST, RESPONSE = b"req", b"res" + +# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka +# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji. +PUBLIC_PATHS = frozenset({"/health"}) + + +class LinkError(Exception): + """Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz.""" + + +# --------------------------------------------------------------------- klucze + +def parse_key(raw: str) -> bytes: + """Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty.""" + text = raw.strip() + if not text: + raise LinkError("pusty klucz łącza") + try: + key = bytes.fromhex(text) + except ValueError: + try: + key = base64.b64decode(text, validate=True) + except (binascii.Error, ValueError) as exc: + raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc + if len(key) != KEY_BYTES: + raise LinkError( + f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez " + f"`openssl rand -hex 32`" + ) + return key + + +def key_from_env(env_name: str) -> bytes | None: + """Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu.""" + raw = os.getenv(env_name, "") + return parse_key(raw) if raw.strip() else None + + +def encryption_required() -> bool: + """Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu. + + Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza + wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść + zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim + cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu. + """ + return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"} + + +def _subkey(link_key: bytes, direction: bytes) -> bytes: + return HKDF( + algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None, + info=b"astrololo/link/" + direction, + ).derive(link_key) + + +class Link: + """Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze.""" + + def __init__(self, link_key: bytes) -> None: + self._by_direction = { + REQUEST: AESGCM(_subkey(link_key, REQUEST)), + RESPONSE: AESGCM(_subkey(link_key, RESPONSE)), + } + + # ---------------------------------------------------------- pojedyncza ramka + + def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes: + return b"|".join([MAGIC, direction, path.encode("utf-8"), + stamp.encode("ascii"), str(seq).encode("ascii")]) + + def seal(self, direction: bytes, path: str, stamp: str, seq: int, + plaintext: bytes) -> bytes: + nonce = os.urandom(NONCE_BYTES) + sealed = self._by_direction[direction].encrypt( + nonce, plaintext, self._aad(direction, path, stamp, seq)) + return MAGIC + nonce + sealed + + def open(self, direction: bytes, path: str, stamp: str, seq: int, + frame: bytes) -> bytes: + if not frame.startswith(MAGIC): + raise LinkError("ramka bez znacznika astrololo") + body = frame[len(MAGIC):] + if len(body) <= NONCE_BYTES: + raise LinkError("ramka za krótka") + nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:] + try: + return self._by_direction[direction].decrypt( + nonce, sealed, self._aad(direction, path, stamp, seq)) + except InvalidTag as exc: + # Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt, + # przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich + # na zewnątrz podpowiadałoby atakującemu, w co trafił. + raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc + + # ------------------------------------------------------------ strumień ramek + + def seal_stream(self, direction: bytes, path: str, stamp: str, + chunks: Iterable[bytes]) -> Iterator[bytes]: + for seq, chunk in enumerate(chunks): + yield frame_out(self.seal(direction, path, stamp, seq, chunk)) + + def open_stream(self, direction: bytes, path: str, stamp: str, + raw: bytes) -> Iterator[bytes]: + for seq, frame in enumerate(frames_in(raw)): + yield self.open(direction, path, stamp, seq, frame) + + def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes: + return b"".join(self.open_stream(direction, path, stamp, raw)) + + +# ---------------------------------------------------------------- ramkowanie + +def frame_out(payload: bytes) -> bytes: + return struct.pack(">I", len(payload)) + payload + + +def frames_in(raw: bytes) -> Iterator[bytes]: + """Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych.""" + offset = 0 + while offset < len(raw): + if offset + LENGTH_PREFIX > len(raw): + raise LinkError("urwana ramka (brak nagłówka długości)") + (size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX]) + if size > MAX_FRAME: + raise LinkError("ramka ponad dopuszczalny rozmiar") + offset += LENGTH_PREFIX + if offset + size > len(raw): + raise LinkError("urwana ramka (za mało danych)") + yield raw[offset:offset + size] + offset += size + + +def unframe_incremental(buffer: bytearray) -> Iterator[bytes]: + """Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później. + + Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi + rozjechać się między dwa odczyty. + """ + while True: + if len(buffer) < LENGTH_PREFIX: + return + (size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX]) + if size > MAX_FRAME: + raise LinkError("ramka ponad dopuszczalny rozmiar") + if len(buffer) < LENGTH_PREFIX + size: + return + frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size]) + del buffer[:LENGTH_PREFIX + size] + yield frame + + +# ------------------------------------------------------------- świeżość ruchu + +def stamp_now() -> str: + return f"{time.time():.3f}" + + +def check_stamp(stamp: str) -> None: + """Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się + odtworzyć w dowolnym momencie w przyszłości.""" + try: + sent = float(stamp) + except (TypeError, ValueError) as exc: + raise LinkError("brak albo błędny znacznik czasu") from exc + if abs(time.time() - sent) > MAX_SKEW_SECONDS: + raise LinkError("znacznik czasu poza dopuszczalnym oknem") + + +# =========================================================== strona serwerowa + +class LinkCryptoMiddleware: + """Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi. + + Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba + podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić + odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci. + """ + + def __init__(self, app, link: Link | None, layer: str) -> None: + self.app = app + self.link = link + self.layer = layer + + async def __call__(self, scope, receive, send): + if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS: + return await self.app(scope, receive, send) + + path = scope["path"] + headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]} + + if headers.get(HEADER_ENC.lower()) != VERSION: + # Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo + # pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie + # chcemy po cichu wrócić do jawnego ruchu. + log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)", + self.layer, path) + return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.") + + stamp = headers.get(HEADER_TS.lower(), "") + try: + check_stamp(stamp) + plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive)) + except LinkError as exc: + log.warning("warstwa %s: %s (%s)", self.layer, exc, path) + return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.") + + scope = dict(scope) + scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext)) + await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path)) + + def _sealing_send(self, send, path: str): + state: dict = {"stamp": "", "seq": 0} + + async def sealing(message): + if message["type"] == "http.response.start": + state["stamp"] = stamp_now() + keep = [(k, v) for k, v in message.get("headers", []) + if k.lower() not in (b"content-length", b"content-type")] + message = dict(message) + message["headers"] = keep + [ + (b"content-type", CONTENT_TYPE.encode()), + (HEADER_ENC.lower().encode(), VERSION.encode()), + (HEADER_TS.lower().encode(), state["stamp"].encode()), + ] + return await send(message) + + if message["type"] == "http.response.body": + chunk = message.get("body", b"") + sealed = b"" + if chunk: + sealed = frame_out(self.link.seal( + RESPONSE, path, state["stamp"], state["seq"], chunk)) + state["seq"] += 1 + return await send({"type": "http.response.body", "body": sealed, + "more_body": message.get("more_body", False)}) + + return await send(message) + + return sealing + + +def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int): + """Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI + próbowałby sparsować JSON o cudzej deklarowanej wielkości.""" + kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")] + kept.append((b"content-length", str(length).encode())) + if length: + kept.append((b"content-type", b"application/json")) + return kept + + +async def _read_body(receive) -> bytes: + body = bytearray() + while True: + message = await receive() + if message["type"] == "http.disconnect": + raise LinkError("rozłączenie w trakcie odbioru żądania") + body += message.get("body", b"") + if not message.get("more_body", False): + return bytes(body) + + +def _replay(body: bytes, original): + """Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji. + + Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu + zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia + równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji + okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały. + """ + delivered = False + + async def receive(): + nonlocal delivered + if delivered: + return await original() + delivered = True + return {"type": "http.request", "body": body, "more_body": False} + + return receive + + +async def _refuse(send, detail: str) -> None: + """Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować, + więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny.""" + payload = f'{{"detail":"{detail}"}}'.encode("utf-8") + await send({"type": "http.response.start", "status": 400, "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(payload)).encode()), + ]}) + await send({"type": "http.response.body", "body": payload}) + + +def install(app, env_name: str, layer: str): + """Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa + tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał.""" + link_key = key_from_env(env_name) + if link_key is None and encryption_required(): + # Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania: + # wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni + # niczego. Pod w CrashLoop widać od razu, jawny ruch — nie. + raise LinkError( + f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — " + f"warstwa {layer} nie wystartuje bez klucza łącza" + ) + if link_key is None: + log.warning( + "UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, " + "więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha " + "ruch wewnątrz sieci.", env_name, layer, + ) + return None + link = Link(link_key) + app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer) + log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name) + return link + + +# ============================================================ strona kliencka + +def call(client, method: str, url: str, *, payload=None, + headers: dict[str, str] | None = None, link: Link | None) -> bytes: + """Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi. + + Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego + argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby + się niejasnym błędem odszyfrowania. + """ + import json as _json + + import httpx + + request_headers = dict(headers or {}) + if link is None: + if encryption_required(): + # Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero + # zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie + # sygnifikatory, o które pytamy bazę. + raise LinkError( + f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie " + f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem" + ) + response = client.request(method, url, json=payload, headers=request_headers) + response.raise_for_status() + return response.content + + path = httpx.URL(url).path + stamp = stamp_now() + plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8") + body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext)) + request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, + "Content-Type": CONTENT_TYPE}) + + response = client.request(method, url, content=body, headers=request_headers) + if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION: + log.error("łącze %s odmówiło: %s", path, response.text[:200]) + response.raise_for_status() + if response.headers.get(HEADER_ENC) != VERSION: + raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony") + reply_stamp = response.headers.get(HEADER_TS, "") + check_stamp(reply_stamp) + return link.open_all(RESPONSE, path, reply_stamp, response.content) + + +def call_json(client, method: str, url: str, *, payload=None, + headers: dict[str, str] | None = None, link: Link | None): + import json as _json + + return _json.loads(call(client, method, url, payload=payload, + headers=headers, link=link)) + + +def open_response_stream(response, link: Link | None) -> Iterator[bytes]: + """Odbiór odpowiedzi płynącej kawałkami (okno postępu). + + Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją + w buforze zamiast zakładać, że każdy kawałek to komplet. + """ + if link is None: + yield from response.iter_bytes() + return + if response.headers.get(HEADER_ENC) != VERSION: + raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony") + stamp = response.headers.get(HEADER_TS, "") + check_stamp(stamp) + path = response.request.url.path + buffer = bytearray() + seq = 0 + for chunk in response.iter_bytes(): + buffer += chunk + for frame in unframe_incremental(buffer): + yield link.open(RESPONSE, path, stamp, seq, frame) + seq += 1 + if buffer: + raise LinkError("strumień urwał się w połowie ramki") + + +def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None, + link: Link | None) -> Iterator[str]: + """Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo. + + Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc + czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest + pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE + pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je + dopiero na znakach nowej linii. + + Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów + działał bez zmian. + """ + import json as _json + + import httpx as _httpx + + request_headers = dict(headers or {}) + if link is None: + if encryption_required(): + # Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy + # szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale + # ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter. + raise LinkError( + f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień " + f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem" + ) + with client.stream("POST", url, json=payload, headers=request_headers) as response: + response.raise_for_status() + for text_line in response.iter_lines(): + if text_line: + yield text_line + return + + path = _httpx.URL(url).path + stamp = stamp_now() + body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8"))) + request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE}) + with client.stream("POST", url, content=body, headers=request_headers) as response: + response.raise_for_status() + buffer = bytearray() + for plain in open_response_stream(response, link): + buffer += plain + while True: + nl = buffer.find(b"\n") + if nl < 0: + break + text_line = bytes(buffer[:nl]) + del buffer[:nl + 1] + if text_line: + yield text_line.decode("utf-8") + if buffer: + yield bytes(buffer).decode("utf-8") diff --git a/services/render/app/main.py b/services/render/app/main.py new file mode 100644 index 0000000..3ff6a44 --- /dev/null +++ b/services/render/app/main.py @@ -0,0 +1,62 @@ +"""Warstwa RENDER — usługa składająca raport PDF (PRE-24). + +OSOBNA usługa, nie część prezentacji, z tego samego powodu co silnik swisseph +(LOG-27): TeX Live waży setki megabajtów. Trzymanie go w obrazie produktu +spowalniałoby każdy build i deploy, a tak aktualizuje się niezależnie i awaria +renderu nie kładzie aplikacji. + +Rozmawia TYLKO z prezentacją, po szyfrowanym łączu (własny, trzeci klucz — +dostaje cały raport: dane urodzeniowe i opisy z baz). W klastrze wystawiona jako +ClusterIP, bez wejścia z zewnątrz. + +Świadomie nie zapisuje niczego na dysk poza katalogiem tymczasowym, który sprząta +po sobie — raport nie może zostawiać śladu w kontenerze. +""" +from __future__ import annotations + +import logging + +from fastapi import FastAPI, HTTPException +from fastapi.responses import Response + +from app import link_crypto, security +from app.compile import RenderError, to_pdf, toolchain_ready + +log = logging.getLogger("astrololo.render") + +app = FastAPI(title="astrololo · usługa render (PDF)") +security.install(app, "render") # token międzywarstwowy (LOG-32) +# Szyfrowanie łącza od prezentacji. PO security.install, żeby także odmowa +# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać. +link_crypto.install(app, link_crypto.ENV_PRESENTATION_RENDER, "render") + + +@app.post("/pdf") +def build_pdf(report: dict) -> Response: + """Raport (JSON) → PDF. Wejście: person, data, wheel_svg, natal, predictions. + + Zwracamy surowe bajty PDF-a, nie base64 — łącze i tak jest szyfrowane, więc + kodowanie tylko rozdmuchałoby odpowiedź o trzecią część. + """ + if not isinstance(report, dict): + raise HTTPException(422, "Oczekiwano obiektu JSON z raportem.") + try: + pdf = to_pdf(report) + except RenderError as e: + raise HTTPException(500, str(e)) + except FileNotFoundError as e: # brak xelatex/rsvg w obrazie + raise HTTPException(503, f"Brak narzędzia w obrazie usługi render: {e}") + return Response( + content=pdf, + media_type="application/pdf", + headers={"Content-Disposition": 'attachment; filename="raport.pdf"'}, + ) + + +@app.get("/health") +def health() -> dict: + """Raportuje też obecność narzędzi — dzięki temu zepsuty obraz widać od razu, + a nie dopiero przy pierwszym raporcie.""" + tools = toolchain_ready() + return {"status": "ok" if all(tools.values()) else "degraded", + "layer": "render", "tools": tools} diff --git a/services/render/app/security.py b/services/render/app/security.py new file mode 100644 index 0000000..618df42 --- /dev/null +++ b/services/render/app/security.py @@ -0,0 +1,50 @@ +"""Uwierzytelnianie międzywarstwowe (LOG-32). + +Warstwa logiczna oddaje treść baz interpretacyjnych, więc samo zalogowanie w +prezentacji nie wystarczy — bez tego kontrolera wystarczyłoby uderzyć w logikę +z pominięciem UI. Gdy ustawiono INTERNAL_TOKEN, każde żądanie (poza /health) +musi go przynieść w nagłówku X-Astrololo-Token. + +Bez INTERNAL_TOKEN kontrola jest wyłączona (dev / zgodność wstecz) — wtedy przy +starcie leci ostrzeżenie. +""" +from __future__ import annotations + +import logging +import os +import secrets + +from fastapi import Request +from fastapi.responses import JSONResponse + +log = logging.getLogger("astrololo.security") + +HEADER = "X-Astrololo-Token" +PUBLIC_PATHS = frozenset({"/health"}) + + +def token() -> str: + """Czytany leniwie — konfiguracja może się zmienić bez importu modułu.""" + return os.getenv("INTERNAL_TOKEN", "") + + +def enabled() -> bool: + return bool(token()) + + +def install(app, layer: str) -> None: + if not enabled(): + log.warning( + "UWAGA: INTERNAL_TOKEN nie ustawiony — warstwa %s przyjmuje żądania od " + "kogokolwiek, kto ma do niej dostęp sieciowy.", layer, + ) + + @app.middleware("http") + async def _guard(request: Request, call_next): + if request.url.path in PUBLIC_PATHS or not enabled(): + return await call_next(request) + got = request.headers.get(HEADER, "") + if not secrets.compare_digest(got, token()): + return JSONResponse({"detail": "Brak lub błędny token międzywarstwowy."}, + status_code=401) + return await call_next(request) diff --git a/services/render/requirements.txt b/services/render/requirements.txt new file mode 100644 index 0000000..4e157e5 --- /dev/null +++ b/services/render/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.115 +uvicorn[standard]>=0.34 +# Szyfrowanie łącza z prezentacją (PRE-16/PRE-24): AES-256-GCM + HKDF +cryptography>=44.0 diff --git a/services/render/tests/test_latex.py b/services/render/tests/test_latex.py new file mode 100644 index 0000000..2633f36 --- /dev/null +++ b/services/render/tests/test_latex.py @@ -0,0 +1,130 @@ +"""Generowanie źródła LaTeX raportu (PRE-24). + +Najważniejsza część to UCIECZKA znaków specjalnych. Kompilacji tu nie +uruchomimy (brak TeX Live w tym środowisku), ale to i tak nie ona jest tu +najbardziej krucha: błędna ucieczka nie wywala kompilacji, tylko po cichu psuje +treść — najgorszy możliwy wynik, bo PDF powstaje i wygląda poprawnie. +""" +import pytest + +from app.latex import build, esc + + +# ─────────────────────────── ucieczka znaków specjalnych ──────────────── + +def test_percent_is_escaped(): + """NAJGROŹNIEJSZY przypadek: `%` bez ucieczki komentuje resztę linii, więc + zdanie urywa się w środku, a PDF powstaje normalnie — tylko krótszy.""" + out = esc("wzrost o 50% w tym okresie") + assert r"50\%" in out + assert "w tym okresie" in out + + +def test_backslash_is_escaped_first(): + """Odwrotny ukośnik musi iść pierwszy, inaczej zepsulibyśmy ucieczki + wstawione później (np. `\\%` zamieniłoby się w coś innego).""" + assert esc("a\\b") == r"a\textbackslash{}b" + + +def test_all_specials_are_escaped(): + for ch in ("$", "&", "#", "_", "%", "{", "}"): + assert ch not in esc(ch).replace("\\" + ch, ""), f"{ch} nie zostało uciec­zone" + + +def test_tilde_and_caret_use_commands(): + assert esc("~") == r"\textasciitilde{}" + assert esc("^") == r"\textasciicircum{}" + + +def test_polish_and_glyphs_pass_through(): + """XeLaTeX bierze unicode wprost — polskich znaków ani glifów nie ruszamy.""" + text = "Zażółć gęślą jaźń ♄ ♓ ☉" + assert esc(text) == text + + +def test_escape_handles_none_and_numbers(): + assert esc(None) == "" + assert esc(42) == "42" + + +# ─────────────────────────────── układ dokumentu ───────────────────────── + +def _report(**over): + base = { + "person": "Jan Kowalski", + "data": {"date": "1984-04-30", "time": "11:20", "tz_offset": 2, + "lat": 50.0647, "lon": 19.945, "place": "Kraków", + "house_system": "whole_sign", "zodiac": "tropical"}, + "natal": {"text": "Interpretacja natalna.\n\nDrugi akapit."}, + "predictions": [ + {"from_date": "2026-01-01", "to_date": "2026-03-31", "text": "Pierwszy kwartał."}, + {"from_date": "2026-07-01", "to_date": "2026-09-30", "text": "Trzeci kwartał."}, + ], + } + base.update(over) + return base + + +def test_document_is_complete(): + tex = build(_report()) + assert tex.startswith(r"\documentclass") + assert tex.rstrip().endswith(r"\end{document}") + + +def test_uses_unicode_engine_setup(): + """XeLaTeX + fontspec — bez tego polskie znaki i glify nie wyjdą.""" + tex = build(_report()) + assert r"\usepackage{fontspec}" in tex + assert r"\setmainfont" in tex + + +def test_section_order_matches_the_brief(): + """Prośba partnerów: imię → dane → rysunek → natalna → predykcje.""" + tex = build(_report(), wheel_pdf="wheel.pdf") + person = tex.index("Jan Kowalski") + data = tex.index("Data urodzenia") + wheel = tex.index("includegraphics") + natal = tex.index("Interpretacja natalna") + preds = tex.index("Predykcje okresowe") + assert person < data < wheel < natal < preds + + +def test_person_name_opens_the_document(): + tex = build(_report()) + body = tex.split(r"\begin{document}")[1] + assert body.index("Jan Kowalski") < 80, "imię ma być na samym początku" + + +def test_all_predictions_are_included(): + tex = build(_report()) + assert "2026-01-01" in tex and "2026-07-01" in tex + assert "Pierwszy kwartał" in tex and "Trzeci kwartał" in tex + + +def test_paragraphs_are_preserved(): + """Model oddziela akapity pustą linią — mają zostać akapitami, nie zlepkiem.""" + tex = build(_report()) + assert "Interpretacja natalna." in tex and "Drugi akapit." in tex + assert "Interpretacja natalna.\n\nDrugi akapit." in tex + + +def test_hostile_text_cannot_break_the_document(): + """Tekst od modelu jest wejściem z ZEWNĄTRZ — nie może wstrzyknąć polecenia + ani urwać dokumentu.""" + nasty = r"100% \end{document} \input{/etc/passwd} $x_1$ #& {}" + tex = build(_report(natal={"text": nasty})) + body = tex.split(r"\begin{document}")[1] + assert body.count(r"\end{document}") == 1, "tekst urwał dokument" + assert r"\input{" not in body + + +def test_missing_parts_are_simply_absent(): + """Niekompletny materiał ma dać krótszy raport, nie wyjątek.""" + tex = build({"person": "", "data": {}}) + assert r"\begin{document}" in tex and r"\end{document}" in tex + assert "Raport astrologiczny" in tex # zapas, gdy brak imienia + + +def test_wheel_is_optional(): + assert "includegraphics" not in build(_report()) + assert "includegraphics" in build(_report(), wheel_pdf="wheel.pdf") -- 2.52.0