tangled
alpha
login
or
join now
baileytownsend.dev
/
pds-gatekeeper
89
fork
atom
Microservice to bring 2FA to self hosted PDSes
89
fork
atom
overview
issues
1
pulls
3
pipelines
combined search/accounts
baileytownsend.dev
3 weeks ago
5698301c
9543e5d5
+127
-179
2 changed files
expand all
collapse all
unified
split
html_templates
admin
error.hbs
src
admin
routes.rs
+6
-6
html_templates/admin/error.hbs
reviewed
···
8
8
<link rel="stylesheet" href="/admin/static/css/admin.css">
9
9
</head>
10
10
<body>
11
11
-
{{#if handle}}
12
12
-
{{!-- Logged-in user: show sidebar layout --}}
11
11
+
{{#if handle}}
12
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
21
-
<a href="/admin/" class="error-link">Back to Dashboard</a>
21
21
+
<a href="/admin/dashboard" class="error-link">Back to Dashboard</a>
22
22
</div>
23
23
</main>
24
24
</div>
25
25
-
{{else}}
26
26
-
{{!-- Not logged in: standalone centered layout --}}
25
25
+
{{else}}
26
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
35
-
{{/if}}
35
35
+
{{/if}}
36
36
</body>
37
37
</html>
+121
-173
src/admin/routes.rs
reviewed
···
31
31
}
32
32
33
33
#[derive(Debug, Deserialize)]
34
34
-
pub struct SearchParams {
35
35
-
pub q: Option<String>,
36
36
-
pub flash_success: Option<String>,
37
37
-
pub flash_error: Option<String>,
38
38
-
}
39
39
-
40
40
-
#[derive(Debug, Deserialize)]
41
34
pub struct AccountsParams {
35
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
288
-
/// GET /admin/accounts — Account list (paginated, 100 per page)
282
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
295
+
let query = params.q.clone().unwrap_or_default();
301
296
302
297
let limit = 5;
303
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
305
-
let limit_as_str = limit_as_string.as_str();
306
306
-
let mut query_params: Vec<(&str, &str)> = vec![("limit", limit_as_str)];
307
307
-
let cursor_val;
308
308
-
if let Some(ref c) = params.cursor {
309
309
-
cursor_val = c.clone();
310
310
-
query_params.push(("cursor", &cursor_val));
311
311
-
}
312
299
313
313
-
let repos = match pds_proxy::public_xrpc_get::<serde_json::Value>(
314
314
-
pds,
315
315
-
"com.atproto.sync.listRepos",
316
316
-
&query_params,
317
317
-
)
318
318
-
.await
319
319
-
{
320
320
-
Ok(r) => r,
321
321
-
Err(e) => {
322
322
-
tracing::error!("Failed to list repos: {}", e);
323
323
-
return flash_redirect(
324
324
-
"/admin/",
325
325
-
None,
326
326
-
Some(&format!("Failed to list accounts: {}", e)),
327
327
-
);
328
328
-
}
329
329
-
};
330
330
-
331
331
-
// Extract the next-page cursor from the response
332
332
-
let next_cursor = repos["cursor"].as_str().map(|s| s.to_string());
333
333
-
334
334
-
let repo_infos: std::collections::HashMap<String, (bool, Option<String>)> = repos["repos"]
335
335
-
.as_array()
336
336
-
.map(|arr| {
337
337
-
arr.iter()
338
338
-
.filter_map(|r| {
339
339
-
let did = r["did"].as_str()?.to_string();
340
340
-
let active = r["active"].as_bool().unwrap_or(true);
341
341
-
let status = r["status"].as_str().map(|s| s.to_string());
342
342
-
Some((did, (active, status)))
343
343
-
})
344
344
-
.collect()
345
345
-
})
346
346
-
.unwrap_or_default();
300
300
+
let mut repo_infos: std::collections::HashMap<String, (bool, Option<String>)> =
301
301
+
std::collections::HashMap::new();
347
302
348
348
-
let dids: Vec<String> = repo_infos.keys().cloned().collect();
349
349
-
350
350
-
let accounts_raw: serde_json::Value = if !dids.is_empty() {
351
351
-
let did_params: Vec<(&str, &str)> = dids.iter().map(|d| ("dids", d.as_str())).collect();
303
303
+
let (accounts_raw, next_cursor) = if !query.is_empty() {
304
304
+
// Search by email
352
305
match pds_proxy::admin_xrpc_get::<serde_json::Value>(
353
306
pds,
354
307
password,
355
355
-
"com.atproto.admin.getAccountInfos",
356
356
-
&did_params,
308
308
+
"com.atproto.admin.searchAccounts",
309
309
+
&[("email", &query)],
357
310
)
358
311
.await
359
312
{
360
360
-
Ok(res) => res["infos"].clone(),
313
313
+
Ok(res) => (res["accounts"].clone(), None),
361
314
Err(e) => {
362
362
-
tracing::error!("Failed to get account infos: {}", e);
363
363
-
serde_json::json!([])
315
315
+
tracing::error!("Search failed: {}", e);
316
316
+
(serde_json::json!([]), None)
364
317
}
365
318
}
366
319
} else {
367
367
-
serde_json::json!([])
320
320
+
// Regular list
321
321
+
let mut query_params: Vec<(&str, &str)> = vec![("limit", &limit_as_string)];
322
322
+
let cursor_val;
323
323
+
if let Some(ref c) = params.cursor {
324
324
+
cursor_val = c.clone();
325
325
+
query_params.push(("cursor", &cursor_val));
326
326
+
}
327
327
+
328
328
+
match pds_proxy::public_xrpc_get::<serde_json::Value>(
329
329
+
pds,
330
330
+
"com.atproto.sync.listRepos",
331
331
+
&query_params,
332
332
+
)
333
333
+
.await
334
334
+
{
335
335
+
Ok(repos) => {
336
336
+
let next_cursor = repos["cursor"].as_str().map(|s| s.to_string());
337
337
+
if let Some(arr) = repos["repos"].as_array() {
338
338
+
for r in arr {
339
339
+
if let Some(did) = r["did"].as_str() {
340
340
+
let active = r["active"].as_bool().unwrap_or(true);
341
341
+
let status = r["status"].as_str().map(|s| s.to_string());
342
342
+
repo_infos.insert(did.to_string(), (active, status));
343
343
+
}
344
344
+
}
345
345
+
}
346
346
+
347
347
+
let dids: Vec<String> = repo_infos.keys().cloned().collect();
348
348
+
349
349
+
if !dids.is_empty() {
350
350
+
let did_params: Vec<(&str, &str)> =
351
351
+
dids.iter().map(|d| ("dids", d.as_str())).collect();
352
352
+
match pds_proxy::admin_xrpc_get::<serde_json::Value>(
353
353
+
pds,
354
354
+
password,
355
355
+
"com.atproto.admin.getAccountInfos",
356
356
+
&did_params,
357
357
+
)
358
358
+
.await
359
359
+
{
360
360
+
Ok(res) => (res["infos"].clone(), next_cursor),
361
361
+
Err(e) => {
362
362
+
tracing::error!("Failed to get account infos: {}", e);
363
363
+
(serde_json::json!([]), next_cursor)
364
364
+
}
365
365
+
}
366
366
+
} else {
367
367
+
(serde_json::json!([]), next_cursor)
368
368
+
}
369
369
+
}
370
370
+
Err(e) => {
371
371
+
tracing::error!("Failed to list repos: {}", e);
372
372
+
return flash_redirect(
373
373
+
"/admin/dashboard",
374
374
+
None,
375
375
+
Some(&format!("Failed to list accounts: {}", e)),
376
376
+
);
377
377
+
}
378
378
+
}
368
379
};
369
369
-
log::debug!("Accounts: {:?}", accounts_raw);
380
380
+
381
381
+
println!("{:?}", accounts_raw);
370
382
371
383
let accounts: Vec<serde_json::Value> = if let Some(arr) = accounts_raw.as_array() {
372
372
-
arr.iter()
373
373
-
.map(|account| {
374
374
-
let mut a = account.clone();
375
375
-
let did = a["did"].as_str().unwrap_or_default();
384
384
+
let mut processed = Vec::new();
385
385
+
for account in arr {
386
386
+
let mut a = account.clone();
387
387
+
let did = a["did"].as_str().unwrap_or_default();
376
388
377
377
-
if let Some((active, status)) = repo_infos.get(did) {
378
378
-
// Map active/status to is_taken_down
379
379
-
380
380
-
let is_taken_down = status.as_deref() == Some("takendown");
381
381
-
a["is_taken_down"] = is_taken_down.into();
389
389
+
// Enrichment from listRepos if available
390
390
+
if let Some((active, status)) = repo_infos.get(did) {
391
391
+
let is_taken_down = !*active && status.as_deref() != Some("deactivated");
392
392
+
a["is_taken_down"] = is_taken_down.into();
382
393
383
383
-
// Also ensure we have the status field if it was missing in AccountView
384
384
-
if a["status"].is_null() {
385
385
-
if let Some(s) = status {
386
386
-
a["status"] = s.clone().into();
387
387
-
}
394
394
+
if a["status"].is_null() {
395
395
+
if let Some(s) = status {
396
396
+
a["status"] = s.clone().into();
388
397
}
389
398
}
390
390
-
a
391
391
-
})
392
392
-
.collect()
399
399
+
} else if query.is_empty() {
400
400
+
// If it's a list but we don't have repo info for this DID (shouldn't happen with our logic)
401
401
+
a["is_taken_down"] = false.into();
402
402
+
} else {
403
403
+
let status_res = pds_proxy::admin_xrpc_get::<serde_json::Value>(
404
404
+
pds,
405
405
+
password,
406
406
+
"com.atproto.admin.getSubjectStatus",
407
407
+
&[("did", did)],
408
408
+
)
409
409
+
.await;
410
410
+
411
411
+
let is_taken_down = status_res
412
412
+
.ok()
413
413
+
.and_then(|s| s["takedown"]["applied"].as_bool())
414
414
+
.unwrap_or(false);
415
415
+
416
416
+
a["is_taken_down"] = is_taken_down.into();
417
417
+
}
418
418
+
processed.push(a);
419
419
+
}
420
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
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
408
-
// If the count returned is not the same as the limit, then we are at the end of the list
409
409
-
if dids.len() == limit {
437
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
416
-
// Pagination: "Previous" link (visible when not on page 1)
444
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
422
-
if let Some(msg) = params.flash_success {
423
423
-
data["flash_success"] = msg.into();
450
450
+
if let Some(msg) = ¶ms.flash_success {
451
451
+
data["flash_success"] = msg.clone().into();
424
452
}
425
425
-
if let Some(msg) = params.flash_error {
426
426
-
data["flash_error"] = msg.into();
453
453
+
if let Some(msg) = ¶ms.flash_error {
454
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
919
-
return flash_redirect("/admin/", None, Some("Access denied"));
947
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
1090
-
return flash_redirect("/admin/", None, Some("Access denied"));
1118
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
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
1198
-
State(state): State<AppState>,
1199
1199
-
Extension(session): Extension<AdminSession>,
1200
1200
-
Extension(permissions): Extension<AdminPermissions>,
1201
1201
-
Query(params): Query<SearchParams>,
1225
1225
+
state: State<AppState>,
1226
1226
+
session: Extension<AdminSession>,
1227
1227
+
permissions: Extension<AdminPermissions>,
1228
1228
+
params: Query<AccountsParams>,
1202
1229
) -> Response {
1203
1203
-
let rbac = match &state.admin_rbac_config {
1204
1204
-
Some(r) => r,
1205
1205
-
None => return StatusCode::NOT_FOUND.into_response(),
1206
1206
-
};
1207
1207
-
1208
1208
-
if !rbac.can_access_endpoint(&session.did, "com.atproto.admin.searchAccounts") {
1209
1209
-
return flash_redirect("/admin/", None, Some("Access denied"));
1210
1210
-
}
1211
1211
-
1212
1212
-
let query = params.q.unwrap_or_default();
1213
1213
-
1214
1214
-
let accounts_raw: serde_json::Value = if !query.is_empty() {
1215
1215
-
// Bug 7 fix: use "email" parameter per the lexicon spec
1216
1216
-
match pds_proxy::admin_xrpc_get::<serde_json::Value>(
1217
1217
-
pds_url(&state),
1218
1218
-
admin_password(&state),
1219
1219
-
"com.atproto.admin.searchAccounts",
1220
1220
-
&[("email", &query)],
1221
1221
-
)
1222
1222
-
.await
1223
1223
-
{
1224
1224
-
Ok(res) => res["accounts"].clone(),
1225
1225
-
Err(e) => {
1226
1226
-
tracing::error!("Search failed: {}", e);
1227
1227
-
serde_json::json!([])
1228
1228
-
}
1229
1229
-
}
1230
1230
-
} else {
1231
1231
-
serde_json::json!([])
1232
1232
-
};
1233
1233
-
1234
1234
-
let pds = pds_url(&state);
1235
1235
-
let password = admin_password(&state);
1236
1236
-
1237
1237
-
let accounts: Vec<serde_json::Value> = if let Some(arr) = accounts_raw.as_array() {
1238
1238
-
let mut processed = Vec::new();
1239
1239
-
for account in arr {
1240
1240
-
let mut a = account.clone();
1241
1241
-
let did = a["did"].as_str().unwrap_or_default();
1242
1242
-
let status_res = pds_proxy::admin_xrpc_get::<serde_json::Value>(
1243
1243
-
pds,
1244
1244
-
password,
1245
1245
-
"com.atproto.admin.getSubjectStatus",
1246
1246
-
&[("did", did)],
1247
1247
-
)
1248
1248
-
.await;
1249
1249
-
1250
1250
-
let is_taken_down = status_res
1251
1251
-
.ok()
1252
1252
-
.and_then(|s| s["takedown"]["applied"].as_bool())
1253
1253
-
.unwrap_or(false);
1254
1254
-
1255
1255
-
a["is_taken_down"] = is_taken_down.into();
1256
1256
-
processed.push(a);
1257
1257
-
}
1258
1258
-
processed
1259
1259
-
} else {
1260
1260
-
Vec::new()
1261
1261
-
};
1262
1262
-
1263
1263
-
let account_count = accounts.len();
1264
1264
-
1265
1265
-
let mut data = serde_json::json!({
1266
1266
-
"accounts": accounts,
1267
1267
-
"account_count": account_count,
1268
1268
-
"search_query": query,
1269
1269
-
"pds_hostname": state.app_config.pds_hostname,
1270
1270
-
"active_page": "accounts",
1271
1271
-
});
1272
1272
-
1273
1273
-
if let Some(msg) = params.flash_success {
1274
1274
-
data["flash_success"] = msg.into();
1275
1275
-
}
1276
1276
-
if let Some(msg) = params.flash_error {
1277
1277
-
data["flash_error"] = msg.into();
1278
1278
-
}
1279
1279
-
1280
1280
-
inject_nav_data(&mut data, &session, &permissions);
1281
1281
-
1282
1282
-
render_template(&state, "admin/accounts.hbs", data)
1230
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
1293
-
return flash_redirect("/admin/", None, Some("Access denied"));
1241
1241
+
return flash_redirect("/admin/dashboard", None, Some("Access denied"));
1294
1242
}
1295
1243
1296
1244
let mut data = serde_json::json!({