+10
-1
moderation/src/admin.rs
+10
-1
moderation/src/admin.rs
···
380
380
db.create_batch(&id, &uris, request.created_by.as_deref())
381
381
.await?;
382
382
383
-
let url = format!("/review/{}", id);
383
+
let url = format!("/admin/review/{}", id);
384
384
385
385
Ok(Json(CreateBatchResponse { id, url, flag_count }))
386
386
}
···
465
465
let resolved_active = if current_filter == "resolved" { " active" } else { "" };
466
466
let all_active = if current_filter == "all" { " active" } else { "" };
467
467
468
+
let count = tracks.len();
469
+
let count_label = match current_filter {
470
+
"pending" => format!("{} pending", count),
471
+
"resolved" => format!("{} resolved", count),
472
+
_ => format!("{} total", count),
473
+
};
474
+
468
475
let filter_buttons = format!(
469
476
"<div class=\"filter-row\">\
470
477
<span class=\"filter-label\">show:</span>\
471
478
<button type=\"button\" class=\"filter-btn{}\" hx-get=\"/admin/flags-html?filter=pending\" hx-target=\"#flags-list\">pending</button>\
472
479
<button type=\"button\" class=\"filter-btn{}\" hx-get=\"/admin/flags-html?filter=resolved\" hx-target=\"#flags-list\">resolved</button>\
473
480
<button type=\"button\" class=\"filter-btn{}\" hx-get=\"/admin/flags-html?filter=all\" hx-target=\"#flags-list\">all</button>\
481
+
<span class=\"filter-count\">{}</span>\
474
482
</div>",
475
483
pending_active,
476
484
resolved_active,
477
485
all_active,
486
+
count_label,
478
487
);
479
488
480
489
if tracks.is_empty() {
+2
-2
moderation/src/auth.rs
+2
-2
moderation/src/auth.rs
···
12
12
let path = req.uri().path();
13
13
14
14
// Public endpoints - no auth required
15
-
// Note: /admin and /review/:id serve HTML, auth is handled client-side for API calls
15
+
// Note: /admin and /admin/review/:id serve HTML, auth is handled client-side for API calls
16
16
// Static files must be public for admin UI CSS/JS to load
17
-
let is_review_page = path.starts_with("/review/")
17
+
let is_review_page = path.starts_with("/admin/review/")
18
18
&& !path.ends_with("/data")
19
19
&& !path.ends_with("/submit");
20
20
if path == "/"
+4
moderation/src/db.rs
+4
moderation/src/db.rs
···
95
95
FingerprintNoise,
96
96
/// Legal cover version or remix
97
97
CoverVersion,
98
+
/// Content was deleted from plyr.fm
99
+
ContentDeleted,
98
100
/// Other reason (see resolution_notes)
99
101
Other,
100
102
}
···
107
109
Self::Licensed => "licensed",
108
110
Self::FingerprintNoise => "fingerprint noise",
109
111
Self::CoverVersion => "cover/remix",
112
+
Self::ContentDeleted => "content deleted",
110
113
Self::Other => "other",
111
114
}
112
115
}
···
118
121
"licensed" => Some(Self::Licensed),
119
122
"fingerprint_noise" => Some(Self::FingerprintNoise),
120
123
"cover_version" => Some(Self::CoverVersion),
124
+
"content_deleted" => Some(Self::ContentDeleted),
121
125
"other" => Some(Self::Other),
122
126
_ => None,
123
127
}
+4
-4
moderation/src/main.rs
+4
-4
moderation/src/main.rs
···
93
93
post(admin::remove_sensitive_image),
94
94
)
95
95
.route("/admin/batches", post(admin::create_batch))
96
-
// Review endpoints (auth protected)
97
-
.route("/review/:id", get(review::review_page))
98
-
.route("/review/:id/data", get(review::review_data))
99
-
.route("/review/:id/submit", post(review::submit_review))
96
+
// Review endpoints (under admin, auth protected)
97
+
.route("/admin/review/:id", get(review::review_page))
98
+
.route("/admin/review/:id/data", get(review::review_data))
99
+
.route("/admin/review/:id/submit", post(review::submit_review))
100
100
// Static files (CSS, JS for admin UI)
101
101
.nest_service("/static", ServeDir::new("static"))
102
102
// ATProto XRPC endpoints (public)
+151
-142
moderation/src/review.rs
+151
-142
moderation/src/review.rs
···
31
31
#[derive(Debug, Deserialize)]
32
32
pub struct ReviewDecision {
33
33
pub uri: String,
34
-
pub decision: String, // "approved" or "rejected"
34
+
/// "clear" (false positive), "defer" (acknowledge, no action), "confirm" (real violation)
35
+
pub decision: String,
35
36
}
36
37
37
38
/// Response after submitting review.
···
110
111
db.mark_flag_reviewed(&batch_id, &decision.uri, &decision.decision)
111
112
.await?;
112
113
113
-
if decision.decision == "approved" {
114
-
let label =
115
-
crate::labels::Label::new(signer.did(), &decision.uri, "copyright-violation")
116
-
.negated();
117
-
let label = signer.sign_label(label)?;
118
-
let seq = db.store_label(&label).await?;
114
+
match decision.decision.as_str() {
115
+
"clear" => {
116
+
// False positive - emit negation label to clear the flag
117
+
let label =
118
+
crate::labels::Label::new(signer.did(), &decision.uri, "copyright-violation")
119
+
.negated();
120
+
let label = signer.sign_label(label)?;
121
+
let seq = db.store_label(&label).await?;
119
122
120
-
db.store_resolution(
121
-
&decision.uri,
122
-
crate::db::ResolutionReason::FingerprintNoise,
123
-
Some("batch review"),
124
-
)
125
-
.await?;
123
+
db.store_resolution(
124
+
&decision.uri,
125
+
crate::db::ResolutionReason::FingerprintNoise,
126
+
Some("batch review: cleared"),
127
+
)
128
+
.await?;
126
129
127
-
if let Some(tx) = &state.label_tx {
128
-
let _ = tx.send((seq, label));
129
-
}
130
+
if let Some(tx) = &state.label_tx {
131
+
let _ = tx.send((seq, label));
132
+
}
130
133
131
-
resolved_count += 1;
134
+
resolved_count += 1;
135
+
}
136
+
"defer" => {
137
+
// Acknowledge but take no action - flag stays active
138
+
// Just mark as reviewed in the batch, no label changes
139
+
tracing::info!(uri = %decision.uri, "deferred - no action taken");
140
+
}
141
+
"confirm" => {
142
+
// Real violation - flag stays active, could add enforcement later
143
+
tracing::info!(uri = %decision.uri, "confirmed as violation");
144
+
}
145
+
_ => {
146
+
tracing::warn!(uri = %decision.uri, decision = %decision.decision, "unknown decision type");
147
+
}
132
148
}
133
149
}
134
150
···
175
191
};
176
192
177
193
let status_badge = if status == "completed" {
178
-
r#"<span class="status-badge completed">completed</span>"#
194
+
r#"<span class="badge resolved">completed</span>"#
179
195
} else {
180
196
""
181
197
};
···
187
203
<meta charset="utf-8">
188
204
<meta name="viewport" content="width=device-width, initial-scale=1">
189
205
<title>review batch - plyr.fm</title>
206
+
<link rel="stylesheet" href="/static/admin.css">
190
207
<style>{}</style>
191
208
</head>
192
209
<body>
193
-
<div class="container">
194
-
<header>
195
-
<h1>plyr.fm moderation</h1>
196
-
<div class="batch-info">{} pending {}</div>
197
-
</header>
210
+
<h1>plyr.fm moderation</h1>
211
+
<p class="subtitle">
212
+
<a href="/admin">← back to dashboard</a>
213
+
<span style="margin: 0 12px; color: var(--text-muted);">|</span>
214
+
batch review: {} pending {}
215
+
</p>
198
216
199
-
<div class="auth-section" id="auth-section">
200
-
<input type="password" id="auth-token" placeholder="auth token"
201
-
onkeyup="if(event.key==='Enter')authenticate()">
202
-
<button class="btn-submit" onclick="authenticate()">authenticate</button>
217
+
<div class="auth-section" id="auth-section">
218
+
<input type="password" id="auth-token" placeholder="auth token"
219
+
onkeyup="if(event.key==='Enter')authenticate()">
220
+
<button class="btn btn-primary" onclick="authenticate()">authenticate</button>
221
+
</div>
222
+
223
+
<form id="review-form" style="display: none;">
224
+
<div class="flags-list">
225
+
{}
203
226
</div>
204
227
205
-
<form id="review-form" class="review-form" style="display: none;">
206
-
<div class="flags-list">
207
-
{}
208
-
</div>
228
+
{}
209
229
210
-
{}
211
-
212
-
<div class="submit-bar">
213
-
<button type="submit" class="btn-submit" id="submit-btn" disabled>
214
-
submit decisions
215
-
</button>
216
-
</div>
217
-
</form>
218
-
</div>
230
+
<div class="submit-bar">
231
+
<button type="submit" class="btn btn-primary" id="submit-btn" disabled>
232
+
submit decisions
233
+
</button>
234
+
</div>
235
+
</form>
219
236
220
237
<script>
221
238
const form = document.getElementById('review-form');
···
255
272
}}
256
273
257
274
function setDecision(uri, decision) {{
258
-
decisions[uri] = decision;
259
-
const card = document.querySelector(`[data-uri="${{CSS.escape(uri)}}"]`);
260
-
if (card) {{
261
-
card.classList.remove('approved', 'rejected');
262
-
card.classList.add(decision);
275
+
// Toggle off if clicking the same decision
276
+
if (decisions[uri] === decision) {{
277
+
delete decisions[uri];
278
+
const card = document.querySelector(`[data-uri="${{CSS.escape(uri)}}"]`);
279
+
if (card) card.classList.remove('decision-clear', 'decision-defer', 'decision-confirm');
280
+
}} else {{
281
+
decisions[uri] = decision;
282
+
const card = document.querySelector(`[data-uri="${{CSS.escape(uri)}}"]`);
283
+
if (card) {{
284
+
card.classList.remove('decision-clear', 'decision-defer', 'decision-confirm');
285
+
card.classList.add('decision-' + decision);
286
+
}}
263
287
}}
264
288
updateSubmitBtn();
265
289
}}
···
270
294
submitBtn.textContent = 'submitting...';
271
295
272
296
try {{
273
-
const response = await fetch(`/review/${{batchId}}/submit`, {{
297
+
const response = await fetch(`/admin/review/${{batchId}}/submit`, {{
274
298
method: 'POST',
275
299
headers: {{
276
300
'Content-Type': 'application/json',
···
284
308
if (response.status === 401) {{
285
309
localStorage.removeItem('mod_token');
286
310
currentToken = '';
287
-
authSection.style.display = 'flex';
311
+
authSection.style.display = 'block';
288
312
form.style.display = 'none';
289
313
document.getElementById('auth-token').value = '';
290
314
alert('invalid token');
···
347
371
.map(|matches| {
348
372
let items: Vec<String> = matches
349
373
.iter()
350
-
.take(2)
374
+
.take(3)
351
375
.map(|m| {
352
376
format!(
353
-
r#"<span class="match">{} - {}</span>"#,
377
+
r#"<div class="match-item"><span class="title">{}</span> <span class="artist">by {}</span></div>"#,
354
378
html_escape(&m.title),
355
379
html_escape(&m.artist)
356
380
)
357
381
})
358
382
.collect();
359
-
format!(r#"<div class="matches">{}</div>"#, items.join(""))
383
+
format!(
384
+
r#"<div class="matches"><h4>potential matches</h4>{}</div>"#,
385
+
items.join("\n")
386
+
)
360
387
})
361
388
.unwrap_or_default();
362
389
363
390
let resolved_badge = if track.resolved {
364
391
r#"<span class="badge resolved">resolved</span>"#
365
392
} else {
366
-
""
393
+
r#"<span class="badge pending">pending</span>"#
367
394
};
368
395
369
396
let action_buttons = if !track.resolved {
370
397
format!(
371
-
r#"<div class="actions">
372
-
<button type="button" class="btn-approve" onclick="setDecision('{}', 'approved')">approve</button>
373
-
<button type="button" class="btn-reject" onclick="setDecision('{}', 'rejected')">reject</button>
398
+
r#"<div class="flag-actions">
399
+
<button type="button" class="btn btn-clear" onclick="setDecision('{}', 'clear')">clear</button>
400
+
<button type="button" class="btn btn-defer" onclick="setDecision('{}', 'defer')">defer</button>
401
+
<button type="button" class="btn btn-confirm" onclick="setDecision('{}', 'confirm')">confirm</button>
374
402
</div>"#,
403
+
html_escape(&track.uri),
375
404
html_escape(&track.uri),
376
405
html_escape(&track.uri)
377
406
)
···
380
409
};
381
410
382
411
format!(
383
-
r#"<div class="review-card{}" data-uri="{}">
384
-
<div class="track-info">
385
-
<div class="title">{}</div>
386
-
<div class="artist">@{}</div>
387
-
{}
412
+
r#"<div class="flag-card{}" data-uri="{}">
413
+
<div class="flag-header">
414
+
<div class="track-info">
415
+
<h3>{}</h3>
416
+
<div class="artist">@{}</div>
417
+
</div>
418
+
<div class="flag-badges">
419
+
{}
420
+
</div>
388
421
</div>
389
422
{}
390
423
{}
···
393
426
html_escape(&track.uri),
394
427
title_html,
395
428
html_escape(artist),
396
-
matches_html,
397
429
resolved_badge,
430
+
matches_html,
398
431
action_buttons
399
432
)
400
433
}
···
407
440
.replace('\'', "'")
408
441
}
409
442
443
+
/// Additional CSS for review page (supplements admin.css)
410
444
const REVIEW_CSS: &str = r#"
411
-
* { box-sizing: border-box; margin: 0; padding: 0; }
445
+
/* review page specific styles */
446
+
body { padding-bottom: 80px; }
412
447
413
-
body {
414
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
415
-
background: #0a0a0a;
416
-
color: #e0e0e0;
417
-
min-height: 100vh;
448
+
.subtitle a {
449
+
color: var(--accent);
450
+
text-decoration: none;
418
451
}
452
+
.subtitle a:hover { text-decoration: underline; }
419
453
420
-
.container {
421
-
max-width: 600px;
422
-
margin: 0 auto;
423
-
padding: 16px;
424
-
padding-bottom: 80px;
454
+
/* action buttons */
455
+
.btn-clear {
456
+
background: rgba(74, 222, 128, 0.15);
457
+
color: var(--success);
458
+
border: 1px solid rgba(74, 222, 128, 0.3);
425
459
}
426
-
427
-
header {
428
-
display: flex;
429
-
justify-content: space-between;
430
-
align-items: center;
431
-
margin-bottom: 20px;
432
-
padding-bottom: 12px;
433
-
border-bottom: 1px solid #333;
460
+
.btn-clear:hover {
461
+
background: rgba(74, 222, 128, 0.25);
434
462
}
435
463
436
-
h1 { font-size: 1.25rem; font-weight: 600; color: #fff; }
437
-
.batch-info { font-size: 0.875rem; color: #888; }
438
-
.status-badge { font-size: 0.7rem; background: #1a3a1a; color: #6d9; padding: 2px 6px; border-radius: 4px; margin-left: 8px; }
439
-
440
-
.auth-section {
441
-
display: flex;
442
-
gap: 10px;
443
-
margin-bottom: 20px;
444
-
align-items: center;
445
-
}
446
-
.auth-section input[type="password"] {
447
-
flex: 1;
448
-
padding: 10px 12px;
449
-
background: #1a1a1a;
450
-
border: 1px solid #333;
451
-
border-radius: 6px;
452
-
color: #fff;
453
-
font-size: 0.875rem;
464
+
.btn-defer {
465
+
background: rgba(251, 191, 36, 0.15);
466
+
color: var(--warning);
467
+
border: 1px solid rgba(251, 191, 36, 0.3);
454
468
}
455
-
.auth-section input:focus {
456
-
outline: none;
457
-
border-color: #4a9eff;
469
+
.btn-defer:hover {
470
+
background: rgba(251, 191, 36, 0.25);
458
471
}
459
472
460
-
.flags-list { display: flex; flex-direction: column; gap: 12px; }
461
-
462
-
.review-card {
463
-
background: #1a1a1a;
464
-
border: 1px solid #333;
465
-
border-radius: 8px;
466
-
padding: 12px;
467
-
transition: border-color 0.2s, background 0.2s;
473
+
.btn-confirm {
474
+
background: rgba(239, 68, 68, 0.15);
475
+
color: var(--error);
476
+
border: 1px solid rgba(239, 68, 68, 0.3);
477
+
}
478
+
.btn-confirm:hover {
479
+
background: rgba(239, 68, 68, 0.25);
468
480
}
469
481
470
-
.review-card.approved { border-color: #2d5a27; background: #1a2a18; }
471
-
.review-card.rejected { border-color: #5a2727; background: #2a1818; }
472
-
.review-card.resolved { opacity: 0.6; }
473
-
.track-info { margin-bottom: 8px; }
474
-
.title { font-weight: 600; font-size: 1rem; margin-bottom: 2px; }
475
-
.title a { color: inherit; text-decoration: none; }
476
-
.title a:hover { text-decoration: underline; }
477
-
.artist { font-size: 0.875rem; color: #888; }
478
-
.matches { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 4px; }
479
-
.match { font-size: 0.75rem; background: #2a2a2a; padding: 2px 6px; border-radius: 4px; color: #aaa; }
480
-
.badge { display: inline-block; font-size: 0.7rem; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; font-weight: 500; }
481
-
.badge.resolved { background: #1a3a1a; color: #6d9; }
482
-
.actions { display: flex; gap: 8px; margin-top: 10px; }
483
-
.actions button { flex: 1; padding: 10px; border: none; border-radius: 6px; font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: opacity 0.2s; }
484
-
.btn-approve { background: #2d5a27; color: #fff; }
485
-
.btn-reject { background: #5a2727; color: #fff; }
486
-
.actions button:active { opacity: 0.8; }
482
+
/* card selection states */
483
+
.flag-card.decision-clear {
484
+
border-color: var(--success);
485
+
background: rgba(74, 222, 128, 0.05);
486
+
}
487
+
.flag-card.decision-defer {
488
+
border-color: var(--warning);
489
+
background: rgba(251, 191, 36, 0.05);
490
+
}
491
+
.flag-card.decision-confirm {
492
+
border-color: var(--error);
493
+
background: rgba(239, 68, 68, 0.05);
494
+
}
487
495
496
+
/* submit bar */
488
497
.submit-bar {
489
498
position: fixed;
490
499
bottom: 0;
491
500
left: 0;
492
501
right: 0;
493
-
padding: 12px 16px;
494
-
background: #111;
495
-
border-top: 1px solid #333;
502
+
padding: 16px 24px;
503
+
background: var(--bg-secondary);
504
+
border-top: 1px solid var(--border-subtle);
496
505
}
497
-
498
-
.btn-submit {
506
+
.submit-bar .btn {
499
507
width: 100%;
500
-
max-width: 600px;
508
+
max-width: 900px;
501
509
margin: 0 auto;
502
510
display: block;
503
511
padding: 14px;
504
-
background: #4a9eff;
505
-
color: #fff;
506
-
border: none;
507
-
border-radius: 8px;
508
-
font-size: 1rem;
509
-
font-weight: 600;
510
-
cursor: pointer;
511
512
}
512
513
513
-
.btn-submit:disabled { background: #333; color: #666; cursor: not-allowed; }
514
-
.empty { text-align: center; padding: 40px 20px; color: #666; }
515
-
.resolved-section { margin-top: 20px; border-top: 1px solid #333; padding-top: 16px; }
516
-
.resolved-section summary { cursor: pointer; color: #888; font-size: 0.875rem; margin-bottom: 12px; }
514
+
/* resolved section */
515
+
.resolved-section {
516
+
margin-top: 24px;
517
+
padding-top: 16px;
518
+
border-top: 1px solid var(--border-subtle);
519
+
}
520
+
.resolved-section summary {
521
+
cursor: pointer;
522
+
color: var(--text-tertiary);
523
+
font-size: 0.85rem;
524
+
margin-bottom: 12px;
525
+
}
517
526
"#;
+6
moderation/static/admin.css
+6
moderation/static/admin.css
+66
-1
scripts/moderation_loop.py
+66
-1
scripts/moderation_loop.py
···
117
117
118
118
119
119
@dataclass
120
+
class PlyrClient:
121
+
"""client for checking track existence in plyr.fm."""
122
+
123
+
env: str = "prod"
124
+
_client: httpx.AsyncClient = field(init=False, repr=False)
125
+
126
+
def __post_init__(self) -> None:
127
+
base_url = {
128
+
"prod": "https://api.plyr.fm",
129
+
"staging": "https://api-stg.plyr.fm",
130
+
"dev": "http://localhost:8001",
131
+
}.get(self.env, "https://api.plyr.fm")
132
+
self._client = httpx.AsyncClient(base_url=base_url, timeout=10.0)
133
+
134
+
async def close(self) -> None:
135
+
await self._client.aclose()
136
+
137
+
async def track_exists(self, track_id: int) -> bool:
138
+
"""check if a track exists (returns False if 404)."""
139
+
try:
140
+
r = await self._client.get(f"/tracks/{track_id}")
141
+
return r.status_code == 200
142
+
except Exception:
143
+
return True # assume exists on error (don't accidentally delete labels)
144
+
145
+
146
+
@dataclass
120
147
class ModClient:
121
148
base_url: str
122
149
auth_token: str
···
203
230
204
231
dm = DMClient(settings.bot_handle, settings.bot_password, settings.recipient_handle)
205
232
mod = ModClient(settings.moderation_service_url, settings.moderation_auth_token)
233
+
plyr = PlyrClient(env=env)
206
234
207
235
try:
208
236
await dm.setup()
···
215
243
216
244
console.print(f"[bold]{len(pending)} pending flags[/bold]")
217
245
218
-
# analyze flags
246
+
# check for deleted tracks and auto-resolve them
247
+
console.print("[dim]checking for deleted tracks...[/dim]")
248
+
active_flags = []
249
+
deleted_count = 0
250
+
for flag in pending:
251
+
track_id = flag.get("context", {}).get("track_id")
252
+
if track_id and not await plyr.track_exists(track_id):
253
+
# track was deleted - resolve the flag
254
+
if not dry_run:
255
+
try:
256
+
await mod.resolve(
257
+
flag["uri"], "content_deleted", "track no longer exists"
258
+
)
259
+
console.print(
260
+
f" [yellow]⌫[/yellow] deleted: {flag['uri'][-40:]}"
261
+
)
262
+
deleted_count += 1
263
+
except Exception as e:
264
+
console.print(f" [red]✗[/red] {e}")
265
+
active_flags.append(flag)
266
+
else:
267
+
console.print(
268
+
f" [yellow]would resolve deleted:[/yellow] {flag['uri'][-40:]}"
269
+
)
270
+
deleted_count += 1
271
+
else:
272
+
active_flags.append(flag)
273
+
274
+
if deleted_count > 0:
275
+
console.print(f"[yellow]{deleted_count} deleted tracks resolved[/yellow]")
276
+
277
+
pending = active_flags
278
+
if not pending:
279
+
console.print("[green]all flags were for deleted tracks[/green]")
280
+
return
281
+
282
+
# analyze remaining flags
219
283
if limit:
220
284
pending = pending[:limit]
221
285
···
268
332
269
333
finally:
270
334
await mod.close()
335
+
await plyr.close()
271
336
272
337
273
338
def main() -> None: