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>
341 lines
12 KiB
Python
341 lines
12 KiB
Python
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"}
|