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

16
web/Dockerfile Normal file
View file

@ -0,0 +1,16 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).status==200 else 1)"
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips", "*"]

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

34
web/apply_client.py Normal file
View file

@ -0,0 +1,34 @@
import logging
import requests
from settings import APPLY_URL, APPLY_TIMEOUT, INTERNAL_API_KEY
logger = logging.getLogger("web")
class ApplyClient:
def __init__(self):
self.base = APPLY_URL.rstrip("/")
self.timeout = APPLY_TIMEOUT
self.headers = {"X-Internal-Api-Key": INTERNAL_API_KEY}
def health(self) -> bool:
try:
r = requests.get(f"{self.base}/health", timeout=5)
return r.ok
except requests.RequestException:
return False
def apply(self, url: str) -> dict:
try:
r = requests.post(
f"{self.base}/apply",
json={"url": url},
headers=self.headers,
timeout=self.timeout,
)
if r.status_code >= 400:
return {"success": False, "message": f"apply HTTP {r.status_code}: {r.text[:300]}"}
return r.json()
except requests.RequestException as e:
return {"success": False, "message": f"apply unreachable: {e}"}

134
web/auth.py Normal file
View file

@ -0,0 +1,134 @@
import hmac
import secrets
import threading
import time
from typing import Optional
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHash
from fastapi import HTTPException, Request, Response, status
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from settings import (
AUTH_PASSWORD_HASH,
AUTH_USERNAME,
COOKIE_SECURE,
LOGIN_RATE_LIMIT,
LOGIN_RATE_WINDOW_SECONDS,
SESSION_COOKIE_NAME,
SESSION_MAX_AGE_SECONDS,
SESSION_SECRET,
)
_hasher = PasswordHasher()
_serializer = URLSafeTimedSerializer(SESSION_SECRET, salt="session")
_csrf_serializer = URLSafeTimedSerializer(SESSION_SECRET, salt="csrf")
# ---------- Password & session ----------
def verify_password(username: str, password: str) -> bool:
if not hmac.compare_digest(username or "", AUTH_USERNAME):
# run hasher anyway to keep timing similar (and not leak whether user exists)
try:
_hasher.verify(AUTH_PASSWORD_HASH, password)
except Exception:
pass
return False
try:
_hasher.verify(AUTH_PASSWORD_HASH, password)
return True
except (VerifyMismatchError, InvalidHash):
return False
def issue_session_cookie(response: Response, username: str) -> None:
token = _serializer.dumps({"u": username, "iat": int(time.time())})
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
max_age=SESSION_MAX_AGE_SECONDS,
httponly=True,
secure=COOKIE_SECURE,
samesite="strict",
path="/",
)
def clear_session_cookie(response: Response) -> None:
response.delete_cookie(
SESSION_COOKIE_NAME,
path="/",
secure=COOKIE_SECURE,
httponly=True,
samesite="strict",
)
def current_user(request: Request) -> Optional[str]:
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
return None
try:
data = _serializer.loads(token, max_age=SESSION_MAX_AGE_SECONDS)
except (BadSignature, SignatureExpired):
return None
return data.get("u")
def require_user(request: Request) -> str:
user = current_user(request)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="login required")
return user
# ---------- CSRF (synchronizer token bound to session) ----------
def issue_csrf_token(username: str) -> str:
return _csrf_serializer.dumps({"u": username})
def verify_csrf(request: Request, submitted: str) -> bool:
user = current_user(request)
if not user or not submitted:
return False
try:
data = _csrf_serializer.loads(submitted, max_age=SESSION_MAX_AGE_SECONDS)
except (BadSignature, SignatureExpired):
return False
return hmac.compare_digest(str(data.get("u", "")), user)
def require_csrf(request: Request, token: str) -> None:
if not verify_csrf(request, token):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad csrf")
# ---------- Login rate limiting (in-memory, per IP) ----------
_rate_lock = threading.Lock()
_rate_log: dict[str, list[float]] = {}
def rate_limit_login(ip: str) -> bool:
"""Returns True if the request is allowed."""
now = time.time()
cutoff = now - LOGIN_RATE_WINDOW_SECONDS
with _rate_lock:
attempts = [t for t in _rate_log.get(ip, []) if t > cutoff]
if len(attempts) >= LOGIN_RATE_LIMIT:
_rate_log[ip] = attempts
return False
attempts.append(now)
_rate_log[ip] = attempts
# opportunistic cleanup
if len(_rate_log) > 1024:
for k in list(_rate_log.keys()):
if not _rate_log[k] or _rate_log[k][-1] < cutoff:
_rate_log.pop(k, None)
return True
def constant_time_compare(a: str, b: str) -> bool:
return hmac.compare_digest(a or "", b or "")

219
web/db.py Normal file
View file

@ -0,0 +1,219 @@
import json
import sqlite3
import threading
from datetime import datetime, timezone
from typing import Any, Iterable
from settings import DB_PATH
_lock = threading.Lock()
def _connect() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH, isolation_level=None, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
_conn: sqlite3.Connection = _connect()
SCHEMA = """
CREATE TABLE IF NOT EXISTS flats (
id TEXT PRIMARY KEY,
link TEXT NOT NULL,
address TEXT,
rooms REAL,
size REAL,
total_rent REAL,
sqm_price REAL,
year_built TEXT,
wbs TEXT,
connectivity_morning_time REAL,
connectivity_night_time REAL,
address_link_gmaps TEXT,
payload_json TEXT NOT NULL,
matched_criteria INTEGER NOT NULL DEFAULT 0,
discovered_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_flats_discovered ON flats(discovered_at DESC);
CREATE INDEX IF NOT EXISTS idx_flats_matched ON flats(matched_criteria);
CREATE TABLE IF NOT EXISTS applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
flat_id TEXT NOT NULL,
url TEXT NOT NULL,
triggered_by TEXT NOT NULL, -- 'user' | 'auto'
started_at TEXT NOT NULL,
finished_at TEXT,
success INTEGER,
message TEXT,
FOREIGN KEY (flat_id) REFERENCES flats(id)
);
CREATE INDEX IF NOT EXISTS idx_applications_flat ON applications(flat_id);
CREATE INDEX IF NOT EXISTS idx_applications_started ON applications(started_at DESC);
CREATE TABLE IF NOT EXISTS state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
actor TEXT NOT NULL,
action TEXT NOT NULL,
details TEXT,
ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp DESC);
"""
DEFAULTS = {
"mode": "manual", # 'manual' | 'auto'
"kill_switch": "0", # '1' = apply disabled
"apply_circuit_open": "0", # '1' = opened by circuit breaker
"apply_recent_failures": "0",
"last_alert_heartbeat": "",
"last_apply_heartbeat": "",
}
def init_db() -> None:
with _lock:
_conn.executescript(SCHEMA)
for k, v in DEFAULTS.items():
_conn.execute("INSERT OR IGNORE INTO state(key, value) VALUES (?, ?)", (k, v))
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def get_state(key: str) -> str | None:
row = _conn.execute("SELECT value FROM state WHERE key = ?", (key,)).fetchone()
return row["value"] if row else None
def set_state(key: str, value: str) -> None:
with _lock:
_conn.execute(
"INSERT INTO state(key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, value),
)
def upsert_flat(payload: dict, matched: bool) -> bool:
"""Returns True if this flat is new."""
flat_id = str(payload["id"])
conn_info = payload.get("connectivity") or {}
with _lock:
existing = _conn.execute("SELECT id FROM flats WHERE id = ?", (flat_id,)).fetchone()
if existing:
return False
_conn.execute(
"""
INSERT INTO flats(
id, link, address, rooms, size, total_rent, sqm_price, year_built, wbs,
connectivity_morning_time, connectivity_night_time, address_link_gmaps,
payload_json, matched_criteria, discovered_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
flat_id,
payload.get("link", ""),
payload.get("address", ""),
payload.get("rooms"),
payload.get("size"),
payload.get("total_rent"),
payload.get("sqm_price"),
str(payload.get("year_built", "")),
str(payload.get("wbs", "")),
conn_info.get("morning_time"),
conn_info.get("night_time"),
payload.get("address_link_gmaps"),
json.dumps(payload, default=str),
1 if matched else 0,
now_iso(),
),
)
return True
def recent_flats(limit: int = 50) -> list[sqlite3.Row]:
return list(
_conn.execute(
"""
SELECT f.*,
(SELECT success FROM applications a WHERE a.flat_id = f.id
ORDER BY a.started_at DESC LIMIT 1) AS last_application_success,
(SELECT message FROM applications a WHERE a.flat_id = f.id
ORDER BY a.started_at DESC LIMIT 1) AS last_application_message,
(SELECT started_at FROM applications a WHERE a.flat_id = f.id
ORDER BY a.started_at DESC LIMIT 1) AS last_application_at
FROM flats f
ORDER BY f.discovered_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
)
def get_flat(flat_id: str) -> sqlite3.Row | None:
return _conn.execute("SELECT * FROM flats WHERE id = ?", (flat_id,)).fetchone()
def start_application(flat_id: str, url: str, triggered_by: str) -> int:
with _lock:
cur = _conn.execute(
"INSERT INTO applications(flat_id, url, triggered_by, started_at) VALUES (?, ?, ?, ?)",
(flat_id, url, triggered_by, now_iso()),
)
return cur.lastrowid
def finish_application(app_id: int, success: bool, message: str) -> None:
with _lock:
_conn.execute(
"UPDATE applications SET finished_at = ?, success = ?, message = ? WHERE id = ?",
(now_iso(), 1 if success else 0, message, app_id),
)
def recent_applications(limit: int = 20) -> list[sqlite3.Row]:
return list(
_conn.execute(
"""
SELECT a.*, f.address, f.link
FROM applications a
JOIN flats f ON f.id = a.flat_id
ORDER BY a.started_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
)
def log_audit(actor: str, action: str, details: str = "", ip: str = "") -> None:
with _lock:
_conn.execute(
"INSERT INTO audit_log(timestamp, actor, action, details, ip) VALUES (?, ?, ?, ?, ?)",
(now_iso(), actor, action, details, ip),
)
def recent_audit(limit: int = 30) -> list[sqlite3.Row]:
return list(
_conn.execute(
"SELECT * FROM audit_log ORDER BY timestamp DESC LIMIT ?",
(limit,),
).fetchall()
)

8
web/requirements.txt Normal file
View file

@ -0,0 +1,8 @@
fastapi==0.115.5
uvicorn[standard]==0.32.1
jinja2==3.1.4
argon2-cffi==23.1.0
itsdangerous==2.2.0
python-multipart==0.0.17
python-dotenv==1.0.1
requests==2.32.5

54
web/settings.py Normal file
View file

@ -0,0 +1,54 @@
import secrets
import sys
from os import getenv
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
def _required(key: str) -> str:
val = getenv(key)
if not val:
print(f"missing required env var: {key}", file=sys.stderr)
sys.exit(1)
return val
# --- Auth ---
AUTH_USERNAME: str = _required("AUTH_USERNAME")
# argon2 hash of the password. Generate via:
# python -c "from argon2 import PasswordHasher; print(PasswordHasher().hash('<password>'))"
AUTH_PASSWORD_HASH: str = _required("AUTH_PASSWORD_HASH")
# Signs session cookies. If missing -> ephemeral random secret (invalidates sessions on restart).
SESSION_SECRET: str = getenv("SESSION_SECRET") or secrets.token_urlsafe(48)
SESSION_COOKIE_NAME: str = "lazyflat_session"
SESSION_MAX_AGE_SECONDS: int = int(getenv("SESSION_MAX_AGE_SECONDS", str(60 * 60 * 24 * 7)))
# When behind an HTTPS proxy (Coolify/Traefik) this MUST be true so cookies are Secure.
COOKIE_SECURE: bool = getenv("COOKIE_SECURE", "true").lower() in ("true", "1", "yes", "on")
# --- Internal service auth ---
INTERNAL_API_KEY: str = _required("INTERNAL_API_KEY")
# --- Apply service ---
APPLY_URL: str = getenv("APPLY_URL", "http://apply:8000")
APPLY_TIMEOUT: int = int(getenv("APPLY_TIMEOUT", "600"))
# Circuit breaker: disable auto-apply after N consecutive apply failures.
APPLY_FAILURE_THRESHOLD: int = int(getenv("APPLY_FAILURE_THRESHOLD", "3"))
# --- Storage ---
DATA_DIR: Path = Path(getenv("DATA_DIR", "/data"))
DATA_DIR.mkdir(parents=True, exist_ok=True)
DB_PATH: Path = DATA_DIR / "lazyflat.sqlite"
# --- Rate limiting ---
LOGIN_RATE_LIMIT: int = int(getenv("LOGIN_RATE_LIMIT", "5"))
LOGIN_RATE_WINDOW_SECONDS: int = int(getenv("LOGIN_RATE_WINDOW_SECONDS", "900"))
# --- Filter criteria (mirrored from original flat-alert) ---
FILTER_ROOMS: list[float] = [float(r) for r in getenv("FILTER_ROOMS", "2.0,2.5").split(",") if r.strip()]
FILTER_MAX_RENT: float = float(getenv("FILTER_MAX_RENT", "1500"))
FILTER_MAX_MORNING_COMMUTE: float = float(getenv("FILTER_MAX_MORNING_COMMUTE", "50"))

0
web/static/.gitkeep Normal file
View file

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