cauldron/cauldron/consolidate_foods.py
Sulkta eb6da73400 audit-fixes: dedupe megacluster, consolidate cancel-poll, login-CSRF, misc
Continuing through the 2nd-pass audit findings.

dedupe_recipes.py CODE-2 (HIGH): _cluster_by_name dropped union-find
single-link agglomerative for the same pair-accumulator pattern
consolidate_foods._cluster already uses. Single-link chained weak
similarities through the recipe corpus the same way it did with foods,
producing one giant cluster on a 250+ corpus that Sonnet would refuse.
Now emits one 2-recipe pair per (i,j) above NAME_THRESHOLD.

consolidate_foods.py + dedupe_recipes.py CODE-1 (HIGH): added
in-loop cancel-poll to both _cluster passes. Polls cancel_check every
5K pair-comparisons so a user-initiated cancel can abort cleanly mid-
scan instead of waiting tens of seconds. Run-walk callers also
re-check _cancelled() right after clustering returns and bail.

server.py CVE-NEW-5 (MED): /login skips OIDC re-init when
session.get('user') exists. Closes the login-CSRF surface where a
malicious cross-origin link `<a href=…/login?next=/poison>` would
re-trigger the OIDC handshake on a logged-in user and silently change
their post-login landing.

server.py CVE-NEW-7 (LOW): Permissions-Policy now sends both
`interest-cohort=()` (FLoC, Chrome ≤94) and `browsing-topics=()`
(Topics API, Chrome ≥115) for opt-out across the lineage.

server.py CVE-NEW-8 (LOW): /auth/callback OAuthError branch no longer
echoes Authentik's raw error string to auth_retry.html. Detail is
logged server-side; users see a generic retry message. Closes the
information-disclosure on Authentik error codes.

server.py CODE-9/CODE-10 (LOW): wrapped int() of ?page= and
?per_page= in try/except so garbage args land on safe defaults
instead of surfacing ValueError as a 500.

Deferred to the proxy-vhost commit:
- CVE-NEW-6 (cauldron bind / ProxyFix trusted-peer filter) needs
  paired Apache vhost config (RequestHeader unset X-Forwarded-*)
  before the docker-side change is safe; landing it solo would
  either break the LAN deploy or leave a half-broken trust chain.
2026-05-02 17:36:25 -07:00

318 lines
12 KiB
Python

"""Foods consolidator — clusters Mealie's food rows by similarity, asks
Sonnet which clusters are dupes, and uses Mealie's PUT /api/foods/merge
to consolidate. Operates household-scoped via the user's Mealie token.
The walk:
1. Fetch all foods via GET /api/foods (group-wide; the user's
household admin scope is enough)
2. Filter to the user's own household (where edit permission exists)
3. Cluster by rapidfuzz.token_set_ratio ≥ THRESHOLD (default 88, slightly
stricter than Mealie's parser threshold of 85 to reduce false positives)
4. Drop singleton clusters (no merge candidate)
5. For each cluster, call forge.cluster_decision(...) — Sonnet
decides merge/keep + picks the canonical survivor
6. Persist the proposal
The apply:
1. For each approved cluster: PUT /api/foods/merge from each discard_id
into canonical_id
2. After merges, optionally PUT /api/foods/{canonical_id} with the
accumulated aliases array so the survivor learns the variant names
Same daemon-thread + cancel-respecting + stuck-job-recovery pattern as
bulk_sterilize.py.
"""
from __future__ import annotations
import json
import logging
import threading
from typing import Callable, Optional
from rapidfuzz import fuzz, process
from .db import DB
from .forge import Forge, ForgeError
from .mealie import Mealie, MealieError
log = logging.getLogger(__name__)
CLUSTER_THRESHOLD = 88
def _household_id_for(mealie: Mealie) -> str | None:
me = mealie.who_am_i()
hid = me.get("householdId") or me.get("household_id")
if not hid:
h = me.get("household")
if isinstance(h, dict):
hid = h.get("id")
return hid
def _all_foods(mealie: Mealie) -> list[dict]:
out: list[dict] = []
page = 1
while page <= 20:
resp = mealie._get("/api/foods", search="", perPage=2000, page=page)
items = resp.get("items") or resp.get("data") or []
for item in items:
out.append(item)
tp = resp.get("total_pages") or resp.get("totalPages") or 1
if not items or page >= tp:
break
page += 1
return out
def _foods_in_household(mealie: Mealie, household_id: str) -> list[dict]:
foods = _all_foods(mealie)
if not household_id:
return foods
out = []
for f in foods:
hh = f.get("householdId") or f.get("household_id")
if not hh:
h = f.get("household")
if isinstance(h, dict):
hh = h.get("id")
if hh and hh == household_id:
out.append(f)
elif not hh:
# Some Mealie versions don't tag foods with householdId at the
# food level — they're group-scoped instead. In that case, we
# consolidate across the whole group (Alice is admin so writes
# land regardless).
out.append(f)
return out
def _cluster(
foods: list[dict],
threshold: int = CLUSTER_THRESHOLD,
cancel_check: Callable[[], bool] | None = None,
) -> list[list[dict]]:
"""Pair-based: emit one 2-food candidate per (i, j) where token_set_ratio
>= threshold. Replaces the original single-link agglomerative which
produced a 50+ food megacluster on Alice's catalog by chaining weak
similarities (`2% milk` → `acai berry` → `acai berry juice` → ...).
Each emitted pair is a clean Sonnet-decision unit — easier prompt,
higher accuracy, uniform UI cards. The trade-off (3-way dupes get
split into 3 pairs that go through review separately) is fine —
Mealie's merge endpoint is per-pair anyway, and the apply path
defensively skips a pair whose canonical_id was already merged
away by an earlier pair.
For ~3000 foods this is ~4M comparisons in pure Python — runs in
a few seconds. For larger catalogs (10K+) the inner loop polls
cancel_check every 5K comparisons so a user-initiated cancel can
abort cleanly mid-scan rather than waiting tens of seconds. 2nd-pass
audit fix CODE-1 (2026-05-02 PM)."""
n = len(foods)
names = [(f.get("name") or "").strip().lower() for f in foods]
pairs: list[list[dict]] = []
poll_every = 5000
cmp_count = 0
for i in range(n):
if not names[i]:
continue
for j in range(i + 1, n):
if not names[j]:
continue
cmp_count += 1
if cancel_check is not None and cmp_count % poll_every == 0:
if cancel_check():
return pairs
if fuzz.token_set_ratio(names[i], names[j]) >= threshold:
pairs.append([foods[i], foods[j]])
return pairs
def _cluster_key(cluster: list[dict]) -> str:
"""Stable key for the cluster — sorted name + ids."""
names = sorted((f.get("name") or "").strip().lower() for f in cluster)
return "|".join(names)[:255]
def run_walk(*, db: DB, job_id: int, mealie: Mealie, forge: Forge) -> None:
log.info("[consolidate:%s] walk start", job_id)
def _cancelled() -> bool:
s = db.get_consolidate_job_state(job_id)
return s in ("cancelled", "failed", "done")
try:
hh = _household_id_for(mealie)
foods = _foods_in_household(mealie, hh)
log.info("[consolidate:%s] household=%s foods=%d", job_id, hh, len(foods))
clusters = _cluster(foods, cancel_check=_cancelled)
log.info("[consolidate:%s] clusters≥2: %d", job_id, len(clusters))
if _cancelled():
log.info("[consolidate:%s] walk aborted during clustering", job_id)
return
with db.conn() as c, c.cursor() as cur:
cur.execute(
"UPDATE cauldron_consolidate_jobs SET total_clusters=%s WHERE id=%s",
(len(clusters), job_id),
)
for cluster in clusters:
if _cancelled():
log.info("[consolidate:%s] walk aborted (state changed)", job_id)
return
key = _cluster_key(cluster)
db.update_consolidate_job_progress(job_id, current_cluster=key[:80])
try:
decision = forge.cluster_decision(cluster)
except (ForgeError, RuntimeError) as e:
msg = str(e)[:500]
log.warning("[consolidate:%s] cluster_decision(%s): %s", job_id, key[:60], msg)
db.insert_consolidate_proposal(
job_id=job_id, cluster_key=key,
cluster=cluster, decision=None, error=msg,
)
db.update_consolidate_job_progress(job_id, error_delta=1, last_error=msg)
continue
db.insert_consolidate_proposal(
job_id=job_id, cluster_key=key,
cluster=cluster, decision=decision, error=None,
)
db.update_consolidate_job_progress(job_id, processed_delta=1)
db.finalize_consolidate_job(job_id, state="review")
log.info("[consolidate:%s] walk done; awaiting review", job_id)
except Exception:
log.exception("[consolidate:%s] walk crashed", job_id)
try:
db.finalize_consolidate_job(job_id, state="failed")
except Exception:
log.exception("[consolidate:%s] couldn't mark failed", job_id)
def run_apply(*, db: DB, job_id: int, mealie: Mealie) -> None:
log.info("[consolidate:%s] apply start", job_id)
def _cancelled() -> bool:
s = db.get_consolidate_job_state(job_id)
return s in ("cancelled", "failed", "done")
try:
approved = db.list_approved_unapplied_consolidate(job_id)
for row in approved:
if _cancelled():
log.info("[consolidate:%s] apply aborted (state changed)", job_id)
return
decision = row.get("sonnet_decision") or {}
if isinstance(decision, str):
try:
decision = json.loads(decision)
except Exception:
decision = {}
if not decision.get("merge"):
# User shouldn't have approved a non-merge cluster; defensive
db.mark_consolidate_proposal_applied(
row["id"], error="cluster decision was 'no merge' but row was approved",
)
continue
canonical_id = decision.get("canonical_id") or ""
discard_ids = decision.get("discard_ids") or []
alias_additions = decision.get("alias_additions") or []
if not canonical_id or not discard_ids:
db.mark_consolidate_proposal_applied(
row["id"], error="missing canonical_id or discard_ids",
)
continue
db.update_consolidate_job_progress(
job_id, current_cluster=row.get("cluster_key", "")[:80]
)
err: str | None = None
for did in discard_ids:
if did == canonical_id:
continue
try:
mealie.merge_foods(from_id=did, to_id=canonical_id)
except MealieError as e:
msg = str(e)
# Pair-based clustering can emit overlapping pairs:
# if (A,B) was already approved+merged, a later (A,C)
# pair has stale A. Mealie returns 404 — treat that
# as already-handled, not an error.
if "404" in msg or "not found" in msg.lower():
log.info("[consolidate:%s] merge %s%s: stale (already merged elsewhere)", job_id, did, canonical_id)
continue
err = f"merge {did}{canonical_id}: {e}"
log.warning("[consolidate:%s] %s", job_id, err)
break
if err is None and alias_additions:
# After successful merges, push the aliases onto the survivor
try:
survivor = mealie._get(f"/api/foods/{canonical_id}")
existing = survivor.get("aliases") or []
existing_names: set = set()
for a in existing:
if isinstance(a, str):
existing_names.add(a.strip().lower())
elif isinstance(a, dict):
existing_names.add((a.get("name") or "").strip().lower())
new_aliases: list[dict] = list(existing)
for n in alias_additions:
if n.strip().lower() not in existing_names and n.strip().lower() != (survivor.get("name") or "").strip().lower():
new_aliases.append({"name": n.strip()})
existing_names.add(n.strip().lower())
if new_aliases != existing:
body = dict(survivor)
body["aliases"] = new_aliases
mealie.update_food(canonical_id, body)
except MealieError as e:
err = f"alias update on {canonical_id}: {e}"
log.warning("[consolidate:%s] %s", job_id, err)
if err:
db.mark_consolidate_proposal_applied(row["id"], error=err)
db.update_consolidate_job_progress(
job_id, error_delta=1, last_error=err,
)
else:
db.mark_consolidate_proposal_applied(row["id"])
db.update_consolidate_job_progress(job_id, merged_delta=1)
db.finalize_consolidate_job(job_id, state="done")
log.info("[consolidate:%s] apply done", job_id)
except Exception:
log.exception("[consolidate:%s] apply crashed", job_id)
try:
db.finalize_consolidate_job(job_id, state="failed")
except Exception:
pass
def spawn_walk_thread(*, db: DB, job_id: int, mealie: Mealie, forge: Forge) -> threading.Thread:
t = threading.Thread(
target=run_walk,
kwargs={"db": db, "job_id": job_id, "mealie": mealie, "forge": forge},
name=f"consolidate-walk-{job_id}",
daemon=True,
)
t.start()
return t
def spawn_apply_thread(*, db: DB, job_id: int, mealie: Mealie) -> threading.Thread:
t = threading.Thread(
target=run_apply,
kwargs={"db": db, "job_id": job_id, "mealie": mealie},
name=f"consolidate-apply-{job_id}",
daemon=True,
)
t.start()
return t