Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
237
fork

Configure Feed

Select the types of activity you want to include in your feed.

Outbound migration perfected

+183 -18
+2
frontend/src/lib/api.ts
··· 48 48 preferredChannel?: string 49 49 preferredChannelVerified?: boolean 50 50 isAdmin?: boolean 51 + active?: boolean 52 + status?: 'active' | 'deactivated' 51 53 accessJwt: string 52 54 refreshJwt: string 53 55 }
+30
frontend/src/routes/Dashboard.svelte
··· 84 84 {/if} 85 85 </div> 86 86 </header> 87 + {#if auth.session.status === 'deactivated' || auth.session.active === false} 88 + <div class="deactivated-banner"> 89 + <strong>Account Deactivated</strong> 90 + <p>Your account is currently deactivated. This typically happens during account migration. Some features may be limited until your account is reactivated.</p> 91 + </div> 92 + {/if} 87 93 <section class="account-overview"> 88 94 <h2>Account Overview</h2> 89 95 <dl> ··· 92 98 @{auth.session.handle} 93 99 {#if auth.session.isAdmin} 94 100 <span class="badge admin">Admin</span> 101 + {/if} 102 + {#if auth.session.status === 'deactivated' || auth.session.active === false} 103 + <span class="badge deactivated">Deactivated</span> 95 104 {/if} 96 105 </dd> 97 106 <dt>DID</dt> ··· 301 310 background: var(--accent); 302 311 color: white; 303 312 } 313 + .badge.deactivated { 314 + background: var(--warning-bg); 315 + color: var(--warning-text); 316 + border: 1px solid #d4a03c; 317 + } 304 318 .nav-grid { 305 319 display: grid; 306 320 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); ··· 340 354 text-align: center; 341 355 padding: 4rem; 342 356 color: var(--text-secondary); 357 + } 358 + .deactivated-banner { 359 + background: var(--warning-bg); 360 + border: 1px solid #d4a03c; 361 + border-radius: 8px; 362 + padding: 1rem 1.5rem; 363 + margin-bottom: 2rem; 364 + } 365 + .deactivated-banner strong { 366 + color: var(--warning-text); 367 + font-size: 1rem; 368 + } 369 + .deactivated-banner p { 370 + margin: 0.5rem 0 0 0; 371 + color: var(--warning-text); 372 + font-size: 0.875rem; 343 373 } 344 374 </style>
+1
src/api/admin/invite.rs
··· 78 78 79 79 #[derive(Serialize)] 80 80 pub struct GetInviteCodesOutput { 81 + #[serde(skip_serializing_if = "Option::is_none")] 81 82 pub cursor: Option<String>, 82 83 pub codes: Vec<InviteCodeInfo>, 83 84 }
+146 -18
src/api/identity/account.rs
··· 364 364 .into_response(); 365 365 } 366 366 }; 367 - let exists_query = sqlx::query!("SELECT 1 as one FROM users WHERE handle = $1", short_handle) 368 - .fetch_optional(&mut *tx) 369 - .await; 370 - match exists_query { 371 - Ok(Some(_)) => { 372 - return ( 373 - StatusCode::BAD_REQUEST, 374 - Json(json!({"error": "HandleTaken", "message": "Handle already taken"})), 367 + if is_migration { 368 + let existing_account: Option<(uuid::Uuid, String, Option<chrono::DateTime<chrono::Utc>>)> = 369 + sqlx::query_as( 370 + "SELECT id, handle, deactivated_at FROM users WHERE did = $1 FOR UPDATE", 375 371 ) 376 - .into_response(); 377 - } 378 - Err(e) => { 379 - error!("Error checking handle: {:?}", e); 380 - return ( 381 - StatusCode::INTERNAL_SERVER_ERROR, 382 - Json(json!({"error": "InternalError"})), 383 - ) 384 - .into_response(); 372 + .bind(&did) 373 + .fetch_optional(&mut *tx) 374 + .await 375 + .unwrap_or(None); 376 + if let Some((account_id, old_handle, deactivated_at)) = existing_account { 377 + if deactivated_at.is_some() { 378 + info!(did = %did, old_handle = %old_handle, new_handle = %short_handle, "Preparing existing account for inbound migration"); 379 + let update_result: Result<_, sqlx::Error> = sqlx::query( 380 + "UPDATE users SET handle = $1 WHERE id = $2", 381 + ) 382 + .bind(short_handle) 383 + .bind(account_id) 384 + .execute(&mut *tx) 385 + .await; 386 + if let Err(e) = update_result { 387 + if let Some(db_err) = e.as_database_error() { 388 + if db_err.constraint().map(|c| c.contains("handle")).unwrap_or(false) { 389 + return ( 390 + StatusCode::BAD_REQUEST, 391 + Json(json!({"error": "HandleTaken", "message": "Handle already taken by another account"})), 392 + ) 393 + .into_response(); 394 + } 395 + } 396 + error!("Error reactivating account: {:?}", e); 397 + return ( 398 + StatusCode::INTERNAL_SERVER_ERROR, 399 + Json(json!({"error": "InternalError"})), 400 + ) 401 + .into_response(); 402 + } 403 + if let Err(e) = tx.commit().await { 404 + error!("Error committing reactivation: {:?}", e); 405 + return ( 406 + StatusCode::INTERNAL_SERVER_ERROR, 407 + Json(json!({"error": "InternalError"})), 408 + ) 409 + .into_response(); 410 + } 411 + let key_row: Option<(Vec<u8>, i32)> = sqlx::query_as( 412 + "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", 413 + ) 414 + .bind(account_id) 415 + .fetch_optional(&state.db) 416 + .await 417 + .unwrap_or(None); 418 + let secret_key_bytes = match key_row { 419 + Some((key_bytes, encryption_version)) => { 420 + match crate::config::decrypt_key(&key_bytes, Some(encryption_version)) { 421 + Ok(k) => k, 422 + Err(e) => { 423 + error!("Error decrypting key for reactivated account: {:?}", e); 424 + return ( 425 + StatusCode::INTERNAL_SERVER_ERROR, 426 + Json(json!({"error": "InternalError"})), 427 + ) 428 + .into_response(); 429 + } 430 + } 431 + } 432 + None => { 433 + error!("No signing key found for reactivated account"); 434 + return ( 435 + StatusCode::INTERNAL_SERVER_ERROR, 436 + Json(json!({"error": "InternalError", "message": "Account signing key not found"})), 437 + ) 438 + .into_response(); 439 + } 440 + }; 441 + let access_meta = match crate::auth::create_access_token_with_metadata(&did, &secret_key_bytes) { 442 + Ok(m) => m, 443 + Err(e) => { 444 + error!("Error creating access token: {:?}", e); 445 + return ( 446 + StatusCode::INTERNAL_SERVER_ERROR, 447 + Json(json!({"error": "InternalError"})), 448 + ) 449 + .into_response(); 450 + } 451 + }; 452 + let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&did, &secret_key_bytes) { 453 + Ok(m) => m, 454 + Err(e) => { 455 + error!("Error creating refresh token: {:?}", e); 456 + return ( 457 + StatusCode::INTERNAL_SERVER_ERROR, 458 + Json(json!({"error": "InternalError"})), 459 + ) 460 + .into_response(); 461 + } 462 + }; 463 + let session_result: Result<_, sqlx::Error> = sqlx::query( 464 + "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", 465 + ) 466 + .bind(&did) 467 + .bind(&access_meta.jti) 468 + .bind(&refresh_meta.jti) 469 + .bind(access_meta.expires_at) 470 + .bind(refresh_meta.expires_at) 471 + .execute(&state.db) 472 + .await; 473 + if let Err(e) = session_result { 474 + error!("Error creating session: {:?}", e); 475 + return ( 476 + StatusCode::INTERNAL_SERVER_ERROR, 477 + Json(json!({"error": "InternalError"})), 478 + ) 479 + .into_response(); 480 + } 481 + return ( 482 + StatusCode::OK, 483 + Json(CreateAccountOutput { 484 + handle: full_handle.clone(), 485 + did, 486 + access_jwt: Some(access_meta.token), 487 + refresh_jwt: Some(refresh_meta.token), 488 + verification_required: false, 489 + verification_channel: "email".to_string(), 490 + }), 491 + ) 492 + .into_response(); 493 + } else { 494 + return ( 495 + StatusCode::BAD_REQUEST, 496 + Json(json!({"error": "AccountAlreadyExists", "message": "An active account with this DID already exists"})), 497 + ) 498 + .into_response(); 499 + } 385 500 } 386 - Ok(None) => {} 501 + } 502 + let exists_result: Option<(i32,)> = sqlx::query_as( 503 + "SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL", 504 + ) 505 + .bind(short_handle) 506 + .fetch_optional(&mut *tx) 507 + .await 508 + .unwrap_or(None); 509 + if exists_result.is_some() { 510 + return ( 511 + StatusCode::BAD_REQUEST, 512 + Json(json!({"error": "HandleTaken", "message": "Handle already taken"})), 513 + ) 514 + .into_response(); 387 515 } 388 516 let invite_code_required = std::env::var("INVITE_CODE_REQUIRED") 389 517 .map(|v| v == "true" || v == "1")
+1
src/api/repo/blob.rs
··· 222 222 223 223 #[derive(Serialize)] 224 224 pub struct ListMissingBlobsOutput { 225 + #[serde(skip_serializing_if = "Option::is_none")] 225 226 pub cursor: Option<String>, 226 227 pub blobs: Vec<RecordBlob>, 227 228 }
+1
src/api/repo/record/read.rs
··· 197 197 } 198 198 #[derive(Serialize)] 199 199 pub struct ListRecordsOutput { 200 + #[serde(skip_serializing_if = "Option::is_none")] 200 201 pub cursor: Option<String>, 201 202 pub records: Vec<serde_json::Value>, 202 203 }
+1
src/sync/blob.rs
··· 110 110 111 111 #[derive(Serialize)] 112 112 pub struct ListBlobsOutput { 113 + #[serde(skip_serializing_if = "Option::is_none")] 113 114 pub cursor: Option<String>, 114 115 pub cids: Vec<String>, 115 116 }
+1
src/sync/commit.rs
··· 101 101 102 102 #[derive(Serialize)] 103 103 pub struct ListReposOutput { 104 + #[serde(skip_serializing_if = "Option::is_none")] 104 105 pub cursor: Option<String>, 105 106 pub repos: Vec<RepoInfo>, 106 107 }