"""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 # Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji — # nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi # je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie # daje, gdy obok stoi usługa, która sama się spisuje (PRE-27). app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None) 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()