10970c579f
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m19s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m26s
Testy / Testy astrodemo (pull_request) Failing after 0s
Testy / Testy astroklient (pull_request) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 7s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 5s
build / build (push) Successful in 19s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m25s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m26s
Testy / Testy astrodemo (push) Failing after 0s
Testy / Testy astroklient (push) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 7s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
Trzeci produkt drabiny: astrodemo (dwie funkcje) → astroklient → astrololo. Po trzech poprzednich krokach jest cienki, bo jest ZŁOŻENIEM, a nie kopią: własne main.py z ośmioma importami, a rdzeń — ekrany, szablony, zasoby — bierze z warstwy prezentacji przy budowaniu obrazu. Jedno źródło, dwa produkty; inaczej te same 2500 linii szablonów żyłyby w dwóch egzemplarzach i rozjechały się w ciągu tygodni, po cichu. CO MA: Horoskop, Interpretacje, Kalendarz, Synastria, Sygnifikatory, wgrywanie plików. Wyszukiwarka miejsca i strefa czasowa zgodnie z ustaleniem. CZEGO NIE MA I DLACZEGO NIE DA SIĘ WŁĄCZYĆ: plików usuniętych wg usun.txt nie ma w obrazie. Nie istnieje uprawnienie, którym dałoby się je odsłonić, bo katalog funkcji składa się ze ZGŁOSZEŃ ekranów obecnych w obrazie. To dlatego „każde konto dostaje wszystko, co ta usługa umie" jest tu bezpieczne i nie wymaga wypisywania listy: zbiór liczy się z katalogu, więc opisuje ten produkt. KONTA jak w astrodemo: z konfiguracji środowiska (ASTROKLIENT_USERS), jeden poziom dostępu, bez pliku kont i bez ekranu ich zakładania. Konta rozdziela się po to, żeby każde miało własną pulę plików. PULE PER KONTO — tu była realna dziura. Warstwa logiczna przenosiła pulę tylko przy raporcie i operacjach na plikach, więc Kalendarz i Sygnifikatory czytałyby CAŁY udział: jedno konto widziałoby pliki drugiego, mimo obietnicy izolacji. Domknięte: TimelineRequest i QueryRequest niosą teraz pulę, a QueryService buduje klienta danych na żądanie. Pula jedzie w każdym żądaniu w dół i bierze się z kontekstu ustawianego przy wejściu, nigdy z formularza. Test podstawia `tenant=ktos-inny` w POST i sprawdza, że w dół poszedł login zalogowanego. WARSTWA WSPÓLNA ROZDZIELONA OD POJĘCIA ADMINISTRATORA. base.html miał wpisany na sztywno warunek `can(request, 'admin')` i odsyłacz do ekranu kont — czyli w produkcie bez tego ekranu zostawał martwy link i nazwa czegoś, czego nie ma. Rejestr niesie teraz wymagane uprawnienie, a szablon dostaje gotową listę. Podstawa przestała też importować moduły służące jednemu ekranowi (konta, stany plików), bo produkt bez tego ekranu wlókł ich zależności. ZAPORA SŁOWNIKOWA NAD REALNYM DRZEWEM. Test buduje złożenie tak samo jak Dockerfile i szuka słów o funkcjach, których nie ma — w odpowiedziach ORAZ w plikach. Pierwsza wersja znalazła dziesięć trafień, w tym trzy moje własne docstringi WYLICZAJĄCE nieobecne funkcje: zdanie „nie ma tu generowania tekstu" mówi wprost, że coś takiego istnieje, więc jest takim samym śladem jak przycisk. Po poprawkach: zero. Test ma kontrolę negatywną — podrzucony plik ma go wywrócić. usun.txt jest DANYMI, nie tekstem w Dockerfile: czyta go też test pilnujący, żeby zgadzał się ze złożeniem w main.py. Rozjazd znaczyłby albo martwy kod w obrazie, albo błąd dopiero przy uruchomieniu. CI: astroklient buduje się z KORZENIA repozytorium (jego Dockerfile sięga po rdzeń), a zmiana w warstwie prezentacji też go przebudowuje — bez tego jego obraz zostawałby ze starymi ekranami, a różnicy nie byłoby widać do zgłoszenia użytkownika. Testy: astroklient 12, presentation 368, astrodemo 28, logic 342, data 42, render 41. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
385 lines
16 KiB
Python
385 lines
16 KiB
Python
"""Logowanie i bramka uprawnień astroklienta.
|
|
|
|
Konta pochodzą WYŁĄCZNIE z konfiguracji środowiska (`ASTROKLIENT_USERS`), a każde
|
|
dostaje ten sam zestaw: wszystko, co ta usługa umie. Konta rozdziela się po to,
|
|
żeby każde miało własną, niewidoczną dla pozostałych pulę plików — nie po to, żeby
|
|
dawać im różne poziomy dostępu. Poziom jest jeden i nie ma go z czym porównywać.
|
|
|
|
Reszta jest wspólna z pozostałymi usługami i celowo nietknięta: hasła jako scrypt,
|
|
sesja w podpisanym ciasteczku, limit żądań, dziennik audytowy, oraz odmowa jako
|
|
404 zamiast 403 — bo 403 samo w sobie mówi, że coś tam jest.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
import hashlib
|
|
import hmac
|
|
import logging
|
|
import os
|
|
import secrets
|
|
import time
|
|
from collections import deque
|
|
from dataclasses import dataclass
|
|
|
|
from fastapi import Request
|
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
|
|
log = logging.getLogger("astrololo.security")
|
|
|
|
MAX_TRACKED_CLIENTS_DEFAULT = 4096
|
|
MAX_TRACKED_CLIENTS = MAX_TRACKED_CLIENTS_DEFAULT # zabezpieczenie przed puchnięciem pamięci
|
|
|
|
|
|
# ── konta ─────────────────────────────────────────────────────────────────
|
|
# Wyłącznie z konfiguracji środowiska. Konta rozdziela się po to, żeby każde
|
|
# miało własną pulę plików — nie po to, żeby różniły się tym, co widzą.
|
|
# ── konta imienne (PRE-17) ───────────────────────────────────────────────
|
|
# Parametry scrypt: rozsądny kompromis czas/pamięć dla logowania do aplikacji
|
|
# wewnętrznej. Zmiana wymaga przeliczenia haseł (hash niesie tylko sól).
|
|
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32
|
|
_HASH_PREFIX = "scrypt$"
|
|
|
|
|
|
def hash_password(password: str, salt: bytes | None = None) -> str:
|
|
"""Hasło → wpis do ASTROKLIENT_USERS: `scrypt$<sól_hex>$<hash_hex>`."""
|
|
salt = salt or secrets.token_bytes(16)
|
|
dk = hashlib.scrypt(password.encode("utf-8"), salt=salt,
|
|
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN)
|
|
return f"{_HASH_PREFIX}{salt.hex()}${dk.hex()}"
|
|
|
|
|
|
def accounts() -> dict[str, str]:
|
|
"""Konta z `ASTROKLIENT_USERS` — `login:sekret`, po przecinku.
|
|
|
|
Sekret to hash scrypt (`scrypt$sól$hash`), ten sam format co w pozostałych
|
|
usługach — jedna implementacja, więc nie ma czego rozjechać."""
|
|
out: dict[str, str] = {}
|
|
for entry in os.getenv("ASTROKLIENT_USERS", "").split(","):
|
|
entry = entry.strip()
|
|
if not entry:
|
|
continue
|
|
user, sep, secret = entry.partition(":")
|
|
if sep and user.strip() and secret.strip():
|
|
out[user.strip()] = secret.strip()
|
|
return out
|
|
|
|
|
|
def _secret_matches(spec: str, password: str) -> bool:
|
|
"""Sekret konta vs podane hasło. Hash scrypt albo (zgodność wstecz) jawne."""
|
|
if spec.startswith(_HASH_PREFIX):
|
|
try:
|
|
_, salt_hex, hash_hex = spec.split("$", 2)
|
|
salt, expected = bytes.fromhex(salt_hex), bytes.fromhex(hash_hex)
|
|
except ValueError:
|
|
return False
|
|
actual = hashlib.scrypt(password.encode("utf-8"), salt=salt,
|
|
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN)
|
|
return hmac.compare_digest(actual, expected)
|
|
# Porównujemy BAJTY, nie tekst: `compare_digest` na stringach rzuca TypeError
|
|
# przy znakach spoza ASCII, więc hasło z polskimi literami wywracało logowanie
|
|
# błędem 500 zamiast zwykłej odmowy (błąd zastany, złapany testem).
|
|
return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8"))
|
|
|
|
|
|
def rate_limit_per_min() -> int:
|
|
return int(os.getenv("RATE_LIMIT_PER_MIN", "120"))
|
|
|
|
|
|
def cookies_secure() -> bool:
|
|
"""Czy ciasteczko sesji ma być wyłącznie dla https.
|
|
|
|
Domyślnie TAK. Wyłączamy jedynie w pracy lokalnej po http — inaczej
|
|
przeglądarka po prostu nie zapamięta ciasteczka i logowanie „nie działa"
|
|
bez żadnego komunikatu, co jest wyjątkowo mylącą awarią."""
|
|
return os.getenv("COOKIES_INSECURE", "").strip().lower() not in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def trust_proxy() -> bool:
|
|
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
# /wyloguj jest „publiczna" tylko technicznie: przechodzi obok bramki, żeby móc
|
|
# odpowiedzieć 401 CELOWO, zamiast dostać 200 od zalogowanej sesji. Bez tego
|
|
# wylogowanie byłoby niemożliwe — przeglądarka dosyła zapamiętane dane przy każdym
|
|
# żądaniu, więc handler i tak widziałby zalogowaną osobę.
|
|
# /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.
|
|
# Ekran logowania musi się wyświetlić PRZED zalogowaniem, więc jego arkusz jest
|
|
# 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]] = {}
|
|
|
|
|
|
def auth_enabled() -> bool:
|
|
return bool(accounts())
|
|
|
|
|
|
def _is_public(path: str) -> bool:
|
|
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Principal:
|
|
"""Kto się zalogował i co wolno mu zobaczyć."""
|
|
|
|
login: str
|
|
permissions: frozenset[str]
|
|
is_admin: bool
|
|
|
|
|
|
def _uprawnienia() -> frozenset[str]:
|
|
"""Każde konto dostaje wszystko, co ta usługa umie — liczone z KATALOGU.
|
|
|
|
Katalog składa się ze zgłoszeń ekranów obecnych w obrazie, więc ten zbiór
|
|
opisuje dokładnie ten produkt, a nie listę wpisaną kiedyś ręcznie. Dopisanie
|
|
ekranu nie wymaga pamiętania o niczym; usunięcie go zabiera uprawnienie samo.
|
|
|
|
Nie ma tu uprawnienia administracyjnego i nie ma go skąd wziąć: `features.ADMIN`
|
|
celowo nie należy do GRANTABLE."""
|
|
from app import features
|
|
|
|
return frozenset(features.GRANTABLE)
|
|
|
|
|
|
def _sekret(login: str) -> str | None:
|
|
return accounts().get(login)
|
|
|
|
|
|
def verify(login: str, password: str) -> Principal | None:
|
|
"""Sprawdza login i hasło. None = odmowa, bez podawania powodu.
|
|
|
|
Nieznany login i złe hasło dają ten sam wynik: rozróżnienie mówiłoby, które
|
|
konta istnieją, a to informacja warta czegoś dla zgadującego."""
|
|
spec = _sekret(login)
|
|
if spec is None or not _secret_matches(spec, password):
|
|
return None
|
|
return Principal(login, _uprawnienia(), False)
|
|
|
|
|
|
def issue_session(login: str) -> str:
|
|
"""Zawartość ciasteczka dla właśnie zalogowanego konta.
|
|
|
|
W ciasteczku ląduje ODCISK poświadczenia, dzięki czemu zmiana hasła albo
|
|
skasowanie konta unieważnia jego sesje same z siebie — bez pamiętania o tym
|
|
i bez żadnego magazynu sesji."""
|
|
from app import session
|
|
|
|
# Wersja sesji zawsze 0: nie ma trwałego magazynu, więc nie ma gdzie trzymać
|
|
# licznika pokolenia. Wylogowanie i zmiana hasła i tak działają — pierwsze
|
|
# przez skasowanie ciasteczka, drugie przez odcisk, który przestaje pasować.
|
|
return session.issue(login, session.fingerprint(_sekret(login) or ""), 0)
|
|
|
|
|
|
def principal(request: Request) -> Principal | None:
|
|
"""Ciasteczko → kto to jest. None = nie wpuszczać."""
|
|
from app import session
|
|
|
|
dane = session.read(request.cookies.get(session.COOKIE))
|
|
if dane is None:
|
|
return None
|
|
spec = _sekret(dane["login"])
|
|
if spec is None:
|
|
return None
|
|
# Zmiana hasła w konfiguracji ubija sesje tego konta — to jest tutaj jedyny
|
|
# sposób na „wyloguj mnie ze wszystkich urządzeń", bo bez trwałego magazynu
|
|
# nie ma gdzie trzymać licznika pokolenia sesji.
|
|
if session.fingerprint(spec) != dane["odcisk"]:
|
|
return None
|
|
return Principal(dane["login"], _uprawnienia(), False)
|
|
|
|
|
|
def client_ip(request: Request) -> str:
|
|
"""Adres, po którym rozliczamy limit żądań.
|
|
|
|
Za odwrotnym proxy (u nas: Ingress/Traefik po włączeniu TLS — PRE-16)
|
|
`request.client.host` to adres POD-a proxy, jednakowy dla wszystkich. Bez
|
|
poprawki cały ruch trafiałby do jednego wiadra i pierwsza osoba, która
|
|
wyklika limit, odcięłaby pozostałe.
|
|
|
|
Nagłówkom wierzymy WYŁĄCZNIE przy TRUST_PROXY — bo inaczej wystarczyłoby
|
|
dopisać własny `X-Forwarded-For`, żeby przy każdym żądaniu wyglądać na kogoś
|
|
innego i ominąć limit całkowicie. Z tego samego powodu bierzemy OSTATNI wpis
|
|
listy: to jedyny, który dopisał nasz proxy. Wcześniejsze mógł podstawić
|
|
klient, więc nie znaczą nic.
|
|
"""
|
|
peer = request.client.host if request.client else "?"
|
|
if not trust_proxy():
|
|
return peer
|
|
forwarded = request.headers.get("x-forwarded-for", "")
|
|
if forwarded:
|
|
last = forwarded.rsplit(",", 1)[-1].strip()
|
|
if last:
|
|
return last
|
|
return request.headers.get("x-real-ip", "").strip() or peer
|
|
|
|
|
|
def _rate_limited(client: str) -> bool:
|
|
cap = rate_limit_per_min()
|
|
if cap <= 0:
|
|
return False
|
|
now = time.monotonic()
|
|
window = _hits.get(client)
|
|
if window is None:
|
|
if len(_hits) >= MAX_TRACKED_CLIENTS:
|
|
_hits.clear() # prosty reset zamiast nieograniczonego wzrostu
|
|
window = _hits[client] = deque()
|
|
while window and now - window[0] > 60.0:
|
|
window.popleft()
|
|
if len(window) >= cap:
|
|
return True
|
|
window.append(now)
|
|
return False
|
|
|
|
|
|
# ── dziennik audytowy (PRE-17) ───────────────────────────────────────────
|
|
audit_log = logging.getLogger("astrololo.audit")
|
|
|
|
|
|
def _setup_audit_logging() -> None:
|
|
"""Gwarantuje, że wpisy audytowe FAKTYCZNIE wychodzą na stdout.
|
|
|
|
Domyślna konfiguracja uvicorna nie obsługuje naszych loggerów, więc wpisy na
|
|
poziomie INFO ginęły — dziennik istniał w kodzie, ale był pusty (sprawdzone
|
|
na żywym serwerze). Niewidoczny dziennik jest gorszy niż jego brak: daje
|
|
złudzenie kontroli. Własny handler na stdout, bo w k8s to stamtąd zbierane
|
|
są logi. `propagate=False` — żeby wpis nie dublował się przez korzeń.
|
|
"""
|
|
audit_log.setLevel(os.getenv("AUDIT_LEVEL", "INFO").upper())
|
|
if not audit_log.handlers:
|
|
handler = logging.StreamHandler()
|
|
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
|
audit_log.addHandler(handler)
|
|
audit_log.propagate = False
|
|
|
|
|
|
def audit_records(request: Request, count: int) -> None:
|
|
"""Handler zgłasza, ILE rekordów baz oddał — trafi do wpisu audytowego.
|
|
|
|
Sama liczba, nigdy treść. Bez tego dziennik pokazywałby tylko „ktoś wchodził",
|
|
a nie „ktoś wyniósł 5000 rekordów" — a to drugie jest sygnałem nadużycia."""
|
|
try:
|
|
request.state.audit_records = int(count)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
|
|
def audit_login(request: Request, login: str, udane: bool) -> None:
|
|
"""Zdarzenie logowania w dzienniku (PRE-17).
|
|
|
|
Nieudane próby są tu ważniejsze od udanych: pojedyncza nic nie znaczy, ale
|
|
seria pod jednym adresem to jedyny widoczny ślad zgadywania haseł. Logujemy
|
|
LOGIN, nigdy hasła ani jego fragmentu — dziennik to kolejny nośnik wycieku."""
|
|
audit_log.info("AUDYT zdarzenie=%s user=%s ip=%s",
|
|
"logowanie-ok" if udane else "logowanie-odmowa",
|
|
login or "-", client_ip(request))
|
|
|
|
|
|
def audit_logout(request: Request) -> None:
|
|
audit_log.info("AUDYT zdarzenie=wylogowanie user=%s ip=%s",
|
|
getattr(request.state, "user", "") or "-", client_ip(request))
|
|
|
|
|
|
def _audit(request: Request, user: str, status: int, ms: float) -> None:
|
|
records = getattr(request.state, "audit_records", None)
|
|
audit_log.info(
|
|
"AUDYT user=%s ip=%s method=%s path=%s status=%s records=%s ms=%.0f",
|
|
user or "-", client_ip(request), request.method, request.url.path,
|
|
status, "-" if records is None else records, ms,
|
|
)
|
|
|
|
|
|
def install(app) -> None:
|
|
"""Podpina ochronę pod wszystkie ścieżki poza publicznymi."""
|
|
from app import session
|
|
|
|
_setup_audit_logging()
|
|
|
|
# FAIL-CLOSED. Usługa z kontami, ale bez klucza podpisu, nie umiałaby odróżnić
|
|
# ważnej sesji od podrobionej. Lepiej, żeby nie wstała, niż żeby wpuszczała —
|
|
# i lepiej teraz, przy starcie, niż przy pierwszym logowaniu człowieka.
|
|
if auth_enabled() and not session.available():
|
|
raise session.SessionError(
|
|
"Konta są skonfigurowane, ale brakuje SESSION_SECRET — bez klucza "
|
|
"podpisu sesje nie działają, a logowanie nie miałoby czym się bronić. "
|
|
"Wygeneruj: openssl rand -hex 32")
|
|
|
|
if not auth_enabled():
|
|
log.warning(
|
|
"UWAGA: brak kont (ASTROKLIENT_USERS) — usługa jest OTWARTA dla "
|
|
"każdego, kto ma do niej dostęp sieciowy, wraz z treścią plików."
|
|
)
|
|
|
|
@app.middleware("http")
|
|
async def _guard(request: Request, call_next):
|
|
if _is_public(request.url.path):
|
|
return await call_next(request)
|
|
|
|
started = time.monotonic()
|
|
if _rate_limited(client_ip(request)):
|
|
_audit(request, "-", 429, (time.monotonic() - started) * 1000)
|
|
return JSONResponse(
|
|
{"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
|
|
status_code=429, headers={"Retry-After": "60"},
|
|
)
|
|
|
|
from app import features
|
|
|
|
user, who, odswiez = "", None, None
|
|
if auth_enabled():
|
|
who = principal(request)
|
|
if who is None:
|
|
_audit(request, "-", 303, (time.monotonic() - started) * 1000)
|
|
# Przekierowanie na ekran logowania, nie 401: 401 z Basic otwierało
|
|
# okienko systemowe, którego nie da się ani opisać, ani z niego wyjść.
|
|
# Zapamiętujemy, dokąd ktoś zmierzał — po zalogowaniu wraca na miejsce.
|
|
from urllib.parse import quote
|
|
|
|
cel = request.url.path
|
|
dokad = f"?dokad={quote(cel, safe='/')}" if request.method == "GET" and cel != "/" else ""
|
|
return RedirectResponse(f"/logowanie{dokad}", status_code=303)
|
|
user = who.login
|
|
# Ruch przedłuża sesję, ale ciasteczko odsyłamy tylko wtedy, gdy
|
|
# znacznik zdążył się zestarzeć — inaczej Set-Cookie leciałby przy
|
|
# każdym obrazku i arkuszu stylów.
|
|
from app import session as _s
|
|
|
|
dane = _s.read(request.cookies.get(_s.COOKIE))
|
|
odswiez = _s.refresh(dane) if dane else None
|
|
else:
|
|
# Bez kont ochrona jest wyłączona (dev), więc widać wszystko — inaczej
|
|
# deweloper zobaczyłby pustą aplikację i szukałby błędu tam, gdzie go nie ma.
|
|
who = Principal("", _uprawnienia(), False)
|
|
|
|
request.state.user = user
|
|
request.state.principal = who
|
|
# Pula plików na czas obsługi tego żądania. Ustawiana TU, w jednym
|
|
# miejscu, zaraz po ustaleniu kto to jest — klient czyta ją z kontekstu,
|
|
# więc żadne wywołanie w dół nie może o niej zapomnieć.
|
|
from app.clients.logic_client import KONTO
|
|
|
|
KONTO.set(user)
|
|
|
|
# 404, NIE 403. Odmowa z powodem jest informacją, że coś tam jest —
|
|
# a konto ograniczone ma widzieć program kompletny, tylko mniejszy.
|
|
need = features.required(request.method, request.url.path)
|
|
if not features.can(who.permissions, need):
|
|
_audit(request, user, 404, (time.monotonic() - started) * 1000)
|
|
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
|
|
response = await call_next(request)
|
|
if odswiez:
|
|
from app import session as _s
|
|
|
|
response.set_cookie(_s.COOKIE, odswiez, max_age=_s.max_age(),
|
|
**_s.cookie_params(secure=cookies_secure()))
|
|
_audit(request, user, response.status_code, (time.monotonic() - started) * 1000)
|
|
return response
|