Skip to content

Commit 6d2caf5

Browse files
committed
feat(abuse): manage sources and API keys from the console
A gear in the reports header opens API keys and Sources. Keys can be created, revoked (rotated in place, the old one dies immediately) and deleted; the secret is shown once and is otherwise the server-side mask. Sources can be created, and deleted only while unused — the default one is permanent. Creating a report now picks a source, and the statistics gained a source pie beside the languages one.
1 parent 942fd88 commit 6d2caf5

1 file changed

Lines changed: 227 additions & 19 deletions

File tree

reverse_image_search_bot/abuse_report/static/reports.html

Lines changed: 227 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,32 @@
7878
.heat th.hh { font-size: 8px; }
7979
.heat td.cell { border-radius: 2px; outline: 1px solid #14151a; }
8080
.heat td.day { width: 30px; color: #9a9ba2; font-size: 10px; text-align: right; padding-right: 5px; }
81+
/* Settings gear + dropdown */
82+
.gear { background: none; border: none; color: #9a9ba2; font-size: 20px; padding: 0 4px; line-height: 1; cursor: pointer; }
83+
.menu { position: absolute; right: 0; top: 26px; background: #1f2128; border: 1px solid #363842; border-radius: 10px; min-width: 170px; z-index: 20; overflow: hidden; }
84+
.menu div { padding: 11px 14px; cursor: pointer; }
85+
.menu div:hover { background: #2a2c33; }
86+
.item { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid #2a2c33; border-radius: 10px; margin-bottom: 8px; background: #1b1d23; }
87+
.item .nm { font-weight: 600; }
88+
button.small { padding: 6px 10px; font-size: 13px; }
89+
button.danger { background: #7a2b2b; }
8190
</style>
8291
</head>
8392
<body>
8493
<header>
85-
<h1>Abuse Reports</h1>
86-
<div class="sub" id="hdr-sub">Locked</div>
94+
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px">
95+
<div>
96+
<h1>Abuse Reports</h1>
97+
<div class="sub" id="hdr-sub">Locked</div>
98+
</div>
99+
<div style="position:relative">
100+
<button class="gear hidden" id="gear-btn" title="Settings"></button>
101+
<div class="menu hidden" id="gear-menu">
102+
<div data-open="keys">API keys</div>
103+
<div data-open="sources">Sources</div>
104+
</div>
105+
</div>
106+
</div>
87107
</header>
88108
<main>
89109
<!-- Global page password gate -->
@@ -107,6 +127,7 @@ <h1>Abuse Reports</h1>
107127
<label for="target">Create a new report</label>
108128
<div class="row">
109129
<textarea id="target" rows="1" placeholder="ID, @username, file or URLs" style="flex:1;min-width:180px"></textarea>
130+
<select id="create-source" title="Source"></select>
110131
<button id="create-btn">Create</button>
111132
</div>
112133
<div class="err" id="create-err"></div>
@@ -169,13 +190,48 @@ <h1>Abuse Reports</h1>
169190
<div class="row" id="st-lang-chips" style="justify-content:center"></div>
170191
</div>
171192

193+
<div class="card">
194+
<b>Sources</b>
195+
<div style="display:flex;justify-content:center;margin:12px 0">
196+
<svg id="st-src-pie" viewBox="0 0 200 200" width="220" height="220" role="img"></svg>
197+
</div>
198+
<div class="row" id="st-src-legend" style="justify-content:center"></div>
199+
</div>
200+
172201
<div class="card">
173202
<b>Posting Time</b>
174203
<div class="heat" id="st-heat" style="margin-top:10px"></div>
175204
</div>
176205

177206
<div class="err" id="st-err"></div>
178207
</div>
208+
209+
<!-- ============ API KEYS ============ -->
210+
<div id="pane-keys" class="hidden">
211+
<div class="card">
212+
<label for="key-name">New API key</label>
213+
<div class="row">
214+
<input type="text" id="key-name" placeholder="e.g. cloudflare-feed" style="flex:1;min-width:160px">
215+
<button id="key-add">Create</button>
216+
</div>
217+
<div class="err" id="key-err"></div>
218+
<div id="key-new" class="hidden" style="margin-top:10px"></div>
219+
</div>
220+
<div class="card"><div id="key-list"></div></div>
221+
</div>
222+
223+
<!-- ============ SOURCES ============ -->
224+
<div id="pane-sources" class="hidden">
225+
<div class="card">
226+
<label for="src-name">New source</label>
227+
<div class="row">
228+
<input type="text" id="src-name" placeholder="e.g. cybertip.ca" style="flex:1;min-width:160px">
229+
<button id="src-add">Create</button>
230+
</div>
231+
<div class="err" id="src-err"></div>
232+
</div>
233+
<div class="card"><div id="src-list"></div></div>
234+
</div>
179235
</div>
180236
</main>
181237

@@ -205,7 +261,9 @@ <h1>Abuse Reports</h1>
205261
try { sessionStorage.setItem("ris_report_pw", P2); } catch (e) {}
206262
$("unlock-card").classList.add("hidden");
207263
$("body").classList.remove("hidden");
264+
$("gear-btn").classList.remove("hidden");
208265
$("hdr-sub").classList.add("hidden");
266+
loadSources().catch(() => {});
209267
} catch (e) { $("unlock-err").textContent = "" + e.message; P2 = ""; }
210268
};
211269

@@ -282,7 +340,7 @@ <h1>Abuse Reports</h1>
282340
try {
283341
const r = await api("/api/create", {
284342
method: "POST", headers: { "Content-Type": "application/json" },
285-
body: JSON.stringify({ target }),
343+
body: JSON.stringify({ target, source: $("create-source").value }),
286344
});
287345
const j = await r.json();
288346
// One entry per unique uploader found in the input.
@@ -352,17 +410,148 @@ <h1>Abuse Reports</h1>
352410
ta.remove();
353411
});
354412

355-
// ============ Tabs ============
356-
document.querySelectorAll(".tab").forEach(t => {
357-
t.onclick = () => {
358-
document.querySelectorAll(".tab").forEach(x => x.classList.toggle("active", x === t));
359-
const isStats = t.dataset.tab === "stats";
360-
$("pane-reports").classList.toggle("hidden", isStats);
361-
$("pane-stats").classList.toggle("hidden", !isStats);
362-
if (isStats) loadStats().catch(e => { $("st-err").textContent = "" + e.message; });
363-
};
413+
// ============ Tabs / panes ============
414+
// Four panes share the body; the two tabs cover reports+stats, the gear menu
415+
// opens keys/sources. showPane() is the single place any of them is switched.
416+
const PANES = ["reports", "stats", "keys", "sources"];
417+
418+
function showPane(name) {
419+
PANES.forEach(p => $("pane-" + p).classList.toggle("hidden", p !== name));
420+
document.querySelectorAll(".tabs")[0].classList.toggle("hidden", name === "keys" || name === "sources");
421+
document.querySelectorAll(".tab").forEach(t => t.classList.toggle("active", t.dataset.tab === name));
422+
if (name === "stats") loadStats().catch(e => { $("st-err").textContent = "" + e.message; });
423+
if (name === "keys") loadKeys().catch(e => { $("key-err").textContent = "" + e.message; });
424+
if (name === "sources") loadSources().catch(e => { $("src-err").textContent = "" + e.message; });
425+
}
426+
427+
document.querySelectorAll(".tab").forEach(t => { t.onclick = () => showPane(t.dataset.tab); });
428+
429+
$("gear-btn").onclick = ev => { ev.stopPropagation(); $("gear-menu").classList.toggle("hidden"); };
430+
document.addEventListener("click", ev => {
431+
const item = ev.target.closest("#gear-menu div");
432+
if (item) showPane(item.dataset.open);
433+
if (!ev.target.closest("#gear-btn")) $("gear-menu").classList.add("hidden");
364434
});
365435

436+
// ============ Sources ============
437+
let SOURCES = [];
438+
let DEFAULT_SOURCE = "sweep";
439+
440+
async function loadSources() {
441+
const j = await (await api("/api/sources")).json();
442+
SOURCES = j.sources;
443+
DEFAULT_SOURCE = j.default;
444+
renderSources();
445+
fillSourceSelect();
446+
}
447+
448+
// Keep the create dropdown on whatever was selected; default otherwise.
449+
function fillSourceSelect() {
450+
const sel = $("create-source");
451+
const keep = sel.value;
452+
sel.innerHTML = SOURCES.map(s => `<option value="${escapeHtml(s.name)}">${escapeHtml(s.name)}</option>`).join("");
453+
sel.value = SOURCES.some(s => s.name === keep) ? keep : DEFAULT_SOURCE;
454+
}
455+
456+
function renderSources() {
457+
const box = $("src-list");
458+
box.innerHTML = "";
459+
for (const s of SOURCES) {
460+
const el = document.createElement("div");
461+
el.className = "item";
462+
el.innerHTML = `<div><div class="nm">${escapeHtml(s.name)}</div>` +
463+
`<div class="muted">${s.reports} report(s) · ${fmtTs(s.created_at)}</div></div>`;
464+
// The default source is permanent, and a used one is kept so the statistics
465+
// stay attributable — the server enforces both; no button is offered.
466+
if (s.name !== DEFAULT_SOURCE && !s.reports) {
467+
const b = document.createElement("button");
468+
b.className = "small danger";
469+
b.textContent = "Delete";
470+
b.onclick = () => sourceAction({ action: "delete", id: s.id });
471+
el.appendChild(b);
472+
}
473+
box.appendChild(el);
474+
}
475+
}
476+
477+
async function sourceAction(body) {
478+
$("src-err").textContent = "";
479+
try {
480+
const j = await (await api("/api/sources", {
481+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
482+
})).json();
483+
SOURCES = j.sources;
484+
DEFAULT_SOURCE = j.default;
485+
renderSources();
486+
fillSourceSelect();
487+
} catch (e) { $("src-err").textContent = "" + e.message; }
488+
}
489+
490+
$("src-add").onclick = async () => {
491+
const name = $("src-name").value.trim();
492+
if (!name) { $("src-err").textContent = "Enter a name."; return; }
493+
await sourceAction({ action: "add", name });
494+
$("src-name").value = "";
495+
};
496+
497+
// ============ API keys ============
498+
async function loadKeys(newKey) {
499+
const j = await (await api("/api/keys")).json();
500+
renderKeys(j.keys, newKey);
501+
}
502+
503+
function renderKeys(keys, newKey) {
504+
// The key itself only ever exists in this one response — show it until the
505+
// pane is reloaded, then it is gone for good.
506+
const nb = $("key-new");
507+
nb.classList.toggle("hidden", !newKey);
508+
if (newKey) {
509+
nb.innerHTML = `<div>Copy it now — it is not shown again.</div>` +
510+
`<div style="margin-top:4px"><code class="copyable" data-copy="${escapeHtml(newKey)}">${escapeHtml(newKey)}</code></div>`;
511+
}
512+
const box = $("key-list");
513+
box.innerHTML = keys.length ? "" : `<div class="muted">No API keys yet.</div>`;
514+
for (const k of keys) {
515+
const used = k.last_used_at ? ` · used ${fmtTs(k.last_used_at)}` : "";
516+
const rotated = k.rotated_at ? ` · rotated ${fmtTs(k.rotated_at)}` : "";
517+
const el = document.createElement("div");
518+
el.className = "item";
519+
el.innerHTML = `<div><div class="nm">${escapeHtml(k.name)}</div>` +
520+
`<div class="muted"><code>${escapeHtml(k.key_preview)}</code> · ${fmtTs(k.created_at)}${rotated}${used}</div></div>`;
521+
const row = document.createElement("div");
522+
row.className = "row";
523+
for (const [label, body, cls] of [
524+
["Revoke", { action: "rotate", id: k.id }, "small ghost"],
525+
["Delete", { action: "delete", id: k.id }, "small danger"],
526+
]) {
527+
const b = document.createElement("button");
528+
b.className = cls;
529+
b.textContent = label;
530+
b.onclick = () => keyAction(body);
531+
row.appendChild(b);
532+
}
533+
el.appendChild(row);
534+
box.appendChild(el);
535+
}
536+
}
537+
538+
async function keyAction(body) {
539+
$("key-err").textContent = "";
540+
try {
541+
const j = await (await api("/api/keys", {
542+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
543+
})).json();
544+
renderKeys(j.keys, j.new_key);
545+
} catch (e) { $("key-err").textContent = "" + e.message; }
546+
}
547+
548+
$("key-add").onclick = async () => {
549+
const name = $("key-name").value.trim();
550+
if (!name) { $("key-err").textContent = "Enter a name."; return; }
551+
await keyAction({ action: "add", name });
552+
$("key-name").value = "";
553+
};
554+
366555
// ============ Statistics ============
367556
let STATS = null; // raw records from the server (filed only)
368557
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
@@ -435,6 +624,7 @@ <h1>Abuse Reports</h1>
435624
$("st-reports").textContent = recs.length;
436625
$("st-users").textContent = new Set(recs.map(r => r.user_id)).size;
437626
renderLangPie(recs);
627+
renderSourcePie(recs);
438628
renderHeatmap(recs);
439629
}
440630

@@ -525,15 +715,19 @@ <h1>Abuse Reports</h1>
525715

526716
// Render the pie from the current PIE_CUR fractions (normalized to fill the circle).
527717
function paintPie(langs, color) {
528-
const svg = $("st-pie");
718+
$("st-pie").innerHTML = pieSvg(langs.map(k => [k, PIE_CUR[k] || 0]), color, fmtLang);
719+
}
720+
721+
// Pie markup for [key, weight] pairs — shared by the animated language pie and
722+
// the static source pie.
723+
function pieSvg(pairs, color, label) {
529724
const cx = 100, cy = 100, rad = 96;
530-
const parts = langs.map(k => [k, PIE_CUR[k] || 0]).filter(([, f]) => f > 0.0005);
725+
const parts = pairs.filter(([, f]) => f > 0.0005);
531726
const sum = parts.reduce((s, [, f]) => s + f, 0);
532-
if (sum < 0.0005) { svg.innerHTML = `<circle cx="100" cy="100" r="96" fill="#1b1d23"/>`; return; }
727+
if (sum < 0.0005) return `<circle cx="100" cy="100" r="96" fill="#1b1d23"/>`;
533728
// A single slice at ~100% can't be drawn as an arc (start==end) — use a circle.
534729
if (parts.length === 1) {
535-
svg.innerHTML = `<circle cx="100" cy="100" r="96" fill="${color[parts[0][0]]}"/>` + pieLabel(cx, cy - 40, "100%");
536-
return;
730+
return `<circle cx="100" cy="100" r="96" fill="${color[parts[0][0]]}"/>` + pieLabel(cx, cy - 40, "100%");
537731
}
538732
let angle = -Math.PI / 2; // 12 o'clock
539733
let out = "";
@@ -544,15 +738,29 @@ <h1>Abuse Reports</h1>
544738
const large = frac > 0.5 ? 1 : 0;
545739
const x1 = cx + rad * Math.cos(angle), y1 = cy + rad * Math.sin(angle);
546740
const x2 = cx + rad * Math.cos(end), y2 = cy + rad * Math.sin(end);
547-
out += `<path d="M${cx},${cy} L${x1.toFixed(2)},${y1.toFixed(2)} A${rad},${rad} 0 ${large},1 ${x2.toFixed(2)},${y2.toFixed(2)} Z" fill="${color[k]}"><title>${escapeHtml(fmtLang(k))}: ${Math.round(frac * 100)}%</title></path>`;
741+
out += `<path d="M${cx},${cy} L${x1.toFixed(2)},${y1.toFixed(2)} A${rad},${rad} 0 ${large},1 ${x2.toFixed(2)},${y2.toFixed(2)} Z" fill="${color[k]}"><title>${escapeHtml(label(k))}: ${Math.round(frac * 100)}%</title></path>`;
548742
if (frac >= 0.06) {
549743
const mid = angle + frac * Math.PI;
550744
const lr = rad * 0.6;
551745
labels.push(pieLabel(cx + lr * Math.cos(mid), cy + lr * Math.sin(mid), `${Math.round(frac * 100)}%`));
552746
}
553747
angle = end;
554748
}
555-
svg.innerHTML = out + labels.join("");
749+
return out + labels.join("");
750+
}
751+
752+
// Static pie of filed reports per source, with a plain (non-toggle) legend.
753+
function renderSourcePie(recs) {
754+
const counts = {};
755+
for (const r of recs) { const k = r.source || "—"; counts[k] = (counts[k] || 0) + 1; }
756+
const names = Object.keys(counts).sort((a, b) => counts[b] - counts[a]);
757+
const color = {};
758+
names.forEach((k, i) => { color[k] = PIE_COLORS[i % PIE_COLORS.length]; });
759+
$("st-src-pie").innerHTML = pieSvg(names.map(k => [k, counts[k]]), color, k => k);
760+
const legend = $("st-src-legend");
761+
legend.innerHTML = names.length
762+
? names.map(k => `<span class="lang-chip" style="background:${color[k]};cursor:default">${escapeHtml(k)} · ${counts[k]}</span>`).join("")
763+
: `<div class="muted">No data for this period.</div>`;
556764
}
557765

558766
function pieLabel(x, y, text) {

0 commit comments

Comments
 (0)