Skip to content

Commit d64bd94

Browse files
authored
Merge pull request #173 from Nachtalb/fix/report-terse-messages
refactor: terse abuse-report messages, no P1/P2 jargon
2 parents 1f5a0af + 0b4184c commit d64bd94

7 files changed

Lines changed: 67 additions & 61 deletions

File tree

β€Žreverse_image_search_bot/abuse_report/prepare.pyβ€Ž

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,9 @@ def restore_report_files(report_uuid: str, p1: str) -> str | None:
250250
try:
251251
data = crypto.decrypt_file(bytes(b["nonce"]), ct, key)
252252
except Exception:
253-
return "image key (P1) incorrect"
253+
return "image key incorrect"
254254
if crypto.sha256_hex(data) != b["plaintext_sha256"]:
255-
return "image key (P1) incorrect"
255+
return "image key incorrect"
256256
plaintexts.append((updir / b["saved_filename"], data))
257257
for fp, data in plaintexts:
258258
try:
@@ -332,35 +332,24 @@ def prepare_report(user_id: int, progress: Callable[[int, int], None] | None = N
332332
existing = abuse.active_report_for_user(user_id)
333333
if existing:
334334
return PrepareResult(
335-
error=f"An active report already exists for user {user_id} (status: {existing['status']}).",
335+
error=f"active report already open ({existing['status']})",
336336
existing_uuid=existing["report_uuid"],
337337
)
338338

339339
present, recorded, cleared = _present_files(user_id)
340340
if not present:
341341
if cleared:
342-
return PrepareResult(
343-
error=f"All {cleared} remaining file(s) of user {user_id} are marked cleared β€” nothing to report."
344-
)
342+
return PrepareResult(error=f"all {cleared} remaining file(s) cleared β€” nothing to report")
345343
filed = abuse.latest_filed_report_for_user(user_id)
346344
if filed and filed.get("ncmec_report_id"):
347-
n = filed.get("reported_files", 0)
348-
others = f" along with {n - 1} other file(s)" if n and n > 1 else ""
349345
return PrepareResult(
350-
error=(
351-
f"User {user_id} was already filed with NCMEC in report "
352-
f"#{filed['ncmec_report_id']}{others}. The plaintext files were "
353-
f"deleted from disk after filing (the encrypted copies are kept "
354-
f"in that report) β€” nothing new to report."
355-
),
346+
error=f"already filed with NCMEC in report #{filed['ncmec_report_id']}",
356347
filed_uuid=filed["report_uuid"],
357348
filed_ncmec_id=filed["ncmec_report_id"],
358349
)
359-
return PrepareResult(
360-
error=f"User {user_id} has {recorded} recorded file(s) but none are still on disk β€” nothing to report."
361-
)
350+
return PrepareResult(error=f"{recorded} recorded file(s), none still on disk β€” nothing to report")
362351

363-
# P1 is the one-time image key β€” shown ONCE and never stored. The page
352+
# The image key is generated here, shown ONCE and never stored. The page
364353
# password is a single global secret (REPORT_PAGE_PASSWORD), not per-report.
365354
p1 = crypto.gen_password()
366355
report_uuid = crypto.gen_report_uuid()

β€Žreverse_image_search_bot/abuse_report/server.pyβ€Ž

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ def note(done: int, total: int) -> None:
249249

250250

251251
async def _dm_report_created(bot, admin_id: int | None, user_id: int, result) -> None:
252-
"""DM the admin the P1 image key + report link for an app-created report."""
252+
"""DM the admin the image key + report link for an app-created report."""
253253
if bot is None or not admin_id:
254254
return
255255
import html as _html
@@ -260,16 +260,15 @@ async def _dm_report_created(bot, admin_id: int | None, user_id: int, result) ->
260260
try:
261261
await bot.send_message(
262262
admin_id,
263-
f"<b>Report prepared</b> for user <code>{user_id}</code> ({_html.escape(uname)})\n"
264-
f"Encrypted <b>{result.encrypted}</b> file(s).\n\n"
265-
f"<b>Image key (P1):</b> <code>{_html.escape(result.p1 or '')}</code>\n\n"
266-
f"Open it from the reports console, or: {_html.escape(url)}\n\n"
267-
f"<i>P1 is not stored β€” keep it to decrypt the images.</i>",
263+
f"πŸ†• <code>{user_id}</code> {_html.escape(uname)} Β· {result.encrypted} file(s) offline\n"
264+
f"Image key: <code>{_html.escape(result.p1 or '')}</code>\n\n"
265+
f"{_html.escape(url)}\n\n"
266+
f"<i>Shown once and not stored β€” losing it loses the files.</i>",
268267
parse_mode="HTML",
269268
disable_web_page_preview=True,
270269
)
271270
except Exception:
272-
logger.warning("failed to DM P1 for app-created report %s", result.report_uuid, exc_info=True)
271+
logger.warning("failed to DM the image key for app-created report %s", result.report_uuid, exc_info=True)
273272

274273

275274
async def index(request: web.Request) -> web.StreamResponse:
@@ -336,7 +335,7 @@ async def api_fetch_video(request: web.Request) -> web.Response:
336335
payload = await request.json()
337336
p1 = payload.get("image_key", "")
338337
if not p1:
339-
raise web.HTTPBadRequest(text="image_key (P1) required")
338+
raise web.HTTPBadRequest(text="image key required")
340339
blob = abuse.get_report_blob(int(request.match_info["blob_id"]))
341340
if not blob or blob["report_uuid"] != rep["report_uuid"]:
342341
raise web.HTTPNotFound(text="blob not found")
@@ -493,9 +492,9 @@ def _note(msg: str) -> None:
493492
try:
494493
plaintext = crypto.decrypt_file(bytes(b["nonce"]), cipher, key)
495494
except Exception as dec_err:
496-
raise web.HTTPBadRequest(text="image key (P1) incorrect β€” decryption failed") from dec_err
495+
raise web.HTTPBadRequest(text="image key incorrect β€” decryption failed") from dec_err
497496
if crypto.sha256_hex(plaintext) != b["plaintext_sha256"]:
498-
raise web.HTTPBadRequest(text="image key (P1) incorrect β€” hash mismatch")
497+
raise web.HTTPBadRequest(text="image key incorrect β€” hash mismatch")
499498
# Report the extracted frame/still. original_file_name keeps the
500499
# uploader's original name when we have one (important to preserve);
501500
# location_of_file is our PUBLIC copy's URL (the one that may have been
@@ -526,9 +525,9 @@ def _note(msg: str) -> None:
526525
try:
527526
vplain = crypto.decrypt_file(bytes(b["video_nonce"]), vfp.read_bytes(), key)
528527
except Exception as verr:
529-
raise web.HTTPBadRequest(text="image key (P1) incorrect β€” video decryption failed") from verr
528+
raise web.HTTPBadRequest(text="image key incorrect β€” video decryption failed") from verr
530529
if crypto.sha256_hex(vplain) != b["video_sha256"]:
531-
raise web.HTTPBadRequest(text="image key (P1) incorrect β€” video hash mismatch")
530+
raise web.HTTPBadRequest(text="image key incorrect β€” video hash mismatch")
532531
files.append(
533532
{
534533
"kind": "video",
@@ -558,7 +557,7 @@ async def api_review(request: web.Request) -> web.Response:
558557
payload = await request.json()
559558
p1 = payload.get("image_key", "")
560559
if not p1:
561-
raise web.HTTPBadRequest(text="image_key (P1) required")
560+
raise web.HTTPBadRequest(text="image key required")
562561

563562
files, selected = await _gather_selected_files(request, rep, p1)
564563
# Fetch the reported user's bio (best-effort) before reading the row, so it
@@ -619,7 +618,7 @@ async def api_submit(request: web.Request) -> web.Response:
619618
payload = await request.json()
620619
p1 = payload.get("image_key", "")
621620
if not p1:
622-
raise web.HTTPBadRequest(text="image_key (P1) required")
621+
raise web.HTTPBadRequest(text="image key required")
623622
uuid = rep["report_uuid"]
624623
if uuid in _submit_tasks and not _submit_tasks[uuid].done():
625624
return web.json_response({"ok": True, "status": abuse.REPORT_SUBMITTING}) # already running
@@ -700,7 +699,7 @@ async def api_cancel(request: web.Request) -> web.Response:
700699
payload = await request.json()
701700
p1 = payload.get("image_key", "")
702701
if not p1:
703-
raise web.HTTPBadRequest(text="image_key (P1) required to restore the files")
702+
raise web.HTTPBadRequest(text="image key required to restore the files")
704703
# Restore BEFORE purging the blobs β€” they are the only copy of the plaintext.
705704
# A wrong key aborts without writing anything, so the files stay recoverable.
706705
err = await asyncio.to_thread(restore_report_files, rep["report_uuid"], p1)

β€Žreverse_image_search_bot/abuse_report/static/report.htmlβ€Ž

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ <h1>NCMEC Abuse Report</h1>
133133
<main>
134134
<!-- Global page password gate -->
135135
<div class="card" id="unlock-card">
136-
<input type="password" id="p2" autocomplete="off" placeholder="Report page password">
136+
<input type="password" id="p2" autocomplete="off" placeholder="Page password">
137137
<div style="height:10px"></div>
138138
<button id="unlock-btn">Unlock report</button>
139139
<div class="muted" id="unlock-err" style="color:#f08a8a;margin-top:8px"></div>
@@ -153,10 +153,10 @@ <h1>NCMEC Abuse Report</h1>
153153
<div class="kv"><span>NCMEC report id</span><b id="r-ncmec">β€”</b></div>
154154
</div>
155155

156-
<!-- Decrypt (P1) -->
156+
<!-- Decrypt with the image key -->
157157
<div class="card" id="decrypt-card">
158158
<label for="p1">Image decryption key</label>
159-
<input type="password" id="p1" autocomplete="off" placeholder="Enter the image key to reveal thumbnails">
159+
<input type="password" id="p1" autocomplete="off" placeholder="Image key">
160160
<div style="height:10px"></div>
161161
<button id="decrypt-btn">Decrypt &amp; show images</button>
162162
<div class="muted" id="decrypt-err" style="color:#f08a8a;margin-top:8px"></div>
@@ -216,7 +216,7 @@ <h2 id="notice-title"></h2>
216216
<dialog id="cancel-dlg">
217217
<div class="dlg-body">
218218
<h2>Cancel this report?</h2>
219-
<div class="muted" style="margin-bottom:12px">The files are decrypted back onto disk and the encrypted blobs discarded. Needs the image key (P1) β€” it is the only copy.</div>
219+
<div class="muted" style="margin-bottom:12px">The files are decrypted back onto disk and the encrypted blobs discarded. Needs the image key β€” the blobs are the only copy.</div>
220220
<label style="display:flex;gap:8px;align-items:flex-start;font-size:14px;color:#e8e8ea;margin-bottom:14px;cursor:pointer">
221221
<input type="checkbox" id="cancel-clear" style="margin-top:3px">
222222
<span>Mark all images in this round as <b>cleared</b>.</span>
@@ -607,7 +607,7 @@ <h2>Review β€” everything sent to NCMEC</h2>
607607
const ids = Object.keys(sel);
608608
if (!ids.length) { $("action-err").textContent = "Select at least one image."; return; }
609609
for (const id of ids) if (!sel[id]) { $("action-err").textContent = "Every selected image needs a classification (A1/A2/B1/B2/NR)."; return; }
610-
if (!P1KEY) { $("action-err").textContent = "Decrypt the images first (the image key is needed to submit)."; return; }
610+
if (!P1KEY) { $("action-err").textContent = "Decrypt the images first."; return; }
611611
const btn = $("review-btn"); btn.disabled = true; btn.textContent = "Preparing review…";
612612
try {
613613
await saveSelection();
@@ -826,8 +826,8 @@ <h2>Review β€” everything sent to NCMEC</h2>
826826
$("cancel-back-btn").onclick = () => cancelDlg.close();
827827
$("cancel-confirm-btn").onclick = async () => {
828828
const clearFiles = $("cancel-clear").checked;
829-
// Cancelling restores the plaintext from the blobs, so it needs P1.
830-
if (!$("p1").value) { cancelDlg.close(); $("action-err").textContent = "Enter the image key (P1) first β€” cancelling restores the files."; return; }
829+
// Cancelling restores the plaintext from the blobs, so it needs the image key.
830+
if (!$("p1").value) { cancelDlg.close(); $("action-err").textContent = "Enter the image key first β€” cancelling restores the files."; return; }
831831
try {
832832
await api("/api/cancel", {
833833
method: "POST", headers: { "Content-Type": "application/json" },

β€Žreverse_image_search_bot/abuse_report/static/reports.htmlβ€Ž

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@
4646
.badge.cancelled, .badge.error, .badge.retracted, .badge.review { background: #3a1c1c; color: #f0a0a0; }
4747
.err { color: #f08a8a; font-size: 13px; margin-top: 8px; }
4848
code { background: #2a2c33; padding: 1px 5px; border-radius: 4px; }
49+
code.copyable { cursor: pointer; border: 1px dashed #4a4c56; }
50+
code.copyable:active { background: #3a6df0; color: #fff; }
4951
/* Tabs */
5052
.tabs { display: flex; gap: 8px; margin-bottom: 14px; }
5153
.tab { flex: 1; text-align: center; padding: 10px; border-radius: 9px; background: #1f2128; border: 1px solid #2a2c33; color: #9a9ba2; cursor: pointer; font-weight: 600; }
@@ -103,7 +105,7 @@ <h1>Abuse Reports</h1>
103105
<div class="card">
104106
<label for="target">Create a new report</label>
105107
<div class="row">
106-
<textarea id="target" rows="2" placeholder="user id, @username, filename, #uid…, or paste Cloudflare file URLs" style="flex:1;min-width:180px"></textarea>
108+
<textarea id="target" rows="1" placeholder="ID, @username, file or URLs" style="flex:1;min-width:180px"></textarea>
107109
<button id="create-btn">Create</button>
108110
</div>
109111
<div class="err" id="create-err"></div>
@@ -290,26 +292,29 @@ <h1>Abuse Reports</h1>
290292
row.style.marginBottom = "10px";
291293
if (res.uuid) {
292294
row.innerHTML =
293-
`<div>πŸ†• <code>${res.user_id}</code> β€” encrypted <b>${res.encrypted}</b> file(s).</div>` +
294-
`<div style="margin:4px 0">Image key (P1): <code>${escapeHtml(res.p1)}</code> ` +
295-
`<span class="muted">(shown once β€” copy it)</span></div>`;
295+
`<div>πŸ†• encrypted <b>${res.encrypted}</b> file(s).</div>` +
296+
`<div style="margin:4px 0">Image key: <code class="copyable" data-copy="${escapeHtml(res.p1)}">${escapeHtml(res.p1)}</code></div>`;
296297
} else {
297298
const icon = res.ncmec_report_id ? "βœ…" : (res.existing_uuid ? "⏳" : "⏭");
298-
row.innerHTML = `<div>${icon} <code>${res.user_id}</code> β€” ${escapeHtml(res.error || "nothing to report")}</div>`;
299+
row.innerHTML = `<div>${icon} ${escapeHtml(res.error || "nothing to report")}</div>`;
299300
}
300301
const uuid = res.uuid || res.existing_uuid || res.filed_uuid;
301302
if (uuid) {
302303
const b = document.createElement("button");
303-
b.textContent = "Open report " + res.user_id;
304+
b.textContent = "Open " + res.user_id;
305+
b.style.width = "100%";
304306
b.onclick = () => openReport(uuid);
305307
row.appendChild(b);
308+
} else {
309+
// No report to open β€” the id has nowhere else to live.
310+
row.firstChild.innerHTML = `<code>${res.user_id}</code> Β· ` + row.firstChild.innerHTML;
306311
}
307312
ok.appendChild(row);
308313
}
309314
if ((j.unknown || []).length) {
310315
const u = document.createElement("div");
311316
u.className = "muted";
312-
u.textContent = `❓ ${j.unknown.length} token(s) with no uploader on record: ${j.unknown.join(", ")}`;
317+
u.textContent = `❓ no uploader on record: ${j.unknown.join(", ")}`;
313318
ok.appendChild(u);
314319
}
315320
$("target").value = "";
@@ -327,6 +332,24 @@ <h1>Abuse Reports</h1>
327332
return String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
328333
}
329334

335+
// Tap a .copyable to copy its data-copy value (clipboard API needs a secure
336+
// context; the textarea fallback covers older Telegram webviews).
337+
document.addEventListener("click", ev => {
338+
const el = ev.target.closest(".copyable");
339+
if (!el) return;
340+
const text = el.dataset.copy || el.textContent;
341+
if (navigator.clipboard && navigator.clipboard.writeText) {
342+
navigator.clipboard.writeText(text).catch(() => {});
343+
return;
344+
}
345+
const ta = document.createElement("textarea");
346+
ta.value = text;
347+
document.body.appendChild(ta);
348+
ta.select();
349+
try { document.execCommand("copy"); } catch (e) {}
350+
ta.remove();
351+
});
352+
330353
// ============ Tabs ============
331354
document.querySelectorAll(".tab").forEach(t => {
332355
t.onclick = () => {

β€Žreverse_image_search_bot/commands/report.pyβ€Ž

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ async def report_users(
128128
"icon": "πŸ†•",
129129
"user_id": uid,
130130
"username": uname,
131-
"detail": f"P1 <code>{html.escape(result.p1 or '')}</code> Β· {result.encrypted} file(s) offline",
131+
"detail": f"key <code>{html.escape(result.p1 or '')}</code> Β· {result.encrypted} file(s) offline",
132132
"uuid": result.report_uuid,
133133
}
134134
)
@@ -272,20 +272,15 @@ async def start_report(update: Update, context: ContextTypes.DEFAULT_TYPE, user_
272272
user = abuse.get_user(user_id) or {}
273273
uname = f"@{user['username']}" if user.get("username") else "β€”"
274274
launch = (
275-
"Tap the <b>Open report</b> menu button (bottom-left ⊞) to open it."
275+
"Tap the <b>Open report</b> menu button (bottom-left ⊞)."
276276
if menu_button_set
277277
else f"Open via the report menu button: {html.escape(url)}"
278278
)
279279
await update.message.reply_html(
280-
f"<b>Report prepared</b> for user <code>{user_id}</code> ({html.escape(uname)})\n"
281-
f"Encrypted <b>{result.encrypted}</b> file(s) and took them offline.\n\n"
282-
+ f"<b>Image key (P1):</b> <code>{html.escape(result.p1 or '')}</code>\n\n"
280+
f"πŸ†• <code>{user_id}</code> {html.escape(uname)} Β· {result.encrypted} file(s) offline\n"
281+
f"Image key: <code>{html.escape(result.p1 or '')}</code>\n\n"
283282
f"{launch}\n\n"
284-
f"<i>Use the global page password to open the report, then P1 to decrypt "
285-
f"the images. The files are no longer on disk β€” the encrypted blobs are "
286-
f"the only copy, so P1 is NOT recoverable and losing it loses the files. "
287-
f"Cancelling restores them to disk; filing keeps the reported ones "
288-
f"encrypted in the DB, bans the user, and deletes the rest.</i>",
283+
f"<i>Shown once and not stored β€” losing it loses the files.</i>",
289284
disable_web_page_preview=True,
290285
)
291286

β€Žtests/test_abuse_prepare.pyβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def test_restore_with_wrong_key_writes_nothing(env):
101101
mkfiles(1, 2)
102102
result = prepare.prepare_report(1)
103103
err = prepare.restore_report_files(result.report_uuid or "", "totally-wrong-key")
104-
assert "P1" in (err or "")
104+
assert "image key" in (err or "")
105105
assert plaintexts(updir) == [] # nothing scattered into the upload dir
106106

107107

β€Žtests/test_report_command.pyβ€Ž

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,8 @@ async def test_report_bulk_urls_group_unique_users(abuse, tmp_path, monkeypatch)
114114
out = "\n".join(replies)
115115
# Two unique uploaders β†’ two πŸ†• rows (user 1 grouped despite two files).
116116
assert out.count("πŸ†•") >= 2
117-
# Both users' P1 keys are surfaced as "P1 <code>…".
118-
assert out.count("P1 <code>") == 2
117+
# Both users' image keys are surfaced as "key <code>…".
118+
assert out.count("key <code>") == 2
119119
# The unknown file is reported.
120120
assert "UNKNOWN.jpg" in out and "no uploader on record" in out
121121
# Reports actually exist, one active per user.
@@ -142,7 +142,7 @@ async def test_report_single_target_by_username(abuse, tmp_path, monkeypatch):
142142
await rc.report_command(cast(Any, update), cast(Any, context))
143143

144144
out = "\n".join(replies)
145-
assert "πŸ†•" in out and "55" in out and "P1 <code>" in out
145+
assert "πŸ†•" in out and "55" in out and "key <code>" in out
146146
assert abuse.active_report_for_user(55) is not None
147147

148148

0 commit comments

Comments
Β (0)