music on atproto
plyr.fm
1//! plyr.fm moderation service
2//!
3//! Provides:
4//! - AuDD audio fingerprinting for copyright detection
5//! - ATProto labeler endpoints (queryLabels, subscribeLabels)
6//! - Label emission for copyright violations
7//! - Admin UI for reviewing and resolving flags
8
9use std::{net::SocketAddr, sync::Arc};
10
11use anyhow::anyhow;
12use axum::{
13 middleware,
14 routing::{get, post},
15 Router,
16};
17use tokio::{net::TcpListener, sync::broadcast};
18use tower_http::services::ServeDir;
19use tracing::{info, warn};
20
21mod admin;
22mod audd;
23mod auth;
24mod claude;
25mod config;
26mod db;
27mod handlers;
28mod labels;
29mod review;
30mod state;
31mod xrpc;
32
33pub use state::{AppError, AppState};
34
35#[tokio::main]
36async fn main() -> anyhow::Result<()> {
37 tracing_subscriber::fmt()
38 .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
39 .with_target(false)
40 .init();
41
42 let config = config::Config::from_env()?;
43 let auth_token = config.auth_token.clone();
44
45 // Initialize labeler components if configured
46 let (db, signer, label_tx) = if config.labeler_enabled() {
47 let db = db::LabelDb::connect(config.database_url.as_ref().unwrap()).await?;
48 db.migrate().await?;
49 info!("labeler database connected and migrated");
50
51 let signer = labels::LabelSigner::from_hex(
52 config.labeler_signing_key.as_ref().unwrap(),
53 config.labeler_did.as_ref().unwrap(),
54 )?;
55 info!(did = %signer.did(), "labeler signer initialized");
56
57 let (tx, _) = broadcast::channel::<(i64, labels::Label)>(1024);
58 (Some(db), Some(signer), Some(tx))
59 } else {
60 warn!("labeler not configured - XRPC endpoints will return 503");
61 (None, None, None)
62 };
63
64 // Initialize Claude client for image moderation if configured
65 let claude_client = if config.claude_enabled() {
66 let client = claude::ClaudeClient::new(
67 config.claude_api_key.clone().unwrap(),
68 Some(config.claude_model.clone()),
69 );
70 info!(model = %config.claude_model, "claude image moderation enabled");
71 Some(client)
72 } else {
73 warn!("claude not configured - /scan-image endpoint will return 503");
74 None
75 };
76
77 let state = AppState {
78 audd_api_token: config.audd_api_token,
79 audd_api_url: config.audd_api_url,
80 db: db.map(Arc::new),
81 signer: signer.map(Arc::new),
82 label_tx,
83 claude: claude_client.map(Arc::new),
84 copyright_score_threshold: config.copyright_score_threshold,
85 };
86
87 let app = Router::new()
88 // Landing page
89 .route("/", get(handlers::landing))
90 // Health check
91 .route("/health", get(handlers::health))
92 // Sensitive images (public)
93 .route("/sensitive-images", get(handlers::get_sensitive_images))
94 // AuDD scanning
95 .route("/scan", post(audd::scan))
96 // Image moderation via Claude
97 .route("/scan-image", post(handlers::scan_image))
98 // Label emission (internal API)
99 .route("/emit-label", post(handlers::emit_label))
100 // Admin UI and API
101 .route("/admin", get(admin::admin_ui))
102 .route("/admin/flags", get(admin::list_flagged))
103 .route("/admin/flags-html", get(admin::list_flagged_html))
104 .route("/admin/resolve", post(admin::resolve_flag))
105 .route("/admin/resolve-htmx", post(admin::resolve_flag_htmx))
106 .route("/admin/context", post(admin::store_context))
107 .route("/admin/active-labels", post(admin::get_active_labels))
108 .route("/admin/sensitive-images", post(admin::add_sensitive_image))
109 .route(
110 "/admin/sensitive-images/remove",
111 post(admin::remove_sensitive_image),
112 )
113 .route("/admin/batches", post(admin::create_batch))
114 // Review endpoints (under admin, auth protected)
115 .route("/admin/review/:id", get(review::review_page))
116 .route("/admin/review/:id/data", get(review::review_data))
117 .route("/admin/review/:id/submit", post(review::submit_review))
118 // Static files (CSS, JS for admin UI)
119 .nest_service("/static", ServeDir::new("static"))
120 // ATProto XRPC endpoints (public)
121 .route(
122 "/xrpc/com.atproto.label.queryLabels",
123 get(xrpc::query_labels),
124 )
125 .route(
126 "/xrpc/com.atproto.label.subscribeLabels",
127 get(xrpc::subscribe_labels),
128 )
129 .layer(middleware::from_fn(move |req, next| {
130 auth::auth_middleware(req, next, auth_token.clone())
131 }))
132 .with_state(state);
133
134 let addr: SocketAddr = format!("{}:{}", config.host, config.port)
135 .parse()
136 .map_err(|e| anyhow!("invalid bind addr: {e}"))?;
137 info!(%addr, "moderation service listening");
138
139 let listener = TcpListener::bind(addr).await?;
140 axum::serve(listener, app).await?;
141 Ok(())
142}