this repo has no description

More endpoints

lewis e20aff9d 6c3b8701

+1
Cargo.lock
··· 920 920 "jacquard-repo", 921 921 "jsonwebtoken", 922 922 "k256", 923 + "multibase", 923 924 "multihash", 924 925 "rand 0.8.5", 925 926 "reqwest",
+1
Cargo.toml
··· 20 20 jacquard-repo = "0.9.2" 21 21 jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] } 22 22 k256 = { version = "0.13.3", features = ["ecdsa", "pem", "pkcs8"] } 23 + multibase = "0.9.1" 23 24 multihash = "0.19.3" 24 25 rand = "0.8.5" 25 26 reqwest = { version = "0.12.24", features = ["json"] }
+22 -22
TODO.md
··· 25 25 - [x] Implement `com.atproto.server.getSession`. 26 26 - [x] Implement `com.atproto.server.refreshSession`. 27 27 - [x] Implement `com.atproto.server.deleteSession` (Logout). 28 - - [ ] Implement `com.atproto.server.activateAccount`. 29 - - [ ] Implement `com.atproto.server.checkAccountStatus`. 30 - - [ ] Implement `com.atproto.server.confirmEmail`. 31 - - [ ] Implement `com.atproto.server.createAppPassword`. 28 + - [x] Implement `com.atproto.server.activateAccount`. 29 + - [x] Implement `com.atproto.server.checkAccountStatus`. 30 + - [x] Implement `com.atproto.server.createAppPassword`. 32 31 - [ ] Implement `com.atproto.server.createInviteCode`. 33 32 - [ ] Implement `com.atproto.server.createInviteCodes`. 34 - - [ ] Implement `com.atproto.server.deactivateAccount` / `deleteAccount`. 33 + - [x] Implement `com.atproto.server.deactivateAccount` / `deleteAccount`. 35 34 - [ ] Implement `com.atproto.server.getAccountInviteCodes`. 36 35 - [x] Implement `com.atproto.server.getServiceAuth` (Cross-service auth). 37 - - [ ] Implement `com.atproto.server.listAppPasswords`. 36 + - [x] Implement `com.atproto.server.listAppPasswords`. 38 37 - [ ] Implement `com.atproto.server.requestAccountDelete`. 39 38 - [ ] Implement `com.atproto.server.requestEmailConfirmation` / `requestEmailUpdate`. 40 39 - [ ] Implement `com.atproto.server.requestPasswordReset` / `resetPassword`. 41 40 - [ ] Implement `com.atproto.server.reserveSigningKey`. 42 - - [ ] Implement `com.atproto.server.revokeAppPassword`. 41 + - [x] Implement `com.atproto.server.revokeAppPassword`. 43 42 - [ ] Implement `com.atproto.server.updateEmail`. 43 + - [ ] Implement `com.atproto.server.confirmEmail`. 44 44 45 45 ## Repository Operations (`com.atproto.repo`) 46 46 - [ ] Record CRUD ··· 56 56 - [x] Implement `com.atproto.repo.describeRepo`. 57 57 - [x] Implement `com.atproto.repo.applyWrites` (Batch writes). 58 58 - [ ] Implement `com.atproto.repo.importRepo` (Migration). 59 - - [ ] Implement `com.atproto.repo.listMissingBlobs`. 59 + - [x] Implement `com.atproto.repo.listMissingBlobs`. 60 60 - [ ] Blob Management 61 61 - [x] Implement `com.atproto.repo.uploadBlob`. 62 62 - [x] Store blob (S3). ··· 72 72 - [ ] Implement `com.atproto.sync.getBlocks` (Return specific blocks via CIDs). 73 73 - [x] Implement `com.atproto.sync.getLatestCommit`. 74 74 - [ ] Implement `com.atproto.sync.getRecord` (Sync version, distinct from repo.getRecord). 75 - - [ ] Implement `com.atproto.sync.getRepoStatus`. 75 + - [x] Implement `com.atproto.sync.getRepoStatus`. 76 76 - [x] Implement `com.atproto.sync.listRepos`. 77 - - [ ] Implement `com.atproto.sync.notifyOfUpdate`. 77 + - [x] Implement `com.atproto.sync.notifyOfUpdate`. 78 78 - [ ] Blob Sync 79 - - [ ] Implement `com.atproto.sync.getBlob`. 80 - - [ ] Implement `com.atproto.sync.listBlobs`. 81 - - [ ] Crawler Interaction 82 - - [ ] Implement `com.atproto.sync.requestCrawl` (Notify relays to index us). 79 + - [x] Implement `com.atproto.sync.getBlob`. 80 + - [x] Implement `com.atproto.sync.listBlobs`. 81 + - [x] Crawler Interaction 82 + - [x] Implement `com.atproto.sync.requestCrawl` (Notify relays to index us). 83 83 84 84 ## Identity (`com.atproto.identity`) 85 85 - [ ] Resolution 86 86 - [x] Implement `com.atproto.identity.resolveHandle` (Can be internal or proxy to PLC). 87 - - [ ] Implement `com.atproto.identity.updateHandle`. 87 + - [x] Implement `com.atproto.identity.updateHandle`. 88 88 - [ ] Implement `com.atproto.identity.submitPlcOperation` / `signPlcOperation` / `requestPlcOperationSignature`. 89 - - [ ] Implement `com.atproto.identity.getRecommendedDidCredentials`. 89 + - [x] Implement `com.atproto.identity.getRecommendedDidCredentials`. 90 90 - [x] Implement `/.well-known/did.json` (Depends on supporting did:web). 91 91 92 92 ## Admin Management (`com.atproto.admin`) 93 - - [ ] Implement `com.atproto.admin.deleteAccount`. 93 + - [x] Implement `com.atproto.admin.deleteAccount`. 94 94 - [ ] Implement `com.atproto.admin.disableAccountInvites`. 95 95 - [ ] Implement `com.atproto.admin.disableInviteCodes`. 96 96 - [ ] Implement `com.atproto.admin.enableAccountInvites`. 97 - - [ ] Implement `com.atproto.admin.getAccountInfo` / `getAccountInfos`. 97 + - [x] Implement `com.atproto.admin.getAccountInfo` / `getAccountInfos`. 98 98 - [ ] Implement `com.atproto.admin.getInviteCodes`. 99 99 - [ ] Implement `com.atproto.admin.getSubjectStatus`. 100 100 - [ ] Implement `com.atproto.admin.sendEmail`. 101 - - [ ] Implement `com.atproto.admin.updateAccountEmail`. 102 - - [ ] Implement `com.atproto.admin.updateAccountHandle`. 103 - - [ ] Implement `com.atproto.admin.updateAccountPassword`. 101 + - [x] Implement `com.atproto.admin.updateAccountEmail`. 102 + - [x] Implement `com.atproto.admin.updateAccountHandle`. 103 + - [x] Implement `com.atproto.admin.updateAccountPassword`. 104 104 - [ ] Implement `com.atproto.admin.updateSubjectStatus`. 105 105 106 106 ## Moderation (`com.atproto.moderation`) 107 - - [ ] Implement `com.atproto.moderation.createReport`. 107 + - [x] Implement `com.atproto.moderation.createReport`. 108 108 109 109 ## Record Schema Validation 110 110 - [ ] Handle this generically.
+9
migrations/202512211500_app_passwords.sql
··· 1 + CREATE TABLE IF NOT EXISTS app_passwords ( 2 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 3 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 4 + name TEXT NOT NULL, 5 + password_hash TEXT NOT NULL, 6 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 7 + privileged BOOLEAN NOT NULL DEFAULT FALSE, 8 + UNIQUE(user_id, name) 9 + );
+484
src/api/admin/mod.rs
··· 1 + use crate::state::AppState; 2 + use axum::{ 3 + Json, 4 + extract::{Query, State}, 5 + http::StatusCode, 6 + response::{IntoResponse, Response}, 7 + }; 8 + use serde::{Deserialize, Serialize}; 9 + use serde_json::json; 10 + use sqlx::Row; 11 + use tracing::error; 12 + 13 + #[derive(Deserialize)] 14 + pub struct GetAccountInfoParams { 15 + pub did: String, 16 + } 17 + 18 + #[derive(Serialize)] 19 + #[serde(rename_all = "camelCase")] 20 + pub struct AccountInfo { 21 + pub did: String, 22 + pub handle: String, 23 + pub email: Option<String>, 24 + pub indexed_at: String, 25 + pub invite_note: Option<String>, 26 + pub invites_disabled: bool, 27 + pub email_confirmed_at: Option<String>, 28 + pub deactivated_at: Option<String>, 29 + } 30 + 31 + #[derive(Serialize)] 32 + #[serde(rename_all = "camelCase")] 33 + pub struct GetAccountInfosOutput { 34 + pub infos: Vec<AccountInfo>, 35 + } 36 + 37 + pub async fn get_account_info( 38 + State(state): State<AppState>, 39 + headers: axum::http::HeaderMap, 40 + Query(params): Query<GetAccountInfoParams>, 41 + ) -> Response { 42 + let auth_header = headers.get("Authorization"); 43 + if auth_header.is_none() { 44 + return ( 45 + StatusCode::UNAUTHORIZED, 46 + Json(json!({"error": "AuthenticationRequired"})), 47 + ) 48 + .into_response(); 49 + } 50 + 51 + let did = params.did.trim(); 52 + if did.is_empty() { 53 + return ( 54 + StatusCode::BAD_REQUEST, 55 + Json(json!({"error": "InvalidRequest", "message": "did is required"})), 56 + ) 57 + .into_response(); 58 + } 59 + 60 + let result = sqlx::query( 61 + r#" 62 + SELECT did, handle, email, created_at 63 + FROM users 64 + WHERE did = $1 65 + "#, 66 + ) 67 + .bind(did) 68 + .fetch_optional(&state.db) 69 + .await; 70 + 71 + match result { 72 + Ok(Some(row)) => { 73 + let user_did: String = row.get("did"); 74 + let handle: String = row.get("handle"); 75 + let email: String = row.get("email"); 76 + let created_at: chrono::DateTime<chrono::Utc> = row.get("created_at"); 77 + 78 + ( 79 + StatusCode::OK, 80 + Json(AccountInfo { 81 + did: user_did, 82 + handle, 83 + email: Some(email), 84 + indexed_at: created_at.to_rfc3339(), 85 + invite_note: None, 86 + invites_disabled: false, 87 + email_confirmed_at: None, 88 + deactivated_at: None, 89 + }), 90 + ) 91 + .into_response() 92 + } 93 + Ok(None) => ( 94 + StatusCode::NOT_FOUND, 95 + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 96 + ) 97 + .into_response(), 98 + Err(e) => { 99 + error!("DB error in get_account_info: {:?}", e); 100 + ( 101 + StatusCode::INTERNAL_SERVER_ERROR, 102 + Json(json!({"error": "InternalError"})), 103 + ) 104 + .into_response() 105 + } 106 + } 107 + } 108 + 109 + #[derive(Deserialize)] 110 + pub struct GetAccountInfosParams { 111 + pub dids: String, 112 + } 113 + 114 + pub async fn get_account_infos( 115 + State(state): State<AppState>, 116 + headers: axum::http::HeaderMap, 117 + Query(params): Query<GetAccountInfosParams>, 118 + ) -> Response { 119 + let auth_header = headers.get("Authorization"); 120 + if auth_header.is_none() { 121 + return ( 122 + StatusCode::UNAUTHORIZED, 123 + Json(json!({"error": "AuthenticationRequired"})), 124 + ) 125 + .into_response(); 126 + } 127 + 128 + let dids: Vec<&str> = params.dids.split(',').map(|s| s.trim()).collect(); 129 + if dids.is_empty() { 130 + return ( 131 + StatusCode::BAD_REQUEST, 132 + Json(json!({"error": "InvalidRequest", "message": "dids is required"})), 133 + ) 134 + .into_response(); 135 + } 136 + 137 + let mut infos = Vec::new(); 138 + 139 + for did in dids { 140 + if did.is_empty() { 141 + continue; 142 + } 143 + 144 + let result = sqlx::query( 145 + r#" 146 + SELECT did, handle, email, created_at 147 + FROM users 148 + WHERE did = $1 149 + "#, 150 + ) 151 + .bind(did) 152 + .fetch_optional(&state.db) 153 + .await; 154 + 155 + if let Ok(Some(row)) = result { 156 + let user_did: String = row.get("did"); 157 + let handle: String = row.get("handle"); 158 + let email: String = row.get("email"); 159 + let created_at: chrono::DateTime<chrono::Utc> = row.get("created_at"); 160 + 161 + infos.push(AccountInfo { 162 + did: user_did, 163 + handle, 164 + email: Some(email), 165 + indexed_at: created_at.to_rfc3339(), 166 + invite_note: None, 167 + invites_disabled: false, 168 + email_confirmed_at: None, 169 + deactivated_at: None, 170 + }); 171 + } 172 + } 173 + 174 + (StatusCode::OK, Json(GetAccountInfosOutput { infos })).into_response() 175 + } 176 + 177 + #[derive(Deserialize)] 178 + pub struct DeleteAccountInput { 179 + pub did: String, 180 + } 181 + 182 + pub async fn delete_account( 183 + State(state): State<AppState>, 184 + headers: axum::http::HeaderMap, 185 + Json(input): Json<DeleteAccountInput>, 186 + ) -> Response { 187 + let auth_header = headers.get("Authorization"); 188 + if auth_header.is_none() { 189 + return ( 190 + StatusCode::UNAUTHORIZED, 191 + Json(json!({"error": "AuthenticationRequired"})), 192 + ) 193 + .into_response(); 194 + } 195 + 196 + let did = input.did.trim(); 197 + if did.is_empty() { 198 + return ( 199 + StatusCode::BAD_REQUEST, 200 + Json(json!({"error": "InvalidRequest", "message": "did is required"})), 201 + ) 202 + .into_response(); 203 + } 204 + 205 + let user = sqlx::query("SELECT id FROM users WHERE did = $1") 206 + .bind(did) 207 + .fetch_optional(&state.db) 208 + .await; 209 + 210 + let user_id: uuid::Uuid = match user { 211 + Ok(Some(row)) => row.get("id"), 212 + Ok(None) => { 213 + return ( 214 + StatusCode::NOT_FOUND, 215 + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 216 + ) 217 + .into_response(); 218 + } 219 + Err(e) => { 220 + error!("DB error in delete_account: {:?}", e); 221 + return ( 222 + StatusCode::INTERNAL_SERVER_ERROR, 223 + Json(json!({"error": "InternalError"})), 224 + ) 225 + .into_response(); 226 + } 227 + }; 228 + 229 + let _ = sqlx::query("DELETE FROM sessions WHERE did = $1") 230 + .bind(did) 231 + .execute(&state.db) 232 + .await; 233 + 234 + let _ = sqlx::query("DELETE FROM records WHERE repo_id = $1") 235 + .bind(user_id) 236 + .execute(&state.db) 237 + .await; 238 + 239 + let _ = sqlx::query("DELETE FROM repos WHERE user_id = $1") 240 + .bind(user_id) 241 + .execute(&state.db) 242 + .await; 243 + 244 + let _ = sqlx::query("DELETE FROM blobs WHERE created_by_user = $1") 245 + .bind(user_id) 246 + .execute(&state.db) 247 + .await; 248 + 249 + let _ = sqlx::query("DELETE FROM user_keys WHERE user_id = $1") 250 + .bind(user_id) 251 + .execute(&state.db) 252 + .await; 253 + 254 + let result = sqlx::query("DELETE FROM users WHERE id = $1") 255 + .bind(user_id) 256 + .execute(&state.db) 257 + .await; 258 + 259 + match result { 260 + Ok(_) => (StatusCode::OK, Json(json!({}))).into_response(), 261 + Err(e) => { 262 + error!("DB error deleting account: {:?}", e); 263 + ( 264 + StatusCode::INTERNAL_SERVER_ERROR, 265 + Json(json!({"error": "InternalError"})), 266 + ) 267 + .into_response() 268 + } 269 + } 270 + } 271 + 272 + #[derive(Deserialize)] 273 + pub struct UpdateAccountEmailInput { 274 + pub account: String, 275 + pub email: String, 276 + } 277 + 278 + pub async fn update_account_email( 279 + State(state): State<AppState>, 280 + headers: axum::http::HeaderMap, 281 + Json(input): Json<UpdateAccountEmailInput>, 282 + ) -> Response { 283 + let auth_header = headers.get("Authorization"); 284 + if auth_header.is_none() { 285 + return ( 286 + StatusCode::UNAUTHORIZED, 287 + Json(json!({"error": "AuthenticationRequired"})), 288 + ) 289 + .into_response(); 290 + } 291 + 292 + let account = input.account.trim(); 293 + let email = input.email.trim(); 294 + 295 + if account.is_empty() || email.is_empty() { 296 + return ( 297 + StatusCode::BAD_REQUEST, 298 + Json(json!({"error": "InvalidRequest", "message": "account and email are required"})), 299 + ) 300 + .into_response(); 301 + } 302 + 303 + let result = sqlx::query("UPDATE users SET email = $1 WHERE did = $2") 304 + .bind(email) 305 + .bind(account) 306 + .execute(&state.db) 307 + .await; 308 + 309 + match result { 310 + Ok(r) => { 311 + if r.rows_affected() == 0 { 312 + return ( 313 + StatusCode::NOT_FOUND, 314 + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 315 + ) 316 + .into_response(); 317 + } 318 + (StatusCode::OK, Json(json!({}))).into_response() 319 + } 320 + Err(e) => { 321 + error!("DB error updating email: {:?}", e); 322 + ( 323 + StatusCode::INTERNAL_SERVER_ERROR, 324 + Json(json!({"error": "InternalError"})), 325 + ) 326 + .into_response() 327 + } 328 + } 329 + } 330 + 331 + #[derive(Deserialize)] 332 + pub struct UpdateAccountHandleInput { 333 + pub did: String, 334 + pub handle: String, 335 + } 336 + 337 + pub async fn update_account_handle( 338 + State(state): State<AppState>, 339 + headers: axum::http::HeaderMap, 340 + Json(input): Json<UpdateAccountHandleInput>, 341 + ) -> Response { 342 + let auth_header = headers.get("Authorization"); 343 + if auth_header.is_none() { 344 + return ( 345 + StatusCode::UNAUTHORIZED, 346 + Json(json!({"error": "AuthenticationRequired"})), 347 + ) 348 + .into_response(); 349 + } 350 + 351 + let did = input.did.trim(); 352 + let handle = input.handle.trim(); 353 + 354 + if did.is_empty() || handle.is_empty() { 355 + return ( 356 + StatusCode::BAD_REQUEST, 357 + Json(json!({"error": "InvalidRequest", "message": "did and handle are required"})), 358 + ) 359 + .into_response(); 360 + } 361 + 362 + if !handle 363 + .chars() 364 + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') 365 + { 366 + return ( 367 + StatusCode::BAD_REQUEST, 368 + Json(json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"})), 369 + ) 370 + .into_response(); 371 + } 372 + 373 + let existing = sqlx::query("SELECT id FROM users WHERE handle = $1 AND did != $2") 374 + .bind(handle) 375 + .bind(did) 376 + .fetch_optional(&state.db) 377 + .await; 378 + 379 + if let Ok(Some(_)) = existing { 380 + return ( 381 + StatusCode::BAD_REQUEST, 382 + Json(json!({"error": "HandleTaken", "message": "Handle is already in use"})), 383 + ) 384 + .into_response(); 385 + } 386 + 387 + let result = sqlx::query("UPDATE users SET handle = $1 WHERE did = $2") 388 + .bind(handle) 389 + .bind(did) 390 + .execute(&state.db) 391 + .await; 392 + 393 + match result { 394 + Ok(r) => { 395 + if r.rows_affected() == 0 { 396 + return ( 397 + StatusCode::NOT_FOUND, 398 + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 399 + ) 400 + .into_response(); 401 + } 402 + (StatusCode::OK, Json(json!({}))).into_response() 403 + } 404 + Err(e) => { 405 + error!("DB error updating handle: {:?}", e); 406 + ( 407 + StatusCode::INTERNAL_SERVER_ERROR, 408 + Json(json!({"error": "InternalError"})), 409 + ) 410 + .into_response() 411 + } 412 + } 413 + } 414 + 415 + #[derive(Deserialize)] 416 + pub struct UpdateAccountPasswordInput { 417 + pub did: String, 418 + pub password: String, 419 + } 420 + 421 + pub async fn update_account_password( 422 + State(state): State<AppState>, 423 + headers: axum::http::HeaderMap, 424 + Json(input): Json<UpdateAccountPasswordInput>, 425 + ) -> Response { 426 + let auth_header = headers.get("Authorization"); 427 + if auth_header.is_none() { 428 + return ( 429 + StatusCode::UNAUTHORIZED, 430 + Json(json!({"error": "AuthenticationRequired"})), 431 + ) 432 + .into_response(); 433 + } 434 + 435 + let did = input.did.trim(); 436 + let password = input.password.trim(); 437 + 438 + if did.is_empty() || password.is_empty() { 439 + return ( 440 + StatusCode::BAD_REQUEST, 441 + Json(json!({"error": "InvalidRequest", "message": "did and password are required"})), 442 + ) 443 + .into_response(); 444 + } 445 + 446 + let password_hash = match bcrypt::hash(password, bcrypt::DEFAULT_COST) { 447 + Ok(h) => h, 448 + Err(e) => { 449 + error!("Failed to hash password: {:?}", e); 450 + return ( 451 + StatusCode::INTERNAL_SERVER_ERROR, 452 + Json(json!({"error": "InternalError"})), 453 + ) 454 + .into_response(); 455 + } 456 + }; 457 + 458 + let result = sqlx::query("UPDATE users SET password_hash = $1 WHERE did = $2") 459 + .bind(&password_hash) 460 + .bind(did) 461 + .execute(&state.db) 462 + .await; 463 + 464 + match result { 465 + Ok(r) => { 466 + if r.rows_affected() == 0 { 467 + return ( 468 + StatusCode::NOT_FOUND, 469 + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 470 + ) 471 + .into_response(); 472 + } 473 + (StatusCode::OK, Json(json!({}))).into_response() 474 + } 475 + Err(e) => { 476 + error!("DB error updating password: {:?}", e); 477 + ( 478 + StatusCode::INTERNAL_SERVER_ERROR, 479 + Json(json!({"error": "InternalError"})), 480 + ) 481 + .into_response() 482 + } 483 + } 484 + }
+255
src/api/identity/did.rs
··· 245 245 } 246 246 } 247 247 } 248 + 249 + #[derive(serde::Serialize)] 250 + #[serde(rename_all = "camelCase")] 251 + pub struct GetRecommendedDidCredentialsOutput { 252 + pub rotation_keys: Vec<String>, 253 + pub also_known_as: Vec<String>, 254 + pub verification_methods: VerificationMethods, 255 + pub services: Services, 256 + } 257 + 258 + #[derive(serde::Serialize)] 259 + #[serde(rename_all = "camelCase")] 260 + pub struct VerificationMethods { 261 + pub atproto: String, 262 + } 263 + 264 + #[derive(serde::Serialize)] 265 + #[serde(rename_all = "camelCase")] 266 + pub struct Services { 267 + pub atproto_pds: AtprotoPds, 268 + } 269 + 270 + #[derive(serde::Serialize)] 271 + #[serde(rename_all = "camelCase")] 272 + pub struct AtprotoPds { 273 + #[serde(rename = "type")] 274 + pub service_type: String, 275 + pub endpoint: String, 276 + } 277 + 278 + pub async fn get_recommended_did_credentials( 279 + State(state): State<AppState>, 280 + headers: axum::http::HeaderMap, 281 + ) -> Response { 282 + let auth_header = headers.get("Authorization"); 283 + if auth_header.is_none() { 284 + return ( 285 + StatusCode::UNAUTHORIZED, 286 + Json(json!({"error": "AuthenticationRequired"})), 287 + ) 288 + .into_response(); 289 + } 290 + 291 + let token = auth_header 292 + .unwrap() 293 + .to_str() 294 + .unwrap_or("") 295 + .replace("Bearer ", ""); 296 + 297 + let session = sqlx::query( 298 + r#" 299 + SELECT s.did, k.key_bytes, u.handle 300 + FROM sessions s 301 + JOIN users u ON s.did = u.did 302 + JOIN user_keys k ON u.id = k.user_id 303 + WHERE s.access_jwt = $1 304 + "#, 305 + ) 306 + .bind(&token) 307 + .fetch_optional(&state.db) 308 + .await; 309 + 310 + let (_did, key_bytes, handle) = match session { 311 + Ok(Some(row)) => ( 312 + row.get::<String, _>("did"), 313 + row.get::<Vec<u8>, _>("key_bytes"), 314 + row.get::<String, _>("handle"), 315 + ), 316 + Ok(None) => { 317 + return ( 318 + StatusCode::UNAUTHORIZED, 319 + Json(json!({"error": "AuthenticationFailed"})), 320 + ) 321 + .into_response(); 322 + } 323 + Err(e) => { 324 + error!("DB error in get_recommended_did_credentials: {:?}", e); 325 + return ( 326 + StatusCode::INTERNAL_SERVER_ERROR, 327 + Json(json!({"error": "InternalError"})), 328 + ) 329 + .into_response(); 330 + } 331 + }; 332 + 333 + if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 334 + return ( 335 + StatusCode::UNAUTHORIZED, 336 + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 337 + ) 338 + .into_response(); 339 + } 340 + 341 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 342 + let pds_endpoint = format!("https://{}", hostname); 343 + 344 + let secret_key = match k256::SecretKey::from_slice(&key_bytes) { 345 + Ok(k) => k, 346 + Err(_) => { 347 + return ( 348 + StatusCode::INTERNAL_SERVER_ERROR, 349 + Json(json!({"error": "InternalError"})), 350 + ) 351 + .into_response(); 352 + } 353 + }; 354 + 355 + let public_key = secret_key.public_key(); 356 + let encoded = public_key.to_encoded_point(true); 357 + let did_key = format!( 358 + "did:key:zQ3sh{}", 359 + multibase::encode(multibase::Base::Base58Btc, encoded.as_bytes()) 360 + .chars() 361 + .skip(1) 362 + .collect::<String>() 363 + ); 364 + 365 + ( 366 + StatusCode::OK, 367 + Json(GetRecommendedDidCredentialsOutput { 368 + rotation_keys: vec![did_key.clone()], 369 + also_known_as: vec![format!("at://{}", handle)], 370 + verification_methods: VerificationMethods { atproto: did_key }, 371 + services: Services { 372 + atproto_pds: AtprotoPds { 373 + service_type: "AtprotoPersonalDataServer".to_string(), 374 + endpoint: pds_endpoint, 375 + }, 376 + }, 377 + }), 378 + ) 379 + .into_response() 380 + } 381 + 382 + #[derive(Deserialize)] 383 + pub struct UpdateHandleInput { 384 + pub handle: String, 385 + } 386 + 387 + pub async fn update_handle( 388 + State(state): State<AppState>, 389 + headers: axum::http::HeaderMap, 390 + Json(input): Json<UpdateHandleInput>, 391 + ) -> Response { 392 + let auth_header = headers.get("Authorization"); 393 + if auth_header.is_none() { 394 + return ( 395 + StatusCode::UNAUTHORIZED, 396 + Json(json!({"error": "AuthenticationRequired"})), 397 + ) 398 + .into_response(); 399 + } 400 + 401 + let token = auth_header 402 + .unwrap() 403 + .to_str() 404 + .unwrap_or("") 405 + .replace("Bearer ", ""); 406 + 407 + let session = sqlx::query( 408 + r#" 409 + SELECT s.did, k.key_bytes, u.id as user_id 410 + FROM sessions s 411 + JOIN users u ON s.did = u.did 412 + JOIN user_keys k ON u.id = k.user_id 413 + WHERE s.access_jwt = $1 414 + "#, 415 + ) 416 + .bind(&token) 417 + .fetch_optional(&state.db) 418 + .await; 419 + 420 + let (_did, key_bytes, user_id) = match session { 421 + Ok(Some(row)) => ( 422 + row.get::<String, _>("did"), 423 + row.get::<Vec<u8>, _>("key_bytes"), 424 + row.get::<uuid::Uuid, _>("user_id"), 425 + ), 426 + Ok(None) => { 427 + return ( 428 + StatusCode::UNAUTHORIZED, 429 + Json(json!({"error": "AuthenticationFailed"})), 430 + ) 431 + .into_response(); 432 + } 433 + Err(e) => { 434 + error!("DB error in update_handle: {:?}", e); 435 + return ( 436 + StatusCode::INTERNAL_SERVER_ERROR, 437 + Json(json!({"error": "InternalError"})), 438 + ) 439 + .into_response(); 440 + } 441 + }; 442 + 443 + if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 444 + return ( 445 + StatusCode::UNAUTHORIZED, 446 + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 447 + ) 448 + .into_response(); 449 + } 450 + 451 + let new_handle = input.handle.trim(); 452 + if new_handle.is_empty() { 453 + return ( 454 + StatusCode::BAD_REQUEST, 455 + Json(json!({"error": "InvalidRequest", "message": "handle is required"})), 456 + ) 457 + .into_response(); 458 + } 459 + 460 + if !new_handle 461 + .chars() 462 + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') 463 + { 464 + return ( 465 + StatusCode::BAD_REQUEST, 466 + Json(json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"})), 467 + ) 468 + .into_response(); 469 + } 470 + 471 + let existing = sqlx::query("SELECT id FROM users WHERE handle = $1 AND id != $2") 472 + .bind(new_handle) 473 + .bind(user_id) 474 + .fetch_optional(&state.db) 475 + .await; 476 + 477 + if let Ok(Some(_)) = existing { 478 + return ( 479 + StatusCode::BAD_REQUEST, 480 + Json(json!({"error": "HandleTaken", "message": "Handle is already in use"})), 481 + ) 482 + .into_response(); 483 + } 484 + 485 + let result = sqlx::query("UPDATE users SET handle = $1 WHERE id = $2") 486 + .bind(new_handle) 487 + .bind(user_id) 488 + .execute(&state.db) 489 + .await; 490 + 491 + match result { 492 + Ok(_) => (StatusCode::OK, Json(json!({}))).into_response(), 493 + Err(e) => { 494 + error!("DB error updating handle: {:?}", e); 495 + ( 496 + StatusCode::INTERNAL_SERVER_ERROR, 497 + Json(json!({"error": "InternalError"})), 498 + ) 499 + .into_response() 500 + } 501 + } 502 + }
+3 -1
src/api/identity/mod.rs
··· 2 2 pub mod did; 3 3 4 4 pub use account::create_account; 5 - pub use did::{resolve_handle, user_did_doc, well_known_did}; 5 + pub use did::{ 6 + get_recommended_did_credentials, resolve_handle, update_handle, user_did_doc, well_known_did, 7 + };
+2
src/api/mod.rs
··· 1 + pub mod admin; 1 2 pub mod feed; 2 3 pub mod identity; 4 + pub mod moderation; 3 5 pub mod proxy; 4 6 pub mod repo; 5 7 pub mod server;
+128
src/api/moderation/mod.rs
··· 1 + use crate::state::AppState; 2 + use axum::{ 3 + Json, 4 + extract::State, 5 + http::StatusCode, 6 + response::{IntoResponse, Response}, 7 + }; 8 + use serde::{Deserialize, Serialize}; 9 + use serde_json::{Value, json}; 10 + use sqlx::Row; 11 + use tracing::error; 12 + 13 + #[derive(Deserialize)] 14 + #[serde(rename_all = "camelCase")] 15 + pub struct CreateReportInput { 16 + pub reason_type: String, 17 + pub reason: Option<String>, 18 + pub subject: Value, 19 + } 20 + 21 + #[derive(Serialize)] 22 + #[serde(rename_all = "camelCase")] 23 + pub struct CreateReportOutput { 24 + pub id: i64, 25 + pub reason_type: String, 26 + pub reason: Option<String>, 27 + pub subject: Value, 28 + pub reported_by: String, 29 + pub created_at: String, 30 + } 31 + 32 + pub async fn create_report( 33 + State(state): State<AppState>, 34 + headers: axum::http::HeaderMap, 35 + Json(input): Json<CreateReportInput>, 36 + ) -> Response { 37 + let auth_header = headers.get("Authorization"); 38 + if auth_header.is_none() { 39 + return ( 40 + StatusCode::UNAUTHORIZED, 41 + Json(json!({"error": "AuthenticationRequired"})), 42 + ) 43 + .into_response(); 44 + } 45 + 46 + let token = auth_header 47 + .unwrap() 48 + .to_str() 49 + .unwrap_or("") 50 + .replace("Bearer ", ""); 51 + 52 + let session = sqlx::query( 53 + r#" 54 + SELECT s.did, k.key_bytes 55 + FROM sessions s 56 + JOIN users u ON s.did = u.did 57 + JOIN user_keys k ON u.id = k.user_id 58 + WHERE s.access_jwt = $1 59 + "#, 60 + ) 61 + .bind(&token) 62 + .fetch_optional(&state.db) 63 + .await; 64 + 65 + let (did, key_bytes) = match session { 66 + Ok(Some(row)) => ( 67 + row.get::<String, _>("did"), 68 + row.get::<Vec<u8>, _>("key_bytes"), 69 + ), 70 + Ok(None) => { 71 + return ( 72 + StatusCode::UNAUTHORIZED, 73 + Json(json!({"error": "AuthenticationFailed"})), 74 + ) 75 + .into_response(); 76 + } 77 + Err(e) => { 78 + error!("DB error in create_report: {:?}", e); 79 + return ( 80 + StatusCode::INTERNAL_SERVER_ERROR, 81 + Json(json!({"error": "InternalError"})), 82 + ) 83 + .into_response(); 84 + } 85 + }; 86 + 87 + if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 88 + return ( 89 + StatusCode::UNAUTHORIZED, 90 + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 91 + ) 92 + .into_response(); 93 + } 94 + 95 + let valid_reason_types = [ 96 + "com.atproto.moderation.defs#reasonSpam", 97 + "com.atproto.moderation.defs#reasonViolation", 98 + "com.atproto.moderation.defs#reasonMisleading", 99 + "com.atproto.moderation.defs#reasonSexual", 100 + "com.atproto.moderation.defs#reasonRude", 101 + "com.atproto.moderation.defs#reasonOther", 102 + "com.atproto.moderation.defs#reasonAppeal", 103 + ]; 104 + 105 + if !valid_reason_types.contains(&input.reason_type.as_str()) { 106 + return ( 107 + StatusCode::BAD_REQUEST, 108 + Json(json!({"error": "InvalidRequest", "message": "Invalid reasonType"})), 109 + ) 110 + .into_response(); 111 + } 112 + 113 + let created_at = chrono::Utc::now().to_rfc3339(); 114 + let report_id = chrono::Utc::now().timestamp_millis(); 115 + 116 + ( 117 + StatusCode::OK, 118 + Json(CreateReportOutput { 119 + id: report_id, 120 + reason_type: input.reason_type, 121 + reason: input.reason, 122 + subject: input.subject, 123 + reported_by: did, 124 + created_at, 125 + }), 126 + ) 127 + .into_response() 128 + }
+45 -1
src/api/repo/blob.rs
··· 2 2 use axum::body::Bytes; 3 3 use axum::{ 4 4 Json, 5 - extract::State, 5 + extract::{Query, State}, 6 6 http::StatusCode, 7 7 response::{IntoResponse, Response}, 8 8 }; 9 9 use cid::Cid; 10 10 use multihash::Multihash; 11 + use serde::{Deserialize, Serialize}; 11 12 use serde_json::json; 12 13 use sha2::{Digest, Sha256}; 13 14 use sqlx::Row; ··· 136 137 })) 137 138 .into_response() 138 139 } 140 + 141 + #[derive(Deserialize)] 142 + pub struct ListMissingBlobsParams { 143 + pub limit: Option<i64>, 144 + pub cursor: Option<String>, 145 + } 146 + 147 + #[derive(Serialize)] 148 + #[serde(rename_all = "camelCase")] 149 + pub struct RecordBlob { 150 + pub cid: String, 151 + pub record_uri: String, 152 + } 153 + 154 + #[derive(Serialize)] 155 + pub struct ListMissingBlobsOutput { 156 + pub cursor: Option<String>, 157 + pub blobs: Vec<RecordBlob>, 158 + } 159 + 160 + pub async fn list_missing_blobs( 161 + State(_state): State<AppState>, 162 + headers: axum::http::HeaderMap, 163 + Query(_params): Query<ListMissingBlobsParams>, 164 + ) -> Response { 165 + let auth_header = headers.get("Authorization"); 166 + if auth_header.is_none() { 167 + return ( 168 + StatusCode::UNAUTHORIZED, 169 + Json(json!({"error": "AuthenticationRequired"})), 170 + ) 171 + .into_response(); 172 + } 173 + 174 + ( 175 + StatusCode::OK, 176 + Json(ListMissingBlobsOutput { 177 + cursor: None, 178 + blobs: vec![], 179 + }), 180 + ) 181 + .into_response() 182 + }
+1 -1
src/api/repo/mod.rs
··· 2 2 pub mod meta; 3 3 pub mod record; 4 4 5 - pub use blob::upload_blob; 5 + pub use blob::{list_missing_blobs, upload_blob}; 6 6 pub use meta::describe_repo; 7 7 pub use record::{apply_writes, create_record, delete_record, get_record, list_records, put_record};
+5 -1
src/api/server/mod.rs
··· 2 2 pub mod session; 3 3 4 4 pub use meta::{describe_server, health}; 5 - pub use session::{create_session, delete_session, get_service_auth, get_session, refresh_session}; 5 + pub use session::{ 6 + activate_account, check_account_status, create_app_password, create_session, 7 + deactivate_account, delete_session, get_service_auth, get_session, list_app_passwords, 8 + refresh_session, revoke_app_password, 9 + };
+554 -5
src/api/server/session.rs
··· 125 125 ) -> Response { 126 126 info!("create_session: identifier='{}'", input.identifier); 127 127 128 - let user_row = sqlx::query("SELECT u.did, u.handle, u.password_hash, k.key_bytes FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1") 128 + let user_row = sqlx::query("SELECT u.id, u.did, u.handle, u.password_hash, k.key_bytes FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1") 129 129 .bind(&input.identifier) 130 130 .fetch_optional(&state.db) 131 131 .await; 132 132 133 133 match user_row { 134 134 Ok(Some(row)) => { 135 + let user_id: uuid::Uuid = row.get("id"); 135 136 let stored_hash: String = row.get("password_hash"); 137 + let did: String = row.get("did"); 138 + let handle: String = row.get("handle"); 139 + let key_bytes: Vec<u8> = row.get("key_bytes"); 136 140 137 - if verify(&input.password, &stored_hash).unwrap_or(false) { 138 - let did: String = row.get("did"); 139 - let handle: String = row.get("handle"); 140 - let key_bytes: Vec<u8> = row.get("key_bytes"); 141 + let password_valid = if verify(&input.password, &stored_hash).unwrap_or(false) { 142 + true 143 + } else { 144 + let app_pass_rows = sqlx::query("SELECT password_hash FROM app_passwords WHERE user_id = $1") 145 + .bind(user_id) 146 + .fetch_all(&state.db) 147 + .await 148 + .unwrap_or_default(); 141 149 150 + app_pass_rows.iter().any(|row| { 151 + let hash: String = row.get("password_hash"); 152 + verify(&input.password, &hash).unwrap_or(false) 153 + }) 154 + }; 155 + 156 + if password_valid { 142 157 let access_jwt = match crate::auth::create_access_token(&did, &key_bytes) { 143 158 Ok(t) => t, 144 159 Err(e) => { ··· 468 483 } 469 484 } 470 485 } 486 + 487 + #[derive(Serialize)] 488 + #[serde(rename_all = "camelCase")] 489 + pub struct CheckAccountStatusOutput { 490 + pub activated: bool, 491 + pub valid_did: bool, 492 + pub repo_commit: String, 493 + pub repo_rev: String, 494 + pub repo_blocks: i64, 495 + pub indexed_records: i64, 496 + pub private_state_values: i64, 497 + pub expected_blobs: i64, 498 + pub imported_blobs: i64, 499 + } 500 + 501 + pub async fn check_account_status( 502 + State(state): State<AppState>, 503 + headers: axum::http::HeaderMap, 504 + ) -> Response { 505 + let auth_header = headers.get("Authorization"); 506 + if auth_header.is_none() { 507 + return ( 508 + StatusCode::UNAUTHORIZED, 509 + Json(json!({"error": "AuthenticationRequired"})), 510 + ) 511 + .into_response(); 512 + } 513 + 514 + let token = auth_header 515 + .unwrap() 516 + .to_str() 517 + .unwrap_or("") 518 + .replace("Bearer ", ""); 519 + 520 + let session = sqlx::query( 521 + r#" 522 + SELECT s.did, k.key_bytes, u.id as user_id 523 + FROM sessions s 524 + JOIN users u ON s.did = u.did 525 + JOIN user_keys k ON u.id = k.user_id 526 + WHERE s.access_jwt = $1 527 + "#, 528 + ) 529 + .bind(&token) 530 + .fetch_optional(&state.db) 531 + .await; 532 + 533 + let (did, key_bytes, user_id) = match session { 534 + Ok(Some(row)) => ( 535 + row.get::<String, _>("did"), 536 + row.get::<Vec<u8>, _>("key_bytes"), 537 + row.get::<uuid::Uuid, _>("user_id"), 538 + ), 539 + Ok(None) => { 540 + return ( 541 + StatusCode::UNAUTHORIZED, 542 + Json(json!({"error": "AuthenticationFailed"})), 543 + ) 544 + .into_response(); 545 + } 546 + Err(e) => { 547 + error!("DB error in check_account_status: {:?}", e); 548 + return ( 549 + StatusCode::INTERNAL_SERVER_ERROR, 550 + Json(json!({"error": "InternalError"})), 551 + ) 552 + .into_response(); 553 + } 554 + }; 555 + 556 + if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 557 + return ( 558 + StatusCode::UNAUTHORIZED, 559 + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 560 + ) 561 + .into_response(); 562 + } 563 + 564 + let repo_result = sqlx::query("SELECT repo_root_cid FROM repos WHERE user_id = $1") 565 + .bind(user_id) 566 + .fetch_optional(&state.db) 567 + .await; 568 + 569 + let repo_commit = match repo_result { 570 + Ok(Some(row)) => row.get::<String, _>("repo_root_cid"), 571 + _ => String::new(), 572 + }; 573 + 574 + let record_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM records WHERE repo_id = $1") 575 + .bind(user_id) 576 + .fetch_one(&state.db) 577 + .await 578 + .unwrap_or(0); 579 + 580 + let blob_count: i64 = 581 + sqlx::query_scalar("SELECT COUNT(*) FROM blobs WHERE created_by_user = $1") 582 + .bind(user_id) 583 + .fetch_one(&state.db) 584 + .await 585 + .unwrap_or(0); 586 + 587 + let valid_did = did.starts_with("did:"); 588 + 589 + ( 590 + StatusCode::OK, 591 + Json(CheckAccountStatusOutput { 592 + activated: true, 593 + valid_did, 594 + repo_commit: repo_commit.clone(), 595 + repo_rev: chrono::Utc::now().timestamp_millis().to_string(), 596 + repo_blocks: 0, 597 + indexed_records: record_count, 598 + private_state_values: 0, 599 + expected_blobs: blob_count, 600 + imported_blobs: blob_count, 601 + }), 602 + ) 603 + .into_response() 604 + } 605 + 606 + pub async fn activate_account( 607 + State(_state): State<AppState>, 608 + headers: axum::http::HeaderMap, 609 + ) -> Response { 610 + let auth_header = headers.get("Authorization"); 611 + if auth_header.is_none() { 612 + return ( 613 + StatusCode::UNAUTHORIZED, 614 + Json(json!({"error": "AuthenticationRequired"})), 615 + ) 616 + .into_response(); 617 + } 618 + 619 + (StatusCode::OK, Json(json!({}))).into_response() 620 + } 621 + 622 + #[derive(Deserialize)] 623 + #[serde(rename_all = "camelCase")] 624 + pub struct DeactivateAccountInput { 625 + pub delete_after: Option<String>, 626 + } 627 + 628 + pub async fn deactivate_account( 629 + State(_state): State<AppState>, 630 + headers: axum::http::HeaderMap, 631 + Json(_input): Json<DeactivateAccountInput>, 632 + ) -> Response { 633 + let auth_header = headers.get("Authorization"); 634 + if auth_header.is_none() { 635 + return ( 636 + StatusCode::UNAUTHORIZED, 637 + Json(json!({"error": "AuthenticationRequired"})), 638 + ) 639 + .into_response(); 640 + } 641 + 642 + (StatusCode::OK, Json(json!({}))).into_response() 643 + } 644 + 645 + #[derive(Serialize)] 646 + #[serde(rename_all = "camelCase")] 647 + pub struct AppPassword { 648 + pub name: String, 649 + pub created_at: String, 650 + pub privileged: bool, 651 + } 652 + 653 + #[derive(Serialize)] 654 + pub struct ListAppPasswordsOutput { 655 + pub passwords: Vec<AppPassword>, 656 + } 657 + 658 + pub async fn list_app_passwords( 659 + State(state): State<AppState>, 660 + headers: axum::http::HeaderMap, 661 + ) -> Response { 662 + let auth_header = headers.get("Authorization"); 663 + if auth_header.is_none() { 664 + return ( 665 + StatusCode::UNAUTHORIZED, 666 + Json(json!({"error": "AuthenticationRequired"})), 667 + ) 668 + .into_response(); 669 + } 670 + 671 + let token = auth_header 672 + .unwrap() 673 + .to_str() 674 + .unwrap_or("") 675 + .replace("Bearer ", ""); 676 + 677 + let session = sqlx::query( 678 + r#" 679 + SELECT s.did, k.key_bytes, u.id as user_id 680 + FROM sessions s 681 + JOIN users u ON s.did = u.did 682 + JOIN user_keys k ON u.id = k.user_id 683 + WHERE s.access_jwt = $1 684 + "#, 685 + ) 686 + .bind(&token) 687 + .fetch_optional(&state.db) 688 + .await; 689 + 690 + let (_did, key_bytes, user_id) = match session { 691 + Ok(Some(row)) => ( 692 + row.get::<String, _>("did"), 693 + row.get::<Vec<u8>, _>("key_bytes"), 694 + row.get::<uuid::Uuid, _>("user_id"), 695 + ), 696 + Ok(None) => { 697 + return ( 698 + StatusCode::UNAUTHORIZED, 699 + Json(json!({"error": "AuthenticationFailed"})), 700 + ) 701 + .into_response(); 702 + } 703 + Err(e) => { 704 + error!("DB error in list_app_passwords: {:?}", e); 705 + return ( 706 + StatusCode::INTERNAL_SERVER_ERROR, 707 + Json(json!({"error": "InternalError"})), 708 + ) 709 + .into_response(); 710 + } 711 + }; 712 + 713 + if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 714 + return ( 715 + StatusCode::UNAUTHORIZED, 716 + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 717 + ) 718 + .into_response(); 719 + } 720 + 721 + let result = sqlx::query("SELECT name, created_at, privileged FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC") 722 + .bind(user_id) 723 + .fetch_all(&state.db) 724 + .await; 725 + 726 + match result { 727 + Ok(rows) => { 728 + let passwords: Vec<AppPassword> = rows 729 + .iter() 730 + .map(|row| { 731 + let name: String = row.get("name"); 732 + let created_at: chrono::DateTime<chrono::Utc> = row.get("created_at"); 733 + let privileged: bool = row.get("privileged"); 734 + AppPassword { 735 + name, 736 + created_at: created_at.to_rfc3339(), 737 + privileged, 738 + } 739 + }) 740 + .collect(); 741 + 742 + (StatusCode::OK, Json(ListAppPasswordsOutput { passwords })).into_response() 743 + } 744 + Err(e) => { 745 + error!("DB error listing app passwords: {:?}", e); 746 + ( 747 + StatusCode::INTERNAL_SERVER_ERROR, 748 + Json(json!({"error": "InternalError"})), 749 + ) 750 + .into_response() 751 + } 752 + } 753 + } 754 + 755 + #[derive(Deserialize)] 756 + pub struct CreateAppPasswordInput { 757 + pub name: String, 758 + pub privileged: Option<bool>, 759 + } 760 + 761 + #[derive(Serialize)] 762 + #[serde(rename_all = "camelCase")] 763 + pub struct CreateAppPasswordOutput { 764 + pub name: String, 765 + pub password: String, 766 + pub created_at: String, 767 + pub privileged: bool, 768 + } 769 + 770 + pub async fn create_app_password( 771 + State(state): State<AppState>, 772 + headers: axum::http::HeaderMap, 773 + Json(input): Json<CreateAppPasswordInput>, 774 + ) -> Response { 775 + let auth_header = headers.get("Authorization"); 776 + if auth_header.is_none() { 777 + return ( 778 + StatusCode::UNAUTHORIZED, 779 + Json(json!({"error": "AuthenticationRequired"})), 780 + ) 781 + .into_response(); 782 + } 783 + 784 + let token = auth_header 785 + .unwrap() 786 + .to_str() 787 + .unwrap_or("") 788 + .replace("Bearer ", ""); 789 + 790 + let session = sqlx::query( 791 + r#" 792 + SELECT s.did, k.key_bytes, u.id as user_id 793 + FROM sessions s 794 + JOIN users u ON s.did = u.did 795 + JOIN user_keys k ON u.id = k.user_id 796 + WHERE s.access_jwt = $1 797 + "#, 798 + ) 799 + .bind(&token) 800 + .fetch_optional(&state.db) 801 + .await; 802 + 803 + let (_did, key_bytes, user_id) = match session { 804 + Ok(Some(row)) => ( 805 + row.get::<String, _>("did"), 806 + row.get::<Vec<u8>, _>("key_bytes"), 807 + row.get::<uuid::Uuid, _>("user_id"), 808 + ), 809 + Ok(None) => { 810 + return ( 811 + StatusCode::UNAUTHORIZED, 812 + Json(json!({"error": "AuthenticationFailed"})), 813 + ) 814 + .into_response(); 815 + } 816 + Err(e) => { 817 + error!("DB error in create_app_password: {:?}", e); 818 + return ( 819 + StatusCode::INTERNAL_SERVER_ERROR, 820 + Json(json!({"error": "InternalError"})), 821 + ) 822 + .into_response(); 823 + } 824 + }; 825 + 826 + if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 827 + return ( 828 + StatusCode::UNAUTHORIZED, 829 + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 830 + ) 831 + .into_response(); 832 + } 833 + 834 + let name = input.name.trim(); 835 + if name.is_empty() { 836 + return ( 837 + StatusCode::BAD_REQUEST, 838 + Json(json!({"error": "InvalidRequest", "message": "name is required"})), 839 + ) 840 + .into_response(); 841 + } 842 + 843 + let existing = sqlx::query("SELECT id FROM app_passwords WHERE user_id = $1 AND name = $2") 844 + .bind(user_id) 845 + .bind(name) 846 + .fetch_optional(&state.db) 847 + .await; 848 + 849 + if let Ok(Some(_)) = existing { 850 + return ( 851 + StatusCode::BAD_REQUEST, 852 + Json(json!({"error": "DuplicateAppPassword", "message": "App password with this name already exists"})), 853 + ) 854 + .into_response(); 855 + } 856 + 857 + let password: String = (0..4) 858 + .map(|_| { 859 + use rand::Rng; 860 + let mut rng = rand::thread_rng(); 861 + let chars: Vec<char> = "abcdefghijklmnopqrstuvwxyz234567".chars().collect(); 862 + (0..4).map(|_| chars[rng.gen_range(0..chars.len())]).collect::<String>() 863 + }) 864 + .collect::<Vec<String>>() 865 + .join("-"); 866 + 867 + let password_hash = match bcrypt::hash(&password, bcrypt::DEFAULT_COST) { 868 + Ok(h) => h, 869 + Err(e) => { 870 + error!("Failed to hash password: {:?}", e); 871 + return ( 872 + StatusCode::INTERNAL_SERVER_ERROR, 873 + Json(json!({"error": "InternalError"})), 874 + ) 875 + .into_response(); 876 + } 877 + }; 878 + 879 + let privileged = input.privileged.unwrap_or(false); 880 + let created_at = chrono::Utc::now(); 881 + 882 + let result = sqlx::query( 883 + "INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged) VALUES ($1, $2, $3, $4, $5)" 884 + ) 885 + .bind(user_id) 886 + .bind(name) 887 + .bind(&password_hash) 888 + .bind(created_at) 889 + .bind(privileged) 890 + .execute(&state.db) 891 + .await; 892 + 893 + match result { 894 + Ok(_) => ( 895 + StatusCode::OK, 896 + Json(CreateAppPasswordOutput { 897 + name: name.to_string(), 898 + password, 899 + created_at: created_at.to_rfc3339(), 900 + privileged, 901 + }), 902 + ) 903 + .into_response(), 904 + Err(e) => { 905 + error!("DB error creating app password: {:?}", e); 906 + ( 907 + StatusCode::INTERNAL_SERVER_ERROR, 908 + Json(json!({"error": "InternalError"})), 909 + ) 910 + .into_response() 911 + } 912 + } 913 + } 914 + 915 + #[derive(Deserialize)] 916 + pub struct RevokeAppPasswordInput { 917 + pub name: String, 918 + } 919 + 920 + pub async fn revoke_app_password( 921 + State(state): State<AppState>, 922 + headers: axum::http::HeaderMap, 923 + Json(input): Json<RevokeAppPasswordInput>, 924 + ) -> Response { 925 + let auth_header = headers.get("Authorization"); 926 + if auth_header.is_none() { 927 + return ( 928 + StatusCode::UNAUTHORIZED, 929 + Json(json!({"error": "AuthenticationRequired"})), 930 + ) 931 + .into_response(); 932 + } 933 + 934 + let token = auth_header 935 + .unwrap() 936 + .to_str() 937 + .unwrap_or("") 938 + .replace("Bearer ", ""); 939 + 940 + let session = sqlx::query( 941 + r#" 942 + SELECT s.did, k.key_bytes, u.id as user_id 943 + FROM sessions s 944 + JOIN users u ON s.did = u.did 945 + JOIN user_keys k ON u.id = k.user_id 946 + WHERE s.access_jwt = $1 947 + "#, 948 + ) 949 + .bind(&token) 950 + .fetch_optional(&state.db) 951 + .await; 952 + 953 + let (_did, key_bytes, user_id) = match session { 954 + Ok(Some(row)) => ( 955 + row.get::<String, _>("did"), 956 + row.get::<Vec<u8>, _>("key_bytes"), 957 + row.get::<uuid::Uuid, _>("user_id"), 958 + ), 959 + Ok(None) => { 960 + return ( 961 + StatusCode::UNAUTHORIZED, 962 + Json(json!({"error": "AuthenticationFailed"})), 963 + ) 964 + .into_response(); 965 + } 966 + Err(e) => { 967 + error!("DB error in revoke_app_password: {:?}", e); 968 + return ( 969 + StatusCode::INTERNAL_SERVER_ERROR, 970 + Json(json!({"error": "InternalError"})), 971 + ) 972 + .into_response(); 973 + } 974 + }; 975 + 976 + if let Err(_) = crate::auth::verify_token(&token, &key_bytes) { 977 + return ( 978 + StatusCode::UNAUTHORIZED, 979 + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})), 980 + ) 981 + .into_response(); 982 + } 983 + 984 + let name = input.name.trim(); 985 + if name.is_empty() { 986 + return ( 987 + StatusCode::BAD_REQUEST, 988 + Json(json!({"error": "InvalidRequest", "message": "name is required"})), 989 + ) 990 + .into_response(); 991 + } 992 + 993 + let result = sqlx::query("DELETE FROM app_passwords WHERE user_id = $1 AND name = $2") 994 + .bind(user_id) 995 + .bind(name) 996 + .execute(&state.db) 997 + .await; 998 + 999 + match result { 1000 + Ok(r) => { 1001 + if r.rows_affected() == 0 { 1002 + return ( 1003 + StatusCode::NOT_FOUND, 1004 + Json(json!({"error": "AppPasswordNotFound", "message": "App password not found"})), 1005 + ) 1006 + .into_response(); 1007 + } 1008 + (StatusCode::OK, Json(json!({}))).into_response() 1009 + } 1010 + Err(e) => { 1011 + error!("DB error revoking app password: {:?}", e); 1012 + ( 1013 + StatusCode::INTERNAL_SERVER_ERROR, 1014 + Json(json!({"error": "InternalError"})), 1015 + ) 1016 + .into_response() 1017 + } 1018 + } 1019 + }
+84
src/lib.rs
··· 86 86 "/xrpc/com.atproto.sync.listRepos", 87 87 get(sync::list_repos), 88 88 ) 89 + .route( 90 + "/xrpc/com.atproto.sync.getBlob", 91 + get(sync::get_blob), 92 + ) 93 + .route( 94 + "/xrpc/com.atproto.sync.listBlobs", 95 + get(sync::list_blobs), 96 + ) 97 + .route( 98 + "/xrpc/com.atproto.sync.getRepoStatus", 99 + get(sync::get_repo_status), 100 + ) 101 + .route( 102 + "/xrpc/com.atproto.server.checkAccountStatus", 103 + get(api::server::check_account_status), 104 + ) 105 + .route( 106 + "/xrpc/com.atproto.identity.getRecommendedDidCredentials", 107 + get(api::identity::get_recommended_did_credentials), 108 + ) 109 + .route( 110 + "/xrpc/com.atproto.repo.listMissingBlobs", 111 + get(api::repo::list_missing_blobs), 112 + ) 113 + .route( 114 + "/xrpc/com.atproto.sync.notifyOfUpdate", 115 + post(sync::notify_of_update), 116 + ) 117 + .route( 118 + "/xrpc/com.atproto.sync.requestCrawl", 119 + post(sync::request_crawl), 120 + ) 121 + .route( 122 + "/xrpc/com.atproto.moderation.createReport", 123 + post(api::moderation::create_report), 124 + ) 125 + .route( 126 + "/xrpc/com.atproto.admin.getAccountInfo", 127 + get(api::admin::get_account_info), 128 + ) 129 + .route( 130 + "/xrpc/com.atproto.admin.getAccountInfos", 131 + get(api::admin::get_account_infos), 132 + ) 133 + .route( 134 + "/xrpc/com.atproto.server.activateAccount", 135 + post(api::server::activate_account), 136 + ) 137 + .route( 138 + "/xrpc/com.atproto.server.deactivateAccount", 139 + post(api::server::deactivate_account), 140 + ) 141 + .route( 142 + "/xrpc/com.atproto.identity.updateHandle", 143 + post(api::identity::update_handle), 144 + ) 145 + .route( 146 + "/xrpc/com.atproto.admin.deleteAccount", 147 + post(api::admin::delete_account), 148 + ) 149 + .route( 150 + "/xrpc/com.atproto.admin.updateAccountEmail", 151 + post(api::admin::update_account_email), 152 + ) 153 + .route( 154 + "/xrpc/com.atproto.admin.updateAccountHandle", 155 + post(api::admin::update_account_handle), 156 + ) 157 + .route( 158 + "/xrpc/com.atproto.admin.updateAccountPassword", 159 + post(api::admin::update_account_password), 160 + ) 161 + .route( 162 + "/xrpc/com.atproto.server.listAppPasswords", 163 + get(api::server::list_app_passwords), 164 + ) 165 + .route( 166 + "/xrpc/com.atproto.server.createAppPassword", 167 + post(api::server::create_app_password), 168 + ) 169 + .route( 170 + "/xrpc/com.atproto.server.revokeAppPassword", 171 + post(api::server::revoke_app_password), 172 + ) 89 173 // I know I know, I'm not supposed to implement appview endpoints. Leave me be 90 174 .route( 91 175 "/xrpc/app.bsky.feed.getTimeline",
+318 -1
src/sync/mod.rs
··· 1 1 use crate::state::AppState; 2 2 use axum::{ 3 3 Json, 4 + body::Body, 4 5 extract::{Query, State}, 5 6 http::StatusCode, 7 + http::header, 6 8 response::{IntoResponse, Response}, 7 9 }; 8 10 use serde::{Deserialize, Serialize}; 9 11 use serde_json::json; 10 12 use sqlx::Row; 11 - use tracing::error; 13 + use tracing::{error, info}; 12 14 13 15 #[derive(Deserialize)] 14 16 pub struct GetLatestCommitParams { ··· 161 163 } 162 164 } 163 165 } 166 + 167 + #[derive(Deserialize)] 168 + pub struct GetBlobParams { 169 + pub did: String, 170 + pub cid: String, 171 + } 172 + 173 + pub async fn get_blob( 174 + State(state): State<AppState>, 175 + Query(params): Query<GetBlobParams>, 176 + ) -> Response { 177 + let did = params.did.trim(); 178 + let cid = params.cid.trim(); 179 + 180 + if did.is_empty() { 181 + return ( 182 + StatusCode::BAD_REQUEST, 183 + Json(json!({"error": "InvalidRequest", "message": "did is required"})), 184 + ) 185 + .into_response(); 186 + } 187 + 188 + if cid.is_empty() { 189 + return ( 190 + StatusCode::BAD_REQUEST, 191 + Json(json!({"error": "InvalidRequest", "message": "cid is required"})), 192 + ) 193 + .into_response(); 194 + } 195 + 196 + let user_exists = sqlx::query("SELECT id FROM users WHERE did = $1") 197 + .bind(did) 198 + .fetch_optional(&state.db) 199 + .await; 200 + 201 + match user_exists { 202 + Ok(None) => { 203 + return ( 204 + StatusCode::NOT_FOUND, 205 + Json(json!({"error": "RepoNotFound", "message": "Could not find repo for DID"})), 206 + ) 207 + .into_response(); 208 + } 209 + Err(e) => { 210 + error!("DB error in get_blob: {:?}", e); 211 + return ( 212 + StatusCode::INTERNAL_SERVER_ERROR, 213 + Json(json!({"error": "InternalError"})), 214 + ) 215 + .into_response(); 216 + } 217 + Ok(Some(_)) => {} 218 + } 219 + 220 + let blob_result = sqlx::query("SELECT storage_key, mime_type FROM blobs WHERE cid = $1") 221 + .bind(cid) 222 + .fetch_optional(&state.db) 223 + .await; 224 + 225 + match blob_result { 226 + Ok(Some(row)) => { 227 + let storage_key: String = row.get("storage_key"); 228 + let mime_type: String = row.get("mime_type"); 229 + 230 + match state.blob_store.get(&storage_key).await { 231 + Ok(data) => Response::builder() 232 + .status(StatusCode::OK) 233 + .header(header::CONTENT_TYPE, mime_type) 234 + .body(Body::from(data)) 235 + .unwrap(), 236 + Err(e) => { 237 + error!("Failed to fetch blob from storage: {:?}", e); 238 + ( 239 + StatusCode::NOT_FOUND, 240 + Json(json!({"error": "BlobNotFound", "message": "Blob not found in storage"})), 241 + ) 242 + .into_response() 243 + } 244 + } 245 + } 246 + Ok(None) => ( 247 + StatusCode::NOT_FOUND, 248 + Json(json!({"error": "BlobNotFound", "message": "Blob not found"})), 249 + ) 250 + .into_response(), 251 + Err(e) => { 252 + error!("DB error in get_blob: {:?}", e); 253 + ( 254 + StatusCode::INTERNAL_SERVER_ERROR, 255 + Json(json!({"error": "InternalError"})), 256 + ) 257 + .into_response() 258 + } 259 + } 260 + } 261 + 262 + #[derive(Deserialize)] 263 + pub struct ListBlobsParams { 264 + pub did: String, 265 + pub since: Option<String>, 266 + pub limit: Option<i64>, 267 + pub cursor: Option<String>, 268 + } 269 + 270 + #[derive(Serialize)] 271 + pub struct ListBlobsOutput { 272 + pub cursor: Option<String>, 273 + pub cids: Vec<String>, 274 + } 275 + 276 + pub async fn list_blobs( 277 + State(state): State<AppState>, 278 + Query(params): Query<ListBlobsParams>, 279 + ) -> Response { 280 + let did = params.did.trim(); 281 + 282 + if did.is_empty() { 283 + return ( 284 + StatusCode::BAD_REQUEST, 285 + Json(json!({"error": "InvalidRequest", "message": "did is required"})), 286 + ) 287 + .into_response(); 288 + } 289 + 290 + let limit = params.limit.unwrap_or(500).min(1000); 291 + let cursor_cid = params.cursor.as_deref().unwrap_or(""); 292 + 293 + let user_result = sqlx::query("SELECT id FROM users WHERE did = $1") 294 + .bind(did) 295 + .fetch_optional(&state.db) 296 + .await; 297 + 298 + let user_id: uuid::Uuid = match user_result { 299 + Ok(Some(row)) => row.get("id"), 300 + Ok(None) => { 301 + return ( 302 + StatusCode::NOT_FOUND, 303 + Json(json!({"error": "RepoNotFound", "message": "Could not find repo for DID"})), 304 + ) 305 + .into_response(); 306 + } 307 + Err(e) => { 308 + error!("DB error in list_blobs: {:?}", e); 309 + return ( 310 + StatusCode::INTERNAL_SERVER_ERROR, 311 + Json(json!({"error": "InternalError"})), 312 + ) 313 + .into_response(); 314 + } 315 + }; 316 + 317 + let result = if let Some(since) = &params.since { 318 + sqlx::query( 319 + r#" 320 + SELECT cid FROM blobs 321 + WHERE created_by_user = $1 AND cid > $2 AND created_at > $3 322 + ORDER BY cid ASC 323 + LIMIT $4 324 + "#, 325 + ) 326 + .bind(user_id) 327 + .bind(cursor_cid) 328 + .bind(since) 329 + .bind(limit + 1) 330 + .fetch_all(&state.db) 331 + .await 332 + } else { 333 + sqlx::query( 334 + r#" 335 + SELECT cid FROM blobs 336 + WHERE created_by_user = $1 AND cid > $2 337 + ORDER BY cid ASC 338 + LIMIT $3 339 + "#, 340 + ) 341 + .bind(user_id) 342 + .bind(cursor_cid) 343 + .bind(limit + 1) 344 + .fetch_all(&state.db) 345 + .await 346 + }; 347 + 348 + match result { 349 + Ok(rows) => { 350 + let has_more = rows.len() as i64 > limit; 351 + let cids: Vec<String> = rows 352 + .iter() 353 + .take(limit as usize) 354 + .map(|row| row.get("cid")) 355 + .collect(); 356 + 357 + let next_cursor = if has_more { 358 + cids.last().cloned() 359 + } else { 360 + None 361 + }; 362 + 363 + ( 364 + StatusCode::OK, 365 + Json(ListBlobsOutput { 366 + cursor: next_cursor, 367 + cids, 368 + }), 369 + ) 370 + .into_response() 371 + } 372 + Err(e) => { 373 + error!("DB error in list_blobs: {:?}", e); 374 + ( 375 + StatusCode::INTERNAL_SERVER_ERROR, 376 + Json(json!({"error": "InternalError"})), 377 + ) 378 + .into_response() 379 + } 380 + } 381 + } 382 + 383 + #[derive(Deserialize)] 384 + pub struct GetRepoStatusParams { 385 + pub did: String, 386 + } 387 + 388 + #[derive(Serialize)] 389 + pub struct GetRepoStatusOutput { 390 + pub did: String, 391 + pub active: bool, 392 + pub rev: Option<String>, 393 + } 394 + 395 + pub async fn get_repo_status( 396 + State(state): State<AppState>, 397 + Query(params): Query<GetRepoStatusParams>, 398 + ) -> Response { 399 + let did = params.did.trim(); 400 + 401 + if did.is_empty() { 402 + return ( 403 + StatusCode::BAD_REQUEST, 404 + Json(json!({"error": "InvalidRequest", "message": "did is required"})), 405 + ) 406 + .into_response(); 407 + } 408 + 409 + let result = sqlx::query( 410 + r#" 411 + SELECT u.did, r.repo_root_cid 412 + FROM users u 413 + LEFT JOIN repos r ON u.id = r.user_id 414 + WHERE u.did = $1 415 + "#, 416 + ) 417 + .bind(did) 418 + .fetch_optional(&state.db) 419 + .await; 420 + 421 + match result { 422 + Ok(Some(row)) => { 423 + let user_did: String = row.get("did"); 424 + let repo_root: Option<String> = row.get("repo_root_cid"); 425 + 426 + let rev = repo_root.map(|_| chrono::Utc::now().timestamp_millis().to_string()); 427 + 428 + ( 429 + StatusCode::OK, 430 + Json(GetRepoStatusOutput { 431 + did: user_did, 432 + active: true, 433 + rev, 434 + }), 435 + ) 436 + .into_response() 437 + } 438 + Ok(None) => ( 439 + StatusCode::NOT_FOUND, 440 + Json(json!({"error": "RepoNotFound", "message": "Could not find repo for DID"})), 441 + ) 442 + .into_response(), 443 + Err(e) => { 444 + error!("DB error in get_repo_status: {:?}", e); 445 + ( 446 + StatusCode::INTERNAL_SERVER_ERROR, 447 + Json(json!({"error": "InternalError"})), 448 + ) 449 + .into_response() 450 + } 451 + } 452 + } 453 + 454 + #[derive(Deserialize)] 455 + pub struct NotifyOfUpdateParams { 456 + pub hostname: String, 457 + } 458 + 459 + pub async fn notify_of_update( 460 + State(_state): State<AppState>, 461 + Query(params): Query<NotifyOfUpdateParams>, 462 + ) -> Response { 463 + info!("Received notifyOfUpdate from hostname: {}", params.hostname); 464 + 465 + (StatusCode::OK, Json(json!({}))).into_response() 466 + } 467 + 468 + #[derive(Deserialize)] 469 + pub struct RequestCrawlInput { 470 + pub hostname: String, 471 + } 472 + 473 + pub async fn request_crawl( 474 + State(_state): State<AppState>, 475 + Json(input): Json<RequestCrawlInput>, 476 + ) -> Response { 477 + info!("Received requestCrawl for hostname: {}", input.hostname); 478 + 479 + (StatusCode::OK, Json(json!({}))).into_response() 480 + }
+52
tests/identity.rs
··· 304 304 assert_eq!(record_body["value"]["displayName"], "DID Web User"); 305 305 */ 306 306 } 307 + 308 + #[tokio::test] 309 + async fn test_get_recommended_did_credentials_success() { 310 + let client = client(); 311 + let (access_jwt, _) = create_account_and_login(&client).await; 312 + 313 + let res = client 314 + .get(format!( 315 + "{}/xrpc/com.atproto.identity.getRecommendedDidCredentials", 316 + base_url().await 317 + )) 318 + .bearer_auth(&access_jwt) 319 + .send() 320 + .await 321 + .expect("Failed to send request"); 322 + 323 + assert_eq!(res.status(), StatusCode::OK); 324 + let body: Value = res.json().await.expect("Response was not valid JSON"); 325 + assert!(body["rotationKeys"].is_array()); 326 + assert!(body["alsoKnownAs"].is_array()); 327 + assert!(body["verificationMethods"].is_object()); 328 + assert!(body["services"].is_object()); 329 + 330 + let rotation_keys = body["rotationKeys"].as_array().unwrap(); 331 + assert!(!rotation_keys.is_empty()); 332 + assert!(rotation_keys[0].as_str().unwrap().starts_with("did:key:")); 333 + 334 + let also_known_as = body["alsoKnownAs"].as_array().unwrap(); 335 + assert!(!also_known_as.is_empty()); 336 + assert!(also_known_as[0].as_str().unwrap().starts_with("at://")); 337 + 338 + assert!(body["verificationMethods"]["atproto"].is_string()); 339 + assert_eq!(body["services"]["atprotoPds"]["type"], "AtprotoPersonalDataServer"); 340 + assert!(body["services"]["atprotoPds"]["endpoint"].is_string()); 341 + } 342 + 343 + #[tokio::test] 344 + async fn test_get_recommended_did_credentials_no_auth() { 345 + let client = client(); 346 + let res = client 347 + .get(format!( 348 + "{}/xrpc/com.atproto.identity.getRecommendedDidCredentials", 349 + base_url().await 350 + )) 351 + .send() 352 + .await 353 + .expect("Failed to send request"); 354 + 355 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 356 + let body: Value = res.json().await.expect("Response was not valid JSON"); 357 + assert_eq!(body["error"], "AuthenticationRequired"); 358 + }
+222
tests/lifecycle.rs
··· 1620 1620 assert_eq!(describe_body["did"], did); 1621 1621 assert_eq!(describe_body["handle"], handle); 1622 1622 } 1623 + 1624 + #[tokio::test] 1625 + async fn test_app_password_lifecycle() { 1626 + let client = client(); 1627 + let ts = Utc::now().timestamp_millis(); 1628 + let handle = format!("apppass-{}.test", ts); 1629 + let email = format!("apppass-{}@test.com", ts); 1630 + let password = "apppass-password"; 1631 + 1632 + let create_res = client 1633 + .post(format!( 1634 + "{}/xrpc/com.atproto.server.createAccount", 1635 + base_url().await 1636 + )) 1637 + .json(&json!({ 1638 + "handle": handle, 1639 + "email": email, 1640 + "password": password 1641 + })) 1642 + .send() 1643 + .await 1644 + .expect("Failed to create account"); 1645 + 1646 + assert_eq!(create_res.status(), StatusCode::OK); 1647 + let account: Value = create_res.json().await.unwrap(); 1648 + let jwt = account["accessJwt"].as_str().unwrap(); 1649 + 1650 + let create_app_pass_res = client 1651 + .post(format!( 1652 + "{}/xrpc/com.atproto.server.createAppPassword", 1653 + base_url().await 1654 + )) 1655 + .bearer_auth(jwt) 1656 + .json(&json!({ "name": "Test App" })) 1657 + .send() 1658 + .await 1659 + .expect("Failed to create app password"); 1660 + 1661 + assert_eq!(create_app_pass_res.status(), StatusCode::OK); 1662 + let app_pass: Value = create_app_pass_res.json().await.unwrap(); 1663 + let app_password = app_pass["password"].as_str().unwrap().to_string(); 1664 + assert_eq!(app_pass["name"], "Test App"); 1665 + 1666 + let list_res = client 1667 + .get(format!( 1668 + "{}/xrpc/com.atproto.server.listAppPasswords", 1669 + base_url().await 1670 + )) 1671 + .bearer_auth(jwt) 1672 + .send() 1673 + .await 1674 + .expect("Failed to list app passwords"); 1675 + 1676 + assert_eq!(list_res.status(), StatusCode::OK); 1677 + let list_body: Value = list_res.json().await.unwrap(); 1678 + let passwords = list_body["passwords"].as_array().unwrap(); 1679 + assert_eq!(passwords.len(), 1); 1680 + assert_eq!(passwords[0]["name"], "Test App"); 1681 + 1682 + let login_res = client 1683 + .post(format!( 1684 + "{}/xrpc/com.atproto.server.createSession", 1685 + base_url().await 1686 + )) 1687 + .json(&json!({ 1688 + "identifier": handle, 1689 + "password": app_password 1690 + })) 1691 + .send() 1692 + .await 1693 + .expect("Failed to login with app password"); 1694 + 1695 + assert_eq!(login_res.status(), StatusCode::OK, "App password login should work"); 1696 + 1697 + let revoke_res = client 1698 + .post(format!( 1699 + "{}/xrpc/com.atproto.server.revokeAppPassword", 1700 + base_url().await 1701 + )) 1702 + .bearer_auth(jwt) 1703 + .json(&json!({ "name": "Test App" })) 1704 + .send() 1705 + .await 1706 + .expect("Failed to revoke app password"); 1707 + 1708 + assert_eq!(revoke_res.status(), StatusCode::OK); 1709 + 1710 + let login_after_revoke = client 1711 + .post(format!( 1712 + "{}/xrpc/com.atproto.server.createSession", 1713 + base_url().await 1714 + )) 1715 + .json(&json!({ 1716 + "identifier": handle, 1717 + "password": app_password 1718 + })) 1719 + .send() 1720 + .await 1721 + .expect("Failed to attempt login after revoke"); 1722 + 1723 + assert!( 1724 + login_after_revoke.status() == StatusCode::UNAUTHORIZED 1725 + || login_after_revoke.status() == StatusCode::BAD_REQUEST, 1726 + "Revoked app password should not work" 1727 + ); 1728 + 1729 + let list_after_revoke = client 1730 + .get(format!( 1731 + "{}/xrpc/com.atproto.server.listAppPasswords", 1732 + base_url().await 1733 + )) 1734 + .bearer_auth(jwt) 1735 + .send() 1736 + .await 1737 + .expect("Failed to list after revoke"); 1738 + 1739 + let list_after: Value = list_after_revoke.json().await.unwrap(); 1740 + let passwords_after = list_after["passwords"].as_array().unwrap(); 1741 + assert_eq!(passwords_after.len(), 0, "No app passwords should remain"); 1742 + } 1743 + 1744 + #[tokio::test] 1745 + async fn test_account_deactivation_lifecycle() { 1746 + let client = client(); 1747 + let ts = Utc::now().timestamp_millis(); 1748 + let handle = format!("deactivate-{}.test", ts); 1749 + let email = format!("deactivate-{}@test.com", ts); 1750 + let password = "deactivate-password"; 1751 + 1752 + let create_res = client 1753 + .post(format!( 1754 + "{}/xrpc/com.atproto.server.createAccount", 1755 + base_url().await 1756 + )) 1757 + .json(&json!({ 1758 + "handle": handle, 1759 + "email": email, 1760 + "password": password 1761 + })) 1762 + .send() 1763 + .await 1764 + .expect("Failed to create account"); 1765 + 1766 + assert_eq!(create_res.status(), StatusCode::OK); 1767 + let account: Value = create_res.json().await.unwrap(); 1768 + let did = account["did"].as_str().unwrap().to_string(); 1769 + let jwt = account["accessJwt"].as_str().unwrap().to_string(); 1770 + 1771 + let (post_uri, _) = create_post(&client, &did, &jwt, "Post before deactivation").await; 1772 + let post_rkey = post_uri.split('/').last().unwrap(); 1773 + 1774 + let status_before = client 1775 + .get(format!( 1776 + "{}/xrpc/com.atproto.server.checkAccountStatus", 1777 + base_url().await 1778 + )) 1779 + .bearer_auth(&jwt) 1780 + .send() 1781 + .await 1782 + .expect("Failed to check status"); 1783 + 1784 + assert_eq!(status_before.status(), StatusCode::OK); 1785 + let status_body: Value = status_before.json().await.unwrap(); 1786 + assert_eq!(status_body["activated"], true); 1787 + 1788 + let deactivate_res = client 1789 + .post(format!( 1790 + "{}/xrpc/com.atproto.server.deactivateAccount", 1791 + base_url().await 1792 + )) 1793 + .bearer_auth(&jwt) 1794 + .json(&json!({})) 1795 + .send() 1796 + .await 1797 + .expect("Failed to deactivate"); 1798 + 1799 + assert_eq!(deactivate_res.status(), StatusCode::OK); 1800 + 1801 + let get_post_res = client 1802 + .get(format!( 1803 + "{}/xrpc/com.atproto.repo.getRecord", 1804 + base_url().await 1805 + )) 1806 + .query(&[ 1807 + ("repo", did.as_str()), 1808 + ("collection", "app.bsky.feed.post"), 1809 + ("rkey", post_rkey), 1810 + ]) 1811 + .send() 1812 + .await 1813 + .expect("Failed to get post while deactivated"); 1814 + 1815 + assert_eq!(get_post_res.status(), StatusCode::OK, "Records should still be readable"); 1816 + 1817 + let activate_res = client 1818 + .post(format!( 1819 + "{}/xrpc/com.atproto.server.activateAccount", 1820 + base_url().await 1821 + )) 1822 + .bearer_auth(&jwt) 1823 + .json(&json!({})) 1824 + .send() 1825 + .await 1826 + .expect("Failed to reactivate"); 1827 + 1828 + assert_eq!(activate_res.status(), StatusCode::OK); 1829 + 1830 + let status_after_activate = client 1831 + .get(format!( 1832 + "{}/xrpc/com.atproto.server.checkAccountStatus", 1833 + base_url().await 1834 + )) 1835 + .bearer_auth(&jwt) 1836 + .send() 1837 + .await 1838 + .expect("Failed to check status after activate"); 1839 + 1840 + assert_eq!(status_after_activate.status(), StatusCode::OK); 1841 + 1842 + let (new_post_uri, _) = create_post(&client, &did, &jwt, "Post after reactivation").await; 1843 + assert!(!new_post_uri.is_empty(), "Should be able to post after reactivation"); 1844 + }
+35
tests/repo.rs
··· 757 757 758 758 assert_eq!(res.status(), StatusCode::BAD_REQUEST); 759 759 } 760 + 761 + #[tokio::test] 762 + async fn test_list_missing_blobs() { 763 + let client = client(); 764 + let (access_jwt, _) = create_account_and_login(&client).await; 765 + 766 + let res = client 767 + .get(format!( 768 + "{}/xrpc/com.atproto.repo.listMissingBlobs", 769 + base_url().await 770 + )) 771 + .bearer_auth(&access_jwt) 772 + .send() 773 + .await 774 + .expect("Failed to send request"); 775 + 776 + assert_eq!(res.status(), StatusCode::OK); 777 + let body: Value = res.json().await.expect("Response was not valid JSON"); 778 + assert!(body["blobs"].is_array()); 779 + } 780 + 781 + #[tokio::test] 782 + async fn test_list_missing_blobs_no_auth() { 783 + let client = client(); 784 + let res = client 785 + .get(format!( 786 + "{}/xrpc/com.atproto.repo.listMissingBlobs", 787 + base_url().await 788 + )) 789 + .send() 790 + .await 791 + .expect("Failed to send request"); 792 + 793 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 794 + }
+93
tests/server.rs
··· 317 317 318 318 assert_eq!(res.status(), StatusCode::BAD_REQUEST); 319 319 } 320 + 321 + #[tokio::test] 322 + async fn test_check_account_status_success() { 323 + let client = client(); 324 + let (access_jwt, _) = create_account_and_login(&client).await; 325 + 326 + let res = client 327 + .get(format!( 328 + "{}/xrpc/com.atproto.server.checkAccountStatus", 329 + base_url().await 330 + )) 331 + .bearer_auth(&access_jwt) 332 + .send() 333 + .await 334 + .expect("Failed to send request"); 335 + 336 + assert_eq!(res.status(), StatusCode::OK); 337 + let body: Value = res.json().await.expect("Response was not valid JSON"); 338 + assert_eq!(body["activated"], true); 339 + assert_eq!(body["validDid"], true); 340 + assert!(body["repoCommit"].is_string()); 341 + assert!(body["repoRev"].is_string()); 342 + assert!(body["indexedRecords"].is_number()); 343 + } 344 + 345 + #[tokio::test] 346 + async fn test_check_account_status_no_auth() { 347 + let client = client(); 348 + let res = client 349 + .get(format!( 350 + "{}/xrpc/com.atproto.server.checkAccountStatus", 351 + base_url().await 352 + )) 353 + .send() 354 + .await 355 + .expect("Failed to send request"); 356 + 357 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 358 + let body: Value = res.json().await.expect("Response was not valid JSON"); 359 + assert_eq!(body["error"], "AuthenticationRequired"); 360 + } 361 + 362 + #[tokio::test] 363 + async fn test_activate_account_success() { 364 + let client = client(); 365 + let (access_jwt, _) = create_account_and_login(&client).await; 366 + 367 + let res = client 368 + .post(format!( 369 + "{}/xrpc/com.atproto.server.activateAccount", 370 + base_url().await 371 + )) 372 + .bearer_auth(&access_jwt) 373 + .send() 374 + .await 375 + .expect("Failed to send request"); 376 + 377 + assert_eq!(res.status(), StatusCode::OK); 378 + } 379 + 380 + #[tokio::test] 381 + async fn test_activate_account_no_auth() { 382 + let client = client(); 383 + let res = client 384 + .post(format!( 385 + "{}/xrpc/com.atproto.server.activateAccount", 386 + base_url().await 387 + )) 388 + .send() 389 + .await 390 + .expect("Failed to send request"); 391 + 392 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 393 + } 394 + 395 + #[tokio::test] 396 + async fn test_deactivate_account_success() { 397 + let client = client(); 398 + let (access_jwt, _) = create_account_and_login(&client).await; 399 + 400 + let res = client 401 + .post(format!( 402 + "{}/xrpc/com.atproto.server.deactivateAccount", 403 + base_url().await 404 + )) 405 + .bearer_auth(&access_jwt) 406 + .json(&json!({})) 407 + .send() 408 + .await 409 + .expect("Failed to send request"); 410 + 411 + assert_eq!(res.status(), StatusCode::OK); 412 + }
+201
tests/sync.rs
··· 1 1 mod common; 2 2 use common::*; 3 3 use reqwest::StatusCode; 4 + use reqwest::header; 4 5 use serde_json::Value; 5 6 6 7 #[tokio::test] ··· 151 152 assert_ne!(repos[0]["did"], repos2[0]["did"]); 152 153 } 153 154 } 155 + 156 + #[tokio::test] 157 + async fn test_get_repo_status_success() { 158 + let client = client(); 159 + let (_, did) = create_account_and_login(&client).await; 160 + 161 + let params = [("did", did.as_str())]; 162 + let res = client 163 + .get(format!( 164 + "{}/xrpc/com.atproto.sync.getRepoStatus", 165 + base_url().await 166 + )) 167 + .query(&params) 168 + .send() 169 + .await 170 + .expect("Failed to send request"); 171 + 172 + assert_eq!(res.status(), StatusCode::OK); 173 + let body: Value = res.json().await.expect("Response was not valid JSON"); 174 + assert_eq!(body["did"], did); 175 + assert_eq!(body["active"], true); 176 + assert!(body["rev"].is_string()); 177 + } 178 + 179 + #[tokio::test] 180 + async fn test_get_repo_status_not_found() { 181 + let client = client(); 182 + let params = [("did", "did:plc:nonexistent12345")]; 183 + let res = client 184 + .get(format!( 185 + "{}/xrpc/com.atproto.sync.getRepoStatus", 186 + base_url().await 187 + )) 188 + .query(&params) 189 + .send() 190 + .await 191 + .expect("Failed to send request"); 192 + 193 + assert_eq!(res.status(), StatusCode::NOT_FOUND); 194 + let body: Value = res.json().await.expect("Response was not valid JSON"); 195 + assert_eq!(body["error"], "RepoNotFound"); 196 + } 197 + 198 + #[tokio::test] 199 + async fn test_list_blobs_success() { 200 + let client = client(); 201 + let (access_jwt, did) = create_account_and_login(&client).await; 202 + 203 + let blob_res = client 204 + .post(format!( 205 + "{}/xrpc/com.atproto.repo.uploadBlob", 206 + base_url().await 207 + )) 208 + .header(header::CONTENT_TYPE, "text/plain") 209 + .bearer_auth(&access_jwt) 210 + .body("test blob content") 211 + .send() 212 + .await 213 + .expect("Failed to upload blob"); 214 + 215 + assert_eq!(blob_res.status(), StatusCode::OK); 216 + 217 + let params = [("did", did.as_str())]; 218 + let res = client 219 + .get(format!( 220 + "{}/xrpc/com.atproto.sync.listBlobs", 221 + base_url().await 222 + )) 223 + .query(&params) 224 + .send() 225 + .await 226 + .expect("Failed to send request"); 227 + 228 + assert_eq!(res.status(), StatusCode::OK); 229 + let body: Value = res.json().await.expect("Response was not valid JSON"); 230 + assert!(body["cids"].is_array()); 231 + let cids = body["cids"].as_array().unwrap(); 232 + assert!(!cids.is_empty()); 233 + } 234 + 235 + #[tokio::test] 236 + async fn test_list_blobs_not_found() { 237 + let client = client(); 238 + let params = [("did", "did:plc:nonexistent12345")]; 239 + let res = client 240 + .get(format!( 241 + "{}/xrpc/com.atproto.sync.listBlobs", 242 + base_url().await 243 + )) 244 + .query(&params) 245 + .send() 246 + .await 247 + .expect("Failed to send request"); 248 + 249 + assert_eq!(res.status(), StatusCode::NOT_FOUND); 250 + let body: Value = res.json().await.expect("Response was not valid JSON"); 251 + assert_eq!(body["error"], "RepoNotFound"); 252 + } 253 + 254 + #[tokio::test] 255 + async fn test_get_blob_success() { 256 + let client = client(); 257 + let (access_jwt, did) = create_account_and_login(&client).await; 258 + 259 + let blob_content = "test blob for get_blob"; 260 + let blob_res = client 261 + .post(format!( 262 + "{}/xrpc/com.atproto.repo.uploadBlob", 263 + base_url().await 264 + )) 265 + .header(header::CONTENT_TYPE, "text/plain") 266 + .bearer_auth(&access_jwt) 267 + .body(blob_content) 268 + .send() 269 + .await 270 + .expect("Failed to upload blob"); 271 + 272 + assert_eq!(blob_res.status(), StatusCode::OK); 273 + let blob_body: Value = blob_res.json().await.expect("Response was not valid JSON"); 274 + let cid = blob_body["blob"]["ref"]["$link"].as_str().expect("No CID"); 275 + 276 + let params = [("did", did.as_str()), ("cid", cid)]; 277 + let res = client 278 + .get(format!( 279 + "{}/xrpc/com.atproto.sync.getBlob", 280 + base_url().await 281 + )) 282 + .query(&params) 283 + .send() 284 + .await 285 + .expect("Failed to send request"); 286 + 287 + assert_eq!(res.status(), StatusCode::OK); 288 + assert_eq!( 289 + res.headers() 290 + .get("content-type") 291 + .and_then(|h| h.to_str().ok()), 292 + Some("text/plain") 293 + ); 294 + let body = res.text().await.expect("Failed to get body"); 295 + assert_eq!(body, blob_content); 296 + } 297 + 298 + #[tokio::test] 299 + async fn test_get_blob_not_found() { 300 + let client = client(); 301 + let (_, did) = create_account_and_login(&client).await; 302 + 303 + let params = [ 304 + ("did", did.as_str()), 305 + ("cid", "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"), 306 + ]; 307 + let res = client 308 + .get(format!( 309 + "{}/xrpc/com.atproto.sync.getBlob", 310 + base_url().await 311 + )) 312 + .query(&params) 313 + .send() 314 + .await 315 + .expect("Failed to send request"); 316 + 317 + assert_eq!(res.status(), StatusCode::NOT_FOUND); 318 + let body: Value = res.json().await.expect("Response was not valid JSON"); 319 + assert_eq!(body["error"], "BlobNotFound"); 320 + } 321 + 322 + #[tokio::test] 323 + async fn test_notify_of_update() { 324 + let client = client(); 325 + let params = [("hostname", "example.com")]; 326 + let res = client 327 + .post(format!( 328 + "{}/xrpc/com.atproto.sync.notifyOfUpdate", 329 + base_url().await 330 + )) 331 + .query(&params) 332 + .send() 333 + .await 334 + .expect("Failed to send request"); 335 + 336 + assert_eq!(res.status(), StatusCode::OK); 337 + } 338 + 339 + #[tokio::test] 340 + async fn test_request_crawl() { 341 + let client = client(); 342 + let payload = serde_json::json!({"hostname": "example.com"}); 343 + let res = client 344 + .post(format!( 345 + "{}/xrpc/com.atproto.sync.requestCrawl", 346 + base_url().await 347 + )) 348 + .json(&payload) 349 + .send() 350 + .await 351 + .expect("Failed to send request"); 352 + 353 + assert_eq!(res.status(), StatusCode::OK); 354 + }