Microservice to bring 2FA to self hosted PDSes

combined search/accounts

+127 -179
+6 -6
html_templates/admin/error.hbs
··· 8 8 <link rel="stylesheet" href="/admin/static/css/admin.css"> 9 9 </head> 10 10 <body> 11 - {{#if handle}} 12 - {{!-- Logged-in user: show sidebar layout --}} 11 + {{#if handle}} 12 + {{!-- Logged-in user: show sidebar layout --}} 13 13 <div class="layout"> 14 14 {{> admin/partials/sidebar.hbs}} 15 15 ··· 18 18 <div class="error-icon">!</div> 19 19 <div class="error-title">{{error_title}}</div> 20 20 <div class="error-message">{{error_message}}</div> 21 - <a href="/admin/" class="error-link">Back to Dashboard</a> 21 + <a href="/admin/dashboard" class="error-link">Back to Dashboard</a> 22 22 </div> 23 23 </main> 24 24 </div> 25 - {{else}} 26 - {{!-- Not logged in: standalone centered layout --}} 25 + {{else}} 26 + {{!-- Not logged in: standalone centered layout --}} 27 27 <div class="centered"> 28 28 <div class="error-card"> 29 29 <div class="error-icon">!</div> ··· 32 32 <a href="/admin/login" class="error-link">Go to Login</a> 33 33 </div> 34 34 </div> 35 - {{/if}} 35 + {{/if}} 36 36 </body> 37 37 </html>
+121 -173
src/admin/routes.rs
··· 31 31 } 32 32 33 33 #[derive(Debug, Deserialize)] 34 - pub struct SearchParams { 35 - pub q: Option<String>, 36 - pub flash_success: Option<String>, 37 - pub flash_error: Option<String>, 38 - } 39 - 40 - #[derive(Debug, Deserialize)] 41 34 pub struct AccountsParams { 35 + pub q: Option<String>, 42 36 pub cursor: Option<String>, 43 37 pub flash_success: Option<String>, 44 38 pub flash_error: Option<String>, ··· 285 279 render_template(&state, "admin/dashboard.hbs", data) 286 280 } 287 281 288 - /// GET /admin/accounts — Account list (paginated, 100 per page) 282 + /// GET /admin/accounts — Account list (paginated) or Search 289 283 pub async fn accounts_list( 290 284 State(state): State<AppState>, 291 285 Extension(session): Extension<AdminSession>, ··· 298 292 299 293 let pds = pds_url(&state); 300 294 let password = admin_password(&state); 295 + let query = params.q.clone().unwrap_or_default(); 301 296 302 297 let limit = 5; 303 - // Yeah I know this looks bad, but I'd like to have the limit in one spot instead of two 304 298 let limit_as_string = limit.to_string(); 305 - let limit_as_str = limit_as_string.as_str(); 306 - let mut query_params: Vec<(&str, &str)> = vec![("limit", limit_as_str)]; 307 - let cursor_val; 308 - if let Some(ref c) = params.cursor { 309 - cursor_val = c.clone(); 310 - query_params.push(("cursor", &cursor_val)); 311 - } 312 299 313 - let repos = match pds_proxy::public_xrpc_get::<serde_json::Value>( 314 - pds, 315 - "com.atproto.sync.listRepos", 316 - &query_params, 317 - ) 318 - .await 319 - { 320 - Ok(r) => r, 321 - Err(e) => { 322 - tracing::error!("Failed to list repos: {}", e); 323 - return flash_redirect( 324 - "/admin/", 325 - None, 326 - Some(&format!("Failed to list accounts: {}", e)), 327 - ); 328 - } 329 - }; 330 - 331 - // Extract the next-page cursor from the response 332 - let next_cursor = repos["cursor"].as_str().map(|s| s.to_string()); 333 - 334 - let repo_infos: std::collections::HashMap<String, (bool, Option<String>)> = repos["repos"] 335 - .as_array() 336 - .map(|arr| { 337 - arr.iter() 338 - .filter_map(|r| { 339 - let did = r["did"].as_str()?.to_string(); 340 - let active = r["active"].as_bool().unwrap_or(true); 341 - let status = r["status"].as_str().map(|s| s.to_string()); 342 - Some((did, (active, status))) 343 - }) 344 - .collect() 345 - }) 346 - .unwrap_or_default(); 300 + let mut repo_infos: std::collections::HashMap<String, (bool, Option<String>)> = 301 + std::collections::HashMap::new(); 347 302 348 - let dids: Vec<String> = repo_infos.keys().cloned().collect(); 349 - 350 - let accounts_raw: serde_json::Value = if !dids.is_empty() { 351 - let did_params: Vec<(&str, &str)> = dids.iter().map(|d| ("dids", d.as_str())).collect(); 303 + let (accounts_raw, next_cursor) = if !query.is_empty() { 304 + // Search by email 352 305 match pds_proxy::admin_xrpc_get::<serde_json::Value>( 353 306 pds, 354 307 password, 355 - "com.atproto.admin.getAccountInfos", 356 - &did_params, 308 + "com.atproto.admin.searchAccounts", 309 + &[("email", &query)], 357 310 ) 358 311 .await 359 312 { 360 - Ok(res) => res["infos"].clone(), 313 + Ok(res) => (res["accounts"].clone(), None), 361 314 Err(e) => { 362 - tracing::error!("Failed to get account infos: {}", e); 363 - serde_json::json!([]) 315 + tracing::error!("Search failed: {}", e); 316 + (serde_json::json!([]), None) 364 317 } 365 318 } 366 319 } else { 367 - serde_json::json!([]) 320 + // Regular list 321 + let mut query_params: Vec<(&str, &str)> = vec![("limit", &limit_as_string)]; 322 + let cursor_val; 323 + if let Some(ref c) = params.cursor { 324 + cursor_val = c.clone(); 325 + query_params.push(("cursor", &cursor_val)); 326 + } 327 + 328 + match pds_proxy::public_xrpc_get::<serde_json::Value>( 329 + pds, 330 + "com.atproto.sync.listRepos", 331 + &query_params, 332 + ) 333 + .await 334 + { 335 + Ok(repos) => { 336 + let next_cursor = repos["cursor"].as_str().map(|s| s.to_string()); 337 + if let Some(arr) = repos["repos"].as_array() { 338 + for r in arr { 339 + if let Some(did) = r["did"].as_str() { 340 + let active = r["active"].as_bool().unwrap_or(true); 341 + let status = r["status"].as_str().map(|s| s.to_string()); 342 + repo_infos.insert(did.to_string(), (active, status)); 343 + } 344 + } 345 + } 346 + 347 + let dids: Vec<String> = repo_infos.keys().cloned().collect(); 348 + 349 + if !dids.is_empty() { 350 + let did_params: Vec<(&str, &str)> = 351 + dids.iter().map(|d| ("dids", d.as_str())).collect(); 352 + match pds_proxy::admin_xrpc_get::<serde_json::Value>( 353 + pds, 354 + password, 355 + "com.atproto.admin.getAccountInfos", 356 + &did_params, 357 + ) 358 + .await 359 + { 360 + Ok(res) => (res["infos"].clone(), next_cursor), 361 + Err(e) => { 362 + tracing::error!("Failed to get account infos: {}", e); 363 + (serde_json::json!([]), next_cursor) 364 + } 365 + } 366 + } else { 367 + (serde_json::json!([]), next_cursor) 368 + } 369 + } 370 + Err(e) => { 371 + tracing::error!("Failed to list repos: {}", e); 372 + return flash_redirect( 373 + "/admin/dashboard", 374 + None, 375 + Some(&format!("Failed to list accounts: {}", e)), 376 + ); 377 + } 378 + } 368 379 }; 369 - log::debug!("Accounts: {:?}", accounts_raw); 380 + 381 + println!("{:?}", accounts_raw); 370 382 371 383 let accounts: Vec<serde_json::Value> = if let Some(arr) = accounts_raw.as_array() { 372 - arr.iter() 373 - .map(|account| { 374 - let mut a = account.clone(); 375 - let did = a["did"].as_str().unwrap_or_default(); 384 + let mut processed = Vec::new(); 385 + for account in arr { 386 + let mut a = account.clone(); 387 + let did = a["did"].as_str().unwrap_or_default(); 376 388 377 - if let Some((active, status)) = repo_infos.get(did) { 378 - // Map active/status to is_taken_down 379 - 380 - let is_taken_down = status.as_deref() == Some("takendown"); 381 - a["is_taken_down"] = is_taken_down.into(); 389 + // Enrichment from listRepos if available 390 + if let Some((active, status)) = repo_infos.get(did) { 391 + let is_taken_down = !*active && status.as_deref() != Some("deactivated"); 392 + a["is_taken_down"] = is_taken_down.into(); 382 393 383 - // Also ensure we have the status field if it was missing in AccountView 384 - if a["status"].is_null() { 385 - if let Some(s) = status { 386 - a["status"] = s.clone().into(); 387 - } 394 + if a["status"].is_null() { 395 + if let Some(s) = status { 396 + a["status"] = s.clone().into(); 388 397 } 389 398 } 390 - a 391 - }) 392 - .collect() 399 + } else if query.is_empty() { 400 + // If it's a list but we don't have repo info for this DID (shouldn't happen with our logic) 401 + a["is_taken_down"] = false.into(); 402 + } else { 403 + let status_res = pds_proxy::admin_xrpc_get::<serde_json::Value>( 404 + pds, 405 + password, 406 + "com.atproto.admin.getSubjectStatus", 407 + &[("did", did)], 408 + ) 409 + .await; 410 + 411 + let is_taken_down = status_res 412 + .ok() 413 + .and_then(|s| s["takedown"]["applied"].as_bool()) 414 + .unwrap_or(false); 415 + 416 + a["is_taken_down"] = is_taken_down.into(); 417 + } 418 + processed.push(a); 419 + } 420 + processed 393 421 } else { 394 422 Vec::new() 395 423 }; ··· 399 427 let mut data = serde_json::json!({ 400 428 "accounts": accounts, 401 429 "account_count": account_count, 430 + "search_query": query, 402 431 "pds_hostname": state.app_config.pds_hostname, 403 432 "active_page": "accounts", 404 433 }); 405 434 406 435 // Pagination: "Next" link 407 436 if let Some(ref cursor) = next_cursor { 408 - // If the count returned is not the same as the limit, then we are at the end of the list 409 - if dids.len() == limit { 437 + if accounts.len() >= limit { 410 438 data["has_next"] = true.into(); 411 439 let next_url = format!("/admin/accounts?cursor={}", urlencoding::encode(cursor)); 412 440 data["next_url"] = next_url.into(); 413 441 } 414 442 } 415 443 416 - // Pagination: "Previous" link (visible when not on page 1) 444 + // Pagination: "Previous" link 417 445 if params.cursor.is_some() { 418 446 data["has_prev"] = true.into(); 419 447 data["prev_url"] = "/admin/accounts".to_string().into(); 420 448 } 421 449 422 - if let Some(msg) = params.flash_success { 423 - data["flash_success"] = msg.into(); 450 + if let Some(msg) = &params.flash_success { 451 + data["flash_success"] = msg.clone().into(); 424 452 } 425 - if let Some(msg) = params.flash_error { 426 - data["flash_error"] = msg.into(); 453 + if let Some(msg) = &params.flash_error { 454 + data["flash_error"] = msg.clone().into(); 427 455 } 428 456 429 457 inject_nav_data(&mut data, &session, &permissions); ··· 916 944 }; 917 945 918 946 if !rbac.can_access_endpoint(&session.did, "com.atproto.admin.getInviteCodes") { 919 - return flash_redirect("/admin/", None, Some("Access denied")); 947 + return flash_redirect("/admin/dashboard", None, Some("Access denied")); 920 948 } 921 949 922 950 // Phase 4: pagination support with cursor ··· 1087 1115 }; 1088 1116 1089 1117 if !rbac.can_access_endpoint(&session.did, "com.atproto.server.createAccount") { 1090 - return flash_redirect("/admin/", None, Some("Access denied")); 1118 + return flash_redirect("/admin/dashboard", None, Some("Access denied")); 1091 1119 } 1092 1120 1093 1121 let mut data = serde_json::json!({ ··· 1193 1221 } 1194 1222 1195 1223 /// GET /admin/search — Search accounts 1196 - /// Bug 7 fix: per lexicon com.atproto.admin.searchAccounts, the search param is "email" not "query" 1197 1224 pub async fn search_accounts( 1198 - State(state): State<AppState>, 1199 - Extension(session): Extension<AdminSession>, 1200 - Extension(permissions): Extension<AdminPermissions>, 1201 - Query(params): Query<SearchParams>, 1225 + state: State<AppState>, 1226 + session: Extension<AdminSession>, 1227 + permissions: Extension<AdminPermissions>, 1228 + params: Query<AccountsParams>, 1202 1229 ) -> Response { 1203 - let rbac = match &state.admin_rbac_config { 1204 - Some(r) => r, 1205 - None => return StatusCode::NOT_FOUND.into_response(), 1206 - }; 1207 - 1208 - if !rbac.can_access_endpoint(&session.did, "com.atproto.admin.searchAccounts") { 1209 - return flash_redirect("/admin/", None, Some("Access denied")); 1210 - } 1211 - 1212 - let query = params.q.unwrap_or_default(); 1213 - 1214 - let accounts_raw: serde_json::Value = if !query.is_empty() { 1215 - // Bug 7 fix: use "email" parameter per the lexicon spec 1216 - match pds_proxy::admin_xrpc_get::<serde_json::Value>( 1217 - pds_url(&state), 1218 - admin_password(&state), 1219 - "com.atproto.admin.searchAccounts", 1220 - &[("email", &query)], 1221 - ) 1222 - .await 1223 - { 1224 - Ok(res) => res["accounts"].clone(), 1225 - Err(e) => { 1226 - tracing::error!("Search failed: {}", e); 1227 - serde_json::json!([]) 1228 - } 1229 - } 1230 - } else { 1231 - serde_json::json!([]) 1232 - }; 1233 - 1234 - let pds = pds_url(&state); 1235 - let password = admin_password(&state); 1236 - 1237 - let accounts: Vec<serde_json::Value> = if let Some(arr) = accounts_raw.as_array() { 1238 - let mut processed = Vec::new(); 1239 - for account in arr { 1240 - let mut a = account.clone(); 1241 - let did = a["did"].as_str().unwrap_or_default(); 1242 - let status_res = pds_proxy::admin_xrpc_get::<serde_json::Value>( 1243 - pds, 1244 - password, 1245 - "com.atproto.admin.getSubjectStatus", 1246 - &[("did", did)], 1247 - ) 1248 - .await; 1249 - 1250 - let is_taken_down = status_res 1251 - .ok() 1252 - .and_then(|s| s["takedown"]["applied"].as_bool()) 1253 - .unwrap_or(false); 1254 - 1255 - a["is_taken_down"] = is_taken_down.into(); 1256 - processed.push(a); 1257 - } 1258 - processed 1259 - } else { 1260 - Vec::new() 1261 - }; 1262 - 1263 - let account_count = accounts.len(); 1264 - 1265 - let mut data = serde_json::json!({ 1266 - "accounts": accounts, 1267 - "account_count": account_count, 1268 - "search_query": query, 1269 - "pds_hostname": state.app_config.pds_hostname, 1270 - "active_page": "accounts", 1271 - }); 1272 - 1273 - if let Some(msg) = params.flash_success { 1274 - data["flash_success"] = msg.into(); 1275 - } 1276 - if let Some(msg) = params.flash_error { 1277 - data["flash_error"] = msg.into(); 1278 - } 1279 - 1280 - inject_nav_data(&mut data, &session, &permissions); 1281 - 1282 - render_template(&state, "admin/accounts.hbs", data) 1230 + accounts_list(state, session, permissions, params).await 1283 1231 } 1284 1232 1285 1233 /// GET /admin/request-crawl — Request Crawl form (Gap 3) ··· 1290 1238 Query(flash): Query<FlashParams>, 1291 1239 ) -> Response { 1292 1240 if !permissions.can_request_crawl { 1293 - return flash_redirect("/admin/", None, Some("Access denied")); 1241 + return flash_redirect("/admin/dashboard", None, Some("Access denied")); 1294 1242 } 1295 1243 1296 1244 let mut data = serde_json::json!({