+16
.sqlx/query-a45ee2c7a075b27a403838b3295604e67c4213023b49e1155c4ab22e657954ff.json
+16
.sqlx/query-a45ee2c7a075b27a403838b3295604e67c4213023b49e1155c4ab22e657954ff.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "INSERT INTO account_deletion_requests (token, did, expires_at) VALUES ($1, $2, $3)",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text",
9
+
"Text",
10
+
"Timestamptz"
11
+
]
12
+
},
13
+
"nullable": []
14
+
},
15
+
"hash": "a45ee2c7a075b27a403838b3295604e67c4213023b49e1155c4ab22e657954ff"
16
+
}
+1
-1
TODO.md
+1
-1
TODO.md
···
34
- [x] Implement `com.atproto.server.getAccountInviteCodes`.
35
- [x] Implement `com.atproto.server.getServiceAuth` (Cross-service auth).
36
- [x] Implement `com.atproto.server.listAppPasswords`.
37
-
- [ ] Implement `com.atproto.server.requestAccountDelete`.
38
- [ ] Implement `com.atproto.server.requestEmailConfirmation` / `requestEmailUpdate`.
39
- [ ] Implement `com.atproto.server.requestPasswordReset` / `resetPassword`.
40
- [ ] Implement `com.atproto.server.reserveSigningKey`.
···
34
- [x] Implement `com.atproto.server.getAccountInviteCodes`.
35
- [x] Implement `com.atproto.server.getServiceAuth` (Cross-service auth).
36
- [x] Implement `com.atproto.server.listAppPasswords`.
37
+
- [x] Implement `com.atproto.server.requestAccountDelete`.
38
- [ ] Implement `com.atproto.server.requestEmailConfirmation` / `requestEmailUpdate`.
39
- [ ] Implement `com.atproto.server.requestPasswordReset` / `resetPassword`.
40
- [ ] Implement `com.atproto.server.reserveSigningKey`.
+6
migrations/202512211900_account_deletion_tokens.sql
+6
migrations/202512211900_account_deletion_tokens.sql
+1
-1
src/api/server/mod.rs
+1
-1
src/api/server/mod.rs
+88
src/api/server/session.rs
+88
src/api/server/session.rs
···
6
response::{IntoResponse, Response},
7
};
8
use bcrypt::verify;
9
+
use chrono::{Duration, Utc};
10
+
use uuid::Uuid;
11
use serde::{Deserialize, Serialize};
12
use serde_json::json;
13
use tracing::{error, info, warn};
···
340
Json(json!({"error": "AuthenticationFailed"})),
341
)
342
.into_response()
343
+
}
344
+
345
+
pub async fn request_account_delete(
346
+
State(state): State<AppState>,
347
+
headers: axum::http::HeaderMap,
348
+
) -> Response {
349
+
let auth_header = headers.get("Authorization");
350
+
if auth_header.is_none() {
351
+
return (
352
+
StatusCode::UNAUTHORIZED,
353
+
Json(json!({"error": "AuthenticationRequired"})),
354
+
)
355
+
.into_response();
356
+
}
357
+
358
+
let token = auth_header
359
+
.unwrap()
360
+
.to_str()
361
+
.unwrap_or("")
362
+
.replace("Bearer ", "");
363
+
364
+
let session = sqlx::query!(
365
+
r#"
366
+
SELECT s.did, k.key_bytes
367
+
FROM sessions s
368
+
JOIN users u ON s.did = u.did
369
+
JOIN user_keys k ON u.id = k.user_id
370
+
WHERE s.access_jwt = $1
371
+
"#,
372
+
token
373
+
)
374
+
.fetch_optional(&state.db)
375
+
.await;
376
+
377
+
let (did, key_bytes) = match session {
378
+
Ok(Some(row)) => (row.did, row.key_bytes),
379
+
Ok(None) => {
380
+
return (
381
+
StatusCode::UNAUTHORIZED,
382
+
Json(json!({"error": "AuthenticationFailed"})),
383
+
)
384
+
.into_response();
385
+
}
386
+
Err(e) => {
387
+
error!("DB error in request_account_delete: {:?}", e);
388
+
return (
389
+
StatusCode::INTERNAL_SERVER_ERROR,
390
+
Json(json!({"error": "InternalError"})),
391
+
)
392
+
.into_response();
393
+
}
394
+
};
395
+
396
+
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
397
+
return (
398
+
StatusCode::UNAUTHORIZED,
399
+
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
400
+
)
401
+
.into_response();
402
+
}
403
+
404
+
let confirmation_token = Uuid::new_v4().to_string();
405
+
let expires_at = Utc::now() + Duration::minutes(15);
406
+
407
+
let insert = sqlx::query!(
408
+
"INSERT INTO account_deletion_requests (token, did, expires_at) VALUES ($1, $2, $3)",
409
+
confirmation_token,
410
+
did,
411
+
expires_at
412
+
)
413
+
.execute(&state.db)
414
+
.await;
415
+
416
+
if let Err(e) = insert {
417
+
error!("DB error creating deletion token: {:?}", e);
418
+
return (
419
+
StatusCode::INTERNAL_SERVER_ERROR,
420
+
Json(json!({"error": "InternalError"})),
421
+
)
422
+
.into_response();
423
+
}
424
+
425
+
// TODO: Send email or other notification
426
+
info!("Account deletion requested for user {}, token: {}", did, confirmation_token);
427
+
428
+
(StatusCode::OK, Json(json!({}))).into_response()
429
}
430
431
pub async fn refresh_session(
+4
src/lib.rs
+4
src/lib.rs
+8
tests/common/mod.rs
+8
tests/common/mod.rs
···
202
}
203
204
#[allow(dead_code)]
205
+
pub async fn get_db_connection_string() -> String {
206
+
base_url().await;
207
+
let container = DB_CONTAINER.get().expect("DB container not initialized");
208
+
let port = container.get_host_port_ipv4(5432).await.expect("Failed to get port");
209
+
format!("postgres://postgres:postgres@127.0.0.1:{}/postgres", port)
210
+
}
211
+
212
+
#[allow(dead_code)]
213
pub async fn upload_test_blob(client: &Client, data: &'static str, mime: &'static str) -> Value {
214
let res = client
215
.post(format!(
+3
tests/helpers/mod.rs
+3
tests/helpers/mod.rs
···
96
(uri, cid)
97
}
98
99
pub async fn create_follow(
100
client: &reqwest::Client,
101
follower_did: &str,
···
142
(uri, cid)
143
}
144
145
pub async fn create_like(
146
client: &reqwest::Client,
147
liker_did: &str,
···
186
)
187
}
188
189
pub async fn create_repost(
190
client: &reqwest::Client,
191
reposter_did: &str,
···
96
(uri, cid)
97
}
98
99
+
#[allow(dead_code)]
100
pub async fn create_follow(
101
client: &reqwest::Client,
102
follower_did: &str,
···
143
(uri, cid)
144
}
145
146
+
#[allow(dead_code)]
147
pub async fn create_like(
148
client: &reqwest::Client,
149
liker_did: &str,
···
188
)
189
}
190
191
+
#[allow(dead_code)]
192
pub async fn create_repost(
193
client: &reqwest::Client,
194
reposter_did: &str,
+32
-1
tests/lifecycle_session.rs
+32
-1
tests/lifecycle_session.rs
···
442
assert_eq!(claims["iss"], did);
443
assert_eq!(claims["aud"], "did:web:api.bsky.app");
444
assert_eq!(claims["lxm"], "com.atproto.repo.uploadBlob");
445
+
}
446
+
447
+
#[tokio::test]
448
+
async fn test_request_account_delete() {
449
+
let client = client();
450
+
let (did, jwt) = setup_new_user("request-delete-test").await;
451
+
452
+
let res = client
453
+
.post(format!(
454
+
"{}/xrpc/com.atproto.server.requestAccountDelete",
455
+
base_url().await
456
+
))
457
+
.bearer_auth(&jwt)
458
+
.send()
459
+
.await
460
+
.expect("Failed to request account deletion");
461
+
462
+
assert_eq!(res.status(), StatusCode::OK);
463
+
464
+
let db_url = get_db_connection_string().await;
465
+
let pool = sqlx::PgPool::connect(&db_url).await.expect("Failed to connect to test DB");
466
+
467
+
let row = sqlx::query!("SELECT token, expires_at FROM account_deletion_requests WHERE did = $1", did)
468
+
.fetch_optional(&pool)
469
+
.await
470
+
.expect("Failed to query DB");
471
+
472
+
assert!(row.is_some(), "Deletion token should exist in DB");
473
+
let row = row.unwrap();
474
+
assert!(!row.token.is_empty(), "Token should not be empty");
475
+
assert!(row.expires_at > Utc::now(), "Token should not be expired");
476
+
}
-1
tests/sync_repo.rs
-1
tests/sync_repo.rs