at main 21 kB view raw
1//! Admin API for reviewing and resolving copyright flags. 2//! 3//! Uses htmx for interactivity with server-rendered HTML. 4 5use axum::{ 6 extract::{Query, State}, 7 http::header::CONTENT_TYPE, 8 response::{IntoResponse, Response}, 9 Json, 10}; 11use serde::{Deserialize, Serialize}; 12 13use crate::db::LabelContext; 14use crate::state::{AppError, AppState}; 15 16/// A flagged track pending review. 17#[derive(Debug, Serialize)] 18pub struct FlaggedTrack { 19 pub seq: i64, 20 pub uri: String, 21 pub val: String, 22 pub created_at: String, 23 /// Track status: pending review, resolved (false positive), or confirmed (takedown). 24 pub resolved: bool, 25 /// Optional context about the track (title, artist, matches). 26 #[serde(skip_serializing_if = "Option::is_none")] 27 pub context: Option<LabelContext>, 28} 29 30/// Response for listing flagged tracks. 31#[derive(Debug, Serialize)] 32pub struct ListFlaggedResponse { 33 pub tracks: Vec<FlaggedTrack>, 34} 35 36/// Query parameters for listing flags. 37#[derive(Debug, Deserialize, Default)] 38pub struct ListFlagsQuery { 39 /// Filter: "pending" (default), "resolved", or "all" 40 #[serde(default = "default_filter")] 41 pub filter: String, 42} 43 44fn default_filter() -> String { 45 "pending".to_string() 46} 47 48/// Request to resolve (negate) a flag. 49#[derive(Debug, Deserialize)] 50pub struct ResolveRequest { 51 pub uri: String, 52 #[serde(default = "default_val")] 53 pub val: String, 54 /// Reason for marking as false positive. 55 pub reason: Option<String>, 56 /// Additional notes about the resolution. 57 pub notes: Option<String>, 58} 59 60fn default_val() -> String { 61 "copyright-violation".to_string() 62} 63 64/// Response after resolving a flag. 65#[derive(Debug, Serialize)] 66pub struct ResolveResponse { 67 pub seq: i64, 68 pub message: String, 69} 70 71/// Request to store label context (for backfill). 72#[derive(Debug, Deserialize)] 73pub struct StoreContextRequest { 74 pub uri: String, 75 pub context: ContextPayload, 76} 77 78/// Context payload for storage. 79#[derive(Debug, Deserialize)] 80pub struct ContextPayload { 81 pub track_id: Option<i64>, 82 pub track_title: Option<String>, 83 pub artist_handle: Option<String>, 84 pub artist_did: Option<String>, 85 pub highest_score: Option<f64>, 86 pub matches: Option<Vec<crate::db::CopyrightMatch>>, 87} 88 89/// Response after storing context. 90#[derive(Debug, Serialize)] 91pub struct StoreContextResponse { 92 pub message: String, 93} 94 95/// Request to check which URIs have active labels. 96#[derive(Debug, Deserialize)] 97pub struct ActiveLabelsRequest { 98 pub uris: Vec<String>, 99} 100 101/// Response with active (non-negated) URIs. 102#[derive(Debug, Serialize)] 103pub struct ActiveLabelsResponse { 104 pub active_uris: Vec<String>, 105} 106 107/// Request to add a sensitive image. 108#[derive(Debug, Deserialize)] 109pub struct AddSensitiveImageRequest { 110 /// R2 storage ID (for track/album artwork) 111 pub image_id: Option<String>, 112 /// Full URL (for external images like avatars) 113 pub url: Option<String>, 114 /// Why this image was flagged 115 pub reason: Option<String>, 116 /// Admin who flagged it 117 pub flagged_by: Option<String>, 118} 119 120/// Response after adding a sensitive image. 121#[derive(Debug, Serialize)] 122pub struct AddSensitiveImageResponse { 123 pub id: i64, 124 pub message: String, 125} 126 127/// Request to remove a sensitive image. 128#[derive(Debug, Deserialize)] 129pub struct RemoveSensitiveImageRequest { 130 pub id: i64, 131} 132 133/// Response after removing a sensitive image. 134#[derive(Debug, Serialize)] 135pub struct RemoveSensitiveImageResponse { 136 pub removed: bool, 137 pub message: String, 138} 139 140/// Request to create a review batch. 141#[derive(Debug, Deserialize)] 142pub struct CreateBatchRequest { 143 /// URIs to include. If empty, uses all pending flags. 144 #[serde(default)] 145 pub uris: Vec<String>, 146 /// Who created this batch. 147 pub created_by: Option<String>, 148} 149 150/// Response after creating a review batch. 151#[derive(Debug, Serialize)] 152pub struct CreateBatchResponse { 153 pub id: String, 154 pub url: String, 155 pub flag_count: usize, 156} 157 158/// List all flagged tracks - returns JSON for API, HTML for htmx. 159pub async fn list_flagged( 160 State(state): State<AppState>, 161 Query(query): Query<ListFlagsQuery>, 162) -> Result<Json<ListFlaggedResponse>, AppError> { 163 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 164 let all_tracks = db.get_pending_flags().await?; 165 let tracks = filter_tracks(all_tracks, &query.filter); 166 Ok(Json(ListFlaggedResponse { tracks })) 167} 168 169/// Render flags as HTML partial for htmx. 170pub async fn list_flagged_html( 171 State(state): State<AppState>, 172 Query(query): Query<ListFlagsQuery>, 173) -> Result<Response, AppError> { 174 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 175 let all_tracks = db.get_pending_flags().await?; 176 let tracks = filter_tracks(all_tracks, &query.filter); 177 178 let html = render_flags_list(&tracks, &query.filter); 179 180 Ok(([(CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()) 181} 182 183/// Filter tracks based on filter parameter. 184fn filter_tracks(tracks: Vec<FlaggedTrack>, filter: &str) -> Vec<FlaggedTrack> { 185 match filter { 186 "resolved" => tracks.into_iter().filter(|t| t.resolved).collect(), 187 "all" => tracks, 188 _ => tracks.into_iter().filter(|t| !t.resolved).collect(), // "pending" is default 189 } 190} 191 192/// Resolve (negate) a copyright flag, marking it as a false positive. 193pub async fn resolve_flag( 194 State(state): State<AppState>, 195 Json(request): Json<ResolveRequest>, 196) -> Result<Json<ResolveResponse>, AppError> { 197 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 198 let signer = state 199 .signer 200 .as_ref() 201 .ok_or(AppError::LabelerNotConfigured)?; 202 203 // Parse the reason 204 let reason = request 205 .reason 206 .as_deref() 207 .and_then(crate::db::ResolutionReason::from_str); 208 209 tracing::info!( 210 uri = %request.uri, 211 val = %request.val, 212 reason = ?reason, 213 notes = ?request.notes, 214 "resolving flag (creating negation)" 215 ); 216 217 // Create a negation label 218 let label = crate::labels::Label::new(signer.did(), &request.uri, &request.val).negated(); 219 let label = signer.sign_label(label)?; 220 221 let seq = db.store_label(&label).await?; 222 223 // Store resolution reason in context 224 if let Some(r) = reason { 225 db.store_resolution(&request.uri, r, request.notes.as_deref()) 226 .await?; 227 } 228 229 // Broadcast to subscribers 230 if let Some(tx) = &state.label_tx { 231 let _ = tx.send((seq, label)); 232 } 233 234 Ok(Json(ResolveResponse { 235 seq, 236 message: format!("created negation label for {}", request.uri), 237 })) 238} 239 240/// Resolve flag and return HTML response for htmx. 241pub async fn resolve_flag_htmx( 242 State(state): State<AppState>, 243 axum::Form(request): axum::Form<ResolveRequest>, 244) -> Result<Response, AppError> { 245 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 246 let signer = state 247 .signer 248 .as_ref() 249 .ok_or(AppError::LabelerNotConfigured)?; 250 251 // Parse the reason 252 let reason = request 253 .reason 254 .as_deref() 255 .and_then(crate::db::ResolutionReason::from_str); 256 257 tracing::info!( 258 uri = %request.uri, 259 val = %request.val, 260 reason = ?reason, 261 notes = ?request.notes, 262 "resolving flag via htmx" 263 ); 264 265 // Create a negation label 266 let label = crate::labels::Label::new(signer.did(), &request.uri, &request.val).negated(); 267 let label = signer.sign_label(label)?; 268 269 let seq = db.store_label(&label).await?; 270 271 // Store resolution reason in context 272 if let Some(r) = reason { 273 db.store_resolution(&request.uri, r, request.notes.as_deref()) 274 .await?; 275 } 276 277 // Broadcast to subscribers 278 if let Some(tx) = &state.label_tx { 279 let _ = tx.send((seq, label)); 280 } 281 282 // Return success toast + trigger refresh 283 let reason_label = reason.map(|r| r.label()).unwrap_or("unknown"); 284 let html = format!( 285 r#"<div id="toast" class="toast success" hx-swap-oob="true">resolved: {} (seq: {})</div>"#, 286 reason_label, seq 287 ); 288 289 Ok(( 290 [(CONTENT_TYPE, "text/html; charset=utf-8")], 291 [( 292 axum::http::header::HeaderName::from_static("hx-trigger"), 293 "flagsUpdated", 294 )], 295 html, 296 ) 297 .into_response()) 298} 299 300/// Get which URIs have active (non-negated) copyright-violation labels. 301/// 302/// Used by the backend to determine which tracks are still flagged. 303pub async fn get_active_labels( 304 State(state): State<AppState>, 305 Json(request): Json<ActiveLabelsRequest>, 306) -> Result<Json<ActiveLabelsResponse>, AppError> { 307 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 308 309 tracing::debug!(uri_count = request.uris.len(), "checking active labels"); 310 311 let active_uris = db.get_active_labels(&request.uris).await?; 312 313 tracing::debug!( 314 active_count = active_uris.len(), 315 "returning active labels" 316 ); 317 318 Ok(Json(ActiveLabelsResponse { active_uris })) 319} 320 321/// Store context for a label (for backfill without re-emitting labels). 322pub async fn store_context( 323 State(state): State<AppState>, 324 Json(request): Json<StoreContextRequest>, 325) -> Result<Json<StoreContextResponse>, AppError> { 326 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 327 328 tracing::info!(uri = %request.uri, "storing label context"); 329 330 let label_ctx = LabelContext { 331 track_id: request.context.track_id, 332 track_title: request.context.track_title, 333 artist_handle: request.context.artist_handle, 334 artist_did: request.context.artist_did, 335 highest_score: request.context.highest_score, 336 matches: request.context.matches, 337 resolution_reason: None, 338 resolution_notes: None, 339 }; 340 341 db.store_context(&request.uri, &label_ctx).await?; 342 343 Ok(Json(StoreContextResponse { 344 message: format!("context stored for {}", request.uri), 345 })) 346} 347 348/// Create a review batch from pending flags. 349pub async fn create_batch( 350 State(state): State<AppState>, 351 Json(request): Json<CreateBatchRequest>, 352) -> Result<Json<CreateBatchResponse>, AppError> { 353 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 354 355 // Get URIs to include 356 let uris = if request.uris.is_empty() { 357 let pending = db.get_pending_flags().await?; 358 pending 359 .into_iter() 360 .filter(|t| !t.resolved) 361 .map(|t| t.uri) 362 .collect() 363 } else { 364 request.uris 365 }; 366 367 if uris.is_empty() { 368 return Err(AppError::BadRequest("no flags to review".to_string())); 369 } 370 371 let id = generate_batch_id(); 372 let flag_count = uris.len(); 373 374 tracing::info!( 375 batch_id = %id, 376 flag_count = flag_count, 377 "creating review batch" 378 ); 379 380 db.create_batch(&id, &uris, request.created_by.as_deref()) 381 .await?; 382 383 let url = format!("/admin/review/{}", id); 384 385 Ok(Json(CreateBatchResponse { id, url, flag_count })) 386} 387 388/// Generate a short, URL-safe batch ID. 389fn generate_batch_id() -> String { 390 use std::time::{SystemTime, UNIX_EPOCH}; 391 let now = SystemTime::now() 392 .duration_since(UNIX_EPOCH) 393 .unwrap() 394 .as_millis(); 395 let rand_part: u32 = rand::random(); 396 format!("{:x}{:x}", (now as u64) & 0xFFFFFFFF, rand_part & 0xFFFF) 397} 398 399/// Add a sensitive image entry. 400pub async fn add_sensitive_image( 401 State(state): State<AppState>, 402 Json(request): Json<AddSensitiveImageRequest>, 403) -> Result<Json<AddSensitiveImageResponse>, AppError> { 404 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 405 406 // Validate: at least one of image_id or url must be provided 407 if request.image_id.is_none() && request.url.is_none() { 408 return Err(AppError::BadRequest( 409 "at least one of image_id or url must be provided".to_string(), 410 )); 411 } 412 413 tracing::info!( 414 image_id = ?request.image_id, 415 url = ?request.url, 416 reason = ?request.reason, 417 flagged_by = ?request.flagged_by, 418 "adding sensitive image" 419 ); 420 421 let id = db 422 .add_sensitive_image( 423 request.image_id.as_deref(), 424 request.url.as_deref(), 425 request.reason.as_deref(), 426 request.flagged_by.as_deref(), 427 ) 428 .await?; 429 430 Ok(Json(AddSensitiveImageResponse { 431 id, 432 message: "sensitive image added".to_string(), 433 })) 434} 435 436/// Remove a sensitive image entry. 437pub async fn remove_sensitive_image( 438 State(state): State<AppState>, 439 Json(request): Json<RemoveSensitiveImageRequest>, 440) -> Result<Json<RemoveSensitiveImageResponse>, AppError> { 441 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 442 443 tracing::info!(id = request.id, "removing sensitive image"); 444 445 let removed = db.remove_sensitive_image(request.id).await?; 446 447 let message = if removed { 448 format!("sensitive image {} removed", request.id) 449 } else { 450 format!("sensitive image {} not found", request.id) 451 }; 452 453 Ok(Json(RemoveSensitiveImageResponse { removed, message })) 454} 455 456/// Serve the admin UI HTML from static file. 457pub async fn admin_ui() -> Result<Response, AppError> { 458 let html = tokio::fs::read_to_string("static/admin.html").await?; 459 Ok(([(CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()) 460} 461 462/// Render the flags list as HTML with filter controls. 463fn render_flags_list(tracks: &[FlaggedTrack], current_filter: &str) -> String { 464 let pending_active = if current_filter == "pending" { " active" } else { "" }; 465 let resolved_active = if current_filter == "resolved" { " active" } else { "" }; 466 let all_active = if current_filter == "all" { " active" } else { "" }; 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 475 let filter_buttons = format!( 476 "<div class=\"filter-row\">\ 477 <span class=\"filter-label\">show:</span>\ 478 <button type=\"button\" class=\"filter-btn{}\" hx-get=\"/admin/flags-html?filter=pending\" hx-target=\"#flags-list\">pending</button>\ 479 <button type=\"button\" class=\"filter-btn{}\" hx-get=\"/admin/flags-html?filter=resolved\" hx-target=\"#flags-list\">resolved</button>\ 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>\ 482 </div>", 483 pending_active, 484 resolved_active, 485 all_active, 486 count_label, 487 ); 488 489 if tracks.is_empty() { 490 let empty_msg = match current_filter { 491 "pending" => "no pending flags", 492 "resolved" => "no resolved flags", 493 _ => "no flagged tracks", 494 }; 495 return format!( 496 "{}<div class=\"empty\">{}</div>", 497 filter_buttons, empty_msg 498 ); 499 } 500 501 let cards: Vec<String> = tracks.iter().map(render_flag_card).collect(); 502 format!("{}\n{}", filter_buttons, cards.join("\n")) 503} 504 505/// Extract namespace from AT URI (e.g., "fm.plyr.dev" from "at://did:plc:xxx/fm.plyr.dev.track/yyy") 506fn extract_namespace(uri: &str) -> Option<&str> { 507 // URI format: at://did:plc:xxx/fm.plyr[.env].track/rkey 508 let collection = uri.split('/').nth(3)?; 509 // Strip ".track" suffix to get namespace 510 collection.strip_suffix(".track") 511} 512 513/// Determine environment from namespace 514fn namespace_to_env(namespace: &str) -> Option<(&'static str, &'static str)> { 515 match namespace { 516 "fm.plyr" => None, // production - no badge needed 517 "fm.plyr.stg" => Some(("staging", "stg")), 518 "fm.plyr.dev" => Some(("development", "dev")), 519 _ => Some(("unknown", "?")), 520 } 521} 522 523/// Render a single flag card as HTML. 524fn render_flag_card(track: &FlaggedTrack) -> String { 525 let ctx = track.context.as_ref(); 526 let has_context = ctx.is_some_and(|c| c.track_title.is_some() || c.artist_handle.is_some()); 527 528 let track_info = if has_context { 529 let c = ctx.unwrap(); 530 let handle = c.artist_handle.as_deref().unwrap_or("unknown"); 531 let title = c.track_title.as_deref().unwrap_or("unknown track"); 532 533 // Link to track if we have track_id 534 let title_html = if let Some(track_id) = c.track_id { 535 format!( 536 r#"<a href="https://plyr.fm/track/{}" target="_blank" rel="noopener">{}</a>"#, 537 track_id, 538 html_escape(title) 539 ) 540 } else { 541 html_escape(title) 542 }; 543 544 // Link to artist if we have handle 545 let artist_link = if handle != "unknown" { 546 format!( 547 r#"<a href="https://plyr.fm/u/{}" target="_blank" rel="noopener">@{}</a>"#, 548 html_escape(handle), 549 html_escape(handle) 550 ) 551 } else { 552 format!("@{}", html_escape(handle)) 553 }; 554 format!( 555 r#"<h3>{}</h3> 556 <div class="artist">by {}</div>"#, 557 title_html, 558 artist_link 559 ) 560 } else { 561 r#"<div class="no-context">no track info available</div>"#.to_string() 562 }; 563 564 // Add environment badge for non-production namespaces 565 let env_badge = extract_namespace(&track.uri) 566 .and_then(namespace_to_env) 567 .map(|(label, short)| { 568 format!( 569 r#"<span class="badge env" title="{}">{}</span>"#, 570 label, short 571 ) 572 }) 573 .unwrap_or_default(); 574 575 // Show match count instead of score (AuDD doesn't provide scores in accurate_offsets mode) 576 let match_count_badge = ctx 577 .and_then(|c| c.matches.as_ref()) 578 .filter(|m| !m.is_empty()) 579 .map(|matches| { 580 format!( 581 r#"<span class="badge matches">{} matches</span>"#, 582 matches.len() 583 ) 584 }) 585 .unwrap_or_default(); 586 587 let status_badge = if track.resolved { 588 r#"<span class="badge resolved">resolved</span>"# 589 } else { 590 r#"<span class="badge pending">pending</span>"# 591 }; 592 593 let matches_html = ctx 594 .and_then(|c| c.matches.as_ref()) 595 .filter(|m| !m.is_empty()) 596 .map(|matches| { 597 let items: Vec<String> = matches 598 .iter() 599 .take(3) 600 .map(|m| { 601 format!( 602 r#"<div class="match-item"> 603 <span class="title">{}</span> <span class="artist">by {}</span> 604 </div>"#, 605 html_escape(&m.title), 606 html_escape(&m.artist), 607 ) 608 }) 609 .collect(); 610 format!( 611 r#"<div class="matches"> 612 <h4>potential matches</h4> 613 {} 614 </div>"#, 615 items.join("\n") 616 ) 617 }) 618 .unwrap_or_default(); 619 620 let action_button = if track.resolved { 621 // Show the resolution reason and notes if available 622 let reason_text = ctx 623 .and_then(|c| c.resolution_reason.as_ref()) 624 .map(|r| r.label()) 625 .unwrap_or("resolved"); 626 let notes_html = ctx 627 .and_then(|c| c.resolution_notes.as_ref()) 628 .map(|n| format!(r#"<div class="resolution-notes">{}</div>"#, html_escape(n))) 629 .unwrap_or_default(); 630 format!( 631 r#"<div class="resolution-info"> 632 <span class="resolution-reason">{}</span> 633 {} 634 </div>"#, 635 reason_text, notes_html 636 ) 637 } else { 638 // Multi-step flow: button -> reason select -> confirm 639 format!( 640 r#"<div class="resolve-flow" data-uri="{}" data-val="{}"> 641 <button type="button" class="btn btn-warning" onclick="showReasonSelect(this)"> 642 mark false positive 643 </button> 644 </div>"#, 645 html_escape(&track.uri), 646 html_escape(&track.val) 647 ) 648 }; 649 650 let resolved_class = if track.resolved { " resolved" } else { "" }; 651 652 format!( 653 r#"<div class="flag-card{}"> 654 <div class="flag-header"> 655 <div class="track-info"> 656 {} 657 <div class="uri">{}</div> 658 </div> 659 <div class="flag-badges"> 660 {} 661 {} 662 {} 663 </div> 664 </div> 665 {} 666 <div class="flag-actions"> 667 {} 668 </div> 669 </div>"#, 670 resolved_class, 671 track_info, 672 html_escape(&track.uri), 673 env_badge, 674 match_count_badge, 675 status_badge, 676 matches_html, 677 action_button 678 ) 679} 680 681/// Simple HTML escaping. 682fn html_escape(s: &str) -> String { 683 s.replace('&', "&amp;") 684 .replace('<', "&lt;") 685 .replace('>', "&gt;") 686 .replace('"', "&quot;") 687 .replace('\'', "&#039;") 688}