""" lazyflat web app. Five tabs: - / → Wohnungen (all flats, per-user match highlighting, filter block, auto-apply switch) - /bewerbungen → Bewerbungen (user's application history, forensics drill-in) - /logs → Logs (user-scoped audit log) - /fehler → Fehler (user-scoped error records + admin-global) - /einstellungen/
→ Einstellungen: profile, filter, notifications, account, admin users All state-changing POSTs require CSRF. Internal endpoints require INTERNAL_API_KEY. """ import asyncio import hmac import json import logging import sqlite3 import threading from fastapi import Depends, FastAPI, Form, Header, HTTPException, Request, Response, status from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates import db import notifications import retention from apply_client import ApplyClient, _row_to_profile from auth import ( bootstrap_admin, clear_session_cookie, current_user, hash_password, issue_csrf_token, issue_session_cookie, rate_limit_login, require_admin, require_csrf, require_user, verify_login, ) from matching import flat_matches_filter, row_to_dict from settings import APPLY_FAILURE_THRESHOLD, INTERNAL_API_KEY, PUBLIC_URL logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)-5s %(name)s: %(message)s", datefmt="%H:%M:%S", ) logging.getLogger("urllib3").setLevel(logging.WARNING) logger = logging.getLogger("web") apply_client = ApplyClient() # --------------------------------------------------------------------------- # App # --------------------------------------------------------------------------- from contextlib import asynccontextmanager @asynccontextmanager async def lifespan(_app: FastAPI): db.init_db() bootstrap_admin() retention.start() logger.info("web service ready") 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") @app.middleware("http") async def security_headers(request: Request, call_next): resp: Response = await call_next(request) resp.headers.setdefault("X-Frame-Options", "DENY") resp.headers.setdefault("X-Content-Type-Options", "nosniff") resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") resp.headers.setdefault("Permissions-Policy", "geolocation=(), camera=(), microphone=()") resp.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 resp # --------------------------------------------------------------------------- # 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=401, detail="invalid internal key") def base_context(request: Request, user, active_tab: str) -> dict: return { "request": request, "user": user, "csrf": issue_csrf_token(user["id"]), "active_tab": active_tab, "is_admin": bool(user["is_admin"]), } def _get_apply_gate(user_id: int) -> tuple[bool, str]: prefs = db.get_preferences(user_id) if prefs["kill_switch"]: return False, "kill switch aktiv" if prefs["apply_circuit_open"]: return False, "circuit breaker offen (zu viele fehlgeschlagene Bewerbungen)" if not apply_client.health(): return False, "apply-service nicht erreichbar" return True, "" def _run_apply_background(user_id: int, flat_id: str, url: str, triggered_by: str) -> None: prefs = db.get_preferences(user_id) profile_row = db.get_profile(user_id) profile = _row_to_profile(profile_row) submit_forms = bool(prefs["submit_forms"]) app_id = db.start_application( user_id=user_id, flat_id=flat_id, url=url, triggered_by=triggered_by, submit_forms=submit_forms, profile_snapshot=profile, ) logger.info("apply.start user=%s flat=%s application=%s submit=%s", user_id, flat_id, app_id, submit_forms) result = apply_client.apply(url=url, profile=profile, submit_forms=submit_forms, application_id=app_id) success = bool(result.get("success")) message = result.get("message", "") provider = result.get("provider", "") forensics = result.get("forensics") or {} db.finish_application(app_id, success=success, message=message, provider=provider, forensics=forensics) # Circuit breaker (per user) if success: db.update_preferences(user_id, {"apply_recent_failures": 0}) else: failures = int(prefs["apply_recent_failures"] or 0) + 1 updates = {"apply_recent_failures": failures} if failures >= APPLY_FAILURE_THRESHOLD: updates["apply_circuit_open"] = 1 db.log_error(source="apply", kind="circuit_open", user_id=user_id, summary=f"{failures} aufeinanderfolgende Fehler", application_id=app_id) db.update_preferences(user_id, updates) # record forensic error row db.log_error(source="apply", kind="apply_failure", user_id=user_id, summary=message or "Bewerbung fehlgeschlagen", application_id=app_id, context={"provider": provider, "url": url}) # Notify user flat = db.get_flat(flat_id) flat_dict = {"address": flat["address"] if flat else "", "link": url, "rooms": flat["rooms"] if flat else None, "total_rent": flat["total_rent"] if flat else None} if success: notifications.on_apply_ok(user_id, flat_dict, message) else: notifications.on_apply_fail(user_id, flat_dict, message) db.log_audit("system", "apply_finished", f"app={app_id} success={success}", user_id=user_id) def _kick_apply(user_id: int, flat_id: str, url: str, triggered_by: str) -> None: asyncio.create_task(asyncio.to_thread( _run_apply_background, user_id, flat_id, url, triggered_by, )) # --------------------------------------------------------------------------- # 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) db.log_error(source="web", kind="rate_limit", summary=f"login throttled for {ip}", context={"username": username or ""}) return templates.TemplateResponse( "login.html", {"request": request, "error": "Zu viele Versuche. Bitte später erneut."}, status_code=status.HTTP_429_TOO_MANY_REQUESTS, ) user = verify_login(username, password) if not user: 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, user["id"]) db.log_audit(user["username"], "login_success", user_id=user["id"], ip=ip) return response @app.post("/logout") def logout(request: Request): u = current_user(request) response = RedirectResponse("/login", status_code=303) clear_session_cookie(response) if u: db.log_audit(u["username"], "logout", user_id=u["id"], ip=client_ip(request)) return response # --------------------------------------------------------------------------- # Tab: Wohnungen # --------------------------------------------------------------------------- @app.get("/", response_class=HTMLResponse) def tab_wohnungen(request: Request): u = current_user(request) if not u: return RedirectResponse("/login", status_code=303) ctx = base_context(request, u, "wohnungen") ctx.update(_wohnungen_context(u)) return templates.TemplateResponse("wohnungen.html", ctx) def _wohnungen_context(user) -> dict: uid = user["id"] filters_row = db.get_filters(uid) prefs = db.get_preferences(uid) filters = row_to_dict(filters_row) flats = db.recent_flats(100) flats_view = [] for f in flats: try: payload = json.loads(f["payload_json"]) except Exception: payload = {} last = db.last_application_for_flat(uid, f["id"]) flats_view.append({ "row": f, "payload": payload, "matched": flat_matches_filter({ "rooms": f["rooms"], "total_rent": f["total_rent"], "size": f["size"], "wbs": f["wbs"], "connectivity": {"morning_time": f["connectivity_morning_time"]}, }, filters), "last": last, }) allowed, reason = _get_apply_gate(uid) return { "flats": flats_view, "filters": filters, "auto_apply_enabled": bool(prefs["auto_apply_enabled"]), "submit_forms": bool(prefs["submit_forms"]), "kill_switch": bool(prefs["kill_switch"]), "circuit_open": bool(prefs["apply_circuit_open"]), "apply_failures": int(prefs["apply_recent_failures"] or 0), "apply_allowed": allowed, "apply_block_reason": reason, "apply_reachable": apply_client.health(), "last_alert_heartbeat": db.get_state("last_alert_heartbeat") or "", } @app.post("/actions/filters") async def action_save_filters( request: Request, csrf: str = Form(...), rooms_min: str = Form(""), rooms_max: str = Form(""), max_rent: str = Form(""), min_size: str = Form(""), max_morning_commute: str = Form(""), wbs_required: str = Form(""), user=Depends(require_user), ): require_csrf(user["id"], csrf) def _f(v): v = (v or "").strip().replace(",", ".") return float(v) if v else None db.update_filters(user["id"], { "rooms_min": _f(rooms_min), "rooms_max": _f(rooms_max), "max_rent": _f(max_rent), "min_size": _f(min_size), "max_morning_commute": _f(max_morning_commute), "wbs_required": (wbs_required or "").strip(), }) db.log_audit(user["username"], "filters.updated", user_id=user["id"], ip=client_ip(request)) return RedirectResponse("/", status_code=303) @app.post("/actions/auto-apply") async def action_auto_apply( request: Request, value: str = Form(...), csrf: str = Form(...), user=Depends(require_user), ): require_csrf(user["id"], csrf) new = 1 if value == "on" else 0 db.update_preferences(user["id"], {"auto_apply_enabled": new}) db.log_audit(user["username"], "auto_apply", "on" if new else "off", user_id=user["id"], ip=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=Depends(require_user), ): require_csrf(user["id"], csrf) new = 1 if value == "on" else 0 db.update_preferences(user["id"], {"kill_switch": new}) db.log_audit(user["username"], "kill_switch", "on" if new else "off", user_id=user["id"], ip=client_ip(request)) return RedirectResponse("/", status_code=303) @app.post("/actions/reset-circuit") async def action_reset_circuit( request: Request, csrf: str = Form(...), user=Depends(require_user), ): require_csrf(user["id"], csrf) db.update_preferences(user["id"], {"apply_circuit_open": 0, "apply_recent_failures": 0}) db.log_audit(user["username"], "reset_circuit", user_id=user["id"], ip=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=Depends(require_user), ): require_csrf(user["id"], csrf) allowed, reason = _get_apply_gate(user["id"]) 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["username"], "trigger_apply", f"flat_id={flat_id}", user_id=user["id"], ip=client_ip(request)) _kick_apply(user["id"], flat_id, flat["link"], "user") return RedirectResponse("/", status_code=303) # --------------------------------------------------------------------------- # Tab: Bewerbungen # --------------------------------------------------------------------------- @app.get("/bewerbungen", response_class=HTMLResponse) def tab_bewerbungen(request: Request): u = current_user(request) if not u: return RedirectResponse("/login", status_code=303) ctx = base_context(request, u, "bewerbungen") ctx["applications"] = db.recent_applications(u["id"], limit=100) return templates.TemplateResponse("bewerbungen.html", ctx) @app.get("/bewerbungen/{app_id}", response_class=HTMLResponse) def bewerbung_detail(request: Request, app_id: int): u = current_user(request) if not u: return RedirectResponse("/login", status_code=303) a = db.get_application(app_id) if not a or (a["user_id"] != u["id"] and not u["is_admin"]): raise HTTPException(404, "not found") forensics = json.loads(a["forensics_json"]) if a["forensics_json"] else None profile = json.loads(a["profile_snapshot_json"]) if a["profile_snapshot_json"] else {} ctx = base_context(request, u, "bewerbungen") ctx.update({"application": a, "forensics": forensics, "profile_snapshot": profile}) return templates.TemplateResponse("bewerbung_detail.html", ctx) # --------------------------------------------------------------------------- # Tab: Logs # --------------------------------------------------------------------------- @app.get("/logs", response_class=HTMLResponse) def tab_logs(request: Request): u = current_user(request) if not u: return RedirectResponse("/login", status_code=303) ctx = base_context(request, u, "logs") ctx["events"] = db.recent_audit(u["id"], limit=200) return templates.TemplateResponse("logs.html", ctx) # --------------------------------------------------------------------------- # Tab: Fehler # --------------------------------------------------------------------------- @app.get("/fehler", response_class=HTMLResponse) def tab_fehler(request: Request): u = current_user(request) if not u: return RedirectResponse("/login", status_code=303) ctx = base_context(request, u, "fehler") ctx["errors"] = db.recent_errors(u["id"], limit=200, include_global=bool(u["is_admin"])) return templates.TemplateResponse("fehler.html", ctx) @app.get("/fehler/{err_id}", response_class=HTMLResponse) def fehler_detail(request: Request, err_id: int): u = current_user(request) if not u: return RedirectResponse("/login", status_code=303) e = db.get_error(err_id) if not e or (e["user_id"] is not None and e["user_id"] != u["id"] and not u["is_admin"]): raise HTTPException(404, "not found") app_row = db.get_application(e["application_id"]) if e["application_id"] else None forensics = None if app_row and app_row["forensics_json"]: try: forensics = json.loads(app_row["forensics_json"]) except Exception: forensics = None context = json.loads(e["context_json"]) if e["context_json"] else None ctx = base_context(request, u, "fehler") ctx.update({"error": e, "application": app_row, "forensics": forensics, "context": context}) return templates.TemplateResponse("fehler_detail.html", ctx) # --------------------------------------------------------------------------- # Tab: Einstellungen (sub-tabs) # --------------------------------------------------------------------------- VALID_SECTIONS = ("profil", "filter", "benachrichtigungen", "account", "benutzer") @app.get("/einstellungen", response_class=HTMLResponse) def tab_settings_root(request: Request): return RedirectResponse("/einstellungen/profil", status_code=303) @app.get("/einstellungen/{section}", response_class=HTMLResponse) def tab_settings(request: Request, section: str): u = current_user(request) if not u: return RedirectResponse("/login", status_code=303) if section not in VALID_SECTIONS: raise HTTPException(404) if section == "benutzer" and not u["is_admin"]: raise HTTPException(403) ctx = base_context(request, u, "einstellungen") ctx["section"] = section if section == "profil": ctx["profile"] = db.get_profile(u["id"]) elif section == "filter": ctx["filters"] = row_to_dict(db.get_filters(u["id"])) elif section == "benachrichtigungen": ctx["notifications"] = db.get_notifications(u["id"]) elif section == "account": pass elif section == "benutzer": ctx["users"] = db.list_users() return templates.TemplateResponse("einstellungen.html", ctx) @app.post("/actions/profile") async def action_profile(request: Request, user=Depends(require_user)): form = await request.form() require_csrf(user["id"], form.get("csrf", "")) def _b(name): return form.get(name, "").lower() in ("true", "on", "yes", "1") def _i(name): try: return int(form.get(name) or 0) except ValueError: return 0 db.update_profile(user["id"], { "salutation": form.get("salutation", ""), "firstname": form.get("firstname", ""), "lastname": form.get("lastname", ""), "email": form.get("email", ""), "telephone": form.get("telephone", ""), "street": form.get("street", ""), "house_number": form.get("house_number", ""), "postcode": form.get("postcode", ""), "city": form.get("city", ""), "is_possessing_wbs": 1 if _b("is_possessing_wbs") else 0, "wbs_type": form.get("wbs_type", "0"), "wbs_valid_till": form.get("wbs_valid_till", "1970-01-01"), "wbs_rooms": _i("wbs_rooms"), "wbs_adults": _i("wbs_adults"), "wbs_children": _i("wbs_children"), "is_prio_wbs": 1 if _b("is_prio_wbs") else 0, "immomio_email": form.get("immomio_email", ""), "immomio_password": form.get("immomio_password", ""), }) db.log_audit(user["username"], "profile.updated", user_id=user["id"], ip=client_ip(request)) return RedirectResponse("/einstellungen/profil", status_code=303) @app.post("/actions/notifications") async def action_notifications(request: Request, user=Depends(require_user)): form = await request.form() require_csrf(user["id"], form.get("csrf", "")) def _b(n): return 1 if form.get(n, "").lower() in ("on", "true", "1", "yes") else 0 db.update_notifications(user["id"], { "channel": form.get("channel", "ui"), "telegram_bot_token": form.get("telegram_bot_token", ""), "telegram_chat_id": form.get("telegram_chat_id", ""), "email_address": form.get("email_address", ""), "notify_on_match": _b("notify_on_match"), "notify_on_apply_success": _b("notify_on_apply_success"), "notify_on_apply_fail": _b("notify_on_apply_fail"), }) db.log_audit(user["username"], "notifications.updated", user_id=user["id"], ip=client_ip(request)) return RedirectResponse("/einstellungen/benachrichtigungen", status_code=303) @app.post("/actions/account/password") async def action_password( request: Request, old_password: str = Form(""), new_password: str = Form(""), new_password_repeat: str = Form(""), csrf: str = Form(...), user=Depends(require_user), ): require_csrf(user["id"], csrf) if not new_password or new_password != new_password_repeat: return RedirectResponse("/einstellungen/account?err=mismatch", status_code=303) if len(new_password) < 10: return RedirectResponse("/einstellungen/account?err=tooshort", status_code=303) row = db.get_user_by_username(user["username"]) from auth import verify_hash if not row or not verify_hash(row["password_hash"], old_password): return RedirectResponse("/einstellungen/account?err=wrongold", status_code=303) db.set_user_password(user["id"], hash_password(new_password)) db.log_audit(user["username"], "password.changed", user_id=user["id"], ip=client_ip(request)) return RedirectResponse("/einstellungen/account?ok=1", status_code=303) @app.post("/actions/submit-forms") async def action_submit_forms( request: Request, value: str = Form(...), csrf: str = Form(...), user=Depends(require_user), ): require_csrf(user["id"], csrf) new = 1 if value == "on" else 0 db.update_preferences(user["id"], {"submit_forms": new}) db.log_audit(user["username"], "submit_forms", "on" if new else "off", user_id=user["id"], ip=client_ip(request)) return RedirectResponse("/einstellungen/profil", status_code=303) # --- Admin: Benutzer --------------------------------------------------------- @app.post("/actions/users/create") async def action_users_create( request: Request, username: str = Form(...), password: str = Form(...), is_admin: str = Form(""), csrf: str = Form(...), admin=Depends(require_admin), ): require_csrf(admin["id"], csrf) username = (username or "").strip() if not username or len(password) < 10: raise HTTPException(400, "username required, password >= 10 chars") try: uid = db.create_user(username, hash_password(password), is_admin=(is_admin.lower() in ("on", "true", "yes", "1"))) except sqlite3.IntegrityError: return RedirectResponse("/einstellungen/benutzer?err=exists", status_code=303) db.log_audit(admin["username"], "user.created", f"new_user={username} id={uid}", user_id=admin["id"], ip=client_ip(request)) return RedirectResponse("/einstellungen/benutzer?ok=1", status_code=303) @app.post("/actions/users/disable") async def action_users_disable( request: Request, target_id: int = Form(...), value: str = Form(...), csrf: str = Form(...), admin=Depends(require_admin), ): require_csrf(admin["id"], csrf) if target_id == admin["id"]: raise HTTPException(400, "refusing to disable self") db.set_user_disabled(target_id, value == "on") db.log_audit(admin["username"], "user.toggle_disable", f"target={target_id} disabled={value=='on'}", user_id=admin["id"], ip=client_ip(request)) return RedirectResponse("/einstellungen/benutzer", status_code=303) # --------------------------------------------------------------------------- # Internal endpoints # --------------------------------------------------------------------------- @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") is_new = db.upsert_flat(payload) if not is_new: return {"status": "duplicate"} # per-user matching + auto-apply + notifications for u in db.list_users(): if u["disabled"]: continue filters = row_to_dict(db.get_filters(u["id"])) if not flat_matches_filter(payload, filters): continue db.log_audit("alert", "flat_matched", f"user={u['username']} flat={payload['id']}", user_id=u["id"]) notifications.on_match(u["id"], payload) prefs = db.get_preferences(u["id"]) if prefs["auto_apply_enabled"] and not prefs["kill_switch"] and not prefs["apply_circuit_open"]: _kick_apply(u["id"], str(payload["id"]), payload["link"], "auto") db.log_audit("system", "auto_apply_kick", f"user={u['username']} flat={payload['id']}", user_id=u["id"]) return {"status": "ok"} @app.post("/internal/heartbeat") async def internal_heartbeat(payload: dict, _g: None = Depends(require_internal)): service = payload.get("service", "unknown") db.set_state(f"last_{service}_heartbeat", db.now_iso()) return {"status": "ok"} @app.post("/internal/error") async def internal_report_error( payload: dict, _g: None = Depends(require_internal), ): """Alert/other services can push errors here.""" db.log_error( source=payload.get("source", "unknown"), kind=payload.get("kind", "error"), summary=payload.get("summary", ""), context=payload.get("context"), ) return {"status": "ok"}