PRE-27: pełne ukrycie niedostępnych funkcji (paranoja)
build-render / build (push) Failing after 8s
build-swisseph / build (push) Successful in 9s
build / build (push) Successful in 8s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 5s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 4s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Failing after 4s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Failing after 4s

Ukrywanie jest teraz nadrzędne wobec wygody i czytelności komunikatów.
Persona: konto z uprawnieniami files + files_input, zatrudnione wyłącznie do
wgrywania plików. Nie ma się dowiedzieć, po co je wgrywa ani co program będzie
robił — bo to rozgada.

Audyt sześciu kanałów wycieku (statyki, HTML, sondowanie HTTP, ekran plików,
odpowiedzi JSON i błędy, pozostałe warstwy) potwierdził 26 wycieków, każdy
odtworzony uruchomionym kodem i zweryfikowany adwersarialnie. Ani jeden nie był
przyciskiem.

ZASÓB JEST CZĘŚCIĄ FUNKCJI
/static/ omijało CAŁĄ bramkę (PUBLIC_PREFIXES), więc każdy skrypt i arkusz
pobierał ktokolwiek, także niezalogowany, pod zgadywalnym adresem — a ich treść
wymienia ekrany, dostawców modeli i przeznaczenie plików. Ruch ten nie trafiał
przy tym ani do dziennika, ani pod limit żądań, więc wyciek był niewidoczny.
Zasoby idą teraz trasą z bramką; każdy ma w features.STATIC uprawnienie swojego
ekranu. Publiczny został jeden base.css, bo potrzebuje go ekran logowania.

KOMENTARZ NIE JEDZIE NA DRUT
Komentarze w CSS/JS opisywały funkcje pełnymi zdaniami po polsku — łącznie
z „Wstrzymane widzi tylko administrator", czyli i mechanizmem kwarantanny,
i istnieniem konta o wyższych uprawnieniach. _asset_body() usuwa je przy
serwowaniu; w repozytorium zostają.

styles.css rozbity na base.css + arkusz na ekran + x-ai.css. Jeden plik z
wszystkimi selektorami był spisem treści programu. Podział zrobiony
mechanicznie, z osobnym sprawdzeniem, że żaden ekran nie stracił reguły.

base.html ładował skrypty kosmogramu na KAŻDEJ stronie — konto mające wyłącznie
Pliki pobierało je przy wejściu na swój jedyny ekran, razem ze wzmianką
o „przyszłej zakładce". Teraz dokłada je ekran, który ich używa.

RÓŻNICA JEST INFORMACJĄ
Komunikat po wgraniu pliku różnił się zależnie od wyniku walidacji — czyli był
wyrocznią do odgadywania reguł, które ma znać tylko administrator — i mówił
wprost, że plik „musi zatwierdzić administrator". Teraz jest jeden, ten sam.

_logic_error wypisywał na ekran nazwę trasy, nazwę podsystemu, nazwę gałęzi
rozwojowej i wewnętrzny host:port. Jedno zdanie dla wszystkich awarii, szczegóły
do dziennika. Odsiew w jednym punkcie, nie w siedemnastu wywołaniach.

Ponadto: stopka nie ogłasza architektury, /health nie nazywa warstwy, konto bez
ekranów dostaje 404 zamiast tłumaczenia, ekran plików mówi o plikach zamiast
o „bazach interpretacyjnych", klasy .house-warning i .account-card przemianowane
na neutralne, a logic/data/render/engine-swisseph nie wystawiają już /docs ani
/openapi.json i nie publikują portów na hoście.

ZAPORA SŁOWNIKOWA
test_slownik_zakazany.py nie sprawdza miejsc, tylko przechodzi wszystko, co dane
konto może pobrać, i szuka słów, które nie mają prawa paść (87 pozycji dla tej
persony). Nazwy funkcji, adresy ekranów i nazwy zasobów biorą się wprost
z katalogu, więc nowa funkcja obejmuje się sama. Kontrola pozytywna pilnuje, żeby
test nie przechodził dlatego, że program jest pusty.

Sprawdzone: zapora puszczona na treść sprzed poprawek daje 16 trafień na samym
styles.css i łapie każdy ze zneutralizowanych komunikatów. 358 testów zielonych,
ekrany obejrzane w przeglądarce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit was merged in pull request #78.
This commit is contained in:
2026-08-21 17:54:03 +02:00
parent 1d0ff2f72e
commit 320a0ab24e
41 changed files with 1203 additions and 382 deletions
+2
View File
@@ -16,6 +16,8 @@ services/logic/.ephemeris/
# Dane wejściowe (duże pliki Excela trzymane poza repo) # Dane wejściowe (duże pliki Excela trzymane poza repo)
services/data/data_files/*.xlsx services/data/data_files/*.xlsx
# Rejestr plików (DAN-27) powstaje przy uruchomieniu — to stan, nie kod.
services/data/data_files/.files-state.json
!services/data/data_files/.gitkeep !services/data/data_files/.gitkeep
# Narzędzia # Narzędzia
+12 -6
View File
@@ -11,8 +11,10 @@ services:
volumes: volumes:
- ./services/data/data_files:/app/data_files - ./services/data/data_files:/app/data_files
- data_cache:/app/.cache - data_cache:/app/.cache
ports: # Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
- "8002:8002" # compose, a opublikowana na hoście była osiągalna z pominięciem logowania
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
# `docker compose run --publish`.
logic: logic:
image: gitea.czernobog.pl/gitea/astrololo-logic:latest image: gitea.czernobog.pl/gitea/astrololo-logic:latest
@@ -25,8 +27,10 @@ services:
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003 ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
depends_on: depends_on:
- data - data
ports: # Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
- "8001:8001" # compose, a opublikowana na hoście była osiągalna z pominięciem logowania
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
# `docker compose run --publish`.
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison": # Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
# docker compose --profile comparison up # docker compose --profile comparison up
@@ -36,8 +40,10 @@ services:
container_name: astrololo-engine-swisseph container_name: astrololo-engine-swisseph
restart: unless-stopped restart: unless-stopped
profiles: ["comparison"] profiles: ["comparison"]
ports: # Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
- "8003:8003" # compose, a opublikowana na hoście była osiągalna z pominięciem logowania
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
# `docker compose run --publish`.
presentation: presentation:
image: gitea.czernobog.pl/gitea/astrololo-presentation:latest image: gitea.czernobog.pl/gitea/astrololo-presentation:latest
+12 -6
View File
@@ -9,8 +9,10 @@ services:
volumes: volumes:
- ./services/data/data_files:/app/data_files - ./services/data/data_files:/app/data_files
- data_cache:/app/.cache - data_cache:/app/.cache
ports: # Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
- "8002:8002" # compose, a opublikowana na hoście była osiągalna z pominięciem logowania
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
# `docker compose run --publish`.
logic: logic:
build: ./services/logic build: ./services/logic
@@ -21,8 +23,10 @@ services:
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003 ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
depends_on: depends_on:
- data - data
ports: # Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
- "8001:8001" # compose, a opublikowana na hoście była osiągalna z pominięciem logowania
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
# `docker compose run --publish`.
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison": # Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
# docker compose --profile comparison up # docker compose --profile comparison up
@@ -30,8 +34,10 @@ services:
engine-swisseph: engine-swisseph:
build: ./services/engine-swisseph build: ./services/engine-swisseph
profiles: ["comparison"] profiles: ["comparison"]
ports: # Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
- "8003:8003" # compose, a opublikowana na hoście była osiągalna z pominięciem logowania
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
# `docker compose run --publish`.
presentation: presentation:
build: ./services/presentation build: ./services/presentation
+57
View File
@@ -93,6 +93,63 @@ sprawdzić, że w źródle strony nie ma żadnego z tych śladów, i mieć kontr
pozytywną, że przy uprawnieniu wszystkie są. Inaczej test przechodzi także wtedy, pozytywną, że przy uprawnieniu wszystkie są. Inaczej test przechodzi także wtedy,
gdy generowanie jest zepsute dla wszystkich. gdy generowanie jest zepsute dla wszystkich.
## Pełna paranoja: ukrywanie jest nadrzędne
Właściciel produktu postawił to wyżej niż wygodę i wyżej niż czytelność
komunikatów: *„nie chcę, żeby osoba wrzucająca bazy wiedziała, po co to robi
i jak będzie w przyszłości działał program, bo to rozgada"*. Persona nazywa się
**wgrywacz** — konto z uprawnieniami `files` + `files_input` i niczym więcej.
Nie ma dowiedzieć się: jakie inne funkcje istnieją, że teksty pisze model
językowy i u jakiego dostawcy, do czego służą wgrywane pliki, co jest planowane,
że istnieje walidacja plików, ani że istnieje konto, które widzi więcej.
### Wyciek prawie nigdy nie siedzi tam, gdzie się go szuka
Audyt sześciu kanałów potwierdził 26 wycieków. Ani jeden nie był przyciskiem.
| Kanał | Co wyciekało |
|---|---|
| `/static/**` poza bramką | komplet skryptów i arkuszy dla **niezalogowanego** |
| komentarze w CSS/JS | pełne zdania po polsku o funkcjach, o kwarantannie i o tym, że administrator widzi więcej |
| `styles.css` jako jeden plik | nazwy selektorów = spis funkcji programu |
| `base.html` | skrypty kosmogramu na **każdej** stronie, łącznie z „przyszłą zakładką" |
| komunikaty błędu | nazwa trasy, nazwa podsystemu, nazwa gałęzi rozwojowej, wewnętrzny `host:port` |
| komunikat po wgraniu | słowo „administrator" — i **dwie różne treści**, czyli wyrocznia do odgadywania reguł walidacji |
| `/openapi.json` warstw wewnętrznych | katalog wszystkich funkcji, bez logowania |
### Trzy zasady, które z tego wynikają
**Zasób jest częścią funkcji.** Skrypt i arkusz przechodzą przez tę samą bramkę
co ekran (`features.STATIC`). Nazwa pliku jest zgadywalna, więc plik publiczny
opowiada o funkcji równie dokładnie jak przycisk. Publiczny został jeden
`base.css` — bo potrzebuje go ekran logowania — i dlatego nie wolno w nim
umieścić niczego, co nazywa funkcję.
**Komentarz nie jedzie na drut.** `_asset_body()` usuwa komentarze przy
serwowaniu. Zostają w repozytorium, gdzie są potrzebne. Ta jedna zmiana zamyka
cztery z sześciu kanałów naraz.
**Różnica jest informacją.** Dwa różne komunikaty po wgraniu pliku były
wyrocznią: wystarczyło wgrywać spreparowane pliki i czytać odpowiedź. Teraz
komunikat jest jeden, niezależnie od wyniku. Z tego samego powodu odmowa to
404 identyczne z „nie ma takiej trasy", a akcja bez uprawnienia cofa się do
domyślnej zamiast tłumaczyć, czego brakuje.
### Zapora słownikowa
Łatanie punkt po punkcie przegrywa z następną zmianą. Dlatego
`test_slownik_zakazany.py` nie sprawdza miejsc, tylko przechodzi **wszystko**,
co dane konto może pobrać, i szuka słów, które nie mają prawa paść.
Dwie z trzech list biorą się wprost z katalogu funkcji — nazwa funkcji, adres
jej ekranu i nazwy jej zasobów — więc dopisanie funkcji automatycznie dopisuje
je do tego, czego konto bez niej nie może zobaczyć. Trzecia lista, słownictwo
dziedziny i mechanizmów, jest pisana ręcznie, bo katalog jej nie zna.
Test ma kontrolę pozytywną: dla administratora te same słowa **muszą** się
pojawiać. Bez niej przechodziłby także wtedy, gdyby program był pusty.
## Gdzie leżą konta ## Gdzie leżą konta
Plik JSON wskazany przez `ACCOUNTS_FILE` (domyślnie `/app/state/accounts.json`), Plik JSON wskazany przez `ACCOUNTS_FILE` (domyślnie `/app/state/accounts.json`),
+6 -1
View File
@@ -28,7 +28,12 @@ async def lifespan(app: FastAPI):
yield yield
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan) # 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) security.install(app, "danych") # token międzywarstwowy (LOG-32)
# Szyfrowanie łącza od logiki. PO `security.install`, żeby także odmowa # Szyfrowanie łącza od logiki. PO `security.install`, żeby także odmowa
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać. # tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
+6 -1
View File
@@ -20,7 +20,12 @@ from pydantic import BaseModel
import swisseph as swe import swisseph as swe
app = FastAPI(title="astrololo · engine-swisseph (AGPL, izolowany)") # 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 · engine-swisseph (AGPL, izolowany)",
docs_url=None, redoc_url=None, openapi_url=None)
# Tryb Moshiera: bez plików efemeryd, w pełni samowystarczalny (~0,1\" dokładności). # Tryb Moshiera: bez plików efemeryd, w pełni samowystarczalny (~0,1\" dokładności).
_FLAGS = swe.FLG_MOSEPH | swe.FLG_SPEED _FLAGS = swe.FLG_MOSEPH | swe.FLG_SPEED
+6 -1
View File
@@ -18,7 +18,12 @@ from app.clients.data_client import DataClient
from app.models import QueryRequest, QueryResponse from app.models import QueryRequest, QueryResponse
from app.service import QueryService from app.service import QueryService
app = FastAPI(title="astrololo · warstwa logiczna") # 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 logiczna",
docs_url=None, redoc_url=None, openapi_url=None)
service = QueryService() service = QueryService()
security.install(app, "logiczna") # token międzywarstwowy (LOG-32) security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
# Szyfrowanie łącza od prezentacji. PO `security.install`, żeby także odmowa # Szyfrowanie łącza od prezentacji. PO `security.install`, żeby także odmowa
+63 -3
View File
@@ -118,17 +118,77 @@ ROUTES: dict[tuple[str, str], str | None] = {
} }
def required(method: str, path: str) -> str | None: # ── zasoby statyczne ─────────────────────────────────────────────────────
# Skrypt i arkusz stylów są CZĘŚCIĄ funkcji, nie jej dekoracją. Nazwa pliku jest
# zgadywalna, a treść opowiada o funkcji dokładniej niż przycisk: `models.js`
# wymienia dostawców modeli, `compile.js` — składanie raportu. Dlatego każdy
# zasób przechodzi przez tę samą bramkę co ekrany.
#
# Wartość to ZBIÓR uprawnień, z których wystarczy JEDNO: wyszukiwarka lokalizacji
# siedzi na trzech ekranach i konto z którymkolwiek z nich musi ją dostać.
# `None` = każdy zalogowany. Zasób spoza mapy wymaga admina — jak trasa.
_FORMULARZE = frozenset({"chart", "interpret", "timeline", "compile"})
_LOKALIZACJA = frozenset({"chart", "interpret", "timeline"})
_KOLO = frozenset({"chart", "compile"})
STATIC: dict[str, frozenset[str] | str | None] = {
# arkusze ekranów — jeden ekran, jedno uprawnienie
"s-chart.css": "chart",
"s-interpret.css": "interpret",
"s-timeline.css": "timeline",
"s-synastry.css": "synastry",
"s-significators.css": "significators",
"s-compile.css": "compile",
"s-files.css": "files",
"s-settings.css": "settings",
"s-accounts.css": ADMIN,
"x-ai.css": "ai",
# skrypty
"formsync.js": _FORMULARZE,
"now.js": _LOKALIZACJA,
"geo.js": _LOKALIZACJA,
"vendor/leaflet/leaflet.js": _LOKALIZACJA,
"vendor/leaflet/leaflet.css": _LOKALIZACJA,
"wheelzoom.js": _KOLO,
"wheeltip.js": _KOLO,
"copy.js": frozenset({"interpret", "timeline"}),
"compile.js": "compile",
"models.js": "ai",
"progress.js": "ai",
"natal.js": "ai",
"predictions.js": "ai",
# base.css celowo NIE MA tu wpisu: potrzebuje go ekran logowania, więc jest
# publiczny (PUBLIC_PATHS w security.py). Dlatego nie wolno w nim trzymać
# niczego, co nazywa jakąkolwiek funkcję.
}
def static_required(name: str) -> frozenset[str] | str | None:
"""Uprawnienie dla zasobu. Nieznany zasób → admin, jak nieznana trasa."""
return STATIC.get(name, ADMIN)
def required(method: str, path: str) -> frozenset[str] | str | None:
"""Uprawnienie wymagane przez trasę. Nieznana trasa → wymaga admina. """Uprawnienie wymagane przez trasę. Nieznana trasa → wymaga admina.
Domyślną odpowiedzią jest NAJWĘŻSZE uprawnienie, nie najszersze: dopisanie Domyślną odpowiedzią jest NAJWĘŻSZE uprawnienie, nie najszersze: dopisanie
trasy bez wpisu w ROUTES ma ją zamknąć, a nie otworzyć. Odwrotny domyślny trasy bez wpisu w ROUTES ma ją zamknąć, a nie otworzyć. Odwrotny domyślny
wybór oznaczałby, że każde przeoczenie tworzy dziurę.""" wybór oznaczałby, że każde przeoczenie tworzy dziurę."""
if path.startswith("/static/"):
return static_required(path[len("/static/"):])
return ROUTES.get((method.upper(), path), ADMIN) return ROUTES.get((method.upper(), path), ADMIN)
def can(perms: frozenset[str], feature: str | None) -> bool: def can(perms: frozenset[str], feature: frozenset[str] | str | None) -> bool:
return feature is None or feature in perms """Czy te uprawnienia otwierają tę funkcję.
Zbiór znaczy „wystarczy którekolwiek": jeden zasób bywa wspólny dla kilku
ekranów i nie ma powodu wymagać ich wszystkich naraz."""
if feature is None:
return True
if isinstance(feature, str):
return feature in perms
return bool(perms & feature)
def screens_for(perms: frozenset[str]) -> list[Feature]: def screens_for(perms: frozenset[str]) -> list[Feature]:
+110 -24
View File
@@ -12,14 +12,15 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import logging
import re
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
import httpx import httpx
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
import base64 import base64
@@ -41,8 +42,9 @@ from app.config import DEFAULT_LOCATION_LABEL, default_form
# gdyby obok leżał ich katalog. Znalezione testem przechodzącym po trasach aplikacji. # gdyby obok leżał ich katalog. Znalezione testem przechodzącym po trasach aplikacji.
app = FastAPI(title="astrololo · warstwa prezentacji", app = FastAPI(title="astrololo · warstwa prezentacji",
docs_url=None, redoc_url=None, openapi_url=None) docs_url=None, redoc_url=None, openapi_url=None)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
# Szczegóły awarii idą TUTAJ, nie na ekran (patrz _logic_error).
log = logging.getLogger("astrololo.prezentacja")
logic = LogicClient() logic = LogicClient()
security.install(app) # logowanie + limit żądań (LOG-32) security.install(app) # logowanie + limit żądań (LOG-32)
@@ -55,18 +57,86 @@ security.install(app) # logowanie + limit żądań (LOG-32)
_STATIC_DIR = Path("app/static") _STATIC_DIR = Path("app/static")
_TYPY_ZASOBOW = {
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".woff2": "font/woff2",
}
def _bez_komentarzy(tresc: str, sufiks: str) -> str:
"""Usuwa komentarze z arkusza albo skryptu przed wysłaniem.
Komentarz w pliku statycznym jedzie do przeglądarki bajt w bajt, a te
komentarze opisują funkcje pełnym zdaniem po polsku — łącznie z tym, czego
konto ograniczone NIE widzi i kto to widzi. Zostają w repozytorium, bo tam
są potrzebne; do odpowiedzi nie mają po co trafiać.
Kasujemy tylko komentarze blokowe i CAŁE wiersze `//`. Komentarz na końcu
wiersza zostaje CELOWO: żeby go usunąć, trzeba rozpoznać, czy `//` nie jest
w napisie albo w wyrażeniu regularnym — a pomyłka tutaj psuje skrypt po
cichu. Test pilnuje słownika zakazanych słów, więc reszta by i tak wyszła.
"""
tresc = re.sub(r"/\*.*?\*/", "", tresc, flags=re.S)
if sufiks == ".js":
tresc = re.sub(r"(?m)^[ \t]*//.*$\n?", "", tresc)
return re.sub(r"\n{3,}", "\n\n", tresc).strip() + "\n"
@lru_cache(maxsize=None)
def _asset_body(name: str) -> bytes | None:
"""Treść zasobu tak, jak pójdzie na drut — albo None, gdy go nie ma."""
sciezka = (_STATIC_DIR / name).resolve()
korzen = _STATIC_DIR.resolve()
# Wyjście poza katalog zasobów: `..` w ścieżce albo dowiązanie na zewnątrz.
if korzen not in sciezka.parents or not sciezka.is_file():
return None
surowe = sciezka.read_bytes()
sufiks = sciezka.suffix.lower()
# Kod obcy zostaje nietknięty: minifikowany plik potrafi mieć `//` w napisie
# albo w wyrażeniu regularnym, a on i tak nie mówi nic o naszym programie.
if sufiks in (".css", ".js") and not name.startswith("vendor/"):
try:
return _bez_komentarzy(surowe.decode("utf-8"), sufiks).encode("utf-8")
except UnicodeDecodeError:
return surowe
return surowe
@lru_cache(maxsize=None) @lru_cache(maxsize=None)
def _asset_version(name: str) -> str: def _asset_version(name: str) -> str:
try: tresc = _asset_body(name)
return hashlib.md5((_STATIC_DIR / name).read_bytes()).hexdigest()[:8] # Hash liczony z treści WYSYŁANEJ, nie z pliku na dysku: inaczej zmiana
except OSError: # samego komentarza podbijałaby adres, choć odpowiedź jest ta sama.
return "0" # brak pliku nie może wywrócić strony return hashlib.md5(tresc).hexdigest()[:8] if tresc is not None else "0"
def static_url(name: str) -> str: def static_url(name: str) -> str:
return f"/static/{name}?v={_asset_version(name)}" return f"/static/{name}?v={_asset_version(name)}"
@app.get("/static/{nazwa:path}")
def static_file(nazwa: str):
"""Zasoby idą przez bramkę uprawnień jak każda inna trasa.
Dopóki `/static/` było w PUBLIC_PREFIXES, katalog omijał całe sprawdzanie:
ukrycie przycisku w szablonie nic nie dawało, bo obok leżał skrypt tej samej
funkcji, pod nazwą, którą wystarczyło zgadnąć. Uprawnienie zasobu sprawdza
middleware (features.static_required); tutaj zostaje samo wydanie pliku.
"""
tresc = _asset_body(nazwa)
if tresc is None:
raise HTTPException(status_code=404)
return Response(
tresc,
media_type=_TYPY_ZASOBOW.get(Path(nazwa).suffix.lower(), "application/octet-stream"),
# Adres niesie hash treści, więc wersja pod danym adresem jest niezmienna.
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
templates.env.globals["static"] = static_url templates.env.globals["static"] = static_url
# Katalog systemów domów — jedno źródło nazw dla wszystkich szablonów. # Katalog systemów domów — jedno źródło nazw dla wszystkich szablonów.
templates.env.globals["HOUSE_SYSTEMS"] = HOUSE_SYSTEMS templates.env.globals["HOUSE_SYSTEMS"] = HOUSE_SYSTEMS
@@ -179,13 +249,24 @@ def _report_records(report: dict | None) -> int:
return total return total
# Jedno zdanie dla wszystkich awarii — i CELOWO żadnego szczegółu.
# Poprzednia treść wypisywała na ekran nazwę endpointu (/chart/positions), nazwę
# podsystemu („silnik efemeryd"), nazwę gałęzi rozwojowej i — przez str(wyjątku
# httpx) — wewnętrzny adres z portem sąsiedniej usługi. Dla konta, które ma
# widzieć wyłącznie Pliki, to był komplet: co program liczy, że stoi za nim
# łańcuch usług, i gdzie zapukać.
_AWARIA = "Nie udało się teraz wykonać tej operacji. Spróbuj ponownie za chwilę."
def _logic_error(e: Exception) -> str: def _logic_error(e: Exception) -> str:
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404: """Komunikat dla użytkownika. Diagnostyka idzie do dziennika, nie na ekran.
return (
"Warstwa logiczna działa, ale nie ma endpointu /chart/positions. " Odsiew siedzi TUTAJ, a nie w siedemnastu miejscach wywołania: gdyby zależał
"Uruchom warstwę logiczną z silnikiem efemeryd (gałąź feat/logic-engine)." od tego, czy ktoś pamiętał przekazać uprawnienia, prędzej czy później jedno
) wywołanie zostałoby pominięte — i nie byłoby tego jak zauważyć.
return f"Warstwa logiczna niedostępna: {e}" """
log.warning("warstwa logiczna: %s: %s", type(e).__name__, e)
return _AWARIA
# ---------------- Horoskop: pozycje (strona główna) ---------------- # ---------------- Horoskop: pozycje (strona główna) ----------------
@@ -198,9 +279,11 @@ def chart_form(request: Request):
target = _landing(request) target = _landing(request)
if target: if target:
return RedirectResponse(target, status_code=303) return RedirectResponse(target, status_code=303)
return HTMLResponse("<h1>Brak przypisanych funkcji</h1>" # Ta sama odpowiedź co bramka dla trasy bez uprawnienia. Poprzednia treść
"<p>Skontaktuj się z osobą, która zakładała konto.</p>", # mówiła o „przypisanych funkcjach" i o „osobie, która zakładała konto",
status_code=200) # czyli wprost: funkcje są komuś przydzielane, jest ktoś, kto to robi,
# a twoje konto zostało okrojone.
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
return templates.TemplateResponse( return templates.TemplateResponse(
request, "chart.html", request, "chart.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL, {"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
@@ -767,7 +850,7 @@ def files_use(request: Request, path: str = Form(...), use: str = Form("")):
except httpx.HTTPError as e: except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e)) return _files_redirect(error=_logic_error(e))
return _files_redirect(done="Zapisano." if want == files_state.READY return _files_redirect(done="Zapisano." if want == files_state.READY
else "Baza włączona do użytku.") else "Plik włączony do użytku.")
@app.post("/files/upload") @app.post("/files/upload")
@@ -782,12 +865,13 @@ async def files_upload(request: Request, upload: UploadFile = File(...)):
base64.b64encode(raw).decode("ascii"), by=_who(request)) base64.b64encode(raw).decode("ascii"), by=_who(request))
except httpx.HTTPError as e: except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e)) return _files_redirect(error=_logic_error(e))
if out.get("accepted"): # DOKŁADNIE ten sam komunikat niezależnie od wyniku sprawdzenia. Dwa różne
return _files_redirect(done=f"Wgrano „{out.get('name')}”. Można ją włączyć do użytku.") # komunikaty były wyrocznią: wystarczyło wgrywać spreparowane pliki i czytać
# Bez powodu i bez słowa „walidacja" — poza administratorem nikt nie ma # odpowiedź, żeby odgadnąć reguły, które ma znać wyłącznie administrator.
# skąd wiedzieć, że taki mechanizm istnieje. # Poprzednia treść mówiła przy tym wprost, że istnieje „administrator", który
return _files_redirect(done=f"Wgrano „{out.get('name')}”. " # musi plik „zatwierdzić" — czyli że konto wgrywającego jest ograniczone
f"Zanim trafi do użytku, musi ją zatwierdzić administrator.") # i że plik przechodzi przez jakąś bramkę.
return _files_redirect(done=f"Wgrano „{out.get('name')}”.")
@app.post("/files/archive") @app.post("/files/archive")
@@ -995,4 +1079,6 @@ def wyloguj(request: Request):
@app.get("/health") @app.get("/health")
def health() -> dict: def health() -> dict:
return {"status": "ok", "layer": "presentation"} # Sama „ok" — nazwa warstwy mówiła, że warstw jest więcej i jak się nazywa.
# Kubernetes i compose czytają wyłącznie kod odpowiedzi.
return {"status": "ok"}
+10 -2
View File
@@ -133,8 +133,16 @@ def trust_proxy() -> bool:
# żądaniu, więc handler i tak widziałby zalogowaną osobę. # żądaniu, więc handler i tak widziałby zalogowaną osobę.
# /logowanie MUSI stać poza bramką — inaczej żeby się zalogować, trzeba by być # /logowanie MUSI stać poza bramką — inaczej żeby się zalogować, trzeba by być
# zalogowanym. /wyloguj też: ma działać także wtedy, gdy sesja już wygasła. # zalogowanym. /wyloguj też: ma działać także wtedy, gdy sesja już wygasła.
PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj"}) # Ekran logowania musi się wyświetlić PRZED zalogowaniem, więc jego arkusz jest
PUBLIC_PREFIXES = ("/static/",) # jedynym publicznym zasobem — i dlatego nie wolno w nim trzymać niczego, co
# nazywa funkcję (patrz features.STATIC).
PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj", "/static/base.css"})
# Pusto CELOWO. Dopóki był tu "/static/", cały katalog omijał bramkę: każdy plik
# JS i arkusz stylów pobierał ktokolwiek, także niezalogowany, pod zgadywalnym
# adresem — a ich treść wymienia ekrany, dostawców modeli i przeznaczenie baz.
# Ruch do zasobów nie trafiał przy tym ani do dziennika, ani pod limit żądań,
# więc wyciek był dla właściciela niewidoczny.
PUBLIC_PREFIXES: tuple[str, ...] = ()
_hits: dict[str, deque[float]] = {} _hits: dict[str, deque[float]] = {}
+87
View File
@@ -0,0 +1,87 @@
/* Powłoka strony i ekran logowania JEDYNY arkusz publiczny.
Nie wolno tu wstawić niczego, co nazywa funkcję: ten plik pobiera
każdy, także niezalogowany, bo potrzebuje go ekran logowania. */
:root {
--bg: #0f1020;
--panel: #1a1c33;
--ink: #e8e8f0;
--muted: #9aa0c0;
--accent: #8b7bf0;
--line: #2a2d4a;
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink);
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
display: flex; justify-content: center; padding: 3rem 1rem;
}
main { width: 100%; max-width: 880px; }
h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
.topbar { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: .25rem; }
.topbar nav { display: flex; gap: .25rem; }
.topbar nav a { color: var(--muted); text-decoration: none; padding: .4rem .8rem; border-radius: 8px; border: 1px solid transparent; }
.topbar nav a:hover { color: var(--ink); }
.topbar nav a.active { color: #fff; background: var(--panel); border-color: var(--line); }
.foot { color: var(--muted); font-size: .8rem; margin-top: 2.5rem; opacity: .7; }
.actions { display: flex; gap: .5rem; margin-top: 1rem; }
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
input, select, button {
font: inherit; padding: .6rem .75rem; border-radius: 8px;
border: 1px solid var(--line); background: #12132a; color: var(--ink);
}
button { background: var(--accent); color: #fff; border: none; cursor: pointer; padding-inline: 1.25rem; }
button:hover { filter: brightness(1.1); }
.opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; }
.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
/* Fallback systemu domów. Celowo NIE jest to dyskretny muted" po fallbacku
kosmogram wygląda bezbłędnie, więc ten akapit jest jedynym sygnałem, że
podział domów pochodzi z innego systemu, niż zamówiono. */
.notice-warn {
margin: 1rem 0; padding: .75rem 1rem; font-size: .92rem; line-height: 1.45;
border: 1px solid var(--warn-border, #b8860b);
border-left-width: 4px;
border-radius: 4px;
background: var(--warn-bg, rgba(184, 134, 11, .10));
color: var(--warn-fg, inherit);
}
table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; }
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
tr:last-child td { border-bottom: none; }
.muted { color: var(--muted); }
.small { font-size: .85rem; }
.nowrap { white-space: nowrap; }
/* ── ekran kont (PRE-26) ────────────────────────────────────────────── */
.card {
margin-top: 1rem; padding: 1rem 1.25rem;
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
}
.card .actions { margin-top: .9rem; }
/* Potwierdzenie operacji. Odróżnia się od .notice-warn, bo tamto ostrzega,
a to tylko potwierdza pomylenie ich uczy ignorować oba. */
.done-note {
margin: 1rem 0; padding: .6rem .9rem; font-size: .9rem;
border: 1px solid var(--line); border-left: 4px solid var(--accent);
border-radius: 4px; background: rgba(255, 255, 255, .03);
}
/* ── wyjście z aplikacji (LOG-32) ───────────────────────────────────── */
/* Odsunięte od zakładek i wyglądające inaczej niż one: to czynność, nie miejsce,
a pomylenie go z zakładką kosztuje przypadkowe wylogowania. Odstęp robimy
marginesem, nie rozpychaczem `nav` ma szerokość treści, więc nie ma czego
rozpychać. */
.whoami { color: var(--muted); font-size: .85rem; align-self: center;
margin-left: 1rem; padding-left: 1rem; border-left: 1px solid var(--line); }
/* Wylogowanie idzie POST-em, więc jest przyciskiem ale ma wyglądać jak
reszta paska, nie jak przycisk akcji w formularzu. */
.wyloguj-form { display: inline; margin: 0; padding: 0; background: none; border: none; }
button.wyloguj {
background: none; border: 1px solid transparent; color: var(--muted);
font: inherit; padding: .4rem .8rem; border-radius: 8px; cursor: pointer;
}
button.wyloguj:hover { color: var(--ink); }
/* Ekran logowania — wąski, bez nawigacji: nie ma dokąd iść przed zalogowaniem. */
.login-main { max-width: 26rem; }
.login-card label { display: block; margin-top: .75rem; }
.login-card input { width: 100%; }
.login-note { margin-top: 1.25rem; }
@@ -0,0 +1,24 @@
/* Arkusz ekranu „admin” — dokładany tylko dla konta z tym uprawnieniem. */
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
.grid input { width: 100%; }
.loc { margin-top: .8rem; }
.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
.row { display: flex; gap: .5rem; }
.row input[type=text] { flex: 1; }
/* Lista systemów domów urosła z 3 do 13 pozycji. Na flexie zawijały się w cztery
poszarpane wiersze o różnej długości; siatka o stałej szerokości kolumny ustawia
nazwy w pionie i daje się przeczytać wzrokiem. auto-fill, więc na wąskim ekranie
kolumn jest po prostu mniej. */
.opts.systems { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
gap: .35rem 1rem; align-items: start; }
.opts.systems > .opts-label { grid-column: 1 / -1; margin-bottom: .1rem; }
.opts.systems label { display: flex; align-items: center; gap: .35rem; }
.opts .hint { font-style: normal; opacity: .75; }
.opts input[type=number] { width: 5rem; }
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
.account-head { display: flex; align-items: baseline; gap: .75rem; margin-bottom: .5rem; }
.account-note input { width: 100%; }
button.danger { background: #8b2f2f; }
@@ -0,0 +1,119 @@
/* Arkusz ekranu „chart” — dokładany tylko dla konta z tym uprawnieniem. */
.sub { color: var(--muted); margin: .25rem 0 2rem; }
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
.grid input { width: 100%; }
.loc { margin-top: .8rem; }
.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
td.retro { color: #ff9b6a; font-weight: 600; }
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
(DAN-18: kolorowe kwadraty nieczytelne i niesterowalne kolorem):
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
2) font-variant-emoji:text nowsza własność CSS,
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
znaków zodiaku (), mimo VS15 potwierdzone wizualnie. Apple Symbols",
Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
"DejaVu Sans", sans-serif; }
td.glyph { color: var(--accent); white-space: nowrap; }
/* Kosmogram (PRE-12) — SVG skaluje się do szerokości, ale nie rośnie w nieskończoność. */
.wheel-fig { margin: 1.5rem 0 .5rem; text-align: center; }
.wheel-fig svg.wheel { width: 100%; max-width: 460px; height: auto; }
.wheel-fig figcaption { margin-top: .4rem; }
/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
svg.wheel text.glyph { font-size: inherit; }
/* Aspektarian (PRE-18) — trójkątna siatka aspektów, węższa od koła. */
.aspectarian-fig { margin: .25rem 0 1.25rem; text-align: center; }
.aspectarian-fig svg.aspectarian { width: 100%; max-width: 380px; height: auto; }
.aspectarian-fig figcaption { margin-top: .4rem; }
svg.aspectarian text.glyph { font-size: inherit; }
/* Wykres deklinacji i oś antyscji (LOG-07, etap 6) — szersze, pełna szerokość. */
.declination-fig, .antiscia-fig { margin: 1.25rem 0 .75rem; text-align: center; }
.declination-fig svg.declination { width: 100%; max-width: 560px; height: auto; }
.antiscia-fig svg.antiscia { width: 100%; max-width: 560px; height: auto; }
.declination-fig figcaption, .antiscia-fig figcaption { margin-top: .4rem; }
svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
ale PONIŻEJ okna postępu (1200) gdy trwa pisanie horoskopu, log operacji ma
zostać na wierzchu. */
.wheel-fig svg.wheel { cursor: zoom-in; }
.wheel-fig svg.wheel:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
.wheel-fig.zoomed {
position: fixed; inset: 0; z-index: 1100; margin: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: .5rem; padding: 1rem; background: var(--bg);
}
/* min() po obu wymiarach: koło jest kwadratowe, więc ograniczamy je krótszym
bokiem okna inaczej na szerokim ekranie wystawałoby w pionie. */
.wheel-fig.zoomed svg.wheel {
max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out;
}
body.wheel-zoom-open { overflow: hidden; }
/* nie przewijaj treści pod spodem */
/* Dymek kosmogramu. z-index 1150 CELOWO pomiędzy: ponad nakładką powiększonego
koła (1100), żeby działał także po powiększeniu, ale PONIŻEJ okna postępu
(1200), które ma zostać na wierzchu. pointer-events: none dymek chodzi za
kursorem i nie może sam siebie zasłonić ani przechwycić najechania. */
.wheel-tip {
position: fixed; z-index: 1150; pointer-events: none;
max-width: 22rem; padding: .4rem .6rem;
background: var(--panel); color: var(--fg, inherit);
border: 1px solid var(--line); border-radius: 8px;
font-size: .85rem; line-height: 1.35;
box-shadow: 0 4px 14px rgba(0, 0, 0, .45);
}
.wheel-tip[hidden] { display: none; }
/* Lista systemów domów urosła z 3 do 13 pozycji. Na flexie zawijały się w cztery
poszarpane wiersze o różnej długości; siatka o stałej szerokości kolumny ustawia
nazwy w pionie i daje się przeczytać wzrokiem. auto-fill, więc na wąskim ekranie
kolumn jest po prostu mniej. */
.opts.systems { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
gap: .35rem 1rem; align-items: start; }
.opts.systems > .opts-label { grid-column: 1 / -1; margin-bottom: .1rem; }
.opts.systems label { display: flex; align-items: center; gap: .35rem; }
.opts .hint { font-style: normal; opacity: .75; }
.opts input[type=number] { width: 5rem; }
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
/* Tabela porównania systemów domów ma tyle kolumn, ile zaznaczono systemów
przy komplecie trzynastu robi się 1520 px i ROZPYCHAŁA CAŁĄ STRONĘ (dokument
1713 px przy oknie 1280 px, poziomy pasek przewijania na body). Przewijać ma
się sama tabela, nigdy strona. */
.table-scroll { overflow-x: auto; margin-top: .5rem; border-radius: 12px; }
.table-scroll > table { margin-top: 0; border-radius: 0; }
.table-scroll th, .table-scroll td { white-space: nowrap; }
/* Numer domu przyklejony do lewej krawędzi: bez tego po przewinięciu w prawo
nie wiadomo, który to wiersz. */
.table-scroll th:first-child, .table-scroll td:first-child {
position: sticky; left: 0; background: var(--panel); z-index: 1;
box-shadow: 1px 0 0 var(--line);
}
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
.geo { margin-top: .9rem; }
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
.geo-results li:last-child { border-bottom: none; }
.geo-results li:hover { background: var(--panel); color: #fff; }
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
.geo-map[hidden] { display: none; }
.geo-attrib { margin: .4rem 0 0; }
.geo-attrib a { color: var(--accent); }
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
.leaflet-control-attribution { font-size: .68rem; }
button.toggle {
background: none; border: 1px solid var(--line); border-radius: 50%;
width: 1.9rem; height: 1.9rem; padding: 0; font-size: 1rem; line-height: 1;
}
button.toggle.on { color: var(--accent); border-color: var(--accent); }
button.toggle.off { color: var(--muted); }
@@ -0,0 +1,114 @@
/* Arkusz ekranu „compile” — dokładany tylko dla konta z tym uprawnieniem. */
.sub { color: var(--muted); margin: .25rem 0 2rem; }
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
.grid input { width: 100%; }
.loc { margin-top: .8rem; }
.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
td.retro { color: #ff9b6a; font-weight: 600; }
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
(DAN-18: kolorowe kwadraty nieczytelne i niesterowalne kolorem):
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
2) font-variant-emoji:text nowsza własność CSS,
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
znaków zodiaku (), mimo VS15 potwierdzone wizualnie. Apple Symbols",
Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
"DejaVu Sans", sans-serif; }
td.glyph { color: var(--accent); white-space: nowrap; }
/* Kosmogram (PRE-12) — SVG skaluje się do szerokości, ale nie rośnie w nieskończoność. */
.wheel-fig { margin: 1.5rem 0 .5rem; text-align: center; }
.wheel-fig svg.wheel { width: 100%; max-width: 460px; height: auto; }
.wheel-fig figcaption { margin-top: .4rem; }
/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
svg.wheel text.glyph { font-size: inherit; }
/* Aspektarian (PRE-18) — trójkątna siatka aspektów, węższa od koła. */
.aspectarian-fig { margin: .25rem 0 1.25rem; text-align: center; }
.aspectarian-fig svg.aspectarian { width: 100%; max-width: 380px; height: auto; }
.aspectarian-fig figcaption { margin-top: .4rem; }
svg.aspectarian text.glyph { font-size: inherit; }
/* Wykres deklinacji i oś antyscji (LOG-07, etap 6) — szersze, pełna szerokość. */
.declination-fig, .antiscia-fig { margin: 1.25rem 0 .75rem; text-align: center; }
.declination-fig svg.declination { width: 100%; max-width: 560px; height: auto; }
.antiscia-fig svg.antiscia { width: 100%; max-width: 560px; height: auto; }
.declination-fig figcaption, .antiscia-fig figcaption { margin-top: .4rem; }
svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
ale PONIŻEJ okna postępu (1200) gdy trwa pisanie horoskopu, log operacji ma
zostać na wierzchu. */
.wheel-fig svg.wheel { cursor: zoom-in; }
.wheel-fig svg.wheel:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
.wheel-fig.zoomed {
position: fixed; inset: 0; z-index: 1100; margin: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: .5rem; padding: 1rem; background: var(--bg);
}
/* min() po obu wymiarach: koło jest kwadratowe, więc ograniczamy je krótszym
bokiem okna inaczej na szerokim ekranie wystawałoby w pionie. */
.wheel-fig.zoomed svg.wheel {
max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out;
}
body.wheel-zoom-open { overflow: hidden; }
/* nie przewijaj treści pod spodem */
/* Dymek kosmogramu. z-index 1150 CELOWO pomiędzy: ponad nakładką powiększonego
koła (1100), żeby działał także po powiększeniu, ale PONIŻEJ okna postępu
(1200), które ma zostać na wierzchu. pointer-events: none dymek chodzi za
kursorem i nie może sam siebie zasłonić ani przechwycić najechania. */
.wheel-tip {
position: fixed; z-index: 1150; pointer-events: none;
max-width: 22rem; padding: .4rem .6rem;
background: var(--panel); color: var(--fg, inherit);
border: 1px solid var(--line); border-radius: 8px;
font-size: .85rem; line-height: 1.35;
box-shadow: 0 4px 14px rgba(0, 0, 0, .45);
}
.wheel-tip[hidden] { display: none; }
/* Lista systemów domów urosła z 3 do 13 pozycji. Na flexie zawijały się w cztery
poszarpane wiersze o różnej długości; siatka o stałej szerokości kolumny ustawia
nazwy w pionie i daje się przeczytać wzrokiem. auto-fill, więc na wąskim ekranie
kolumn jest po prostu mniej. */
.opts.systems { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
gap: .35rem 1rem; align-items: start; }
.opts.systems > .opts-label { grid-column: 1 / -1; margin-bottom: .1rem; }
.opts.systems label { display: flex; align-items: center; gap: .35rem; }
.opts .hint { font-style: normal; opacity: .75; }
.opts input[type=number] { width: 5rem; }
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
/* Tabela porównania systemów domów ma tyle kolumn, ile zaznaczono systemów
przy komplecie trzynastu robi się 1520 px i ROZPYCHAŁA CAŁĄ STRONĘ (dokument
1713 px przy oknie 1280 px, poziomy pasek przewijania na body). Przewijać ma
się sama tabela, nigdy strona. */
.table-scroll { overflow-x: auto; margin-top: .5rem; border-radius: 12px; }
.table-scroll > table { margin-top: 0; border-radius: 0; }
.table-scroll th, .table-scroll td { white-space: nowrap; }
/* Numer domu przyklejony do lewej krawędzi: bez tego po przewinięciu w prawo
nie wiadomo, który to wiersz. */
.table-scroll th:first-child, .table-scroll td:first-child {
position: sticky; left: 0; background: var(--panel); z-index: 1;
box-shadow: 1px 0 0 var(--line);
}
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
/* Zakładka „Skompiluj" (PRE-23) — składanie raportu z trzech źródeł. */
.readiness { list-style: none; margin: .3rem 0 1rem; padding: 0; }
.readiness li { padding: .2rem 0; }
.readiness .rdy-ok { color: #7fd18b; }
.readiness .rdy-missing { color: #e0c060; }
.report-head { margin: 1.5rem 0 .5rem; }
.report-head h2 { margin: 0; font-size: 1.4rem; }
/* Treść od AI bywa długa white-space:pre-wrap zachowuje akapity modelu,
bez przepisywania ich na HTML. */
.report-text { white-space: pre-wrap; background: var(--panel); border: 1px solid var(--line);
border-radius: 10px; padding: .8rem 1rem; margin-top: .4rem; line-height: 1.6; }
button.toggle {
background: none; border: 1px solid var(--line); border-radius: 50%;
width: 1.9rem; height: 1.9rem; padding: 0; font-size: 1rem; line-height: 1;
}
button.toggle.on { color: var(--accent); border-color: var(--accent); }
button.toggle.off { color: var(--muted); }
@@ -0,0 +1,36 @@
/* Arkusz ekranu „files” — dokładany tylko dla konta z tym uprawnieniem. */
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
.grid input { width: 100%; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
.opts input[type=number] { width: 5rem; }
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
/* Tabela porównania systemów domów ma tyle kolumn, ile zaznaczono systemów
przy komplecie trzynastu robi się 1520 px i ROZPYCHAŁA CAŁĄ STRONĘ (dokument
1713 px przy oknie 1280 px, poziomy pasek przewijania na body). Przewijać ma
się sama tabela, nigdy strona. */
.table-scroll { overflow-x: auto; margin-top: .5rem; border-radius: 12px; }
.table-scroll > table { margin-top: 0; border-radius: 0; }
.table-scroll th, .table-scroll td { white-space: nowrap; }
/* Numer domu przyklejony do lewej krawędzi: bez tego po przewinięciu w prawo
nie wiadomo, który to wiersz. */
.table-scroll th:first-child, .table-scroll td:first-child {
position: sticky; left: 0; background: var(--panel); z-index: 1;
box-shadow: 1px 0 0 var(--line);
}
button.danger { background: #8b2f2f; }
/* ── ekran plików (DAN-27) ──────────────────────────────────────────── */
form.inline { display: inline; }
td.ops form.inline + form.inline { margin-left: .4rem; }
button.toggle {
background: none; border: 1px solid var(--line); border-radius: 50%;
width: 1.9rem; height: 1.9rem; padding: 0; font-size: 1rem; line-height: 1;
}
button.toggle.on { color: var(--accent); border-color: var(--accent); }
button.toggle.off { color: var(--muted); }
tr.row-archived td { opacity: .55; }
/* Wstrzymane widzi tylko administrator dla reszty tych wierszy nie ma
w ogóle w odpowiedzi, więc ten styl nigdy nie dotyczy ich strony. */
tr.row-held td { background: rgba(184, 134, 11, .07); }
@@ -0,0 +1,42 @@
/* Arkusz ekranu „interpret” — dokładany tylko dla konta z tym uprawnieniem. */
.sub { color: var(--muted); margin: .25rem 0 2rem; }
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
.grid input { width: 100%; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
td.retro { color: #ff9b6a; font-weight: 600; }
.opts input[type=number] { width: 5rem; }
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
.sig-item { margin-top: 1.25rem; }
.sig-head { padding: .5rem 0; }
.samples { margin-top: .3rem; }
.samples td { font-size: .92rem; vertical-align: top; }
.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
.facet { margin: .4rem 0 .6rem 1rem; }
.facet-head { color: var(--ink); font-size: .9rem; }
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
.geo { margin-top: .9rem; }
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
.geo-results li:last-child { border-bottom: none; }
.geo-results li:hover { background: var(--panel); color: #fff; }
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
.geo-map[hidden] { display: none; }
.geo-attrib { margin: .4rem 0 0; }
.geo-attrib a { color: var(--accent); }
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
.leaflet-control-attribution { font-size: .68rem; }
/* Generator promptu do LLM (LOG-29/30) */
textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
background: #12132a; color: var(--ink); border: 1px solid var(--line);
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
@@ -0,0 +1,7 @@
/* Arkusz ekranu „settings” — dokładany tylko dla konta z tym uprawnieniem. */
.sub { color: var(--muted); margin: .25rem 0 2rem; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
@@ -0,0 +1,8 @@
/* Arkusz ekranu „significators” — dokładany tylko dla konta z tym uprawnieniem. */
.sub { color: var(--muted); margin: .25rem 0 2rem; }
.row { display: flex; gap: .5rem; }
.row input[type=text] { flex: 1; }
.opts input[type=number] { width: 5rem; }
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
.empty { color: var(--muted); }
@@ -0,0 +1,23 @@
/* Arkusz ekranu „synastry” — dokładany tylko dla konta z tym uprawnieniem. */
.sub { color: var(--muted); margin: .25rem 0 2rem; }
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
.grid input { width: 100%; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
td.retro { color: #ff9b6a; font-weight: 600; }
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
(DAN-18: kolorowe kwadraty nieczytelne i niesterowalne kolorem):
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
2) font-variant-emoji:text nowsza własność CSS,
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
znaków zodiaku (), mimo VS15 potwierdzone wizualnie. Apple Symbols",
Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
"DejaVu Sans", sans-serif; }
td.glyph { color: var(--accent); white-space: nowrap; }
svg.aspectarian text.glyph { font-size: inherit; }
.opts input[type=number] { width: 5rem; }
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
@@ -0,0 +1,39 @@
/* Arkusz ekranu „timeline” — dokładany tylko dla konta z tym uprawnieniem. */
.sub { color: var(--muted); margin: .25rem 0 2rem; }
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
.grid input { width: 100%; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
td.retro { color: #ff9b6a; font-weight: 600; }
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
.sig-item { margin-top: 1.25rem; }
.sig-head { padding: .5rem 0; }
.samples { margin-top: .3rem; }
.samples td { font-size: .92rem; vertical-align: top; }
.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
.geo { margin-top: .9rem; }
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
.geo-results li:last-child { border-bottom: none; }
.geo-results li:hover { background: var(--panel); color: #fff; }
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
.geo-map[hidden] { display: none; }
.geo-attrib { margin: .4rem 0 0; }
.geo-attrib a { color: var(--accent); }
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
.leaflet-control-attribution { font-size: .68rem; }
/* Generator promptu do LLM (LOG-29/30) */
textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
background: #12132a; color: var(--ink); border: 1px solid var(--line);
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
-287
View File
@@ -1,287 +0,0 @@
:root {
--bg: #0f1020;
--panel: #1a1c33;
--ink: #e8e8f0;
--muted: #9aa0c0;
--accent: #8b7bf0;
--line: #2a2d4a;
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink);
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
display: flex; justify-content: center; padding: 3rem 1rem;
}
main { width: 100%; max-width: 880px; }
h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
.sub { color: var(--muted); margin: .25rem 0 2rem; }
.topbar { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: .25rem; }
.topbar nav { display: flex; gap: .25rem; }
.topbar nav a { color: var(--muted); text-decoration: none; padding: .4rem .8rem; border-radius: 8px; border: 1px solid transparent; }
.topbar nav a:hover { color: var(--ink); }
.topbar nav a.active { color: #fff; background: var(--panel); border-color: var(--line); }
.foot { color: var(--muted); font-size: .8rem; margin-top: 2.5rem; opacity: .7; }
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
.grid input { width: 100%; }
.loc { margin-top: .8rem; }
.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
.actions { display: flex; gap: .5rem; margin-top: 1rem; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
td.retro { color: #ff9b6a; font-weight: 600; }
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
(DAN-18: kolorowe kwadraty nieczytelne i niesterowalne kolorem):
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
2) font-variant-emoji:text nowsza własność CSS,
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
znaków zodiaku (), mimo VS15 potwierdzone wizualnie. Apple Symbols",
Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
"DejaVu Sans", sans-serif; }
td.glyph { color: var(--accent); white-space: nowrap; }
/* Kosmogram (PRE-12) — SVG skaluje się do szerokości, ale nie rośnie w nieskończoność. */
.wheel-fig { margin: 1.5rem 0 .5rem; text-align: center; }
.wheel-fig svg.wheel { width: 100%; max-width: 460px; height: auto; }
.wheel-fig figcaption { margin-top: .4rem; }
/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
svg.wheel text.glyph { font-size: inherit; }
/* Aspektarian (PRE-18) — trójkątna siatka aspektów, węższa od koła. */
.aspectarian-fig { margin: .25rem 0 1.25rem; text-align: center; }
.aspectarian-fig svg.aspectarian { width: 100%; max-width: 380px; height: auto; }
.aspectarian-fig figcaption { margin-top: .4rem; }
svg.aspectarian text.glyph { font-size: inherit; }
/* Wykres deklinacji i oś antyscji (LOG-07, etap 6) — szersze, pełna szerokość. */
.declination-fig, .antiscia-fig { margin: 1.25rem 0 .75rem; text-align: center; }
.declination-fig svg.declination { width: 100%; max-width: 560px; height: auto; }
.antiscia-fig svg.antiscia { width: 100%; max-width: 560px; height: auto; }
.declination-fig figcaption, .antiscia-fig figcaption { margin-top: .4rem; }
svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
ale PONIŻEJ okna postępu (1200) gdy trwa pisanie horoskopu, log operacji ma
zostać na wierzchu. */
.wheel-fig svg.wheel { cursor: zoom-in; }
.wheel-fig svg.wheel:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
.wheel-fig.zoomed {
position: fixed; inset: 0; z-index: 1100; margin: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: .5rem; padding: 1rem; background: var(--bg);
}
/* min() po obu wymiarach: koło jest kwadratowe, więc ograniczamy je krótszym
bokiem okna inaczej na szerokim ekranie wystawałoby w pionie. */
.wheel-fig.zoomed svg.wheel {
max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out;
}
body.wheel-zoom-open { overflow: hidden; } /* nie przewijaj treści pod spodem */
/* Dymek kosmogramu. z-index 1150 CELOWO pomiędzy: ponad nakładką powiększonego
koła (1100), żeby działał także po powiększeniu, ale PONIŻEJ okna postępu
(1200), które ma zostać na wierzchu. pointer-events: none dymek chodzi za
kursorem i nie może sam siebie zasłonić ani przechwycić najechania. */
.wheel-tip {
position: fixed; z-index: 1150; pointer-events: none;
max-width: 22rem; padding: .4rem .6rem;
background: var(--panel); color: var(--fg, inherit);
border: 1px solid var(--line); border-radius: 8px;
font-size: .85rem; line-height: 1.35;
box-shadow: 0 4px 14px rgba(0, 0, 0, .45);
}
.wheel-tip[hidden] { display: none; }
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
.row { display: flex; gap: .5rem; }
.row input[type=text] { flex: 1; }
input, select, button {
font: inherit; padding: .6rem .75rem; border-radius: 8px;
border: 1px solid var(--line); background: #12132a; color: var(--ink);
}
button { background: var(--accent); color: #fff; border: none; cursor: pointer; padding-inline: 1.25rem; }
button:hover { filter: brightness(1.1); }
.opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; }
/* Lista systemów domów urosła z 3 do 13 pozycji. Na flexie zawijały się w cztery
poszarpane wiersze o różnej długości; siatka o stałej szerokości kolumny ustawia
nazwy w pionie i daje się przeczytać wzrokiem. auto-fill, więc na wąskim ekranie
kolumn jest po prostu mniej. */
.opts.systems { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
gap: .35rem 1rem; align-items: start; }
.opts.systems > .opts-label { grid-column: 1 / -1; margin-bottom: .1rem; }
.opts.systems label { display: flex; align-items: center; gap: .35rem; }
.opts .hint { font-style: normal; opacity: .75; }
.opts input[type=number] { width: 5rem; }
.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
/* Fallback systemu domów. Celowo NIE jest to dyskretny muted" po fallbacku
kosmogram wygląda bezbłędnie, więc ten akapit jest jedynym sygnałem, że
podział domów pochodzi z innego systemu, niż zamówiono. */
.house-warning {
margin: 1rem 0; padding: .75rem 1rem; font-size: .92rem; line-height: 1.45;
border: 1px solid var(--warn-border, #b8860b);
border-left-width: 4px;
border-radius: 4px;
background: var(--warn-bg, rgba(184, 134, 11, .10));
color: var(--warn-fg, inherit);
}
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
.empty { color: var(--muted); }
table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; }
/* Tabela porównania systemów domów ma tyle kolumn, ile zaznaczono systemów
przy komplecie trzynastu robi się 1520 px i ROZPYCHAŁA CAŁĄ STRONĘ (dokument
1713 px przy oknie 1280 px, poziomy pasek przewijania na body). Przewijać ma
się sama tabela, nigdy strona. */
.table-scroll { overflow-x: auto; margin-top: .5rem; border-radius: 12px; }
.table-scroll > table { margin-top: 0; border-radius: 0; }
.table-scroll th, .table-scroll td { white-space: nowrap; }
/* Numer domu przyklejony do lewej krawędzi: bez tego po przewinięciu w prawo
nie wiadomo, który to wiersz. */
.table-scroll th:first-child, .table-scroll td:first-child {
position: sticky; left: 0; background: var(--panel); z-index: 1;
box-shadow: 1px 0 0 var(--line);
}
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
tr:last-child td { border-bottom: none; }
.muted { color: var(--muted); }
.small { font-size: .85rem; }
.nowrap { white-space: nowrap; }
.sig-item { margin-top: 1.25rem; }
.sig-head { padding: .5rem 0; }
.samples { margin-top: .3rem; }
.samples td { font-size: .92rem; vertical-align: top; }
.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
.facet { margin: .4rem 0 .6rem 1rem; }
.facet-head { color: var(--ink); font-size: .9rem; }
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
.geo { margin-top: .9rem; }
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
.geo-results li:last-child { border-bottom: none; }
.geo-results li:hover { background: var(--panel); color: #fff; }
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
.geo-map[hidden] { display: none; }
.geo-attrib { margin: .4rem 0 0; }
.geo-attrib a { color: var(--accent); }
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
.leaflet-control-attribution { font-size: .68rem; }
/* Generator promptu do LLM (LOG-29/30) */
textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
background: #12132a; color: var(--ink); border: 1px solid var(--line);
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
/* Okno postępu przy generowaniu horoskopu */
/* z-index ponad Leaflet: jego kontrolki (.leaflet-top/.leaflet-bottom zoom,
atrybucja) siedzą na z-index:1000 i bo .leaflet-container nie tworzy własnego
kontekstu stackowania trafiają wprost do korzenia. Przy z-index:50 mapa
wychodziła NA WIERZCH modala. 1200 daje zapas nad 1000. */
.progress-overlay { position: fixed; inset: 0; background: rgba(8,9,20,.72);
display: flex; align-items: center; justify-content: center;
z-index: 1200; padding: 1rem; }
.progress-overlay[hidden] { display: none; }
.progress-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
padding: 1rem 1.1rem; width: min(38rem, 100%); box-shadow: 0 18px 48px rgba(0,0,0,.45); }
.progress-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .6rem; }
.progress-clock { margin-left: auto; font-family: ui-monospace, Menlo, monospace;
color: var(--muted); font-size: .9rem; }
.progress-spinner { width: 14px; height: 14px; border-radius: 50%; flex: none;
border: 2px solid var(--line); border-top-color: var(--accent);
animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .progress-spinner { animation: none; } }
.progress-log { list-style: none; margin: 0 0 .6rem; padding: .6rem .7rem;
background: #12132a; border: 1px solid var(--line); border-radius: 10px;
max-height: 15rem; overflow-y: auto;
font-family: ui-monospace, Menlo, monospace; font-size: .78rem; line-height: 1.6; }
.progress-log li { color: var(--ink); white-space: pre-wrap; }
.progress-log li.log-ok { color: #7fd18b; }
.progress-log li.log-warn { color: #e0c060; }
.progress-log li.log-err { color: #ef6b6b; }
/* Zakładka „Skompiluj" (PRE-23) — składanie raportu z trzech źródeł. */
.readiness { list-style: none; margin: .3rem 0 1rem; padding: 0; }
.readiness li { padding: .2rem 0; }
.readiness .rdy-ok { color: #7fd18b; }
.readiness .rdy-missing { color: #e0c060; }
.report-head { margin: 1.5rem 0 .5rem; }
.report-head h2 { margin: 0; font-size: 1.4rem; }
/* Treść od AI bywa długa white-space:pre-wrap zachowuje akapity modelu,
bez przepisywania ich na HTML. */
.report-text { white-space: pre-wrap; background: var(--panel); border: 1px solid var(--line);
border-radius: 10px; padding: .8rem 1rem; margin-top: .4rem; line-height: 1.6; }
/* ── ekran kont (PRE-26) ────────────────────────────────────────────── */
.account-card {
margin-top: 1rem; padding: 1rem 1.25rem;
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
}
.account-head { display: flex; align-items: baseline; gap: .75rem; margin-bottom: .5rem; }
.account-note input { width: 100%; }
.account-card .actions { margin-top: .9rem; }
button.danger { background: #8b2f2f; }
/* Potwierdzenie operacji. Odróżnia się od .house-warning, bo tamto ostrzega,
a to tylko potwierdza pomylenie ich uczy ignorować oba. */
.done-note {
margin: 1rem 0; padding: .6rem .9rem; font-size: .9rem;
border: 1px solid var(--line); border-left: 4px solid var(--accent);
border-radius: 4px; background: rgba(255, 255, 255, .03);
}
/* ── ekran plików (DAN-27) ──────────────────────────────────────────── */
form.inline { display: inline; }
td.ops form.inline + form.inline { margin-left: .4rem; }
button.toggle {
background: none; border: 1px solid var(--line); border-radius: 50%;
width: 1.9rem; height: 1.9rem; padding: 0; font-size: 1rem; line-height: 1;
}
button.toggle.on { color: var(--accent); border-color: var(--accent); }
button.toggle.off { color: var(--muted); }
tr.row-archived td { opacity: .55; }
/* Wstrzymane widzi tylko administrator dla reszty tych wierszy nie ma
w ogóle w odpowiedzi, więc ten styl nigdy nie dotyczy ich strony. */
tr.row-held td { background: rgba(184, 134, 11, .07); }
/* ── wyjście z aplikacji (LOG-32) ───────────────────────────────────── */
/* Odsunięte od zakładek i wyglądające inaczej niż one: to czynność, nie miejsce,
a pomylenie go z zakładką kosztuje przypadkowe wylogowania. Odstęp robimy
marginesem, nie rozpychaczem `nav` ma szerokość treści, więc nie ma czego
rozpychać. */
.whoami { color: var(--muted); font-size: .85rem; align-self: center;
margin-left: 1rem; padding-left: 1rem; border-left: 1px solid var(--line); }
/* Wylogowanie idzie POST-em, więc jest przyciskiem ale ma wyglądać jak
reszta paska, nie jak przycisk akcji w formularzu. */
.wyloguj-form { display: inline; margin: 0; padding: 0; background: none; border: none; }
button.wyloguj {
background: none; border: 1px solid transparent; color: var(--muted);
font: inherit; padding: .4rem .8rem; border-radius: 8px; cursor: pointer;
}
button.wyloguj:hover { color: var(--ink); }
/* Ekran logowania — wąski, bez nawigacji: nie ma dokąd iść przed zalogowaniem. */
.login-main { max-width: 26rem; }
.login-card label { display: block; margin-top: .75rem; }
.login-card input { width: 100%; }
.login-note { margin-top: 1.25rem; }
+31
View File
@@ -0,0 +1,31 @@
/* Generowanie tekstu przez model dokładany wyłącznie, gdy konto ma
uprawnienie ai. Selektory nazywają funkcję, więc arkusz nie może
wchodzić do arkuszy ekranów, na których ta funkcja bywa wyłączona. */
/* Okno postępu przy generowaniu horoskopu */
/* z-index ponad Leaflet: jego kontrolki (.leaflet-top/.leaflet-bottom zoom,
atrybucja) siedzą na z-index:1000 i bo .leaflet-container nie tworzy własnego
kontekstu stackowania trafiają wprost do korzenia. Przy z-index:50 mapa
wychodziła NA WIERZCH modala. 1200 daje zapas nad 1000. */
.progress-overlay { position: fixed; inset: 0; background: rgba(8,9,20,.72);
display: flex; align-items: center; justify-content: center;
z-index: 1200; padding: 1rem; }
.progress-overlay[hidden] { display: none; }
.progress-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
padding: 1rem 1.1rem; width: min(38rem, 100%); box-shadow: 0 18px 48px rgba(0,0,0,.45); }
.progress-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .6rem; }
.progress-clock { margin-left: auto; font-family: ui-monospace, Menlo, monospace;
color: var(--muted); font-size: .9rem; }
.progress-spinner { width: 14px; height: 14px; border-radius: 50%; flex: none;
border: 2px solid var(--line); border-top-color: var(--accent);
animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .progress-spinner { animation: none; } }
.progress-log { list-style: none; margin: 0 0 .6rem; padding: .6rem .7rem;
background: #12132a; border: 1px solid var(--line); border-radius: 10px;
max-height: 15rem; overflow-y: auto;
font-family: ui-monospace, Menlo, monospace; font-size: .78rem; line-height: 1.6; }
.progress-log li { color: var(--ink); white-space: pre-wrap; }
.progress-log li.log-ok { color: #7fd18b; }
.progress-log li.log-warn { color: #e0c060; }
.progress-log li.log-err { color: #ef6b6b; }
@@ -10,7 +10,7 @@
inny. Musi być WIDOCZNE, bo z samego wykresu tego nie da się poznać. #} inny. Musi być WIDOCZNE, bo z samego wykresu tego nie da się poznać. #}
{% if result.house_warnings %} {% if result.house_warnings %}
{% for w in result.house_warnings %} {% for w in result.house_warnings %}
<p class="house-warning">{{ w }}</p> <p class="notice-warn">{{ w }}</p>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
@@ -2,6 +2,9 @@
{% set nav_active = "accounts" %} {% set nav_active = "accounts" %}
{% block title %}Konta{% endblock %} {% block title %}Konta{% endblock %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-accounts.css') }}">
{% endblock %}
{% block content %} {% block content %}
<p class="muted"> <p class="muted">
Konta i ich dostęp do funkcji programu. Konto <strong>{{ admin_login }}</strong> Konta i ich dostęp do funkcji programu. Konto <strong>{{ admin_login }}</strong>
@@ -15,7 +18,7 @@
odpowiadają tak samo jak nieistniejące. odpowiadają tak samo jak nieistniejące.
</p> </p>
{% if error %}<p class="house-warning">{{ error }}</p>{% endif %} {% if error %}<p class="notice-warn">{{ error }}</p>{% endif %}
{% if done %}<p class="done-note">{{ done }}</p>{% endif %} {% if done %}<p class="done-note">{{ done }}</p>{% endif %}
<div class="meta">Konta ({{ users | length }})</div> <div class="meta">Konta ({{ users | length }})</div>
@@ -24,7 +27,7 @@
{% endif %} {% endif %}
{% for login, row in users | dictsort %} {% for login, row in users | dictsort %}
<form method="post" action="/accounts/update" class="account-card"> <form method="post" action="/accounts/update" class="card">
<input type="hidden" name="login" value="{{ login }}"> <input type="hidden" name="login" value="{{ login }}">
<div class="account-head"> <div class="account-head">
<strong>{{ login }}</strong> <strong>{{ login }}</strong>
@@ -66,7 +69,7 @@
{% endfor %} {% endfor %}
<div class="meta">Nowe konto</div> <div class="meta">Nowe konto</div>
<form method="post" action="/accounts/create" class="account-card"> <form method="post" action="/accounts/create" class="card">
<div class="grid"> <div class="grid">
<label>Login <input type="text" name="login" required autocomplete="off"></label> <label>Login <input type="text" name="login" required autocomplete="off"></label>
<label>Hasło <input type="password" name="password" required autocomplete="new-password"></label> <label>Hasło <input type="password" name="password" required autocomplete="new-password"></label>
+18 -10
View File
@@ -4,16 +4,18 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>astrololo · {% block title %}{% endblock %}</title> <title>astrololo · {% block title %}{% endblock %}</title>
<link rel="stylesheet" href="{{ static('styles.css') }}"> {# Arkusz wspólny niesie WYŁĄCZNIE powłokę i logowanie — wszystko, co nazywa
{# Wspólne dane formularza między zakładkami (PRE-21). W <head> z `defer` jakąkolwiek funkcję, mieszka w arkuszu tej funkcji i dokłada je `styles`.
CELOWO: skrypty defer wykonują się w kolejności dokumentu, więc ten zdąży Inaczej jeden plik, pobierany też przed zalogowaniem, byłby spisem treści
odtworzyć współrzędne, ZANIM geo.js zbuduje mapę — mapa startuje od razu programu: nazwa selektora opowiada o funkcji tak samo dobrze jak przycisk. #}
we właściwym miejscu, bez przestawiania. #} <link rel="stylesheet" href="{{ static('base.css') }}">
<script defer src="{{ static('formsync.js') }}"></script> {% block styles %}{% endblock %}
{# Powiększanie kosmogramu (PRE-25) — globalnie, bo koło pojawi się też na {# Skrypty dokłada ekran, który ich potrzebuje. Ładowane stąd docierały na
zakładce „Skompiluj"; skrypt sam sprawdza, czy jest co powiększać. #} KAŻDĄ stronę — także do konta, które ma wyłącznie Pliki — a ich treść
<script defer src="{{ static('wheelzoom.js') }}"></script> wymienia z nazwy ekrany, których takie konto nie ma prawa znać.
<script defer src="{{ static('wheeltip.js') }}"></script> Blok jest w <head>, bo `defer` wykonuje skrypty w kolejności dokumentu:
formsync musi odtworzyć współrzędne, ZANIM geo.js zbuduje mapę. #}
{% block head_scripts %}{% endblock %}
</head> </head>
<body> <body>
<main> <main>
@@ -45,9 +47,15 @@
</nav> </nav>
</header> </header>
{% block content %}{% endblock %} {% block content %}{% endblock %}
{# Stopka wymieniała warstwy architektury i nazywała całość „widokiem
testowym" — czyli mówiła, że to nie jest gotowy produkt i że za nim stoi
łańcuch usług. Dla konta ograniczonego program ma wyglądać na kompletny,
więc ten opis widzi wyłącznie administrator. #}
{% if can(request, 'admin') %}
<footer class="foot"> <footer class="foot">
prezentacja → logika → dane · widok testowy prezentacja → logika → dane · widok testowy
</footer> </footer>
{% endif %}
</main> </main>
</body> </body>
</html> </html>
@@ -2,6 +2,14 @@
{% block title %}Horoskop{% endblock %} {% block title %}Horoskop{% endblock %}
{% set nav_active = "chart" %} {% set nav_active = "chart" %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-chart.css') }}">
{% endblock %}
{% block head_scripts %}
<script defer src="{{ static('formsync.js') }}"></script>
<script defer src="{{ static('wheelzoom.js') }}"></script>
<script defer src="{{ static('wheeltip.js') }}"></script>
{% endblock %}
{% block content %} {% block content %}
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p> <p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
@@ -71,7 +79,7 @@
{% if result %} {% if result %}
{# Fallback systemu domów — z kosmogramu nie da się go poznać, więc mówimy wprost #} {# Fallback systemu domów — z kosmogramu nie da się go poznać, więc mówimy wprost #}
{% for w in result.house_warnings or [] %} {% for w in result.house_warnings or [] %}
<p class="house-warning">{{ w }}</p> <p class="notice-warn">{{ w }}</p>
{% endfor %} {% endfor %}
<div class="meta"> <div class="meta">
Silnik: <strong>{{ result.engine }}</strong> · Silnik: <strong>{{ result.engine }}</strong> ·
@@ -2,6 +2,15 @@
{% block title %}Skompiluj{% endblock %} {% block title %}Skompiluj{% endblock %}
{% set nav_active = "compile" %} {% set nav_active = "compile" %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-compile.css') }}">
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
{% endblock %}
{% block head_scripts %}
<script defer src="{{ static('formsync.js') }}"></script>
<script defer src="{{ static('wheelzoom.js') }}"></script>
<script defer src="{{ static('wheeltip.js') }}"></script>
{% endblock %}
{% block content %} {% block content %}
{# Opis raportu zależy od tego, z czego konto może go złożyć. Wersja z AI {# Opis raportu zależy od tego, z czego konto może go złożyć. Wersja z AI
wymieniałaby funkcje, których część kont nie ma — a wymienienie ich w zdaniu wymieniałaby funkcje, których część kont nie ma — a wymienienie ich w zdaniu
+16 -9
View File
@@ -2,18 +2,25 @@
{% set nav_active = "files" %} {% set nav_active = "files" %}
{% block title %}Pliki{% endblock %} {% block title %}Pliki{% endblock %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-files.css') }}">
{% endblock %}
{% block content %} {% block content %}
{# Opis czysto operacyjny: co robi zaznaczenie, i nic o tym, do CZEGO te dane
służą. Poprzednia treść nazywała je „bazami interpretacyjnymi" i mówiła
o „wyszukiwaniu" — czyli zdradzała i dziedzinę programu, i istnienie ekranu
wyszukiwarki. To jest jedyny ekran, który ogląda konto wgrywające dane, więc
każde zdanie na nim waży najwięcej. #}
<p class="muted"> <p class="muted">
Bazy interpretacyjne, z których korzysta program. Zaznaczona baza bierze udział Zaznaczony plik jest w użyciu. Odznaczony zostaje na dysku, ale nie jest używany.
w wyszukiwaniu; odznaczona zostaje na dysku, ale program jej nie używa.
</p> </p>
{% if error %}<p class="house-warning">{{ error }}</p>{% endif %} {% if error %}<p class="notice-warn">{{ error }}</p>{% endif %}
{% if done %}<p class="done-note">{{ done }}</p>{% endif %} {% if done %}<p class="done-note">{{ done }}</p>{% endif %}
<div class="meta">Bazy ({{ files | length }})</div> <div class="meta">Pliki ({{ files | length }})</div>
{% if not files %} {% if not files %}
<p class="muted">Nie ma jeszcze żadnego pliku bazy.</p> <p class="muted">Nie ma jeszcze żadnego pliku.</p>
{% endif %} {% endif %}
<div class="table-scroll"> <div class="table-scroll">
@@ -35,7 +42,7 @@
<input type="hidden" name="path" value="{{ f.path }}"> <input type="hidden" name="path" value="{{ f.path }}">
<input type="hidden" name="use" value="{{ '0' if f.in_use else '1' }}"> <input type="hidden" name="use" value="{{ '0' if f.in_use else '1' }}">
<button type="submit" class="toggle {{ 'on' if f.in_use else 'off' }}" <button type="submit" class="toggle {{ 'on' if f.in_use else 'off' }}"
title="{{ 'Odstaw tę bazę' if f.in_use else 'Włącz tę bazę do użytku' }}"> title="{{ 'Odstaw ten plik' if f.in_use else 'Włącz ten plik do użytku' }}">
{{ '●' if f.in_use else '○' }} {{ '●' if f.in_use else '○' }}
</button> </button>
</form> </form>
@@ -84,8 +91,8 @@
</div> </div>
{% if can(request, 'files_input') %} {% if can(request, 'files_input') %}
<div class="meta">Wgraj nową bazę</div> <div class="meta">Wgraj plik</div>
<form method="post" action="/files/upload" enctype="multipart/form-data" class="account-card"> <form method="post" action="/files/upload" enctype="multipart/form-data" class="card">
<div class="grid"> <div class="grid">
<label>Plik <input type="file" name="upload" required></label> <label>Plik <input type="file" name="upload" required></label>
</div> </div>
@@ -101,7 +108,7 @@
Plik musi je spełnić, żeby dało się go włączyć do użytku. Plik, który ich nie Plik musi je spełnić, żeby dało się go włączyć do użytku. Plik, który ich nie
spełnia, <strong>nie jest kasowany</strong> — czeka na Twoją decyzję. spełnia, <strong>nie jest kasowany</strong> — czeka na Twoją decyzję.
</p> </p>
<form method="post" action="/files/rules" class="account-card"> <form method="post" action="/files/rules" class="card">
<div class="grid"> <div class="grid">
<label>Dozwolone rozszerzenia (po przecinku) <label>Dozwolone rozszerzenia (po przecinku)
<input type="text" name="extensions" value="{{ (rules.extensions or []) | join(', ') }}"></label> <input type="text" name="extensions" value="{{ (rules.extensions or []) | join(', ') }}"></label>
@@ -2,6 +2,13 @@
{% block title %}Interpretacje{% endblock %} {% block title %}Interpretacje{% endblock %}
{% set nav_active = "interpret" %} {% set nav_active = "interpret" %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-interpret.css') }}">
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
{% endblock %}
{% block head_scripts %}
<script defer src="{{ static('formsync.js') }}"></script>
{% endblock %}
{% block content %} {% block content %}
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p> <p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
@@ -4,7 +4,9 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>astrololo · logowanie</title> <title>astrololo · logowanie</title>
<link rel="stylesheet" href="{{ static('styles.css') }}"> {# Ekran logowania nie dziedziczy z base.html (nie ma paska zakładek), więc
linkuje arkusz sam. To JEDYNY arkusz publiczny — patrz features.STATIC. #}
<link rel="stylesheet" href="{{ static('base.css') }}">
</head> </head>
<body> <body>
<main class="login-main"> <main class="login-main">
@@ -13,9 +15,9 @@
{% if request.query_params.get('wylogowano') %} {% if request.query_params.get('wylogowano') %}
<p class="done-note">Wylogowano.</p> <p class="done-note">Wylogowano.</p>
{% endif %} {% endif %}
{% if blad %}<p class="house-warning">{{ blad }}</p>{% endif %} {% if blad %}<p class="notice-warn">{{ blad }}</p>{% endif %}
<form method="post" action="/logowanie" class="account-card login-card"> <form method="post" action="/logowanie" class="card login-card">
{# Adres powrotu jedzie w ukrytym polu, ale i tak przechodzi przez sito {# Adres powrotu jedzie w ukrytym polu, ale i tak przechodzi przez sito
po stronie serwera — pole formularza jest wejściem od użytkownika. #} po stronie serwera — pole formularza jest wejściem od użytkownika. #}
<input type="hidden" name="dokad" value="{{ dokad }}"> <input type="hidden" name="dokad" value="{{ dokad }}">
@@ -28,8 +30,9 @@
</form> </form>
<p class="muted small login-note"> <p class="muted small login-note">
Nie masz konta albo nie pamiętasz hasła? Zgłoś się do osoby, która zakładała {# Bez wskazywania, że dostęp ktoś „zakłada": to mówi wprost, że istnieje
Ci dostęp — kont nie da się odzyskać samodzielnie. konto o wyższych uprawnieniach i że własne jest okrojone. #}
Nie pamiętasz hasła? Skontaktuj się z osobą, od której masz dane logowania.
</p> </p>
</main> </main>
</body> </body>
@@ -2,6 +2,9 @@
{% block title %}Ustawienia{% endblock %} {% block title %}Ustawienia{% endblock %}
{% set nav_active = "settings" %} {% set nav_active = "settings" %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-settings.css') }}">
{% endblock %}
{% block content %} {% block content %}
<p class="sub">Bazy interpretacyjne dostępne na udziale sieciowym — i to, które biorą udział w interpretacji.</p> <p class="sub">Bazy interpretacyjne dostępne na udziale sieciowym — i to, które biorą udział w interpretacji.</p>
@@ -2,6 +2,9 @@
{% block title %}Sygnifikatory{% endblock %} {% block title %}Sygnifikatory{% endblock %}
{% set nav_active = "significators" %} {% set nav_active = "significators" %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-significators.css') }}">
{% endblock %}
{% block content %} {% block content %}
<p class="sub">Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.</p> <p class="sub">Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.</p>
@@ -2,6 +2,9 @@
{% block title %}Synastria{% endblock %} {% block title %}Synastria{% endblock %}
{% set nav_active = "synastry" %} {% set nav_active = "synastry" %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-synastry.css') }}">
{% endblock %}
{% block content %} {% block content %}
<p class="sub">Dwie osoby → aspekty <strong>między</strong> ich horoskopami (planeta jednej osoby do planety drugiej). Bez interpretacji z bazy — sama siatka aspektów.</p> <p class="sub">Dwie osoby → aspekty <strong>między</strong> ich horoskopami (planeta jednej osoby do planety drugiej). Bez interpretacji z bazy — sama siatka aspektów.</p>
@@ -2,6 +2,13 @@
{% block title %}Kalendarz{% endblock %} {% block title %}Kalendarz{% endblock %}
{% set nav_active = "timeline" %} {% set nav_active = "timeline" %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-timeline.css') }}">
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
{% endblock %}
{% block head_scripts %}
<script defer src="{{ static('formsync.js') }}"></script>
{% endblock %}
{% block content %} {% block content %}
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p> <p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
@@ -16,11 +16,11 @@ TEMPLATES = pathlib.Path(__file__).resolve().parents[1] / "app" / "templates"
def test_static_url_carries_content_hash(): def test_static_url_carries_content_hash():
assert re.fullmatch(r"/static/styles\.css\?v=[0-9a-f]{8}", static_url("styles.css")) assert re.fullmatch(r"/static/base\.css\?v=[0-9a-f]{8}", static_url("base.css"))
def test_version_is_content_based_so_differs_between_files(): def test_version_is_content_based_so_differs_between_files():
assert _asset_version("styles.css") != _asset_version("formsync.js") assert _asset_version("base.css") != _asset_version("formsync.js")
def test_missing_file_does_not_crash(): def test_missing_file_does_not_crash():
@@ -35,7 +35,17 @@ def test_no_template_serves_raw_static_paths():
assert 'href="/static/' not in txt, f"{f.name}: surowy href bez cache-bustingu" assert 'href="/static/' not in txt, f"{f.name}: surowy href bez cache-bustingu"
def test_base_uses_helper_for_stylesheet_and_scripts(): def test_base_uses_helper_for_the_shared_stylesheet():
base = (TEMPLATES / "base.html").read_text(encoding="utf-8") base = (TEMPLATES / "base.html").read_text(encoding="utf-8")
assert "static('styles.css')" in base assert "static('base.css')" in base
assert "static('formsync.js')" in base
def test_version_follows_what_is_sent_not_what_is_on_disk():
"""Adres niesie hash treści WYSYŁANEJ, bo komentarze są przy serwowaniu
usuwane. Gdyby hash szedł z pliku na dysku, poprawka w samym komentarzu
podbijałaby adres i kasowała cache przeglądarki bez żadnej zmiany dla niej."""
import hashlib
from app.main import _asset_body
assert _asset_version("base.css") == hashlib.md5(_asset_body("base.css")).hexdigest()[:8]
+13 -6
View File
@@ -65,12 +65,19 @@ def test_every_shared_field_is_synced(key):
assert f"'{key}'" in SYNC_JS, f"pole {key} nie jest synchronizowane" assert f"'{key}'" in SYNC_JS, f"pole {key} nie jest synchronizowane"
def test_sync_script_loaded_before_map_script(): @pytest.mark.parametrize("name", WITH_FORM)
"""KOLEJNOŚĆ MA ZNACZENIE: formsync w <head> z `defer` wykonuje się przed def test_sync_script_loaded_before_map_script(name):
geo.js (skrypty defer idą w kolejności dokumentu), więc mapa startuje już """KOLEJNOŚĆ MA ZNACZENIE: formsync z `defer` wykonuje się przed geo.js
z odtworzonymi współrzędnymi zamiast skakać po chwili.""" (skrypty defer idą w kolejności dokumentu), więc mapa startuje już
assert "formsync.js" in BASE_HTML, "formsync.js nie jest ładowany globalnie" z odtworzonymi współrzędnymi zamiast skakać po chwili.
assert "defer" in BASE_HTML.split("formsync.js")[0].rsplit("<script", 1)[-1] + "defer"
Skrypt siedzi teraz w bloku `head_scripts` EKRANU, nie w base.html. Ładowany
globalnie docierał także do konta, które ma wyłącznie Pliki a jego treść
wymienia z nazwy zakładki, których takie konto nie ma."""
html = (TEMPLATES / name).read_text(encoding="utf-8")
assert "head_scripts" in html and "formsync.js" in html
przed_geo = html.split("formsync.js")[0]
assert "defer" in przed_geo.rsplit("<script", 1)[-1] + "defer"
def test_sync_reacts_to_other_browser_tab(): def test_sync_reacts_to_other_browser_tab():
@@ -113,7 +113,10 @@ def test_fallback_notice_is_shown_prominently():
sygnałem, że podział domów jest z innego systemu.""" sygnałem, że podział domów jest z innego systemu."""
html = _render("_result_tables.html", html = _render("_result_tables.html",
result={"house_warnings": ["UWAGA: system domów nie ma definicji"]}) result={"house_warnings": ["UWAGA: system domów nie ma definicji"]})
assert "house-warning" in html # Klasa nazywa się neutralnie: `house-warning` siedziała też na ekranie
# logowania i na ekranie plików, czyli nazwa z astrologii lądowała tam,
# gdzie astrologii ma nie być widać.
assert "notice-warn" in html
assert "UWAGA: system domów nie ma definicji" in html assert "UWAGA: system domów nie ma definicji" in html
@@ -184,7 +187,12 @@ def test_wheel_orientation_reaches_the_renderer(monkeypatch, orientation):
# overflow-x, ROZPYCHAŁA CAŁY DOKUMENT — 1713 px przy oknie 1280 px, czyli # overflow-x, ROZPYCHAŁA CAŁY DOKUMENT — 1713 px przy oknie 1280 px, czyli
# poziomy pasek przewijania na body i rozjechany układ. # poziomy pasek przewijania na body i rozjechany układ.
CSS = (APP / "static" / "styles.css").read_text(encoding="utf-8") # Arkusz jest teraz rozbity per uprawnienie (arkusz wspólny + arkusze ekranów),
# bo jeden plik ze wszystkimi selektorami był spisem funkcji programu dla
# każdego, kto go pobrał. Reguły szukamy w całości — który arkusz ją niesie,
# pilnuje osobny test pokrycia.
CSS = "\n".join(f.read_text(encoding="utf-8")
for f in sorted((APP / "static").glob("*.css")))
def test_comparison_table_scrolls_instead_of_widening_the_page(): def test_comparison_table_scrolls_instead_of_widening_the_page():
@@ -0,0 +1,211 @@
"""Zapora słownikowa: czego konto NIE MOŻE zobaczyć w żadnej swojej odpowiedzi.
Poprzednie testy pilnowały pojedynczych miejsc tego przycisku, tamtego bloku.
Wyciek prawie nigdy nie siedzi w miejscu, o którym się pamięta: katalog modeli
jechał blokiem JSON, nazwy ekranów komentarzem w arkuszu stylów, mechanizm
kwarantanny zdaniem po polsku w komentarzu CSS, a nazwa gałęzi rozwojowej
w komunikacie o błędzie. Wspólne mają jedno pewne SŁOWA nie mają prawa paść.
Więc test nie sprawdza miejsc, tylko przechodzi WSZYSTKO, co dane konto może
pobrać (strony i zasoby), i szuka słów zakazanych. Nowy ekran, nowy skrypt,
nowy komunikat wchodzą pod samą zaporę bez dopisywania czegokolwiek.
Dwie z trzech list biorą się wprost z katalogu funkcji, więc nie rozjadą się
z programem: dopisanie funkcji automatycznie dopisuje jej nazwę i jej adres do
tego, czego konto bez niej nie ma prawa zobaczyć.
"""
import re
import pytest
from app import accounts as store
from app import features
# ── persona ──────────────────────────────────────────────────────────────
# Osoba zatrudniona wyłącznie do wgrywania plików. Ma widzieć swój ekran i nic
# poza nim — ani co program robi, ani co będzie robił, ani że ktoś widzi więcej.
WGRYWACZ = ["files", "files_input"]
# ── słowa zakazane, których NIE DA SIĘ wyprowadzić z katalogu ────────────
# Katalog zna nazwy funkcji, ale nie zna dziedziny programu ani słownictwa jego
# mechanizmów. Te trzeba wypisać, i każde ma tu swój powód.
DZIEDZINA = [
# (c) po co są wgrywane pliki
"astrolog", "horoskop", "kosmogram", "zodiak", "efemeryd", "sygnifikator",
"interpretacyjn", "aspektarian", "antyscj", "deklinacj",
# „baza" mówi, że to nie są zwykłe pliki, tylko zbiory czegoś. Na ekranie
# wgrywającego wszystko nazywa się plikiem; w częściach administracyjnych
# słowo zostaje, bo tam jest właściwe (i tam ten test nie zagląda).
"baz",
# (b) że tekst pisze model językowy i u kogo
"LLM", "anthropic", "openai", "gpt", "claude", "prompt", "token",
# (e)/(f) mechanizmy i to, że istnieje ktoś, kto widzi więcej
"administrator", "uprawnieni", "walidacj", "kwarantann", "wstrzyman",
"zatwierdzi", "przypisan", "zakłada", "zakładał",
# „konto" w obu przypadkach: pierwsza wersja listy miała samo „konto",
# więc przepuszczała zdanie „Nie masz konta" — literówka, która kosztowałaby
# cały sens testu, gdyby nie wyszła przy oglądaniu strony.
"konto", "konta", "kontem",
# (d) że produkt jest w budowie i ma ponumerowany backlog
"roadmap", "przyszł", "planowan", "w budowie", "widok testowy",
# topologia: gdzie pukać, żeby ominąć logowanie
"warstwa logiczna", "warstwie logicznej", "openapi", "swagger",
# architektura: „to jedna z warstw" mówi, że warstw jest więcej
"layer", "presentation", "prezentacj",
"localhost", "http://logic", "8001", "8002", "8003",
]
WZORCE = [
(r"(?i)\b(PRE|LOG|DAN)-\d+\b", "identyfikator wymagania (ujawnia backlog)"),
(r"(?i)\bfeat/[\w-]+", "nazwa gałęzi rozwojowej"),
(r"(?i)\bmodel(u|em|e|i|ach|owi)?\b", "generowanie tekstu przez model"),
(r"(?i)\bAI\b", "generowanie tekstu przez model"),
]
def _zakazane_dla(perms: set[str]) -> list[tuple[str, str]]:
"""(igła, powód) — wyprowadzone z katalogu plus lista dziedzinowa."""
out = [(s, "słownik dziedziny/mechanizmu") for s in DZIEDZINA]
for f in features.ALL:
if f.key in perms:
continue
out.append((f.label, f"nazwa funkcji, której to konto nie ma ({f.key})"))
if f.href and f.href != "/":
out.append((f.href, f"adres ekranu, którego to konto nie ma ({f.key})"))
# Nazwa zasobu też jest wyciekiem: `<script src=".../wheelzoom.js">` w źródle
# mówi, że istnieje coś do powiększania, nawet jeśli sam plik zwróci 404.
# Wykaz bierze się z mapy zasobów, więc nowy skrypt obejmuje sam z siebie.
for nazwa, need in features.STATIC.items():
if not features.can(frozenset(perms), need):
out.append((nazwa, f"nazwa zasobu, którego to konto nie może pobrać"))
return out
# Ekran logowania MUSI mówić o koncie — inaczej nie da się go opisać. To jedyne
# odstępstwo i jest wąskie: dotyczy tej jednej strony i tych dwóch słów.
WYJATKI = {"/logowanie": {"konto", "konta", "kontem"}}
def _przeskanuj(tresc: str, perms: set[str], gdzie: str) -> list[str]:
wolno = WYJATKI.get(gdzie, set())
grzechy = []
for igla, powod in _zakazane_dla(perms):
if igla in wolno:
continue
if re.search(rf"(?i)(?<![\w-]){re.escape(igla)}", tresc):
grzechy.append(f"{gdzie}: „{igla}” — {powod}")
for wzor, powod in WZORCE:
m = re.search(wzor, tresc)
if m:
grzechy.append(f"{gdzie}: „{m.group(0)}” — {powod}")
return grzechy
@pytest.fixture()
def env(tmp_path, monkeypatch):
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "accounts.json"))
monkeypatch.setenv("APP_USER", "szef")
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
monkeypatch.delenv("APP_USERS", raising=False)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
monkeypatch.setenv("COOKIES_INSECURE", "1")
def _klient(monkeypatch):
from starlette.testclient import TestClient
from app.main import app, logic
# Warstwa logiczna niedostępna CELOWO: ekran plików idzie wtedy ścieżką
# błędu, a to właśnie komunikaty o błędach wypisywały nazwy tras, nazwę
# gałęzi i wewnętrzny adres z portem.
import httpx
def pada(*a, **k):
raise httpx.ConnectError("[Errno 111] connect to http://logic:8001/files failed")
for nazwa in ("files_list", "report", "timeline", "prompt", "horoscope", "positions"):
if hasattr(logic, nazwa):
monkeypatch.setattr(logic, nazwa, pada, raising=False)
return TestClient(app, follow_redirects=False)
def _co_moze_pobrac(perms: set[str]) -> list[str]:
"""Wszystko, co to konto ma prawo dostać: strony i zasoby."""
strony = [p for (m, p), need in features.ROUTES.items()
if m == "GET" and features.can(frozenset(perms), need)]
zasoby = ["/static/base.css"] + [
f"/static/{n}" for n, need in features.STATIC.items()
if features.can(frozenset(perms), need)]
return sorted(set(strony + zasoby))
def test_the_uploader_never_reads_a_forbidden_word(env, monkeypatch):
store.create("wgrywacz", "x", WGRYWACZ)
from app import security, session
c = _klient(monkeypatch)
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('wgrywacz')}"}
perms = set(WGRYWACZ)
grzechy, obejrzane = [], 0
for adres in _co_moze_pobrac(perms):
r = c.get(adres, headers=naglowki)
if r.status_code != 200:
continue
obejrzane += 1
grzechy += _przeskanuj(r.text, perms, adres)
assert obejrzane >= 3, f"test niczego nie obejrzał ({obejrzane}) — zapora byłaby pozorna"
assert not grzechy, "\n" + "\n".join(grzechy)
def test_an_anonymous_visitor_never_reads_a_forbidden_word(env, monkeypatch):
"""Przed zalogowaniem widać ekran logowania i jeden arkusz. Nic więcej,
i nic w nich nie może nazywać funkcji."""
store.create("wgrywacz", "x", WGRYWACZ)
c = _klient(monkeypatch)
grzechy, obejrzane = [], 0
for adres in ("/logowanie", "/health", "/static/base.css"):
r = c.get(adres)
assert r.status_code == 200, f"{adres}: {r.status_code}"
obejrzane += 1
grzechy += _przeskanuj(r.text, set(), adres)
assert obejrzane == 3
assert not grzechy, "\n" + "\n".join(grzechy)
def test_every_other_asset_is_denied_to_the_uploader(env, monkeypatch):
"""Zasób spoza jego zestawu ma być NIE DO ODRÓŻNIENIA od nieistniejącego."""
store.create("wgrywacz", "x", WGRYWACZ)
from app import security, session
c = _klient(monkeypatch)
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('wgrywacz')}"}
wolno = set(_co_moze_pobrac(set(WGRYWACZ)))
nieistniejacy = c.get("/static/nie-ma-takiego-pliku.js", headers=naglowki)
for nazwa in features.STATIC:
adres = f"/static/{nazwa}"
if adres in wolno:
continue
r = c.get(adres, headers=naglowki)
assert r.status_code == nieistniejacy.status_code == 404, f"{adres}: {r.status_code}"
assert r.text == nieistniejacy.text, f"{adres}: odpowiedź różni się od „nie ma takiego pliku”"
def test_the_catalogue_drives_the_list_so_it_cannot_go_stale(env):
"""Zapora ma się uczyć z katalogu, nie z pamięci tego, kto ją pisał."""
zakazane = {i for i, _ in _zakazane_dla(set(WGRYWACZ))}
assert "Sygnifikatory" in zakazane and "/interpret" in zakazane
# a to, co konto MA, nie może trafić na listę zakazanych
assert "Pliki" not in zakazane
def test_the_owner_sees_everything(env, monkeypatch):
"""Kontrola pozytywna: dla administratora te słowa MAJĄ się pojawiać.
Bez niej zapora przechodziłaby także wtedy, gdyby program był pusty."""
from app import security, session
c = _klient(monkeypatch)
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('szef')}"}
html = c.get("/accounts", headers=naglowki).text
assert "Sygnifikatory" in html and "Generowanie tekstu przez model" in html
+22 -4
View File
@@ -15,13 +15,31 @@ import re
APP = pathlib.Path(__file__).resolve().parents[1] / "app" APP = pathlib.Path(__file__).resolve().parents[1] / "app"
JS = (APP / "static" / "wheeltip.js").read_text(encoding="utf-8") JS = (APP / "static" / "wheeltip.js").read_text(encoding="utf-8")
CSS = (APP / "static" / "styles.css").read_text(encoding="utf-8") # Arkusz jest teraz rozbity per uprawnienie (arkusz wspólny + arkusze ekranów),
# bo jeden plik ze wszystkimi selektorami był spisem funkcji programu dla
# każdego, kto go pobrał. Reguły szukamy w całości — który arkusz ją niesie,
# pilnuje osobny test pokrycia.
CSS = "\n".join(f.read_text(encoding="utf-8")
for f in sorted((APP / "static").glob("*.css")))
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8") BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
def test_script_is_loaded_globally(): def test_script_is_loaded_only_where_there_is_a_wheel():
"""W base.html, bo koło jest też na zakładce „Skompiluj".""" """Koło jest na Horoskopie i na Skompiluj — i tylko tam ten skrypt ma prawo
assert "wheeltip.js" in BASE się pojawić.
Wcześniej ładowany był z base.html, czyli na KAŻDEJ stronie. Konto, które ma
wyłącznie Pliki, pobierało go przy zwykłym wejściu na swój jedyny ekran,
a jego treść wymienia z nazwy zakładki, o których takie konto nie ma prawa
wiedzieć łącznie z , której jeszcze nie ma."""
from pathlib import Path
szablony = Path(__file__).resolve().parents[1] / "app" / "templates"
z_kolem, bez_kola = set(), set()
for f in szablony.glob("*.html"):
(z_kolem if "wheeltip.js" in f.read_text(encoding="utf-8") else bez_kola).add(f.name)
assert z_kolem == {"chart.html", "compile.html"}, f"ładowany też przez: {z_kolem}"
assert "files.html" in bez_kola and "logowanie.html" in bez_kola
def test_tooltip_sits_above_the_zoom_overlay_but_below_the_progress_window(): def test_tooltip_sits_above_the_zoom_overlay_but_below_the_progress_window():
+22 -4
View File
@@ -9,13 +9,31 @@ import re
APP = pathlib.Path(__file__).resolve().parents[1] / "app" APP = pathlib.Path(__file__).resolve().parents[1] / "app"
JS = (APP / "static" / "wheelzoom.js").read_text(encoding="utf-8") JS = (APP / "static" / "wheelzoom.js").read_text(encoding="utf-8")
CSS = (APP / "static" / "styles.css").read_text(encoding="utf-8") # Arkusz jest teraz rozbity per uprawnienie (arkusz wspólny + arkusze ekranów),
# bo jeden plik ze wszystkimi selektorami był spisem funkcji programu dla
# każdego, kto go pobrał. Reguły szukamy w całości — który arkusz ją niesie,
# pilnuje osobny test pokrycia.
CSS = "\n".join(f.read_text(encoding="utf-8")
for f in sorted((APP / "static").glob("*.css")))
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8") BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
def test_script_is_loaded_globally(): def test_script_is_loaded_only_where_there_is_a_wheel():
"""Ładowane w base.html, bo koło pojawi się też na zakładce Skompiluj.""" """Koło jest na Horoskopie i na Skompiluj — i tylko tam ten skrypt ma prawo
assert "wheelzoom.js" in BASE się pojawić.
Wcześniej ładowany był z base.html, czyli na KAŻDEJ stronie. Konto, które ma
wyłącznie Pliki, pobierało go przy zwykłym wejściu na swój jedyny ekran,
a jego treść wymienia z nazwy zakładki, o których takie konto nie ma prawa
wiedzieć łącznie z , której jeszcze nie ma."""
from pathlib import Path
szablony = Path(__file__).resolve().parents[1] / "app" / "templates"
z_kolem, bez_kola = set(), set()
for f in szablony.glob("*.html"):
(z_kolem if "wheelzoom.js" in f.read_text(encoding="utf-8") else bez_kola).add(f.name)
assert z_kolem == {"chart.html", "compile.html"}, f"ładowany też przez: {z_kolem}"
assert "files.html" in bez_kola and "logowanie.html" in bez_kola
def test_toggles_on_click(): def test_toggles_on_click():
+6 -1
View File
@@ -24,7 +24,12 @@ from app.compile import RenderError, to_pdf, toolchain_ready
log = logging.getLogger("astrololo.render") log = logging.getLogger("astrololo.render")
app = FastAPI(title="astrololo · usługa render (PDF)") # 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 · usługa render (PDF)",
docs_url=None, redoc_url=None, openapi_url=None)
security.install(app, "render") # token międzywarstwowy (LOG-32) security.install(app, "render") # token międzywarstwowy (LOG-32)
# Szyfrowanie łącza od prezentacji. PO security.install, żeby także odmowa # Szyfrowanie łącza od prezentacji. PO security.install, żeby także odmowa
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać. # tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.