Skip to content

Commit ac75cfe

Browse files
authored
Merge #167: take report files offline; ciphertext on disk until filed
feat(abuse): take a user's files offline while a report is open
2 parents 6901818 + a7c2129 commit ac75cfe

9 files changed

Lines changed: 560 additions & 234 deletions

File tree

reverse_image_search_bot/abuse_report/prepare.py

Lines changed: 156 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from __future__ import annotations
1414

1515
import logging
16+
from collections.abc import Callable
1617
from pathlib import Path
1718

1819
from reverse_image_search_bot import settings
@@ -21,16 +22,44 @@
2122

2223
logger = logging.getLogger("abuse.prepare")
2324

24-
# Hard cap on files encrypted into a report round at once. Reports with more
25-
# on-disk uploads get a "show more" in the webview that prepares the next batch.
26-
PREPARE_BATCH = 25
27-
2825

2926
def upload_dir() -> Path | None:
3027
p = settings.UPLOADER.get("configuration", {}).get("path")
3128
return Path(p) if p else None
3229

3330

31+
def cipher_dir(report_uuid: str) -> Path | None:
32+
"""Directory holding a report's encrypted image ciphertext (under the PVC).
33+
34+
Mirrors how videos are stored: only FILED files' bytes ever move into
35+
SQLite, so an open report keeps its ciphertext on disk and the DB row just
36+
points at it.
37+
"""
38+
updir = upload_dir()
39+
if updir is None:
40+
return None
41+
d = updir / "report_files" / report_uuid
42+
d.mkdir(parents=True, exist_ok=True)
43+
return d
44+
45+
46+
def blob_ciphertext(blob: dict) -> bytes | None:
47+
"""The encrypted bytes of a blob, wherever they live (disk or DB).
48+
49+
Open reports keep ciphertext on disk (``cipher_path``); filed ones hold it
50+
in the ``ciphertext`` column. Returns None if the on-disk file is missing.
51+
"""
52+
path = blob.get("cipher_path")
53+
if path:
54+
updir = upload_dir()
55+
if updir is None:
56+
return None
57+
fp = updir / path
58+
return fp.read_bytes() if fp.is_file() else None
59+
ct = blob.get("ciphertext")
60+
return bytes(ct) if ct else None
61+
62+
3463
def resolve_user(arg: str) -> int | None:
3564
"""Resolve a target user id from a raw token: numeric id, @username, or filename."""
3665
arg = arg.strip()
@@ -58,7 +87,6 @@ def __init__(
5887
report_uuid: str | None = None,
5988
p1: str | None = None,
6089
encrypted: int = 0,
61-
remaining: int = 0,
6290
error: str | None = None,
6391
existing_uuid: str | None = None,
6492
filed_uuid: str | None = None,
@@ -67,9 +95,6 @@ def __init__(
6795
self.report_uuid = report_uuid
6896
self.p1 = p1
6997
self.encrypted = encrypted
70-
# Files still on disk but beyond the PREPARE_BATCH cap — preparable via
71-
# the webview's "show more".
72-
self.remaining = remaining
7398
self.error = error
7499
self.existing_uuid = existing_uuid
75100
# Set when the failure is "already filed with NCMEC" so the caller can
@@ -106,36 +131,150 @@ def _present_files(user_id: int) -> tuple[list, int, int]:
106131
return present, len(files), cleared
107132

108133

109-
def _encrypt_batch(report_uuid: str, batch: list, key: bytes) -> int:
110-
"""Encrypt a batch of (file_row, path) into report blobs. Returns count."""
134+
def _encrypt_and_remove(
135+
report_uuid: str, batch: list, key: bytes, progress: Callable[[int, int], None] | None = None
136+
) -> int:
137+
"""Encrypt (file_row, path) pairs into report blobs, deleting each plaintext.
138+
139+
The ciphertext is written to disk (``report_files/<uuid>/``) and the DB row
140+
only points at it — nothing enters SQLite until the report is actually
141+
FILED. The plaintext is unlinked only AFTER its ciphertext is on disk and
142+
the row committed, so a crash mid-round can never lose a file: at worst it
143+
stays on disk and is picked up again. Taking the file offline is the point
144+
of preparing a report — while a round is open the material must not be
145+
publicly reachable.
146+
147+
``progress`` (optional) is called with ``(done, total)`` after each file;
148+
a big user/group takes a while and the admin wants to see it move.
149+
"""
150+
cdir = cipher_dir(report_uuid)
151+
if cdir is None:
152+
return 0
111153
encrypted = 0
154+
total = len(batch)
112155
for f, fp in batch:
113156
try:
114157
data = fp.read_bytes()
115158
except Exception:
116159
logger.warning("failed to read %s", fp, exc_info=True)
117160
continue
118161
nonce, ct = crypto.encrypt_file(data, key)
162+
cipher_name = f"{f['file_unique_id']}.enc"
163+
(cdir / cipher_name).write_bytes(ct)
119164
abuse.add_report_blob(
120165
report_uuid,
121166
file_unique_id=f["file_unique_id"],
122167
saved_filename=f["saved_filename"],
123168
nonce=nonce,
124-
ciphertext=ct,
169+
cipher_path=f"report_files/{report_uuid}/{cipher_name}",
125170
plaintext_sha256=crypto.sha256_hex(data),
126171
)
172+
try:
173+
fp.unlink()
174+
except Exception:
175+
logger.warning("failed to remove plaintext %s", fp, exc_info=True)
127176
encrypted += 1
177+
if progress is not None:
178+
progress(encrypted, total)
128179
return encrypted
129180

130181

131-
def prepare_report(user_id: int) -> PrepareResult:
182+
def restore_report_files(report_uuid: str, p1: str) -> str | None:
183+
"""Decrypt a report's blobs back onto disk. Returns an error string, or None.
184+
185+
The inverse of preparing: cancelling a round means the files were fine, so
186+
they go back where they were. Verifies P1 against every blob's stored hash
187+
BEFORE writing anything — a wrong key must not scatter garbage into the
188+
upload directory.
189+
"""
190+
updir = upload_dir()
191+
if updir is None:
192+
return "no upload path configured"
193+
key = crypto.derive_key(p1)
194+
plaintexts: list[tuple[Path, bytes]] = []
195+
for b in abuse.report_blobs(report_uuid):
196+
ct = blob_ciphertext(b)
197+
if ct is None:
198+
logger.warning("ciphertext missing for blob %s — cannot restore", b["id"])
199+
continue
200+
try:
201+
data = crypto.decrypt_file(bytes(b["nonce"]), ct, key)
202+
except Exception:
203+
return "image key (P1) incorrect"
204+
if crypto.sha256_hex(data) != b["plaintext_sha256"]:
205+
return "image key (P1) incorrect"
206+
plaintexts.append((updir / b["saved_filename"], data))
207+
for fp, data in plaintexts:
208+
try:
209+
fp.write_bytes(data)
210+
except Exception:
211+
logger.warning("failed to restore %s", fp, exc_info=True)
212+
return None
213+
214+
215+
def purge_cipher_dir(report_uuid: str) -> None:
216+
"""Delete a report's on-disk ciphertext directory (and its contents)."""
217+
updir = upload_dir()
218+
if updir is None:
219+
return
220+
d = updir / "report_files" / report_uuid
221+
if not d.is_dir():
222+
return
223+
for fp in d.iterdir():
224+
try:
225+
fp.unlink()
226+
except Exception:
227+
logger.warning("failed to delete ciphertext %s", fp, exc_info=True)
228+
try:
229+
d.rmdir()
230+
except Exception:
231+
logger.warning("failed to remove cipher dir %s", d, exc_info=True)
232+
233+
234+
def delete_user_files(user_id: int) -> int:
235+
"""Delete every on-disk file of a user. Returns how many were removed.
236+
237+
Banning is the end of the line: nothing of theirs stays publicly reachable.
238+
That includes the still-ENCRYPTED leftovers of any report of theirs that was
239+
never filed — an open round's ciphertext is deleted along with its blob rows.
240+
Filed reports are untouched: their ciphertext moved into the DB at filing
241+
time and is the evidence.
242+
"""
243+
updir = upload_dir()
244+
if updir is None:
245+
return 0
246+
removed = 0
247+
for f in abuse.files_for_user(user_id):
248+
fp = updir / f["saved_filename"]
249+
try:
250+
if fp.is_file():
251+
fp.unlink()
252+
removed += 1
253+
except Exception:
254+
logger.warning("failed to delete %s on ban", fp, exc_info=True)
255+
for rep in abuse.reports_for_user(user_id):
256+
if rep["status"] == abuse.REPORT_FILED:
257+
continue
258+
abuse.purge_report_blobs(rep["report_uuid"])
259+
purge_cipher_dir(rep["report_uuid"])
260+
return removed
261+
262+
263+
def prepare_report(user_id: int, progress: Callable[[int, int], None] | None = None) -> PrepareResult:
132264
"""Gather → encrypt → create a ``ready`` report for ``user_id``.
133265
134266
Returns a :class:`PrepareResult`. On success it carries the new
135267
``report_uuid``, the one-time image key ``p1`` (shown once, never stored),
136-
and the ``encrypted`` file count. At most ``PREPARE_BATCH`` files are
137-
encrypted; the rest are reported via ``remaining`` (the webview's
138-
"show more" prepares them in later batches).
268+
and the ``encrypted`` file count.
269+
270+
EVERY on-disk file of the user is encrypted into the report and its
271+
plaintext removed from disk, so opening a report takes the material offline
272+
for as long as the round is open. The ciphertext lives on disk too — only
273+
FILED files ever enter the DB. Cancelling restores the files; filing moves
274+
the reported ciphertext into the DB and deletes the rest.
275+
276+
``progress`` (optional) receives ``(done, total)`` per encrypted file — a
277+
user or group with many uploads takes a while.
139278
"""
140279
if not settings.REPORT_BASE_URL:
141280
return PrepareResult(error="Report server is not configured (REPORT_BASE_URL unset).")
@@ -178,45 +317,6 @@ def prepare_report(user_id: int) -> PrepareResult:
178317
key = crypto.derive_key(p1)
179318

180319
abuse.create_report(report_uuid, user_id, "")
181-
batch = present[:PREPARE_BATCH]
182-
encrypted = _encrypt_batch(report_uuid, batch, key)
320+
encrypted = _encrypt_and_remove(report_uuid, present, key, progress)
183321
abuse.set_report_status(report_uuid, abuse.REPORT_READY)
184-
return PrepareResult(report_uuid=report_uuid, p1=p1, encrypted=encrypted, remaining=len(present) - len(batch))
185-
186-
187-
def pending_files(report_uuid: str) -> int:
188-
"""How many of the report user's on-disk, non-cleared files are NOT yet blobs."""
189-
rep = abuse.get_report(report_uuid)
190-
if not rep:
191-
return 0
192-
in_report = {b["file_unique_id"] for b in abuse.report_blobs(report_uuid)}
193-
present, _, _ = _present_files(rep["user_id"])
194-
return sum(1 for f, _ in present if f["file_unique_id"] not in in_report)
195-
196-
197-
def extend_report(report_uuid: str, p1: str) -> PrepareResult:
198-
"""Encrypt the next ``PREPARE_BATCH`` not-yet-included files into the report.
199-
200-
``p1`` must be the report's original image key — it is verified against an
201-
existing blob's hash before anything is encrypted, so a typo can't split the
202-
report across two keys.
203-
"""
204-
rep = abuse.get_report(report_uuid)
205-
if not rep:
206-
return PrepareResult(error="report not found")
207-
key = crypto.derive_key(p1)
208-
blobs = abuse.report_blobs(report_uuid)
209-
if blobs:
210-
probe = blobs[0]
211-
try:
212-
data = crypto.decrypt_file(bytes(probe["nonce"]), bytes(probe["ciphertext"]), key)
213-
except Exception:
214-
return PrepareResult(error="image key (P1) incorrect")
215-
if crypto.sha256_hex(data) != probe["plaintext_sha256"]:
216-
return PrepareResult(error="image key (P1) incorrect")
217-
in_report = {b["file_unique_id"] for b in blobs}
218-
present, _, _ = _present_files(rep["user_id"])
219-
todo = [(f, fp) for f, fp in present if f["file_unique_id"] not in in_report]
220-
batch = todo[:PREPARE_BATCH]
221-
encrypted = _encrypt_batch(report_uuid, batch, key)
222-
return PrepareResult(report_uuid=report_uuid, encrypted=encrypted, remaining=len(todo) - len(batch))
322+
return PrepareResult(report_uuid=report_uuid, p1=p1, encrypted=encrypted)

0 commit comments

Comments
 (0)