1313from __future__ import annotations
1414
1515import logging
16+ from collections .abc import Callable
1617from pathlib import Path
1718
1819from reverse_image_search_bot import settings
2122
2223logger = 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
2926def 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+
3463def 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