2d7775f9b3
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m17s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Testy astroklienta (wersja demo) (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Successful in 4s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m21s
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 astroklienta (wersja demo) (pull_request) Successful in 9m26s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 5s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 4s
Demo ma być rozdawane szeroko i różnym osobom, więc pierwsza wersja — jedno konto na produkcyjnej warstwie danych — nie nadawała się do użycia: każdy dostawałby dostęp do oryginalnych baz, a wgrania jednego klienta widzieliby wszyscy. IZOLACJA OD PRODUKCJI. Warstwa danych i logiczna demo są osobne (manifesty w repo deploy). Osobna musi być TEŻ LOGICZNA, bo zna ona jeden adres warstwy danych — demo korzystające z produkcyjnej logiki i tak trafiłoby na produkcyjne bazy. PULE PER KONTO w warstwie danych. Zapytanie i lista plików niosą nazwę puli; puste = cały udział, czyli produkcja działa dokładnie jak dotąd i o pulach nic nie wie. Nazwa puli przechodzi przez sito dopuszczające wyłącznie znaki bezpieczne w nazwie katalogu — „../..” albo ukośnik wyprowadziłyby zapytanie wprost do cudzych baz, więc sito ZAMIENIA podejrzane znaki zamiast ufać, że nikt ich nie poda. PULA MUSI BYĆ W KLUCZU CACHE ZAPYTAŃ. Bez tego wynik policzony dla jednego konta trafiłby z cache do drugiego — cicha wymiana treści baz między klientami, niewidoczna w logach i nie do wykrycia z zewnątrz. Osobny test tego pilnuje. PULA WYNIKA Z LOGINU, nigdy z żądania. Klient warstwy logicznej jest budowany per żądanie i związany z pulą zalogowanej osoby; gdyby nazwa przychodziła z formularza, wystarczyłoby podstawić cudzy login. Test wysyła `tenant`, `user` i `login` w polach formularza i sprawdza, że nie mają na nią wpływu. Pulę wstrzykujemy w INSTANCJĘ klienta, nie w sygnatury metod. Argumentem trzeba by ją przeprowadzić przez protokół DataSource i build_report — kod, który o kontach nie ma prawa nic wiedzieć — a każde nowe wywołanie byłoby okazją, żeby o nią zapomnieć i sięgnąć nie tam. Konta demo to lista `login:sekret` (DEMO_USERS), bo jedno wspólne konto oznaczałoby wspólną pulę. Format i skrypt haseł te same, co w głównej aplikacji. Pula klienta to JEDEN KATALOG, więc przejście na pełną wersję nie oznacza utraty wgrań — procedurę importu opisuje runbook w repo deploy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
143 lines
5.0 KiB
Python
143 lines
5.0 KiB
Python
"""Warstwa BAZODANOWA — usługa HTTP.
|
|
|
|
Jedyne zadanie: przyjąć znormalizowane zapytanie z warstwy logicznej, wyszukać
|
|
dane (w Excelu z cache lub w SQL) i zwrócić je w górę. Nie zna warstwy logicznej
|
|
ani prezentacji.
|
|
"""
|
|
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, HTTPException
|
|
|
|
from app import canary, files, link_crypto, security
|
|
from app.config import settings
|
|
from app.models import HealthInfo, SearchQuery, SearchResult
|
|
from pydantic import BaseModel
|
|
from app.providers.factory import build_provider
|
|
|
|
provider = build_provider(settings)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
provider.warmup() # zbuduj/odśwież indeks i cache przy starcie
|
|
yield
|
|
|
|
|
|
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
|
|
security.install(app, "danych") # token międzywarstwowy (LOG-32)
|
|
# Szyfrowanie łącza od logiki. 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_LOGIC_DATA, "danych")
|
|
|
|
|
|
@app.post("/search", response_model=SearchResult)
|
|
def search(query: SearchQuery) -> SearchResult:
|
|
result = provider.search(query)
|
|
# Rekordy-pułapki (DAN-26) odsiewamy TU, na wyjściu z warstwy danych — dzięki
|
|
# temu nie dotrą ani wyżej, ani do promptu LLM (LOG-30), niezależnie od dostawcy.
|
|
visible, report = canary.screen(result.rows, query.value)
|
|
if report["active"]:
|
|
result.rows = visible
|
|
result.total = len(visible)
|
|
return result
|
|
|
|
|
|
@app.get("/bases")
|
|
def bases() -> dict:
|
|
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09).
|
|
|
|
Same METADANE — nazwy plików, rozmiar, data. Żadnej treści baz, więc podgląd
|
|
listy nie jest kolejną drogą do ich wyniesienia."""
|
|
items = provider.list_bases()
|
|
return {"bases": items, "enabled": sum(1 for b in items if b["enabled"]), "total": len(items)}
|
|
|
|
|
|
# ── zarządzanie plikami baz (DAN-27) ─────────────────────────────────────
|
|
# Warstwa danych jest właścicielem plików, więc to ona nimi zarządza. Uprawnienia
|
|
# rozstrzyga PREZENTACJA (PRE-27) i przekazuje tu wynik jako `for_admin` / `by` —
|
|
# ta warstwa nie zna kont i nie ma jak ich znać. Nie jest to dziura: warstwa
|
|
# danych stoi za tokenem międzywarstwowym i szyfrowanym łączem, więc rozmawia
|
|
# z nią wyłącznie warstwa logiczna.
|
|
|
|
class FilesQuery(BaseModel):
|
|
for_admin: bool = False
|
|
# Pula konta (PRE-29). Puste = cały udział — tak woła produkcja, która o pulach
|
|
# nic nie wie. Wersja demo podaje login, przez co widzi wyłącznie swój katalog.
|
|
tenant: str = ""
|
|
|
|
|
|
class FileAction(BaseModel):
|
|
path: str
|
|
status: str = ""
|
|
by: str = ""
|
|
tenant: str = ""
|
|
|
|
|
|
class FileUpload(BaseModel):
|
|
filename: str
|
|
content_b64: str
|
|
by: str = ""
|
|
tenant: str = ""
|
|
|
|
|
|
class RulesUpdate(BaseModel):
|
|
rules: dict
|
|
tenant: str = ""
|
|
|
|
|
|
@app.post("/files")
|
|
def files_list(q: FilesQuery) -> dict:
|
|
"""Rejestr plików. Kwarantanna WYCHODZI stąd tylko przy for_admin — filtrujemy
|
|
u źródła, żeby nie dało się jej odczytać z podglądu źródła strony."""
|
|
root = files.tenant_root(settings.excel_dir, q.tenant)
|
|
return {"files": files.registry(root, for_admin=q.for_admin),
|
|
"rules": files.rules(root) if q.for_admin else {},
|
|
"root": str(root)}
|
|
|
|
|
|
@app.post("/files/status")
|
|
def files_status(a: FileAction) -> dict:
|
|
try:
|
|
row = files.set_status(files.tenant_root(settings.excel_dir, a.tenant),
|
|
a.path, a.status, by=a.by)
|
|
except ValueError as e:
|
|
raise HTTPException(422, str(e)) from e
|
|
return {"path": a.path, "status": row.get("status")}
|
|
|
|
|
|
@app.post("/files/upload")
|
|
def files_upload(u: FileUpload) -> dict:
|
|
"""Plik wędruje w base64 wewnątrz zaszyfrowanego łącza — tym samym kanałem,
|
|
co reszta ruchu międzywarstwowego. Osobny, nieszyfrowany kanał na pliki
|
|
byłby obejściem PRE-16."""
|
|
try:
|
|
raw = base64.b64decode(u.content_b64, validate=True)
|
|
except (binascii.Error, ValueError) as e:
|
|
raise HTTPException(422, "Nieczytelna zawartość pliku.") from e
|
|
return files.store_upload(files.tenant_root(settings.excel_dir, u.tenant),
|
|
u.filename, raw, by=u.by)
|
|
|
|
|
|
@app.post("/files/delete")
|
|
def files_delete(a: FileAction) -> dict:
|
|
try:
|
|
files.delete(files.tenant_root(settings.excel_dir, a.tenant), a.path)
|
|
except ValueError as e:
|
|
raise HTTPException(422, str(e)) from e
|
|
return {"deleted": a.path}
|
|
|
|
|
|
@app.post("/files/rules")
|
|
def files_rules(u: RulesUpdate) -> dict:
|
|
return {"rules": files.set_rules(files.tenant_root(settings.excel_dir, u.tenant), u.rules)}
|
|
|
|
|
|
@app.get("/health", response_model=HealthInfo)
|
|
def health() -> HealthInfo:
|
|
return provider.health()
|