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"}