feat(cleanup): purge misgrabbed gated-post blurred previews (#874 follow-up)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m18s

A one-shot Maintenance action to remove the blurred locked-preview images
the ingester downloaded from tier-gated Patreon posts before #874.

current_user_can_view was never persisted, so the cleanup re-walks each
enabled Patreon source (read-only) to re-derive which posts are gated now
and the blurred filehashes Patreon serves for them, then matches by
CONTENT HASH against stored source_filehash. Because the hash is
content-addressed, a real file downloaded when access existed has a
different hash and can never match — regained-then-lost-access content is
provably spared (operator's hard requirement). NULL source_filehash =>
unverifiable, kept + reported.

On apply: delete matched ImageRecords + files (provenance cascades),
clear seen/dead-letter ledger rows for those hashes so the real media
re-ingests if access returns, and delete gated posts left bare. Shares
one match predicate between preview and apply (rule 93).

- cleanup_service: collect_gated_previews + purge_gated_previews
- tasks.admin: purge_gated_previews_task (async re-walk bridge, timeboxed)
- api.admin: POST /maintenance/purge-gated-previews
- GatedPurgeCard.vue in Settings > Maintenance (preview -> confirm -> apply)
- tests: collect predicate, hash-match delete/spare/unverifiable, ledger
  clear, bare-post removal, no-op

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 15:20:35 -04:00
parent 9422eadabe
commit 540151290b
6 changed files with 650 additions and 1 deletions
+199 -1
View File
@@ -17,7 +17,7 @@ from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from sqlalchemy import func, or_, select, update
from sqlalchemy import delete, func, or_, select, update
from sqlalchemy.orm import Session
from ..models import (
@@ -25,6 +25,8 @@ from ..models import (
ImageProvenance,
ImageRecord,
LibraryAuditRun,
PatreonFailedMedia,
PatreonSeenMedia,
Post,
PostAttachment,
Tag,
@@ -1138,3 +1140,199 @@ def dedup_videos(
"files_deleted": deleted["files_deleted"],
"relinked_posts": relinked, "sample": sample,
}
# ---- Gated-post blurred-preview cleanup (#874 follow-up) -----------------
#
# Before the #874 ingester fix, tier-gated Patreon posts (current_user_can_view
# == False) had their BLURRED locked-preview media downloaded as if real. That
# flag was never persisted (the sidecar/Post.raw_metadata store only
# category/id/title/content/url), so we can't tell from the DB which stored
# images are blurred previews. This cleanup RE-WALKS each feed to re-derive what
# Patreon serves as gated NOW, then matches by CONTENT HASH: an ImageRecord whose
# stored `source_filehash` equals a currently-served blurred file's hash IS that
# blurred preview. Because the hash is content-addressed, a real file the operator
# downloaded when they HAD access has a different hash and can never match — so
# regained-then-lost-access content is provably spared (operator's hard
# requirement, 2026-06-16). NULL source_filehash → can't verify → kept + reported.
def collect_gated_previews(client, campaign_id: str) -> dict[str, list[str]]:
"""Read-only walk of one Patreon feed → `{external_post_id: [filehashes]}` for
every GATED post, where the list is the CDN filehashes of the blurred preview
media Patreon is serving for it right now. No downloads.
`client` is a PatreonClient (or test stub) exposing the same seams the
ingester uses: `post_is_gated`, `iter_posts`, `extract_media`. extract_media
on a gated post yields exactly the blurred preview items (the original bug), so
their filehashes are the match keys the purge needs.
"""
gated: dict[str, list[str]] = {}
is_gated = getattr(client, "post_is_gated", None)
if is_gated is None:
return gated
for post, included, _cursor in client.iter_posts(campaign_id):
if not is_gated(post):
continue
pid = str(post.get("id") or "")
if not pid:
continue
hashes = sorted(
{m.filehash for m in client.extract_media(post, included) if m.filehash}
)
gated[pid] = hashes
return gated
def _gated_source_hashes(gated_map: dict) -> dict[int, set[str]]:
"""{source_id: set(blurred filehashes)} from the discovery map
{source_id: {external_post_id: [filehashes]}}."""
out: dict[int, set[str]] = {}
for sid, posts in gated_map.items():
bucket: set[str] = set()
for hashes in posts.values():
bucket.update(hashes)
out[int(sid)] = bucket
return out
def _match_gated_preview_images(
session: Session, all_hashes: set[str],
) -> dict[int, int]:
"""{image_id: size_bytes} for ImageRecords whose source_filehash is one of the
currently-served blurred filehashes — the confirmed blurred previews. Chunked
under the psycopg parameter ceiling."""
matched: dict[int, int] = {}
hash_list = list(all_hashes)
for i in range(0, len(hash_list), 500):
chunk = hash_list[i:i + 500]
for rid, size in session.execute(
select(ImageRecord.id, ImageRecord.size_bytes)
.where(ImageRecord.source_filehash.in_(chunk))
).all():
matched[rid] = size or 0
return matched
def _count_unverifiable_gated_images(session: Session, gated_map: dict) -> int:
"""Images linked (via provenance) to a gated post but with a NULL
source_filehash — we can't prove they're blurred previews, so they're KEPT and
only reported. Counts distinct images so one shared across posts counts once."""
image_ids: set[int] = set()
for sid, posts in gated_map.items():
ext_ids = list(posts.keys())
if not ext_ids:
continue
post_ids = session.execute(
select(Post.id).where(
Post.source_id == int(sid), Post.external_post_id.in_(ext_ids)
)
).scalars().all()
if not post_ids:
continue
rows = session.execute(
select(ImageProvenance.image_record_id)
.join(ImageRecord, ImageRecord.id == ImageProvenance.image_record_id)
.where(
ImageProvenance.post_id.in_(list(post_ids)),
ImageRecord.source_filehash.is_(None),
)
).scalars().all()
image_ids.update(rows)
return len(image_ids)
def purge_gated_previews(
session: Session, *, gated_map: dict, images_root: Path, dry_run: bool = False,
) -> dict:
"""Find and (unless dry_run) delete the blurred locked-preview images grabbed
from gated posts before the #874 fix (see module section above).
`gated_map` is the discovery output `{source_id: {external_post_id:
[filehashes]}}` (assembled by collect_gated_previews per source). Matching is
by content hash, so real content is provably spared. dry_run shares the exact
same match predicate and returns the projection without deleting (rule 93).
On apply: delete the matched ImageRecords + files (provenance cascades), clear
the seen/dead-letter ledger rows for those blurred hashes so the REAL media
re-ingests if access is later regained, and delete the gated post records that
are left bare.
"""
per_source = _gated_source_hashes(gated_map)
all_hashes: set[str] = set()
for bucket in per_source.values():
all_hashes |= bucket
gated_posts = sum(len(posts) for posts in gated_map.values())
matched = _match_gated_preview_images(session, all_hashes)
reclaim = sum(matched.values())
unverifiable = _count_unverifiable_gated_images(session, gated_map)
sample = [{"image_id": rid} for rid in sorted(matched)[:50]]
projection = {
"gated_posts": gated_posts,
"matched": len(matched),
"reclaim_bytes": reclaim,
"unverifiable": unverifiable,
"sample": sample,
}
if dry_run:
return projection
deleted = delete_images(
session, image_ids=list(matched), images_root=images_root,
)
# Clear the seen + dead-letter ledger for the blurred hashes so a later
# recovery/backfill re-ingests the REAL media if access is regained.
ledger_cleared = 0
for sid, hashes in per_source.items():
hl = list(hashes)
for i in range(0, len(hl), 500):
chunk = hl[i:i + 500]
res = session.execute(
delete(PatreonSeenMedia).where(
PatreonSeenMedia.source_id == sid,
PatreonSeenMedia.filehash.in_(chunk),
)
)
ledger_cleared += res.rowcount or 0
session.execute(
delete(PatreonFailedMedia).where(
PatreonFailedMedia.source_id == sid,
PatreonFailedMedia.filehash.in_(chunk),
)
)
session.commit()
# Delete gated post records left bare by the deletion (shares the bare-post
# predicate so a gated post that still has real content is never removed).
bare = _bare_post_conditions()
posts_deleted = 0
for sid, posts in gated_map.items():
ext_ids = list(posts.keys())
for i in range(0, len(ext_ids), 500):
chunk = ext_ids[i:i + 500]
res = session.execute(
Post.__table__.delete().where(
Post.source_id == int(sid),
Post.external_post_id.in_(chunk),
*bare,
)
)
posts_deleted += res.rowcount or 0
session.commit()
log.info(
"gated-preview purge: %d gated post(s), %d blurred image(s) deleted, "
"%d ledger row(s) cleared, %d bare post(s) removed, %d unverifiable kept",
gated_posts, deleted["images_deleted"], ledger_cleared, posts_deleted,
unverifiable,
)
return {
**projection,
"deleted": deleted["images_deleted"],
"files_deleted": deleted["files_deleted"],
"ledger_cleared": ledger_cleared,
"posts_deleted": posts_deleted,
}