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

View file

@ -0,0 +1,176 @@
<section class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="card p-4">
<div class="text-xs uppercase tracking-wide text-slate-400">alert</div>
<div class="mt-2 text-lg">
{% if last_alert_heartbeat %}
<span class="chip chip-ok">live</span>
{% else %}
<span class="chip chip-warn">kein Heartbeat</span>
{% endif %}
</div>
<div class="text-xs text-slate-400 mt-1">letzter Heartbeat: {{ last_alert_heartbeat or "—" }}</div>
</div>
<div class="card p-4">
<div class="text-xs uppercase tracking-wide text-slate-400">apply</div>
<div class="mt-2 text-lg">
{% if apply_reachable %}
<span class="chip chip-ok">reachable</span>
{% else %}
<span class="chip chip-bad">down</span>
{% endif %}
</div>
<div class="text-xs text-slate-400 mt-1">
{% if circuit_open %}
<span class="chip chip-bad">circuit open</span>
{% elif apply_failures > 0 %}
{{ apply_failures }} recent failure(s)
{% else %}
healthy
{% endif %}
</div>
</div>
<div class="card p-4">
<div class="text-xs uppercase tracking-wide text-slate-400">Modus</div>
<div class="mt-2 text-lg">
{% if mode == "auto" %}
<span class="chip chip-warn">full-auto</span>
{% else %}
<span class="chip chip-info">manuell</span>
{% endif %}
</div>
<form method="post" action="/actions/mode" class="mt-2 flex gap-2">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="mode" value="{% if mode == 'auto' %}manual{% else %}auto{% endif %}">
<button class="btn btn-ghost text-sm" type="submit">
→ zu {% if mode == 'auto' %}manuell{% else %}full-auto{% endif %}
</button>
</form>
</div>
<div class="card p-4">
<div class="text-xs uppercase tracking-wide text-slate-400">KillSwitch</div>
<div class="mt-2 text-lg">
{% if kill_switch %}
<span class="chip chip-bad">apply gestoppt</span>
{% else %}
<span class="chip chip-ok">aktiv</span>
{% endif %}
</div>
<form method="post" action="/actions/kill-switch" class="mt-2 flex gap-2">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="{% if kill_switch %}off{% else %}on{% endif %}">
<button class="btn {% if kill_switch %}btn-ghost{% else %}btn-danger{% endif %} text-sm" type="submit">
{% if kill_switch %}Freigeben{% else %}Alles stoppen{% endif %}
</button>
</form>
{% if circuit_open %}
<form method="post" action="/actions/reset-circuit" class="mt-2">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button class="btn btn-ghost text-sm" type="submit">Circuit zurücksetzen</button>
</form>
{% endif %}
</div>
</section>
{% if not apply_allowed %}
<div class="card p-4 border-red-900/50">
<span class="chip chip-bad">apply blockiert</span>
<span class="ml-2 text-sm text-slate-300">{{ apply_block_reason }}</span>
</div>
{% endif %}
<section class="card">
<div class="flex items-center justify-between px-4 py-3 border-b border-[#1e2335]">
<h2 class="font-semibold">Wohnungen</h2>
<span class="text-xs text-slate-400">{{ flats|length }} zuletzt gesehen</span>
</div>
<div class="divide-y divide-[#1e2335]">
{% for flat in flats %}
<div class="px-4 py-3 flex flex-col md:flex-row md:items-center gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<a class="font-medium truncate hover:underline" href="{{ flat.link }}" target="_blank" rel="noopener noreferrer">
{{ flat.address or flat.link }}
</a>
{% if flat.matched_criteria %}
<span class="chip chip-ok">match</span>
{% else %}
<span class="chip chip-info">info</span>
{% endif %}
{% if flat.last_application_success == 1 %}
<span class="chip chip-ok">beworben</span>
{% elif flat.last_application_success == 0 %}
<span class="chip chip-bad">apply fehlgeschlagen</span>
{% endif %}
</div>
<div class="text-xs text-slate-400 mt-0.5">
{% if flat.rooms %}{{ "%.1f"|format(flat.rooms) }} Z{% endif %}
{% if flat.size %} · {{ "%.0f"|format(flat.size) }} m²{% endif %}
{% if flat.total_rent %} · {{ "%.0f"|format(flat.total_rent) }} €{% endif %}
{% if flat.sqm_price %} ({{ "%.2f"|format(flat.sqm_price) }} €/m²){% endif %}
{% if flat.connectivity_morning_time %} · {{ "%.0f"|format(flat.connectivity_morning_time) }} min morgens{% endif %}
· entdeckt {{ flat.discovered_at }}
</div>
{% if flat.last_application_message %}
<div class="text-xs text-slate-500 mt-1 truncate">↳ {{ flat.last_application_message }}</div>
{% endif %}
</div>
<div class="flex gap-2">
{% if apply_allowed and not flat.last_application_success %}
<form method="post" action="/actions/apply">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="flat_id" value="{{ flat.id }}">
<button class="btn btn-primary text-sm" type="submit"
onclick="return confirm('Bewerbung für {{ (flat.address or flat.link)|e }} ausführen?');">
Bewerben
</button>
</form>
{% endif %}
</div>
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">Noch keine Wohnungen gesehen.</div>
{% endfor %}
</div>
</section>
<section class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card">
<div class="px-4 py-3 border-b border-[#1e2335]"><h2 class="font-semibold">Letzte Bewerbungen</h2></div>
<div class="divide-y divide-[#1e2335]">
{% for a in applications %}
<div class="px-4 py-3 text-sm">
<div class="flex items-center gap-2">
{% if a.success == 1 %}<span class="chip chip-ok">ok</span>
{% elif a.success == 0 %}<span class="chip chip-bad">fail</span>
{% else %}<span class="chip chip-warn">läuft</span>{% endif %}
<span class="chip chip-info">{{ a.triggered_by }}</span>
<span class="text-slate-400 text-xs">{{ a.started_at }}</span>
</div>
<div class="mt-1 truncate">{{ a.address or a.url }}</div>
{% if a.message %}<div class="text-xs text-slate-500 mt-0.5">{{ a.message }}</div>{% endif %}
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">Keine Bewerbungen bisher.</div>
{% endfor %}
</div>
</div>
<div class="card">
<div class="px-4 py-3 border-b border-[#1e2335]"><h2 class="font-semibold">Audit-Log</h2></div>
<div class="divide-y divide-[#1e2335]">
{% for e in audit %}
<div class="px-4 py-2 text-xs font-mono">
<span class="text-slate-500">{{ e.timestamp }}</span>
<span class="text-slate-400">{{ e.actor }}</span>
<span class="text-slate-200">{{ e.action }}</span>
{% if e.details %}<span class="text-slate-500">— {{ e.details }}</span>{% endif %}
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">leer</div>
{% endfor %}
</div>
</div>
</section>

31
web/templates/base.html Normal file
View file

@ -0,0 +1,31 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{% block title %}lazyflat{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
<style>
html { color-scheme: dark; }
body { background: #0b0d12; color: #e6e8ee; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Inter, sans-serif; }
.card { background: #121521; border: 1px solid #1e2335; border-radius: 12px; }
.btn { border-radius: 8px; padding: 0.4rem 0.9rem; font-weight: 500; transition: background 0.15s; }
.btn-primary { background: #5b8def; color: white; }
.btn-primary:hover { background: #4a7ce0; }
.btn-danger { background: #e14a56; color: white; }
.btn-danger:hover { background: #c63c48; }
.btn-ghost { background: #1e2335; color: #e6e8ee; }
.btn-ghost:hover { background: #2a3048; }
.chip { padding: 0.15rem 0.6rem; border-radius: 999px; font-size: 0.75rem; font-weight: 500; }
.chip-ok { background: #1a3a2a; color: #7bd88f; border: 1px solid #2d5a3f; }
.chip-warn { background: #3a2a1a; color: #f5b26b; border: 1px solid #5a432d; }
.chip-bad { background: #3a1a1f; color: #e14a56; border: 1px solid #5a2d33; }
.chip-info { background: #1a253a; color: #7ca7ea; border: 1px solid #2d3f5a; }
</style>
</head>
<body class="min-h-screen">
{% block body %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}lazyflat dashboard{% endblock %}
{% block body %}
<header class="border-b border-[#1e2335]">
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-[#5b8def] to-[#7bd88f]"></div>
<h1 class="text-xl font-semibold">lazyflat</h1>
</div>
<div class="flex items-center gap-3 text-sm">
<span class="text-slate-400">{{ user }}</span>
<form method="post" action="/logout">
<button class="btn btn-ghost text-sm" type="submit">Logout</button>
</form>
</div>
</div>
</header>
<main class="max-w-6xl mx-auto px-6 py-6 space-y-6"
hx-get="/partials/dashboard" hx-trigger="every 15s" hx-target="#dashboard-body" hx-swap="innerHTML">
<div id="dashboard-body">
{% include "_dashboard_body.html" %}
</div>
</main>
{% endblock %}

28
web/templates/login.html Normal file
View file

@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block title %}Login — lazyflat{% endblock %}
{% block body %}
<main class="flex min-h-screen items-center justify-center p-4">
<div class="card w-full max-w-sm p-8">
<h1 class="text-2xl font-semibold mb-1">lazyflat</h1>
<p class="text-sm text-slate-400 mb-6">Anmeldung erforderlich</p>
{% if error %}
<div class="chip chip-bad inline-block mb-4">{{ error }}</div>
{% endif %}
<form method="post" action="/login" class="space-y-4">
<div>
<label class="block text-xs uppercase tracking-wide text-slate-400 mb-1">Benutzer</label>
<input type="text" name="username" autocomplete="username" required
class="w-full bg-[#0b0d12] border border-[#1e2335] rounded-lg px-3 py-2 focus:outline-none focus:border-[#5b8def]">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-400 mb-1">Passwort</label>
<input type="password" name="password" autocomplete="current-password" required
class="w-full bg-[#0b0d12] border border-[#1e2335] rounded-lg px-3 py-2 focus:outline-none focus:border-[#5b8def]">
</div>
<button type="submit" class="btn btn-primary w-full">Anmelden</button>
</form>
</div>
</main>
{% endblock %}