lazyflat: combined alert + apply behind authenticated web UI

Three isolated services (alert scraper, apply HTTP worker, web UI+DB)
with argon2 auth, signed cookies, CSRF, rate-limited login, kill switch,
apply circuit breaker, audit log, and strict CSP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Moritz 2026-04-21 09:51:35 +02:00
commit 69f2f1f635
46 changed files with 4183 additions and 0 deletions

341
web/app.py Normal file
View file

@ -0,0 +1,341 @@
import asyncio
import hmac
import logging
import threading
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import Depends, FastAPI, Form, HTTPException, Header, Request, Response, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
import db
from apply_client import ApplyClient
from auth import (
clear_session_cookie,
current_user,
issue_csrf_token,
issue_session_cookie,
rate_limit_login,
require_csrf,
require_user,
verify_password,
)
from settings import (
APPLY_FAILURE_THRESHOLD,
AUTH_USERNAME,
FILTER_MAX_MORNING_COMMUTE,
FILTER_MAX_RENT,
FILTER_ROOMS,
INTERNAL_API_KEY,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger("web")
apply_client = ApplyClient()
_apply_lock = threading.Lock()
@asynccontextmanager
async def lifespan(_app: FastAPI):
db.init_db()
logger.info("web service started")
yield
app = FastAPI(lifespan=lifespan, title="lazyflat", docs_url=None, redoc_url=None, openapi_url=None)
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
# -----------------------------------------------------------------------------
# Security headers middleware
# -----------------------------------------------------------------------------
@app.middleware("http")
async def security_headers(request: Request, call_next):
response: Response = await call_next(request)
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault("Permissions-Policy", "geolocation=(), camera=(), microphone=()")
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; "
"script-src 'self' https://cdn.tailwindcss.com https://unpkg.com; "
"style-src 'self' https://cdn.tailwindcss.com 'unsafe-inline'; "
"img-src 'self' data:; "
"connect-src 'self'; "
"frame-ancestors 'none';"
)
return response
# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
def client_ip(request: Request) -> str:
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def require_internal(x_internal_api_key: str | None = Header(default=None)) -> None:
if not x_internal_api_key or not hmac.compare_digest(x_internal_api_key, INTERNAL_API_KEY):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid internal key")
def matches_criteria(payload: dict) -> bool:
rooms = payload.get("rooms") or 0.0
rent = payload.get("total_rent") or 0.0
commute = (payload.get("connectivity") or {}).get("morning_time") or 0.0
if FILTER_ROOMS and rooms not in FILTER_ROOMS:
return False
if rent > FILTER_MAX_RENT:
return False
if commute > FILTER_MAX_MORNING_COMMUTE:
return False
return True
def apply_allowed() -> tuple[bool, str]:
if db.get_state("kill_switch") == "1":
return False, "kill switch aktiv"
if db.get_state("apply_circuit_open") == "1":
return False, "circuit breaker offen (zu viele Fehler)"
return True, ""
def run_apply(flat_id: str, url: str, triggered_by: str) -> None:
"""Run synchronously inside a worker thread via asyncio.to_thread."""
app_id = db.start_application(flat_id, url, triggered_by)
try:
result = apply_client.apply(url)
except Exception as e:
result = {"success": False, "message": f"client error: {e}"}
success = bool(result.get("success"))
db.finish_application(app_id, success, result.get("message", ""))
# Circuit breaker accounting
if success:
db.set_state("apply_recent_failures", "0")
else:
fails = int(db.get_state("apply_recent_failures") or "0") + 1
db.set_state("apply_recent_failures", str(fails))
if fails >= APPLY_FAILURE_THRESHOLD:
db.set_state("apply_circuit_open", "1")
db.log_audit("system", "circuit_open", f"{fails} consecutive failures")
# -----------------------------------------------------------------------------
# Public routes
# -----------------------------------------------------------------------------
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/login", response_class=HTMLResponse)
def login_form(request: Request, error: str | None = None):
if current_user(request):
return RedirectResponse("/", status_code=303)
return templates.TemplateResponse("login.html", {"request": request, "error": error})
@app.post("/login")
def login_submit(request: Request, username: str = Form(...), password: str = Form(...)):
ip = client_ip(request)
if not rate_limit_login(ip):
db.log_audit(username or "?", "login_rate_limited", ip=ip)
return templates.TemplateResponse(
"login.html",
{"request": request, "error": "Zu viele Versuche. Bitte später erneut versuchen."},
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
)
if not verify_password(username, password):
db.log_audit(username or "?", "login_failed", ip=ip)
return templates.TemplateResponse(
"login.html",
{"request": request, "error": "Login fehlgeschlagen."},
status_code=status.HTTP_401_UNAUTHORIZED,
)
response = RedirectResponse("/", status_code=303)
issue_session_cookie(response, username)
db.log_audit(username, "login_success", ip=ip)
return response
@app.post("/logout")
def logout(request: Request):
user = current_user(request) or "?"
response = RedirectResponse("/login", status_code=303)
clear_session_cookie(response)
db.log_audit(user, "logout", ip=client_ip(request))
return response
# -----------------------------------------------------------------------------
# Authenticated dashboard
# -----------------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
def dashboard(request: Request):
user = current_user(request)
if not user:
return RedirectResponse("/login", status_code=303)
allowed, reason = apply_allowed()
ctx = {
"request": request,
"user": user,
"csrf": issue_csrf_token(user),
"mode": db.get_state("mode") or "manual",
"kill_switch": db.get_state("kill_switch") == "1",
"circuit_open": db.get_state("apply_circuit_open") == "1",
"apply_failures": int(db.get_state("apply_recent_failures") or "0"),
"last_alert_heartbeat": db.get_state("last_alert_heartbeat") or "",
"apply_reachable": apply_client.health(),
"apply_allowed": allowed,
"apply_block_reason": reason,
"flats": db.recent_flats(50),
"applications": db.recent_applications(20),
"audit": db.recent_audit(15),
}
return templates.TemplateResponse("dashboard.html", ctx)
@app.get("/partials/dashboard", response_class=HTMLResponse)
def dashboard_partial(request: Request, user: str = Depends(require_user)):
"""HTMX partial refresh — avoids leaking data to unauthenticated clients."""
allowed, reason = apply_allowed()
ctx = {
"request": request,
"user": user,
"csrf": issue_csrf_token(user),
"mode": db.get_state("mode") or "manual",
"kill_switch": db.get_state("kill_switch") == "1",
"circuit_open": db.get_state("apply_circuit_open") == "1",
"apply_failures": int(db.get_state("apply_recent_failures") or "0"),
"last_alert_heartbeat": db.get_state("last_alert_heartbeat") or "",
"apply_reachable": apply_client.health(),
"apply_allowed": allowed,
"apply_block_reason": reason,
"flats": db.recent_flats(50),
"applications": db.recent_applications(20),
"audit": db.recent_audit(15),
}
return templates.TemplateResponse("_dashboard_body.html", ctx)
# -----------------------------------------------------------------------------
# State-changing actions (require auth + CSRF)
# -----------------------------------------------------------------------------
@app.post("/actions/mode")
async def action_mode(
request: Request,
mode: str = Form(...),
csrf: str = Form(...),
user: str = Depends(require_user),
):
require_csrf(request, csrf)
if mode not in ("manual", "auto"):
raise HTTPException(400, "invalid mode")
db.set_state("mode", mode)
db.log_audit(user, "set_mode", mode, client_ip(request))
return RedirectResponse("/", status_code=303)
@app.post("/actions/kill-switch")
async def action_kill_switch(
request: Request,
value: str = Form(...),
csrf: str = Form(...),
user: str = Depends(require_user),
):
require_csrf(request, csrf)
new = "1" if value == "on" else "0"
db.set_state("kill_switch", new)
db.log_audit(user, "set_kill_switch", new, client_ip(request))
return RedirectResponse("/", status_code=303)
@app.post("/actions/reset-circuit")
async def action_reset_circuit(
request: Request,
csrf: str = Form(...),
user: str = Depends(require_user),
):
require_csrf(request, csrf)
db.set_state("apply_circuit_open", "0")
db.set_state("apply_recent_failures", "0")
db.log_audit(user, "reset_circuit", "", client_ip(request))
return RedirectResponse("/", status_code=303)
@app.post("/actions/apply")
async def action_apply(
request: Request,
flat_id: str = Form(...),
csrf: str = Form(...),
user: str = Depends(require_user),
):
require_csrf(request, csrf)
allowed, reason = apply_allowed()
if not allowed:
raise HTTPException(409, f"apply disabled: {reason}")
flat = db.get_flat(flat_id)
if not flat:
raise HTTPException(404, "flat not found")
db.log_audit(user, "trigger_apply", f"flat_id={flat_id}", client_ip(request))
# Run apply in background so the UI returns fast
asyncio.create_task(asyncio.to_thread(run_apply, flat_id, flat["link"], "user"))
return RedirectResponse("/", status_code=303)
# -----------------------------------------------------------------------------
# Internal endpoints (called by alert/apply services)
# -----------------------------------------------------------------------------
@app.post("/internal/flats")
async def internal_submit_flat(
payload: dict,
_guard: None = Depends(require_internal),
):
if not payload.get("id") or not payload.get("link"):
raise HTTPException(400, "id and link required")
matched = matches_criteria(payload)
is_new = db.upsert_flat(payload, matched)
if not is_new:
return {"status": "duplicate"}
if matched:
db.log_audit("alert", "flat_matched", f"id={payload['id']} rent={payload.get('total_rent')}")
if db.get_state("mode") == "auto":
allowed, reason = apply_allowed()
if allowed:
db.log_audit("system", "auto_apply", f"flat_id={payload['id']}")
asyncio.create_task(asyncio.to_thread(run_apply, str(payload["id"]), payload["link"], "auto"))
else:
db.log_audit("system", "auto_apply_blocked", reason)
return {"status": "ok", "matched": matched}
@app.post("/internal/heartbeat")
async def internal_heartbeat(
payload: dict,
_guard: None = Depends(require_internal),
):
service = payload.get("service", "unknown")
db.set_state(f"last_{service}_heartbeat", db.now_iso())
return {"status": "ok"}