at main 16 kB view raw
1//! Review endpoints for batch flag review. 2//! 3//! These endpoints are behind the same auth as admin endpoints. 4 5use axum::{ 6 extract::{Path, State}, 7 http::header::CONTENT_TYPE, 8 response::{IntoResponse, Response}, 9 Json, 10}; 11use serde::{Deserialize, Serialize}; 12 13use crate::admin::FlaggedTrack; 14use crate::state::{AppError, AppState}; 15 16/// Response for review page data. 17#[derive(Debug, Serialize)] 18pub struct ReviewPageData { 19 pub batch_id: String, 20 pub flags: Vec<FlaggedTrack>, 21 pub status: String, 22} 23 24/// Request to submit review decisions. 25#[derive(Debug, Deserialize)] 26pub struct SubmitReviewRequest { 27 pub decisions: Vec<ReviewDecision>, 28} 29 30/// A single review decision. 31#[derive(Debug, Deserialize)] 32pub struct ReviewDecision { 33 pub uri: String, 34 /// "clear" (false positive), "defer" (acknowledge, no action), "confirm" (real violation) 35 pub decision: String, 36} 37 38/// Response after submitting review. 39#[derive(Debug, Serialize)] 40pub struct SubmitReviewResponse { 41 pub resolved_count: usize, 42 pub message: String, 43} 44 45/// Get review page HTML. 46pub async fn review_page( 47 State(state): State<AppState>, 48 Path(batch_id): Path<String>, 49) -> Result<Response, AppError> { 50 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 51 52 let batch = db 53 .get_batch(&batch_id) 54 .await? 55 .ok_or(AppError::NotFound("batch not found".to_string()))?; 56 57 let flags = db.get_batch_flags(&batch_id).await?; 58 let html = render_review_page(&batch_id, &flags, &batch.status); 59 60 Ok(([(CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()) 61} 62 63/// Get review data as JSON. 64pub async fn review_data( 65 State(state): State<AppState>, 66 Path(batch_id): Path<String>, 67) -> Result<Json<ReviewPageData>, AppError> { 68 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 69 70 let batch = db 71 .get_batch(&batch_id) 72 .await? 73 .ok_or(AppError::NotFound("batch not found".to_string()))?; 74 75 let flags = db.get_batch_flags(&batch_id).await?; 76 77 Ok(Json(ReviewPageData { 78 batch_id, 79 flags, 80 status: batch.status, 81 })) 82} 83 84/// Submit review decisions. 85pub async fn submit_review( 86 State(state): State<AppState>, 87 Path(batch_id): Path<String>, 88 Json(request): Json<SubmitReviewRequest>, 89) -> Result<Json<SubmitReviewResponse>, AppError> { 90 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 91 let signer = state 92 .signer 93 .as_ref() 94 .ok_or(AppError::LabelerNotConfigured)?; 95 96 let _batch = db 97 .get_batch(&batch_id) 98 .await? 99 .ok_or(AppError::NotFound("batch not found".to_string()))?; 100 101 let mut resolved_count = 0; 102 103 for decision in &request.decisions { 104 tracing::info!( 105 batch_id = %batch_id, 106 uri = %decision.uri, 107 decision = %decision.decision, 108 "processing review decision" 109 ); 110 111 db.mark_flag_reviewed(&batch_id, &decision.uri, &decision.decision) 112 .await?; 113 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?; 122 123 db.store_resolution( 124 &decision.uri, 125 crate::db::ResolutionReason::FingerprintNoise, 126 Some("batch review: cleared"), 127 ) 128 .await?; 129 130 if let Some(tx) = &state.label_tx { 131 let _ = tx.send((seq, label)); 132 } 133 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 } 148 } 149 } 150 151 let pending = db.get_batch_pending_uris(&batch_id).await?; 152 if pending.is_empty() { 153 db.update_batch_status(&batch_id, "completed").await?; 154 } 155 156 Ok(Json(SubmitReviewResponse { 157 resolved_count, 158 message: format!( 159 "processed {} decisions, resolved {} flags", 160 request.decisions.len(), 161 resolved_count 162 ), 163 })) 164} 165 166/// Render the review page. 167fn render_review_page(batch_id: &str, flags: &[FlaggedTrack], status: &str) -> String { 168 let pending: Vec<_> = flags.iter().filter(|f| !f.resolved).collect(); 169 let resolved: Vec<_> = flags.iter().filter(|f| f.resolved).collect(); 170 171 let pending_cards: Vec<String> = pending.iter().map(|f| render_review_card(f)).collect(); 172 let resolved_cards: Vec<String> = resolved.iter().map(|f| render_review_card(f)).collect(); 173 174 let pending_html = if pending_cards.is_empty() { 175 "<div class=\"empty\">all flags reviewed!</div>".to_string() 176 } else { 177 pending_cards.join("\n") 178 }; 179 180 let resolved_html = if resolved_cards.is_empty() { 181 String::new() 182 } else { 183 format!( 184 r#"<details class="resolved-section"> 185 <summary>{} resolved</summary> 186 {} 187 </details>"#, 188 resolved_cards.len(), 189 resolved_cards.join("\n") 190 ) 191 }; 192 193 let status_badge = if status == "completed" { 194 r#"<span class="badge resolved">completed</span>"# 195 } else { 196 "" 197 }; 198 199 format!( 200 r#"<!DOCTYPE html> 201<html lang="en"> 202<head> 203 <meta charset="utf-8"> 204 <meta name="viewport" content="width=device-width, initial-scale=1"> 205 <title>review batch - plyr.fm</title> 206 <link rel="stylesheet" href="/static/admin.css"> 207 <style>{}</style> 208</head> 209<body> 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> 216 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 {} 226 </div> 227 228 {} 229 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> 236 237 <script> 238 const form = document.getElementById('review-form'); 239 const submitBtn = document.getElementById('submit-btn'); 240 const authSection = document.getElementById('auth-section'); 241 const batchId = '{}'; 242 243 let currentToken = ''; 244 const decisions = {{}}; 245 246 function authenticate() {{ 247 const token = document.getElementById('auth-token').value; 248 if (token && token !== '••••••••') {{ 249 localStorage.setItem('mod_token', token); 250 currentToken = token; 251 showReviewForm(); 252 }} 253 }} 254 255 function showReviewForm() {{ 256 authSection.style.display = 'none'; 257 form.style.display = 'block'; 258 }} 259 260 // Check for saved token on load 261 const savedToken = localStorage.getItem('mod_token'); 262 if (savedToken) {{ 263 currentToken = savedToken; 264 document.getElementById('auth-token').value = '••••••••'; 265 showReviewForm(); 266 }} 267 268 function updateSubmitBtn() {{ 269 const count = Object.keys(decisions).length; 270 submitBtn.disabled = count === 0; 271 submitBtn.textContent = count > 0 ? `submit ${{count}} decision${{count > 1 ? 's' : ''}}` : 'submit decisions'; 272 }} 273 274 function setDecision(uri, 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 }} 287 }} 288 updateSubmitBtn(); 289 }} 290 291 form.addEventListener('submit', async (e) => {{ 292 e.preventDefault(); 293 submitBtn.disabled = true; 294 submitBtn.textContent = 'submitting...'; 295 296 try {{ 297 const response = await fetch(`/admin/review/${{batchId}}/submit`, {{ 298 method: 'POST', 299 headers: {{ 300 'Content-Type': 'application/json', 301 'X-Moderation-Key': currentToken 302 }}, 303 body: JSON.stringify({{ 304 decisions: Object.entries(decisions).map(([uri, decision]) => ({{ uri, decision }})) 305 }}) 306 }}); 307 308 if (response.status === 401) {{ 309 localStorage.removeItem('mod_token'); 310 currentToken = ''; 311 authSection.style.display = 'block'; 312 form.style.display = 'none'; 313 document.getElementById('auth-token').value = ''; 314 alert('invalid token'); 315 return; 316 }} 317 318 if (response.ok) {{ 319 const result = await response.json(); 320 alert(result.message); 321 location.reload(); 322 }} else {{ 323 const err = await response.json(); 324 alert('error: ' + (err.message || 'unknown error')); 325 submitBtn.disabled = false; 326 updateSubmitBtn(); 327 }} 328 }} catch (err) {{ 329 alert('network error: ' + err.message); 330 submitBtn.disabled = false; 331 updateSubmitBtn(); 332 }} 333 }}); 334 </script> 335</body> 336</html>"#, 337 REVIEW_CSS, 338 pending.len(), 339 status_badge, 340 pending_html, 341 resolved_html, 342 html_escape(batch_id) 343 ) 344} 345 346/// Render a single review card. 347fn render_review_card(track: &FlaggedTrack) -> String { 348 let ctx = track.context.as_ref(); 349 350 let title = ctx 351 .and_then(|c| c.track_title.as_deref()) 352 .unwrap_or("unknown track"); 353 let artist = ctx 354 .and_then(|c| c.artist_handle.as_deref()) 355 .unwrap_or("unknown"); 356 let track_id = ctx.and_then(|c| c.track_id); 357 358 let title_html = if let Some(id) = track_id { 359 format!( 360 r#"<a href="https://plyr.fm/track/{}" target="_blank">{}</a>"#, 361 id, 362 html_escape(title) 363 ) 364 } else { 365 html_escape(title) 366 }; 367 368 let matches_html = ctx 369 .and_then(|c| c.matches.as_ref()) 370 .filter(|m| !m.is_empty()) 371 .map(|matches| { 372 let items: Vec<String> = matches 373 .iter() 374 .take(3) 375 .map(|m| { 376 format!( 377 r#"<div class="match-item"><span class="title">{}</span> <span class="artist">by {}</span></div>"#, 378 html_escape(&m.title), 379 html_escape(&m.artist) 380 ) 381 }) 382 .collect(); 383 format!( 384 r#"<div class="matches"><h4>potential matches</h4>{}</div>"#, 385 items.join("\n") 386 ) 387 }) 388 .unwrap_or_default(); 389 390 let resolved_badge = if track.resolved { 391 r#"<span class="badge resolved">resolved</span>"# 392 } else { 393 r#"<span class="badge pending">pending</span>"# 394 }; 395 396 let action_buttons = if !track.resolved { 397 format!( 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> 402 </div>"#, 403 html_escape(&track.uri), 404 html_escape(&track.uri), 405 html_escape(&track.uri) 406 ) 407 } else { 408 String::new() 409 }; 410 411 format!( 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> 421 </div> 422 {} 423 {} 424 </div>"#, 425 if track.resolved { " resolved" } else { "" }, 426 html_escape(&track.uri), 427 title_html, 428 html_escape(artist), 429 resolved_badge, 430 matches_html, 431 action_buttons 432 ) 433} 434 435fn html_escape(s: &str) -> String { 436 s.replace('&', "&amp;") 437 .replace('<', "&lt;") 438 .replace('>', "&gt;") 439 .replace('"', "&quot;") 440 .replace('\'', "&#039;") 441} 442 443/// Additional CSS for review page (supplements admin.css) 444const REVIEW_CSS: &str = r#" 445/* review page specific styles */ 446body { padding-bottom: 80px; } 447 448.subtitle a { 449 color: var(--accent); 450 text-decoration: none; 451} 452.subtitle a:hover { text-decoration: underline; } 453 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); 459} 460.btn-clear:hover { 461 background: rgba(74, 222, 128, 0.25); 462} 463 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); 468} 469.btn-defer:hover { 470 background: rgba(251, 191, 36, 0.25); 471} 472 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); 480} 481 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} 495 496/* submit bar */ 497.submit-bar { 498 position: fixed; 499 bottom: 0; 500 left: 0; 501 right: 0; 502 padding: 16px 24px; 503 background: var(--bg-secondary); 504 border-top: 1px solid var(--border-subtle); 505} 506.submit-bar .btn { 507 width: 100%; 508 max-width: 900px; 509 margin: 0 auto; 510 display: block; 511 padding: 14px; 512} 513 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} 526"#;