df6d89cb59
Operator-flagged 2026-05-27: walk the whole project for the same shape
as the min-dim Delete-button silent failure (crypto.subtle TypeError
on plain HTTP). FC runs over plain HTTP per the homelab posture;
Secure-Context-gated browser APIs are undefined on the production
origin.
**Audit results across `frontend/src/`:**
crypto.subtle.digest — 2 sites:
- MinDimensionCard (fixed 2026-05-27)
- BulkEditorPanel (THIS FIX)
navigator.clipboard — 1 site, already guarded:
- utils/clipboard.js writeText with execCommand fallback
serviceWorker / mediaDevices / Push / Web USB|HID|Bluetooth|Serial /
cookieStore / queryLocalFonts / WebAuthn / geolocation
— NOT USED, nothing to fix
Extension scripts (background.js) use crypto.subtle but run from
moz-extension:// which IS a Secure Context — left as-is.
**BulkEditorPanel double bug**
The bulk-delete UI on the gallery selection had been broken since
FC-3k shipped, in two ways:
1. `crypto.subtle.digest` swallowed TypeError on plain HTTP — modal
never opened. Same symptom as min-dim.
2. Even on HTTPS, the modal's `kind="images-selection"` produced
`delete-images-selection-<sha8>` while the backend expected
`delete-images-<sha8>`. The two would never match.
Fix:
- Backend `/api/admin/images/bulk-delete` dry-run response now returns
`confirm_token` (the canonical `delete-images-<sha8>` string).
Integration test `test_bulk_delete_dry_run_returns_counts` pinned to
assert the new field.
- DestructiveConfirmModal gains an `expectedTokenOverride` prop. When
set, it bypasses the `${action}-${kind}-${runId}` formula and uses
the explicit string. This decouples the UI label (`kind`) from the
wire-format token (server-provided), so future endpoints can use a
kind-specific label without their kind name leaking into the token.
- BulkEditorPanel passes `:expected-token-override="bulkProjected?.confirm_token"`
— no client-side crypto, no kind-prefix mismatch.
- MinDimensionCard refactored to the same explicit pattern (was
slicing the 8-char suffix off the backend's token and passing it
through `runId`; now passes the full backend token via
`expected-token-override` directly). Cleaner; one source of truth.
**Banked memory**
`feedback_no_secure_context_apis.md` documents the full table of
Secure-Context-gated APIs, which ones FC currently uses, and how each
is handled. Indexed in MEMORY.md. Sites for the audit also listed in
the memory for future drift-checking.
No other Secure-Context-gated APIs found in `frontend/src/`. The same
shape won't recur unless someone adds a new dependency on one — at
which point the banked memory should fire.
209 lines
7.4 KiB
Python
209 lines
7.4 KiB
Python
"""FC-3k: /api/admin — destructive admin actions.
|
|
|
|
Five action surfaces:
|
|
POST /api/admin/artists/<slug>/cascade-delete (Tier C)
|
|
POST /api/admin/images/bulk-delete (Tier C)
|
|
DELETE /api/admin/tags/<int:tag_id> (Tier B)
|
|
POST /api/admin/tags/<int:dest_id>/merge (Tier B)
|
|
POST /api/admin/tags/prune-unused (Tier A)
|
|
GET /api/admin/tags/<int:tag_id>/usage-count (helper)
|
|
|
|
Tier-C ops take a dry_run body flag (returns projection inline,
|
|
no dispatch) and a confirm body field (server-recomputed token).
|
|
Long-running ops dispatch a maintenance-queue Celery task; the UI
|
|
tails FC-3i's /api/system/activity/runs to surface progress.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
|
|
from quart import Blueprint, jsonify, request
|
|
from sqlalchemy import select
|
|
|
|
from ..extensions import get_session
|
|
from ..models import Artist
|
|
from ..services.cleanup_service import project_artist_cascade, project_bulk_image_delete
|
|
|
|
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
|
|
|
|
|
|
def _bad(error: str, *, status: int = 400, **extra):
|
|
body = {"error": error}
|
|
body.update(extra)
|
|
return jsonify(body), status
|
|
|
|
|
|
def _bulk_image_confirm_token(image_ids: list[int]) -> str:
|
|
"""Stable 8-hex token derived from the sorted id list. Mutates
|
|
when the selection changes; stays the same across modal opens of
|
|
the same selection so the operator can paste without confusion."""
|
|
canon = ",".join(str(i) for i in sorted(image_ids))
|
|
digest = hashlib.sha256(canon.encode("utf-8")).hexdigest()
|
|
return digest[:8]
|
|
|
|
|
|
@admin_bp.route("/artists/<slug>/cascade-delete", methods=["POST"])
|
|
async def artist_cascade_delete(slug: str):
|
|
body = await request.get_json(silent=True) or {}
|
|
dry_run = bool(body.get("dry_run", False))
|
|
supplied_confirm = body.get("confirm", "")
|
|
|
|
async with get_session() as session:
|
|
artist = (await session.execute(
|
|
select(Artist).where(Artist.slug == slug)
|
|
)).scalar_one_or_none()
|
|
if artist is None:
|
|
return _bad("not_found", status=404)
|
|
artist_id = artist.id
|
|
|
|
projected = await session.run_sync(
|
|
lambda sync_sess: project_artist_cascade(sync_sess, slug=slug)
|
|
)
|
|
|
|
if dry_run:
|
|
return jsonify(projected)
|
|
|
|
expected = f"delete-artist-{artist_id}"
|
|
if supplied_confirm != expected:
|
|
return _bad(
|
|
"confirm_mismatch",
|
|
detail=f"confirm must equal {expected!r}",
|
|
expected=expected,
|
|
)
|
|
|
|
from ..tasks.admin import delete_artist_cascade_task
|
|
async_result = delete_artist_cascade_task.delay(artist_id=artist_id)
|
|
return jsonify({"task_id": async_result.id}), 202
|
|
|
|
|
|
@admin_bp.route("/images/bulk-delete", methods=["POST"])
|
|
async def images_bulk_delete():
|
|
body = await request.get_json(silent=True) or {}
|
|
image_ids = body.get("image_ids")
|
|
if not isinstance(image_ids, list) or not image_ids:
|
|
return _bad("invalid_image_ids", detail="image_ids must be non-empty list of int")
|
|
try:
|
|
image_ids = [int(i) for i in image_ids]
|
|
except (TypeError, ValueError):
|
|
return _bad("invalid_image_ids", detail="image_ids must contain only ints")
|
|
|
|
dry_run = bool(body.get("dry_run", False))
|
|
supplied_confirm = body.get("confirm", "")
|
|
|
|
async with get_session() as session:
|
|
projected = await session.run_sync(
|
|
lambda sync_sess: project_bulk_image_delete(
|
|
sync_sess, image_ids=image_ids,
|
|
)
|
|
)
|
|
|
|
sha8 = _bulk_image_confirm_token(image_ids)
|
|
expected = f"delete-images-{sha8}"
|
|
|
|
if dry_run:
|
|
# Hand the canonical Tier-C confirm token back with the
|
|
# projection so the frontend doesn't have to recompute SHA-256
|
|
# client-side via crypto.subtle (Secure-Context-gated,
|
|
# undefined on plain-HTTP origins per the homelab posture).
|
|
# Operator-flagged 2026-05-27.
|
|
projected["confirm_token"] = expected
|
|
return jsonify(projected)
|
|
|
|
|
|
if supplied_confirm != expected:
|
|
return _bad(
|
|
"confirm_mismatch",
|
|
detail=f"confirm must equal {expected!r}",
|
|
expected=expected,
|
|
)
|
|
|
|
from ..tasks.admin import bulk_delete_images_task
|
|
async_result = bulk_delete_images_task.delay(image_ids=image_ids)
|
|
return jsonify({"task_id": async_result.id}), 202
|
|
|
|
|
|
@admin_bp.route("/tags/<int:tag_id>", methods=["DELETE"])
|
|
async def tag_delete(tag_id: int):
|
|
"""Tier-B sync delete. UI yes/no modal is the only confirmation."""
|
|
from ..services.cleanup_service import delete_tag
|
|
|
|
async with get_session() as session:
|
|
try:
|
|
result = await session.run_sync(
|
|
lambda sync_sess: delete_tag(sync_sess, tag_id=tag_id)
|
|
)
|
|
except LookupError:
|
|
return _bad("not_found", status=404)
|
|
return jsonify(result)
|
|
|
|
|
|
@admin_bp.route("/tags/<int:dest_id>/merge", methods=["POST"])
|
|
async def tag_merge(dest_id: int):
|
|
"""Wraps TagService.merge. Source repoints to dest, dest survives,
|
|
source row deleted, protective alias auto-created if source was
|
|
ML-applied or allowlisted."""
|
|
from ..services.tag_service import TagMergeConflict, TagService, TagValidationError
|
|
|
|
body = await request.get_json(silent=True) or {}
|
|
source_id = body.get("source_id")
|
|
if not isinstance(source_id, int) or source_id == dest_id:
|
|
return _bad("invalid_source_id", detail="source_id must be int and differ from dest")
|
|
|
|
async with get_session() as session:
|
|
try:
|
|
result = await TagService(session).merge(
|
|
source_id=source_id, target_id=dest_id,
|
|
)
|
|
except TagMergeConflict as exc:
|
|
return _bad("merge_conflict", status=409, detail=str(exc))
|
|
except TagValidationError as exc:
|
|
return _bad("tag_kind_mismatch", detail=str(exc))
|
|
except LookupError:
|
|
return _bad("not_found", status=404)
|
|
|
|
# MergeResult is a frozen dataclass — flatten to dict.
|
|
return jsonify({
|
|
"result": {
|
|
"target_id": result.target_id,
|
|
"target_name": result.target_name,
|
|
"target_kind": result.target_kind,
|
|
"merged_count": result.merged_count,
|
|
"alias_created": result.alias_created,
|
|
"source_deleted": result.source_deleted,
|
|
},
|
|
})
|
|
|
|
|
|
@admin_bp.route("/tags/<int:tag_id>/usage-count", methods=["GET"])
|
|
async def tag_usage_count(tag_id: int):
|
|
"""Helper for the Tier-B yes/no prompt; surfaces "N associations"
|
|
in the dialog so the operator knows what they're nuking."""
|
|
from ..services.cleanup_service import count_tag_associations
|
|
|
|
async with get_session() as session:
|
|
count = await session.run_sync(
|
|
lambda sync_sess: count_tag_associations(
|
|
sync_sess, tag_id=tag_id,
|
|
)
|
|
)
|
|
return jsonify({"count": count})
|
|
|
|
|
|
@admin_bp.route("/tags/prune-unused", methods=["POST"])
|
|
async def tags_prune_unused():
|
|
"""Tier-A: dry-run preview list IS the prompt. UI calls with
|
|
dry_run=true first, shows the list, operator clicks button to
|
|
re-call with dry_run=false."""
|
|
from ..services.cleanup_service import prune_unused_tags
|
|
|
|
body = await request.get_json(silent=True) or {}
|
|
dry_run = bool(body.get("dry_run", False))
|
|
|
|
async with get_session() as session:
|
|
result = await session.run_sync(
|
|
lambda sync_sess: prune_unused_tags(
|
|
sync_sess, dry_run=dry_run,
|
|
)
|
|
)
|
|
return jsonify(result)
|