"""FC-Cleanup: /api/cleanup/* — retroactive enforcement of import filters. Endpoints: POST /min-dimension/preview synchronous SQL audit POST /min-dimension/delete synchronous SQL delete (Tier-C token) POST /audit async transparency / single_color start GET /audit list recent audit_run rows GET /audit/ single audit_run row POST /audit//apply apply matched_ids deletes (Tier-C token) POST /audit//cancel flip running audit to cancelled Unused-tags retroactive prune intentionally NOT in this namespace — TagMaintenanceCard (Maintenance tab → moved to Cleanup tab in v26.05.25.7) uses the existing /api/admin/tags/prune-unused endpoint via the admin store. No duplicate route here. Confirm-token format matches modal/DestructiveConfirmModal.vue convention: `delete-min-dim-` for min-dim delete `delete-audit-` for audit apply (Modal hardcodes action ∈ {'restore', 'delete'}; "apply audit" is semantically a delete of the matched images, so we use `delete-audit-`.) """ from __future__ import annotations import hashlib from pathlib import Path from quart import Blueprint, jsonify, request from sqlalchemy import select from ..extensions import get_session from ..models import LibraryAuditRun from ..services import cleanup_service from ._responses import error_response as _bad cleanup_bp = Blueprint("cleanup", __name__, url_prefix="/api/cleanup") IMAGES_ROOT = Path("/images") def _min_dim_token(min_w: int, min_h: int) -> str: # SHA-256 (not MD5) — Web Crypto's subtle.digest rejects MD5; both # sides use SHA-256 truncated to 8 hex chars. canon = f"{min_w}x{min_h}" return f"delete-min-dim-{hashlib.sha256(canon.encode()).hexdigest()[:8]}" def _serialize_audit_run(audit: LibraryAuditRun) -> dict: return { "id": audit.id, "rule": audit.rule, "params": audit.params, "status": audit.status, "started_at": audit.started_at.isoformat() if audit.started_at else None, "finished_at": audit.finished_at.isoformat() if audit.finished_at else None, "scanned_count": audit.scanned_count, "matched_count": audit.matched_count, "matched_ids": audit.matched_ids, "error": audit.error, } @cleanup_bp.route("/min-dimension/preview", methods=["POST"]) async def min_dim_preview(): body = await request.get_json(silent=True) or {} try: min_w = int(body.get("min_width", 0)) min_h = int(body.get("min_height", 0)) except (TypeError, ValueError): return _bad("invalid_dimensions") if min_w < 0 or min_h < 0: return _bad("invalid_dimensions") async with get_session() as session: projection = await session.run_sync( lambda s: cleanup_service.project_min_dimension_violations( s, min_width=min_w, min_height=min_h, ) ) # Hand the canonical Tier-C delete token back with the preview so # the frontend doesn't have to recompute SHA-256 client-side. # window.crypto.subtle is Secure-Context-gated and undefined on # plain-HTTP origins (homelab posture); without this the Delete # button silently swallowed the TypeError and never opened the # confirm modal. Operator-flagged 2026-05-27. projection["confirm_token"] = _min_dim_token(min_w, min_h) return jsonify(projection) @cleanup_bp.route("/min-dimension/delete", methods=["POST"]) async def min_dim_delete(): body = await request.get_json(silent=True) or {} try: min_w = int(body.get("min_width", 0)) min_h = int(body.get("min_height", 0)) except (TypeError, ValueError): return _bad("invalid_dimensions") if min_w < 0 or min_h < 0: return _bad("invalid_dimensions") supplied = body.get("confirm", "") expected = _min_dim_token(min_w, min_h) if supplied != expected: return _bad("confirm_mismatch", expected=expected) async with get_session() as session: deleted = await session.run_sync( lambda s: cleanup_service.delete_min_dimension_violations( s, min_width=min_w, min_height=min_h, images_root=IMAGES_ROOT, ) ) await session.commit() return jsonify({"deleted": deleted}) @cleanup_bp.route("/audit", methods=["POST"]) async def audit_create(): body = await request.get_json(silent=True) or {} rule = body.get("rule") params = body.get("params") or {} if rule not in ("transparency", "single_color"): return _bad("invalid_rule") if not isinstance(params, dict): return _bad("invalid_params") async with get_session() as session: try: audit_id = await session.run_sync( lambda s: cleanup_service.start_audit_run( s, rule=rule, params=params, ) ) except cleanup_service.AuditAlreadyRunning as running_id: return _bad( "audit_already_running", status=409, running_id=int(str(running_id)), ) except ValueError as exc: return _bad(str(exc)) await session.commit() return jsonify({"audit_id": audit_id, "status": "running"}), 202 @cleanup_bp.route("/audit/", methods=["GET"]) async def audit_get(audit_id: int): async with get_session() as session: audit = (await session.execute( select(LibraryAuditRun).where(LibraryAuditRun.id == audit_id) )).scalar_one_or_none() if audit is None: return _bad("not_found", status=404) return jsonify(_serialize_audit_run(audit)) @cleanup_bp.route("/audit", methods=["GET"]) async def audit_history(): try: limit = min(int(request.args.get("limit", "20")), 100) except ValueError: return _bad("invalid_limit") async with get_session() as session: rows = (await session.execute( select(LibraryAuditRun) .order_by(LibraryAuditRun.id.desc()) .limit(limit) )).scalars().all() return jsonify({"runs": [_serialize_audit_run(r) for r in rows]}) @cleanup_bp.route("/audit//apply", methods=["POST"]) async def audit_apply(audit_id: int): body = await request.get_json(silent=True) or {} confirm = body.get("confirm", "") async with get_session() as session: try: deleted = await session.run_sync( lambda s: cleanup_service.apply_audit_run( s, audit_id=audit_id, confirm_token=confirm, images_root=IMAGES_ROOT, ) ) except cleanup_service.AuditNotReady as exc: return _bad("audit_not_ready", current_status=str(exc)) except cleanup_service.ConfirmTokenMismatch as exc: return _bad("confirm_mismatch", expected=str(exc)) except ValueError as exc: return _bad("not_found", status=404, detail=str(exc)) await session.commit() return jsonify({"deleted": deleted}) @cleanup_bp.route("/audit//cancel", methods=["POST"]) async def audit_cancel(audit_id: int): async with get_session() as session: await session.run_sync( lambda s: cleanup_service.cancel_audit_run(s, audit_id=audit_id) ) await session.commit() return jsonify({"cancelled": True})