Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 40 additions & 8 deletions reverse_image_search_bot/abuse_report/prepare.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def resolve_user(arg: str) -> int | None:
_TARGETISH_RE = re.compile(r"^(-?\d+|@\w+|[A-Za-z0-9_\-]+\.[A-Za-z0-9]+)$")


def resolve_targets(text: str) -> tuple[list[int], list[str]]:
def resolve_targets(text: str) -> tuple[list[int], list[str], dict[int, list[str]]]:
"""Resolve ANY blob of text into uploader user ids + unresolvable tokens.

Accepts a single token (user id, @username, filename, ``#uid…`` tag) or a
Expand All @@ -97,30 +97,46 @@ def resolve_targets(text: str) -> tuple[list[int], list[str]]:
In a multi-token paste only target-SHAPED tokens are considered (file URL,
``#uid`` tag, bare id, ``@username``, ``name.ext``), so surrounding prose
("URLs:", "Hi Nick") is neither resolved nor reported as unknown.

The third element maps each user id to the ``file_unique_id``s that pointed
at them — the files the report was actually opened over. They are flagged on
their blobs so the admin can still tell them apart from the rest of the
user's material.
"""
tokens = [t.strip().strip(".,;:()[]<>\"'") for t in re.split(r"[\s,;]+", text or "")]
tokens = [t for t in tokens if t]
lone = len(tokens) == 1
ids: list[int] = []
unknown: list[str] = []
indicators: dict[int, list[str]] = {}
for tok in tokens:
url = _FILE_URL_RE.search(tok)
tag = _TAG_RE.match(tok)
fname = None
if url:
tok = url.group(1)
tok = fname = url.group(1)
uid = abuse.find_user_by_filename(tok)
elif tag:
uid = -int(tag.group(2)) if tag.group(1) in ("cid", "gid") else int(tag.group(2))
elif lone or _TARGETISH_RE.match(tok):
uid = resolve_user(tok)
# A bare filename is an indicator too — same as a pasted URL.
if uid is not None and not tok.startswith("@") and not tok.lstrip("-").isdigit():
fname = tok
else:
continue
if uid is None:
if tok not in unknown:
unknown.append(tok)
elif uid not in ids:
continue
if uid not in ids:
ids.append(uid)
return ids, unknown
if fname:
stem = fname.rsplit(".", 1)[0]
indicators.setdefault(uid, [])
if stem not in indicators[uid]:
indicators[uid].append(stem)
return ids, unknown, indicators


class PrepareResult:
Expand Down Expand Up @@ -182,7 +198,11 @@ def _present_files(user_id: int) -> tuple[list, int, int]:


def _encrypt_and_remove(
report_uuid: str, batch: list, key: bytes, progress: Callable[[int, int], None] | None = None
report_uuid: str,
batch: list,
key: bytes,
progress: Callable[[int, int], None] | None = None,
indicators: set[str] | None = None,
) -> int:
"""Encrypt (file_row, path) pairs into report blobs, deleting each plaintext.

Expand Down Expand Up @@ -218,6 +238,7 @@ def _encrypt_and_remove(
nonce=nonce,
cipher_path=f"report_files/{report_uuid}/{cipher_name}",
plaintext_sha256=crypto.sha256_hex(data),
indicator=bool(indicators and f["file_unique_id"] in indicators),
)
try:
fp.unlink()
Expand All @@ -229,13 +250,18 @@ def _encrypt_and_remove(
return encrypted


def restore_report_files(report_uuid: str, p1: str) -> str | None:
def restore_report_files(report_uuid: str, p1: str, skip_ids: set[int] | None = None) -> str | None:
"""Decrypt a report's blobs back onto disk. Returns an error string, or None.

The inverse of preparing: cancelling a round means the files were fine, so
they go back where they were. Verifies P1 against every blob's stored hash
BEFORE writing anything — a wrong key must not scatter garbage into the
upload directory.

``skip_ids`` leaves those blobs' plaintext on the floor — used by the
delete flow, where the selected files are the ones being destroyed and only
the rest go back online. They are still decrypted and hash-checked, so a
wrong key is caught before anything is written or deleted.
"""
updir = upload_dir()
if updir is None:
Expand All @@ -253,6 +279,8 @@ def restore_report_files(report_uuid: str, p1: str) -> str | None:
return "image key incorrect"
if crypto.sha256_hex(data) != b["plaintext_sha256"]:
return "image key incorrect"
if skip_ids and b["id"] in skip_ids:
continue
plaintexts.append((updir / b["saved_filename"], data))
for fp, data in plaintexts:
try:
Expand Down Expand Up @@ -310,7 +338,11 @@ def delete_user_files(user_id: int) -> int:
return removed


def prepare_report(user_id: int, progress: Callable[[int, int], None] | None = None) -> PrepareResult:
def prepare_report(
user_id: int,
progress: Callable[[int, int], None] | None = None,
indicators: list[str] | None = None,
) -> PrepareResult:
"""Gather → encrypt → create a ``ready`` report for ``user_id``.

Returns a :class:`PrepareResult`. On success it carries the new
Expand Down Expand Up @@ -356,6 +388,6 @@ def prepare_report(user_id: int, progress: Callable[[int, int], None] | None = N
key = crypto.derive_key(p1)

abuse.create_report(report_uuid, user_id, "")
encrypted = _encrypt_and_remove(report_uuid, present, key, progress)
encrypted = _encrypt_and_remove(report_uuid, present, key, progress, set(indicators or ()))
abuse.set_report_status(report_uuid, abuse.REPORT_READY)
return PrepareResult(report_uuid=report_uuid, p1=p1, encrypted=encrypted)
78 changes: 69 additions & 9 deletions reverse_image_search_bot/abuse_report/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from urllib.parse import parse_qsl

from aiohttp import web
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo

from reverse_image_search_bot import settings
from reverse_image_search_bot.abuse_report import crypto, ncmec
Expand Down Expand Up @@ -148,6 +149,7 @@ async def api_reports_list(request: web.Request) -> web.Response:
"uuid": r["report_uuid"],
"user_id": r["user_id"],
"username": r.get("username"),
"display_name": " ".join(filter(None, (r.get("first_name"), r.get("last_name")))) or None,
"status": r["status"],
"ncmec_report_id": r["ncmec_report_id"],
"created_at": r["created_at"],
Expand Down Expand Up @@ -204,7 +206,7 @@ async def api_reports_create(request: web.Request) -> web.Response:
target = (payload.get("target") or "").strip()
if not target:
raise web.HTTPBadRequest(text="target (user id, @username, filename, or file URLs) required")
user_ids, unknown = resolve_targets(target)
user_ids, unknown, indicators = resolve_targets(target)
if not user_ids:
raise web.HTTPNotFound(text=f"no uploader found for: {target}")

Expand All @@ -219,7 +221,7 @@ def note(done: int, total: int) -> None:

_prepare_progress[target] = "0/?"
try:
result = await asyncio.to_thread(prepare_report, user_id, note)
result = await asyncio.to_thread(prepare_report, user_id, note, indicators.get(user_id))
finally:
_prepare_progress.pop(target, None)
if result.ok:
Expand Down Expand Up @@ -248,24 +250,35 @@ def note(done: int, total: int) -> None:
return web.json_response({"ok": True, "results": results, "unknown": unknown})


def _who(user_id: int) -> str:
"""Human label for a user: @username, else full name, else empty.

Empty rather than a placeholder dash — every call site already shows the id,
so a "—" would just be noise between it and the detail.
"""
u = abuse.get_user(user_id) or {}
if u.get("username"):
return f"@{u['username']}"
return " ".join(filter(None, (u.get("first_name"), u.get("last_name"))))


async def _dm_report_created(bot, admin_id: int | None, user_id: int, result) -> None:
"""DM the admin the image key + report link for an app-created report."""
if bot is None or not admin_id:
return
import html as _html

user = abuse.get_user(user_id) or {}
uname = f"@{user['username']}" if user.get("username") else "—"
url = f"{settings.REPORT_BASE_URL}/report/{result.report_uuid}" if settings.REPORT_BASE_URL else result.report_uuid
url = f"{settings.REPORT_BASE_URL}/report/console" if settings.REPORT_BASE_URL else ""
try:
await bot.send_message(
admin_id,
f"🆕 <code>{user_id}</code> {_html.escape(uname)} · {result.encrypted} file(s) offline\n"
f"Image key: <code>{_html.escape(result.p1 or '')}</code>\n\n"
f"{_html.escape(url)}\n\n"
f"<i>Shown once and not stored — losing it loses the files.</i>",
f"🆕 <code>{user_id}</code> {_html.escape(_who(user_id))} · {result.encrypted} file(s)\n"
f"Image key: <code>{_html.escape(result.p1 or '')}</code>",
parse_mode="HTML",
disable_web_page_preview=True,
reply_markup=(
InlineKeyboardMarkup([[InlineKeyboardButton("Reports", web_app=WebAppInfo(url=url))]]) if url else None
),
)
except Exception:
logger.warning("failed to DM the image key for app-created report %s", result.report_uuid, exc_info=True)
Expand Down Expand Up @@ -727,6 +740,52 @@ async def api_cancel(request: web.Request) -> web.Response:
return web.json_response({"ok": True, "status": abuse.REPORT_CANCELLED})


async def api_delete(request: web.Request) -> web.Response:
"""Destroy the SELECTED files and put the rest back online. Nothing is filed.

For material that is plainly unwanted but can't be attributed to a minor —
a Cloudflare complaint about content nobody can age-verify. Deleting it is
the right call; banning the uploader and filing with NCMEC is not.

The selected blobs' plaintext is never written back (their ciphertext and
rows are dropped with the round), every other file is restored to disk, and
the report is closed as ``deleted``. The uploader is NOT banned. Deleted
files are marked cleared so a later round doesn't drag them back in.
"""
_require_admin(request)
rep = _report_or_404(request.match_info["uuid"])
_require_page_secret(request, rep)
payload = await request.json()
p1 = payload.get("image_key", "")
if not p1:
raise web.HTTPBadRequest(text="image key required")
doomed = [b for b in abuse.report_blobs(rep["report_uuid"]) if b["selected"]]
if not doomed:
raise web.HTTPBadRequest(text="select the files to delete first")
# Restore everything EXCEPT the doomed files. This verifies the key against
# every blob before writing or deleting anything, so a wrong key destroys
# nothing.
err = await asyncio.to_thread(restore_report_files, rep["report_uuid"], p1, {b["id"] for b in doomed})
if err:
raise web.HTTPBadRequest(text=err)
# The deleted files must not come back in a future round for this user.
abuse.set_files_cleared([b["file_unique_id"] for b in doomed])
abuse.set_report_status(rep["report_uuid"], abuse.REPORT_DELETED, detail=f"{len(doomed)} file(s) deleted")
base = settings.UPLOADER.get("configuration", {}).get("path")
if base:
for b in abuse.report_blobs(rep["report_uuid"]):
if b.get("video_path"):
try:
vfp = Path(base) / b["video_path"]
if vfp.is_file():
vfp.unlink()
except Exception:
logger.warning("failed to delete video on delete %s", b["video_path"], exc_info=True)
abuse.purge_report_blobs(rep["report_uuid"])
purge_cipher_dir(rep["report_uuid"])
return web.json_response({"ok": True, "status": abuse.REPORT_DELETED, "deleted": len(doomed)})


def _public_file_url(saved_filename: str) -> str:
base = settings.UPLOADER.get("url", "").rstrip("/")
return f"{base}/{saved_filename}" if base else saved_filename
Expand Down Expand Up @@ -829,6 +888,7 @@ def build_app(bot=None, bot_data=None) -> web.Application:
app.router.add_post("/report/{uuid}/api/review", api_review)
app.router.add_post("/report/{uuid}/api/submit", api_submit)
app.router.add_post("/report/{uuid}/api/cancel", api_cancel)
app.router.add_post("/report/{uuid}/api/delete", api_delete)
app.router.add_get("/healthz", healthz)
return app

Expand Down
Loading
Loading