secrets tab, drop commute filter, favicon, robust error reports

1. Admin → Geheimnisse sub-tab lets you edit ANTHROPIC_API_KEY +
   BERLIN_WOHNEN_USERNAME/PASSWORD at runtime. Migration v7 adds a
   secrets(key,value,updated_at) table; startup seeds missing keys from
   env (idempotent). web reads secrets DB-first (env fallback) via
   llm._api_key(); alert fetches them from web /internal/secrets on each
   scan, passes them into Scraper(). Rotating creds no longer needs a
   redeploy.
   Masked display: 6 leading + 4 trailing chars, "…" in the middle.
   Blank form fields leave the stored value untouched.

2. Drop the max_morning_commute filter from UI + server + FILTER_KEYS +
   filter summary (the underlying Maps.calculate_score code stays for
   potential future re-enable).

3. /static/didi.webp wired as favicon via <link rel="icon"> in base.html.

4. apply.open_page wraps page.goto in try/except so a failed load still
   produces a "goto.failed" step + screenshot instead of returning an
   empty forensics blob. networkidle + post-submission sleep are also
   made best-effort. The error ZIP export already writes screenshot+HTML
   per step and final_html — with this change every apply run leaves a
   reconstructable trail even when the listing is already offline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
EiSiMo 2026-04-21 17:56:57 +02:00
parent 9fbe1ce728
commit 3bb04210c4
12 changed files with 211 additions and 27 deletions

View file

@ -78,6 +78,7 @@ apply_client = ApplyClient()
@asynccontextmanager
async def lifespan(_app: FastAPI):
db.init_db()
db.seed_secrets_from_env()
bootstrap_admin()
retention.start()
logger.info("web service ready")
@ -220,7 +221,7 @@ def _last_scrape_utc() -> str:
FILTER_KEYS = ("rooms_min", "rooms_max", "max_rent", "min_size",
"max_morning_commute", "wbs_required", "max_age_hours")
"wbs_required", "max_age_hours")
def _has_filters(f) -> bool:
@ -263,8 +264,6 @@ def _filter_summary(f) -> str:
parts.append(f"{int(f['max_rent'])}")
if f["min_size"]:
parts.append(f"{int(f['min_size'])}")
if f["max_morning_commute"]:
parts.append(f"{int(f['max_morning_commute'])} min")
if f["wbs_required"] == "yes":
parts.append("WBS")
elif f["wbs_required"] == "no":
@ -552,7 +551,6 @@ async def action_save_filters(
rooms_max: str = Form(""),
max_rent: str = Form(""),
min_size: str = Form(""),
max_morning_commute: str = Form(""),
wbs_required: str = Form(""),
max_age_hours: str = Form(""),
user=Depends(require_user),
@ -575,7 +573,6 @@ async def action_save_filters(
"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(),
"max_age_hours": _i(max_age_hours),
})
@ -825,7 +822,15 @@ def tab_logs_legacy():
return RedirectResponse("/admin/protokoll", status_code=301)
ADMIN_SECTIONS = ("protokoll", "benutzer")
ADMIN_SECTIONS = ("protokoll", "benutzer", "geheimnisse")
def _mask_secret(value: str) -> str:
if not value:
return ""
if len(value) <= 10:
return "" * len(value)
return value[:6] + "" + value[-4:]
@app.get("/admin", response_class=HTMLResponse)
@ -857,6 +862,10 @@ def tab_admin(request: Request, section: str):
})
elif section == "benutzer":
ctx["users"] = db.list_users()
elif section == "geheimnisse":
secrets = db.all_secrets()
ctx["secrets_masked"] = {k: _mask_secret(secrets.get(k, "")) for k in db.SECRET_KEYS}
ctx["secret_flash"] = request.query_params.get("ok")
return templates.TemplateResponse("admin.html", ctx)
@ -1073,6 +1082,23 @@ async def action_users_disable(
return RedirectResponse("/admin/benutzer", status_code=303)
@app.post("/actions/secrets")
async def action_secrets(request: Request, admin=Depends(require_admin)):
form = await request.form()
require_csrf(admin["id"], form.get("csrf", ""))
changed = []
for key in db.SECRET_KEYS:
raw = (form.get(key) or "").strip()
if not raw:
continue
db.set_secret(key, raw)
changed.append(key)
db.log_audit(admin["username"], "secrets.updated",
",".join(changed) or "no-op",
user_id=admin["id"], ip=client_ip(request))
return RedirectResponse("/admin/geheimnisse?ok=1", status_code=303)
@app.post("/actions/enrich-all")
async def action_enrich_all(
request: Request,
@ -1181,3 +1207,10 @@ async def internal_report_error(
context=payload.get("context"),
)
return {"status": "ok"}
@app.get("/internal/secrets")
async def internal_secrets(_g: None = Depends(require_internal)):
"""Give sibling services (alert) the current runtime creds that the admin
may have edited via the UI, so no redeploy is needed when rotating."""
return db.all_secrets()