4102039447
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m1s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m30s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Testy astrodemo (push) Successful in 9m25s
Testy / Testy astroklient (push) Successful in 9m24s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 21s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m25s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m25s
Testy / Testy astrodemo (pull_request) Successful in 9m25s
Testy / Testy astroklient (pull_request) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 7s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 5s
_post dokłada pulę z kontekstu sesji do każdego żądania, ale strumień (używany przez /chart/horoscope/stream) szedł obok niego i wysyłał payload bez zmian. Generowania w tym obrazie dziś nie ma, ale most modułów zostaje celowo — po dołożeniu modułu horoskop strumieniowany szukałby w całym udziale, a pole tenant podsunięte w payloadzie przeszłoby dalej. Sonda w osobnym procesie przepuszcza prawdziwe żądanie przez bramkę sesji do trasy w miejscu modułu (prompt, horoskop, strumień konsumowany leniwie) i podsuwa cudzy login w payloadzie. Przed poprawką strumień wysyłał „ktos-inny", po — konto z sesji. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
180 lines
8.9 KiB
Python
180 lines
8.9 KiB
Python
"""Klient warstwy logicznej — z PULĄ KONTA (PRE-29).
|
|
|
|
Każde konto ma własny, niewidoczny dla pozostałych zbiór plików. Pula jedzie
|
|
w każdym żądaniu w dół i bierze się WYŁĄCZNIE z kontekstu ustawianego przy
|
|
wejściu, nigdy z pola formularza: gdyby przychodziła z żądania, wystarczyłoby
|
|
podstawić cudzy login, żeby czytać cudze pliki.
|
|
|
|
Kontekst zamiast argumentu, bo ekrany są wspólne z pełną aplikacją i wołają
|
|
klienta jako obiekt modułowy. Przekazywanie puli przez wszystkie wywołania
|
|
znaczyłoby, że pominięcie jednego z nich cicho otwiera dostęp do całego udziału —
|
|
a takiego pominięcia nie widać.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import contextvars
|
|
import os
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
# Login zalogowanego konta na czas obsługi żądania. Ustawia go bramka wejścia.
|
|
KONTO: contextvars.ContextVar[str] = contextvars.ContextVar("konto", default="")
|
|
|
|
|
|
from app import link_crypto
|
|
from app.config import settings
|
|
|
|
|
|
def _auth_headers() -> 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↔logika. Czytany przy każdym wywołaniu, bo
|
|
konfiguracja może się zmienić bez restartu procesu (testy, podmiana sekretu)."""
|
|
key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_LOGIC)
|
|
return link_crypto.Link(key) if key else None
|
|
|
|
|
|
class LogicClient:
|
|
def __init__(self, base_url: str | None = None) -> None:
|
|
self.base_url = (base_url or settings.logic_url).rstrip("/")
|
|
|
|
def _post(self, path: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
|
# Pula konta dokładana do KAŻDEGO żądania w dół. Bierze się z kontekstu
|
|
# ustawianego przy wejściu, nigdy z pola formularza — gdyby przychodziła
|
|
# z żądania, wystarczyłoby podstawić cudzy login, żeby czytać cudze pliki.
|
|
# Trasy, których pula nie dotyczy, po prostu ignorują nadmiarowe pole.
|
|
payload = {**payload, "tenant": KONTO.get("")}
|
|
"""Jedyna droga w dół. Celowo JEDNA: dopóki każda metoda składała żądanie
|
|
sama, dołożenie nowej znaczyło, że łatwo zapomnieć o tokenie albo kluczu
|
|
łącza — i tak się już raz stało (401 wyszedł dopiero na produkcji)."""
|
|
with httpx.Client(timeout=timeout) as client:
|
|
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
|
|
payload=payload, headers=_auth_headers(),
|
|
link=_link())
|
|
|
|
# ── transport dla modułów opcjonalnych ─────────────────────────────────
|
|
# Moduł nie może dopisywać sobie metod do tej klasy: jego nazwy siedziałyby
|
|
# wtedy w pliku, który jedzie do KAŻDEGO obrazu. Dostaje więc samą drogę
|
|
# w dół, a co nią wysyła, jest jego sprawą. Szyfrowanie łącza i token
|
|
# międzywarstwowy obowiązują tak samo — to nie jest obejście ochrony.
|
|
def wywolaj(self, sciezka: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
|
return self._post(sciezka, payload, timeout)
|
|
|
|
def pobierz(self, sciezka: str, timeout: float | None = None) -> dict[str, Any]:
|
|
with httpx.Client(timeout=timeout or settings.http_timeout) as client:
|
|
return link_crypto.call_json(client, "GET", f"{self.base_url}{sciezka}",
|
|
headers=_auth_headers(), link=_link())
|
|
|
|
def strumien(self, sciezka: str, payload: dict[str, Any]):
|
|
"""Strumień NDJSON w dół — przekazywany dalej ramka po ramce.
|
|
|
|
Idzie przez szyfrowane łącze jak reszta ruchu (PRE-16): bez tego przy
|
|
włączonym LINK_ENCRYPTION_REQUIRED serwer odrzuciłby żądanie.
|
|
|
|
Nie przechodzi przez `_post`, więc pulę konta dokłada sam — tak samo,
|
|
z kontekstu i z pierwszeństwem przed polem przyniesionym w payloadzie.
|
|
Bez tego horoskop strumieniowany szukałby w całym udziale."""
|
|
payload = {**payload, "tenant": KONTO.get("")}
|
|
with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
|
|
yield from link_crypto.stream_lines(
|
|
client, f"{self.base_url}{sciezka}",
|
|
payload=payload, headers=_auth_headers(), link=_link())
|
|
|
|
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
|
|
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
|
|
return self._post("/api/query", payload, settings.http_timeout)
|
|
|
|
def positions(
|
|
self,
|
|
when_utc_iso: str,
|
|
lat: float,
|
|
lon: float,
|
|
objects: list[str] | None = None,
|
|
house_system: str = "whole_sign",
|
|
stations: bool = False,
|
|
zodiac: str = "tropical",
|
|
tables: bool = False,
|
|
house_systems: list[str] | None = None,
|
|
aspect_orb: float = 8.0,
|
|
aspect_luminary_bonus: float = 2.0,
|
|
aspect_minor: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
|
|
payload = {
|
|
"when_utc": when_utc_iso,
|
|
"lat": lat,
|
|
"lon": lon,
|
|
"objects": objects,
|
|
"house_system": house_system,
|
|
"house_systems": house_systems,
|
|
"aspect_orb": aspect_orb,
|
|
"aspect_luminary_bonus": aspect_luminary_bonus,
|
|
"aspect_minor": aspect_minor,
|
|
"stations": stations,
|
|
"zodiac": zodiac,
|
|
"tables": tables,
|
|
}
|
|
# stacje ORAZ tabele wymagają root-findów / szukania numerycznego — dłuższy timeout
|
|
timeout = max(settings.http_timeout, 60.0) if (stations or tables) else settings.http_timeout
|
|
return self._post("/chart/positions", payload, timeout)
|
|
|
|
def synastry(
|
|
self, person_a: dict, person_b: dict, zodiac: str = "tropical",
|
|
aspect_orb: float = 8.0, aspect_luminary_bonus: float = 2.0, aspect_minor: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Synastria dwóch osób (PRE-04) — aspekty między horoskopami."""
|
|
payload = {
|
|
"person_a": person_a, "person_b": person_b, "zodiac": zodiac,
|
|
"aspect_orb": aspect_orb, "aspect_luminary_bonus": aspect_luminary_bonus,
|
|
"aspect_minor": aspect_minor,
|
|
}
|
|
return self._post("/chart/synastry", payload, settings.http_timeout)
|
|
|
|
def report(
|
|
self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False
|
|
) -> dict[str, Any]:
|
|
"""Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report."""
|
|
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
|
|
return self._post("/chart/report", payload, max(settings.http_timeout, 30.0))
|
|
|
|
def bases(self) -> dict[str, Any]:
|
|
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09)."""
|
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
|
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
|
|
headers=_auth_headers(), link=_link())
|
|
|
|
|
|
# ── zarządzanie plikami baz (DAN-27) ────────────────────────────────
|
|
# Jedna metoda na trasę, bez sprytnego generyka: te wywołania różnią się
|
|
# skutkiem (odczyt / zapis / skasowanie), a ujednolicenie ich w jedno
|
|
# `call(path, payload)` zaciera tę różnicę dokładnie tam, gdzie jest ważna.
|
|
|
|
def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
|
|
return self._files_post("/files/status", {"path": path, "status": status, "by": by})
|
|
|
|
def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]:
|
|
return self._files_post("/files/upload",
|
|
{"filename": filename, "content_b64": content_b64, "by": by})
|
|
|
|
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
|
|
payload = {**payload, "tenant": KONTO.get("")}
|
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
|
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
|
|
payload=payload, headers=_auth_headers(), link=_link())
|
|
|
|
def timeline(
|
|
self, when_utc_iso: str, lat: float, lon: float,
|
|
from_date: str, to_date: str, interpret: bool = True,
|
|
) -> dict[str, Any]:
|
|
"""Oś czasu z technik (+interpretacje z bazy) — woła logic /chart/timeline."""
|
|
payload = {
|
|
"when_utc": when_utc_iso, "lat": lat, "lon": lon,
|
|
"from_date": from_date, "to_date": to_date, "interpret": interpret,
|
|
}
|
|
return self._post("/chart/timeline", payload, max(settings.http_timeout, 60.0))
|