diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md new file mode 100644 index 0000000..a2cf974 --- /dev/null +++ b/KNOWN_ISSUES.md @@ -0,0 +1,9 @@ +# Known Issues + +## Account migration from bsky.social + +Migrating your account from bsky.social to this PDS works, but Bluesky's appview may not recognize your new signing key. This means you can post and your followers will see it, but some authenticated requests might fail with "jwt signature does not match jwt issuer". + +We've been trying hard to verify that our side is correct (PLC updated, signing keys match, relays have the account) but something about how we're emitting events isn't triggering Bluesky's appview to refresh its identity data. Still investigating. + +No workaround yet. diff --git a/frontend/src/lib/migration/atproto-client.ts b/frontend/src/lib/migration/atproto-client.ts index 2121352..15e3025 100644 --- a/frontend/src/lib/migration/atproto-client.ts +++ b/frontend/src/lib/migration/atproto-client.ts @@ -11,6 +11,20 @@ import type { Session, } from "./types"; +function apiLog( + method: string, + endpoint: string, + data?: Record, +) { + const timestamp = new Date().toISOString(); + const msg = `[API ${timestamp}] ${method} ${endpoint}`; + if (data) { + console.log(msg, JSON.stringify(data, null, 2)); + } else { + console.log(msg); + } +} + export class AtprotoClient { private baseUrl: string; private accessToken: string | null = null; @@ -107,10 +121,13 @@ export class AtprotoClient { body.authFactorToken = authFactorToken; } - const session = await this.xrpc("com.atproto.server.createSession", { - httpMethod: "POST", - body, - }); + const session = await this.xrpc( + "com.atproto.server.createSession", + { + httpMethod: "POST", + body, + }, + ); this.accessToken = session.accessJwt; return session; @@ -239,7 +256,9 @@ export class AtprotoClient { async listMissingBlobs( cursor?: string, limit = 100, - ): Promise<{ blobs: Array<{ cid: string; recordUri: string }>; cursor?: string }> { + ): Promise< + { blobs: Array<{ cid: string; recordUri: string }>; cursor?: string } + > { const params: Record = { limit: String(limit) }; if (cursor) { params.cursor = cursor; @@ -267,10 +286,26 @@ export class AtprotoClient { } async submitPlcOperation(operation: PlcOperation): Promise { + apiLog( + "POST", + `${this.baseUrl}/xrpc/com.atproto.identity.submitPlcOperation`, + { + operationType: operation.type, + operationPrev: operation.prev, + }, + ); + const start = Date.now(); await this.xrpc("com.atproto.identity.submitPlcOperation", { httpMethod: "POST", body: { operation }, }); + apiLog( + "POST", + `${this.baseUrl}/xrpc/com.atproto.identity.submitPlcOperation COMPLETE`, + { + durationMs: Date.now() - start, + }, + ); } async getRecommendedDidCredentials(): Promise { @@ -278,15 +313,49 @@ export class AtprotoClient { } async activateAccount(): Promise { + apiLog("POST", `${this.baseUrl}/xrpc/com.atproto.server.activateAccount`); + const start = Date.now(); await this.xrpc("com.atproto.server.activateAccount", { httpMethod: "POST", }); + apiLog( + "POST", + `${this.baseUrl}/xrpc/com.atproto.server.activateAccount COMPLETE`, + { + durationMs: Date.now() - start, + }, + ); } async deactivateAccount(): Promise { - await this.xrpc("com.atproto.server.deactivateAccount", { - httpMethod: "POST", - }); + apiLog("POST", `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`); + const start = Date.now(); + try { + await this.xrpc("com.atproto.server.deactivateAccount", { + httpMethod: "POST", + }); + apiLog( + "POST", + `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount COMPLETE`, + { + durationMs: Date.now() - start, + success: true, + }, + ); + } catch (e) { + const err = e as Error & { error?: string; status?: number }; + apiLog( + "POST", + `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount FAILED`, + { + durationMs: Date.now() - start, + error: err.message, + errorCode: err.error, + status: err.status, + }, + ); + throw e; + } } async checkAccountStatus(): Promise { @@ -330,10 +399,13 @@ export class AtprotoClient { identifier: string, password: string, ): Promise { - const session = await this.xrpc("com.atproto.server.createSession", { - httpMethod: "POST", - body: { identifier, password, allowDeactivated: true }, - }); + const session = await this.xrpc( + "com.atproto.server.createSession", + { + httpMethod: "POST", + body: { identifier, password, allowDeactivated: true }, + }, + ); this.accessToken = session.accessJwt; return session; } @@ -341,7 +413,9 @@ export class AtprotoClient { async verifyToken( token: string, identifier: string, - ): Promise<{ success: boolean; did: string; purpose: string; channel: string }> { + ): Promise< + { success: boolean; did: string; purpose: string; channel: string } + > { return this.xrpc("com.tranquil.account.verifyToken", { httpMethod: "POST", body: { token, identifier }, @@ -392,7 +466,9 @@ export async function resolvePdsUrl( if (handle.endsWith(".bsky.social")) { const res = await fetch( - `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${ + encodeURIComponent(handle) + }`, ); if (!res.ok) { throw new Error(`Failed to resolve handle: ${res.statusText}`); diff --git a/frontend/src/lib/migration/flow.svelte.ts b/frontend/src/lib/migration/flow.svelte.ts index d6f895a..5346d3b 100644 --- a/frontend/src/lib/migration/flow.svelte.ts +++ b/frontend/src/lib/migration/flow.svelte.ts @@ -20,6 +20,16 @@ import { updateStep, } from "./storage"; +function migrationLog(stage: string, data?: Record) { + const timestamp = new Date().toISOString(); + const msg = `[MIGRATION ${timestamp}] ${stage}`; + if (data) { + console.log(msg, JSON.stringify(data, null, 2)); + } else { + console.log(msg); + } +} + function createInitialProgress(): MigrationProgress { return { repoExported: false, @@ -105,6 +115,8 @@ export function createInboundMigrationFlow() { password: string, twoFactorCode?: string, ): Promise { + migrationLog("loginToSource START", { handle, has2FA: !!twoFactorCode }); + if (!state.sourcePdsUrl) { await resolveSourcePds(handle); } @@ -114,7 +126,15 @@ export function createInboundMigrationFlow() { } try { + migrationLog("loginToSource: Calling createSession on OLD PDS", { + pdsUrl: state.sourcePdsUrl, + }); const session = await sourceClient.login(handle, password, twoFactorCode); + migrationLog("loginToSource SUCCESS", { + did: session.did, + handle: session.handle, + pdsUrl: state.sourcePdsUrl, + }); state.sourceAccessToken = session.accessJwt; state.sourceRefreshToken = session.refreshJwt; state.sourceDid = session.did; @@ -123,9 +143,15 @@ export function createInboundMigrationFlow() { saveMigrationState(state); } catch (e) { const err = e as Error & { error?: string }; + migrationLog("loginToSource FAILED", { + error: err.message, + errorCode: err.error, + }); if (err.error === "AuthFactorTokenRequired") { state.requires2FA = true; - throw new Error("Two-factor authentication required. Please enter the code sent to your email."); + throw new Error( + "Two-factor authentication required. Please enter the code sent to your email.", + ); } throw e; } @@ -143,7 +169,10 @@ export function createInboundMigrationFlow() { } } - async function authenticateToLocal(email: string, password: string): Promise { + async function authenticateToLocal( + email: string, + password: string, + ): Promise { if (!localClient) { localClient = createLocalClient(); } @@ -151,7 +180,15 @@ export function createInboundMigrationFlow() { } async function startMigration(): Promise { + migrationLog("startMigration START", { + sourceDid: state.sourceDid, + sourceHandle: state.sourceHandle, + targetHandle: state.targetHandle, + sourcePdsUrl: state.sourcePdsUrl, + }); + if (!sourceClient || !state.sourceAccessToken) { + migrationLog("startMigration ERROR: Not logged in to source PDS"); throw new Error("Not logged in to source PDS"); } @@ -163,11 +200,18 @@ export function createInboundMigrationFlow() { setProgress({ currentOperation: "Getting service auth token..." }); try { + migrationLog("startMigration: Loading local server info"); const serverInfo = await loadLocalServerInfo(); + migrationLog("startMigration: Got server info", { + serverDid: serverInfo.did, + }); + + migrationLog("startMigration: Getting service auth token from OLD PDS"); const { token } = await sourceClient.getServiceAuth( serverInfo.did, "com.atproto.server.createAccount", ); + migrationLog("startMigration: Got service auth token"); state.serviceAuthToken = token; setProgress({ currentOperation: "Creating account on new PDS..." }); @@ -180,18 +224,45 @@ export function createInboundMigrationFlow() { inviteCode: state.inviteCode || undefined, }; + migrationLog("startMigration: Creating account on NEW PDS", { + did: accountParams.did, + handle: accountParams.handle, + }); const session = await localClient.createAccount(accountParams, token); + migrationLog("startMigration: Account created on NEW PDS", { + did: session.did, + }); localClient.setAccessToken(session.accessJwt); setProgress({ currentOperation: "Exporting repository..." }); - + migrationLog("startMigration: Exporting repo from OLD PDS"); + const exportStart = Date.now(); const car = await sourceClient.getRepo(state.sourceDid); - setProgress({ repoExported: true, currentOperation: "Importing repository..." }); + migrationLog("startMigration: Repo exported", { + durationMs: Date.now() - exportStart, + sizeBytes: car.byteLength, + }); + setProgress({ + repoExported: true, + currentOperation: "Importing repository...", + }); + migrationLog("startMigration: Importing repo to NEW PDS"); + const importStart = Date.now(); await localClient.importRepo(car); - setProgress({ repoImported: true, currentOperation: "Counting blobs..." }); + migrationLog("startMigration: Repo imported", { + durationMs: Date.now() - importStart, + }); + setProgress({ + repoImported: true, + currentOperation: "Counting blobs...", + }); const accountStatus = await localClient.checkAccountStatus(); + migrationLog("startMigration: Account status", { + expectedBlobs: accountStatus.expectedBlobs, + importedBlobs: accountStatus.importedBlobs, + }); setProgress({ blobsTotal: accountStatus.expectedBlobs, currentOperation: "Migrating blobs...", @@ -202,10 +273,20 @@ export function createInboundMigrationFlow() { setProgress({ currentOperation: "Migrating preferences..." }); await migratePreferences(); + migrationLog( + "startMigration: Initial migration complete, waiting for email verification", + ); setStep("email-verify"); } catch (e) { const err = e as Error & { error?: string; status?: number }; - const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`; + const message = err.message || err.error || + `Unknown error (status ${err.status || "unknown"})`; + migrationLog("startMigration FAILED", { + error: message, + errorCode: err.error, + status: err.status, + stack: err.stack, + }); setError(message); setStep("error"); } @@ -226,10 +307,15 @@ export function createInboundMigrationFlow() { for (const blob of blobs) { try { setProgress({ - currentOperation: `Migrating blob ${migrated + 1}/${state.progress.blobsTotal}...`, + currentOperation: `Migrating blob ${ + migrated + 1 + }/${state.progress.blobsTotal}...`, }); - const blobData = await sourceClient.getBlob(state.sourceDid, blob.cid); + const blobData = await sourceClient.getBlob( + state.sourceDid, + blob.cid, + ); await localClient.uploadBlob(blobData, "application/octet-stream"); migrated++; setProgress({ blobsMigrated: migrated }); @@ -253,7 +339,10 @@ export function createInboundMigrationFlow() { } } - async function submitEmailVerifyToken(token: string, localPassword?: string): Promise { + async function submitEmailVerifyToken( + token: string, + localPassword?: string, + ): Promise { if (!localClient) { localClient = createLocalClient(); } @@ -266,7 +355,9 @@ export function createInboundMigrationFlow() { if (!sourceClient) { setStep("source-login"); - setError("Email verified! Please log in to your old account again to complete the migration."); + setError( + "Email verified! Please log in to your old account again to complete the migration.", + ); return; } @@ -285,7 +376,8 @@ export function createInboundMigrationFlow() { setStep("plc-token"); } catch (e) { const err = e as Error & { error?: string; status?: number }; - const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`; + const message = err.message || err.error || + `Unknown error (status ${err.status || "unknown"})`; setError(message); } } @@ -305,7 +397,10 @@ export function createInboundMigrationFlow() { checkingEmailVerification = true; try { - await localClient.loginDeactivated(state.targetEmail, state.targetPassword); + await localClient.loginDeactivated( + state.targetEmail, + state.targetPassword, + ); await sourceClient.requestPlcOperationSignature(); setStep("plc-token"); return true; @@ -321,7 +416,18 @@ export function createInboundMigrationFlow() { } async function submitPlcToken(token: string): Promise { + migrationLog("submitPlcToken START", { + sourceDid: state.sourceDid, + sourceHandle: state.sourceHandle, + targetHandle: state.targetHandle, + sourcePdsUrl: state.sourcePdsUrl, + }); + if (!sourceClient || !localClient) { + migrationLog("submitPlcToken ERROR: Not connected to PDSes", { + hasSourceClient: !!sourceClient, + hasLocalClient: !!localClient, + }); throw new Error("Not connected to PDSes"); } @@ -330,32 +436,92 @@ export function createInboundMigrationFlow() { setProgress({ currentOperation: "Signing PLC operation..." }); try { + migrationLog("Step 1: Getting recommended DID credentials from NEW PDS"); const credentials = await localClient.getRecommendedDidCredentials(); + migrationLog("Step 1 COMPLETE: Got credentials", { + rotationKeys: credentials.rotationKeys, + alsoKnownAs: credentials.alsoKnownAs, + verificationMethods: credentials.verificationMethods, + services: credentials.services, + }); + migrationLog("Step 2: Signing PLC operation on OLD PDS", { + sourcePdsUrl: state.sourcePdsUrl, + }); + const signStart = Date.now(); const { operation } = await sourceClient.signPlcOperation({ token, ...credentials, }); + migrationLog("Step 2 COMPLETE: PLC operation signed", { + durationMs: Date.now() - signStart, + operationType: operation.type, + operationPrev: operation.prev, + }); - setProgress({ plcSigned: true, currentOperation: "Submitting PLC operation..." }); + setProgress({ + plcSigned: true, + currentOperation: "Submitting PLC operation...", + }); + migrationLog("Step 3: Submitting PLC operation to NEW PDS"); + const submitStart = Date.now(); await localClient.submitPlcOperation(operation); + migrationLog("Step 3 COMPLETE: PLC operation submitted", { + durationMs: Date.now() - submitStart, + }); - setProgress({ currentOperation: "Activating account (waiting for DID propagation)..." }); + setProgress({ + currentOperation: "Activating account (waiting for DID propagation)...", + }); + migrationLog("Step 4: Activating account on NEW PDS"); + const activateStart = Date.now(); await localClient.activateAccount(); + migrationLog("Step 4 COMPLETE: Account activated on NEW PDS", { + durationMs: Date.now() - activateStart, + }); setProgress({ activated: true }); setProgress({ currentOperation: "Deactivating old account..." }); + migrationLog("Step 5: Deactivating account on OLD PDS", { + sourcePdsUrl: state.sourcePdsUrl, + }); + const deactivateStart = Date.now(); try { await sourceClient.deactivateAccount(); + migrationLog("Step 5 COMPLETE: Account deactivated on OLD PDS", { + durationMs: Date.now() - deactivateStart, + success: true, + }); setProgress({ deactivated: true }); - } catch { + } catch (deactivateErr) { + const err = deactivateErr as Error & { + error?: string; + status?: number; + }; + migrationLog("Step 5 FAILED: Could not deactivate on OLD PDS", { + durationMs: Date.now() - deactivateStart, + error: err.message, + errorCode: err.error, + status: err.status, + }); } + migrationLog("submitPlcToken SUCCESS: Migration complete", { + sourceDid: state.sourceDid, + newHandle: state.targetHandle, + }); setStep("success"); clearMigrationState(); } catch (e) { const err = e as Error & { error?: string; status?: number }; - const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`; + const message = err.message || err.error || + `Unknown error (status ${err.status || "unknown"})`; + migrationLog("submitPlcToken FAILED", { + error: message, + errorCode: err.error, + status: err.status, + stack: err.stack, + }); state.step = "plc-token"; state.error = message; saveMigrationState(state); @@ -418,7 +584,9 @@ export function createInboundMigrationFlow() { state.step = "source-login"; } - function getLocalSession(): { accessJwt: string; did: string; handle: string } | null { + function getLocalSession(): + | { accessJwt: string; did: string; handle: string } + | null { if (!localClient) return null; const token = localClient.getAccessToken(); if (!token) return null; @@ -430,7 +598,9 @@ export function createInboundMigrationFlow() { } return { - get state() { return state; }, + get state() { + return state; + }, setStep, setError, loadLocalServerInfo, @@ -513,7 +683,11 @@ export function createOutboundMigrationFlow() { } } - function initLocalClient(accessToken: string, did?: string, handle?: string): void { + function initLocalClient( + accessToken: string, + did?: string, + handle?: string, + ): void { localClient = createLocalClient(); localClient.setAccessToken(accessToken); if (did) { @@ -557,10 +731,16 @@ export function createOutboundMigrationFlow() { setProgress({ currentOperation: "Exporting repository..." }); const car = await localClient.getRepo(currentDid); - setProgress({ repoExported: true, currentOperation: "Importing repository..." }); + setProgress({ + repoExported: true, + currentOperation: "Importing repository...", + }); await targetClient.importRepo(car); - setProgress({ repoImported: true, currentOperation: "Counting blobs..." }); + setProgress({ + repoImported: true, + currentOperation: "Counting blobs...", + }); const accountStatus = await targetClient.checkAccountStatus(); setProgress({ @@ -579,7 +759,8 @@ export function createOutboundMigrationFlow() { setStep("plc-token"); } catch (e) { const err = e as Error & { error?: string; status?: number }; - const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`; + const message = err.message || err.error || + `Unknown error (status ${err.status || "unknown"})`; setError(message); setStep("error"); } @@ -600,7 +781,9 @@ export function createOutboundMigrationFlow() { for (const blob of blobs) { try { setProgress({ - currentOperation: `Migrating blob ${migrated + 1}/${state.progress.blobsTotal}...`, + currentOperation: `Migrating blob ${ + migrated + 1 + }/${state.progress.blobsTotal}...`, }); const blobData = await localClient.getBlob(did, blob.cid); @@ -644,7 +827,10 @@ export function createOutboundMigrationFlow() { ...credentials, }); - setProgress({ plcSigned: true, currentOperation: "Submitting PLC operation..." }); + setProgress({ + plcSigned: true, + currentOperation: "Submitting PLC operation...", + }); await targetClient.submitPlcOperation(operation); @@ -660,7 +846,9 @@ export function createOutboundMigrationFlow() { } if (state.localDid.startsWith("did:web:")) { - setProgress({ currentOperation: "Updating DID document forwarding..." }); + setProgress({ + currentOperation: "Updating DID document forwarding...", + }); try { await localClient.updateMigrationForwarding(state.targetPdsUrl); } catch (e) { @@ -672,7 +860,8 @@ export function createOutboundMigrationFlow() { clearMigrationState(); } catch (e) { const err = e as Error & { error?: string; status?: number }; - const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`; + const message = err.message || err.error || + `Unknown error (status ${err.status || "unknown"})`; setError(message); setStep("plc-token"); } @@ -711,7 +900,9 @@ export function createOutboundMigrationFlow() { } return { - get state() { return state; }, + get state() { + return state; + }, setStep, setError, validateTargetPds, @@ -730,5 +921,9 @@ export function createOutboundMigrationFlow() { }; } -export type InboundMigrationFlow = ReturnType; -export type OutboundMigrationFlow = ReturnType; +export type InboundMigrationFlow = ReturnType< + typeof createInboundMigrationFlow +>; +export type OutboundMigrationFlow = ReturnType< + typeof createOutboundMigrationFlow +>; diff --git a/frontend/src/lib/migration/storage.ts b/frontend/src/lib/migration/storage.ts index cecab9f..1007bd3 100644 --- a/frontend/src/lib/migration/storage.ts +++ b/frontend/src/lib/migration/storage.ts @@ -1,4 +1,8 @@ -import type { MigrationDirection, MigrationState, StoredMigrationState } from "./types"; +import type { + MigrationDirection, + MigrationState, + StoredMigrationState, +} from "./types"; const STORAGE_KEY = "tranquil_migration_state"; const MAX_AGE_MS = 24 * 60 * 60 * 1000; @@ -9,8 +13,12 @@ export function saveMigrationState(state: MigrationState): void { direction: state.direction, step: state.direction === "inbound" ? state.step : state.step, startedAt: new Date().toISOString(), - sourcePdsUrl: state.direction === "inbound" ? state.sourcePdsUrl : window.location.origin, - targetPdsUrl: state.direction === "inbound" ? window.location.origin : state.targetPdsUrl, + sourcePdsUrl: state.direction === "inbound" + ? state.sourcePdsUrl + : window.location.origin, + targetPdsUrl: state.direction === "inbound" + ? window.location.origin + : state.targetPdsUrl, sourceDid: state.direction === "inbound" ? state.sourceDid : "", sourceHandle: state.direction === "inbound" ? state.sourceHandle : "", targetHandle: state.targetHandle, diff --git a/src/api/delegation.rs b/src/api/delegation.rs index 374ceca..f62abdd 100644 --- a/src/api/delegation.rs +++ b/src/api/delegation.rs @@ -468,7 +468,7 @@ pub async fn get_audit_log( auth: BearerAuth, Query(params): Query, ) -> Response { - let limit = params.limit.min(100).max(1); + let limit = params.limit.clamp(1, 100); let offset = params.offset.max(0); let entries = @@ -489,10 +489,9 @@ pub async fn get_audit_log( } }; - let total = match delegation::audit::count_audit_log_entries(&state.db, &auth.0.did).await { - Ok(t) => t, - Err(_) => 0, - }; + let total = delegation::audit::count_audit_log_entries(&state.db, &auth.0.did) + .await + .unwrap_or_default(); Json(GetAuditLogResponse { entries: entries diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs index 686cbe9..c052eff 100644 --- a/src/api/identity/account.rs +++ b/src/api/identity/account.rs @@ -69,7 +69,19 @@ pub async fn create_account( headers: HeaderMap, Json(input): Json, ) -> Response { - info!("create_account called"); + let is_potential_migration = input + .did + .as_ref() + .map(|d| d.starts_with("did:plc:")) + .unwrap_or(false); + if is_potential_migration { + info!( + "[MIGRATION] createAccount called for potential migration did={:?} handle={}", + input.did, input.handle + ); + } else { + info!("create_account called"); + } let client_ip = extract_client_ip(&headers); if !state .check_rate_limit(RateLimitKind::AccountCreation, &client_ip) @@ -136,6 +148,10 @@ pub async fn create_account( && let (Some(provided_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref()) { if provided_did != auth_did { + info!( + "[MIGRATION] createAccount: Service token mismatch - token_did={} provided_did={}", + auth_did, provided_did + ); return ( StatusCode::FORBIDDEN, Json(json!({ @@ -148,7 +164,10 @@ pub async fn create_account( if is_did_web_byod { info!(did = %provided_did, "Processing did:web BYOD account creation"); } else { - info!(did = %provided_did, "Processing account migration"); + info!( + "[MIGRATION] createAccount: Service token verified, processing migration for did={}", + provided_did + ); } } @@ -1005,30 +1024,44 @@ pub async fn create_account( } let (access_jwt, refresh_jwt) = if is_migration { - let access_meta = - match crate::auth::create_access_token_with_metadata(&did, &secret_key_bytes) { - Ok(m) => m, - Err(e) => { - error!("Error creating access token for migration: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - let refresh_meta = - match crate::auth::create_refresh_token_with_metadata(&did, &secret_key_bytes) { - Ok(m) => m, - Err(e) => { - error!("Error creating refresh token for migration: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; + info!( + "[MIGRATION] createAccount: Creating session tokens for migration did={}", + did + ); + let access_meta = match crate::auth::create_access_token_with_metadata( + &did, + &secret_key_bytes, + ) { + Ok(m) => m, + Err(e) => { + error!( + "[MIGRATION] createAccount: Error creating access token for migration: {:?}", + e + ); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + let refresh_meta = match crate::auth::create_refresh_token_with_metadata( + &did, + &secret_key_bytes, + ) { + Ok(m) => m, + Err(e) => { + error!( + "[MIGRATION] createAccount: Error creating refresh token for migration: {:?}", + e + ); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; if let Err(e) = sqlx::query!( "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", did, @@ -1040,18 +1073,29 @@ pub async fn create_account( .execute(&state.db) .await { - error!("Error creating session for migration: {:?}", e); + error!("[MIGRATION] createAccount: Error creating session for migration: {:?}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), ) .into_response(); } + info!( + "[MIGRATION] createAccount: Session created successfully for did={}", + did + ); (Some(access_meta.token), Some(refresh_meta.token)) } else { (None, None) }; + if is_migration { + info!( + "[MIGRATION] createAccount: SUCCESS - Account ready for migration did={} handle={}", + did, handle + ); + } + ( StatusCode::OK, Json(CreateAccountOutput { diff --git a/src/api/identity/plc/submit.rs b/src/api/identity/plc/submit.rs index b3039b6..b483336 100644 --- a/src/api/identity/plc/submit.rs +++ b/src/api/identity/plc/submit.rs @@ -23,22 +23,34 @@ pub async fn submit_plc_operation( headers: axum::http::HeaderMap, Json(input): Json, ) -> Response { + info!("[MIGRATION] submitPlcOperation called"); let bearer = match crate::auth::extract_bearer_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, - None => return ApiError::AuthenticationRequired.into_response(), + None => { + info!("[MIGRATION] submitPlcOperation: No bearer token"); + return ApiError::AuthenticationRequired.into_response(); + } }; let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &bearer).await { Ok(user) => user, - Err(e) => return ApiError::from(e).into_response(), + Err(e) => { + info!("[MIGRATION] submitPlcOperation: Auth failed: {:?}", e); + return ApiError::from(e).into_response(); + } }; + info!( + "[MIGRATION] submitPlcOperation: Authenticated user did={}", + auth_user.did + ); if let Err(e) = crate::auth::scope_check::check_identity_scope( auth_user.is_oauth, auth_user.scope.as_deref(), crate::oauth::scopes::IdentityAttr::Wildcard, ) { + info!("[MIGRATION] submitPlcOperation: Scope check failed"); return e; } let did = &auth_user.did; @@ -188,6 +200,11 @@ pub async fn submit_plc_operation( let plc_client = PlcClient::new(None); let operation_clone = input.operation.clone(); let did_clone = did.clone(); + info!( + "[MIGRATION] submitPlcOperation: Sending operation to PLC directory for did={}", + did + ); + let plc_start = std::time::Instant::now(); let result: Result<(), CircuitBreakerError> = with_circuit_breaker(&state.circuit_breakers.plc_directory, || async { plc_client @@ -196,9 +213,17 @@ pub async fn submit_plc_operation( }) .await; match result { - Ok(()) => {} + Ok(()) => { + info!( + "[MIGRATION] submitPlcOperation: PLC directory accepted operation in {:?}", + plc_start.elapsed() + ); + } Err(CircuitBreakerError::CircuitOpen(e)) => { - warn!("PLC directory circuit breaker open: {}", e); + warn!( + "[MIGRATION] submitPlcOperation: PLC directory circuit breaker open: {}", + e + ); return ( StatusCode::SERVICE_UNAVAILABLE, Json(json!({ @@ -209,7 +234,10 @@ pub async fn submit_plc_operation( .into_response(); } Err(CircuitBreakerError::OperationFailed(e)) => { - error!("Failed to submit PLC operation: {:?}", e); + error!( + "[MIGRATION] submitPlcOperation: PLC operation failed: {:?}", + e + ); return ( StatusCode::BAD_GATEWAY, Json(json!({ @@ -220,6 +248,10 @@ pub async fn submit_plc_operation( .into_response(); } } + info!( + "[MIGRATION] submitPlcOperation: Sequencing identity event for did={}", + did + ); match sqlx::query!( "INSERT INTO repo_seq (did, event_type) VALUES ($1, 'identity') RETURNING seq", did @@ -228,17 +260,27 @@ pub async fn submit_plc_operation( .await { Ok(row) => { + info!( + "[MIGRATION] submitPlcOperation: Identity event sequenced with seq={}", + row.seq + ); if let Err(e) = sqlx::query(&format!("NOTIFY repo_updates, '{}'", row.seq)) .execute(&state.db) .await { - warn!("Failed to notify identity event: {:?}", e); + warn!( + "[MIGRATION] submitPlcOperation: Failed to notify identity event: {:?}", + e + ); } } Err(e) => { - warn!("Failed to sequence identity event: {:?}", e); + warn!( + "[MIGRATION] submitPlcOperation: Failed to sequence identity event: {:?}", + e + ); } } - info!("Submitted PLC operation for user {}", did); + info!("[MIGRATION] submitPlcOperation: SUCCESS for did={}", did); (StatusCode::OK, Json(json!({}))).into_response() } diff --git a/src/api/repo/blob.rs b/src/api/repo/blob.rs index a4674bc..a3af3f3 100644 --- a/src/api/repo/blob.rs +++ b/src/api/repo/blob.rs @@ -1,6 +1,7 @@ use crate::auth::{ServiceTokenVerifier, is_service_token}; use crate::delegation::{self, DelegationActionType}; use crate::state::AppState; +use crate::sync::import::find_blob_refs_ipld; use axum::body::Bytes; use axum::{ Json, @@ -9,13 +10,14 @@ use axum::{ response::{IntoResponse, Response}, }; use cid::Cid; +use ipld_core::ipld::Ipld; use jacquard_repo::storage::BlockStore; use multihash::Multihash; use serde::{Deserialize, Serialize}; use serde_json::json; use sha2::{Digest, Sha256}; use std::str::FromStr; -use tracing::{debug, error}; +use tracing::{debug, error, warn}; const MAX_BLOB_SIZE: usize = 10_000_000_000; const MAX_VIDEO_BLOB_SIZE: usize = 10_000_000_000; @@ -258,26 +260,6 @@ pub struct ListMissingBlobsOutput { pub blobs: Vec, } -fn find_blobs(val: &serde_json::Value, blobs: &mut Vec) { - if let Some(obj) = val.as_object() { - if let Some(type_val) = obj.get("$type") - && type_val == "blob" - && let Some(r) = obj.get("ref") - && let Some(link) = r.get("$link") - && let Some(s) = link.as_str() - { - blobs.push(s.to_string()); - } - for (_, v) in obj { - find_blobs(v, blobs); - } - } else if let Some(arr) = val.as_array() { - for v in arr { - find_blobs(v, blobs); - } - } -} - pub async fn list_missing_blobs( State(state): State, headers: axum::http::HeaderMap, @@ -295,16 +277,17 @@ pub async fn list_missing_blobs( .into_response(); } }; - let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { - Ok(user) => user, - Err(_) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), - ) - .into_response(); - } - }; + let auth_user = + match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { + Ok(user) => user, + Err(_) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed"})), + ) + .into_response(); + } + }; let did = auth_user.did; let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", did) .fetch_optional(&state.db) @@ -362,13 +345,16 @@ pub async fn list_missing_blobs( Ok(Some(b)) => b, _ => continue, }; - let record_val: serde_json::Value = match serde_ipld_dagcbor::from_slice(&block_bytes) { + let record_ipld: Ipld = match serde_ipld_dagcbor::from_slice(&block_bytes) { Ok(v) => v, - Err(_) => continue, + Err(e) => { + warn!("Failed to parse record {} as IPLD: {:?}", record_cid_str, e); + continue; + } }; - let mut blobs = Vec::new(); - find_blobs(&record_val, &mut blobs); - for blob_cid_str in blobs { + let blob_refs = find_blob_refs_ipld(&record_ipld, 0); + for blob_ref in blob_refs { + let blob_cid_str = blob_ref.cid; let exists = sqlx::query!( "SELECT 1 as one FROM blobs WHERE cid = $1 AND created_by_user = $2", blob_cid_str, diff --git a/src/api/repo/import.rs b/src/api/repo/import.rs index 35fd520..fc703ed 100644 --- a/src/api/repo/import.rs +++ b/src/api/repo/import.rs @@ -350,17 +350,18 @@ pub async fn import_repo( .into_response(); } }; - let key_bytes = match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version) { - Ok(k) => k, - Err(e) => { - error!("Failed to decrypt signing key: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; + let key_bytes = + match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version) { + Ok(k) => k, + Err(e) => { + error!("Failed to decrypt signing key: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; let signing_key = match SigningKey::from_slice(&key_bytes) { Ok(k) => k, Err(e) => { @@ -422,10 +423,9 @@ pub async fn import_repo( "Created new commit for imported repo: cid={}, rev={}", new_root_str, new_rev_str ); - if !is_migration { - if let Err(e) = sequence_import_event(&state, did, &new_root_str).await { - warn!("Failed to sequence import event: {:?}", e); - } + if !is_migration && let Err(e) = sequence_import_event(&state, did, &new_root_str).await + { + warn!("Failed to sequence import event: {:?}", e); } (StatusCode::OK, Json(json!({}))).into_response() } diff --git a/src/api/repo/record/read.rs b/src/api/repo/record/read.rs index 28a2846..219114f 100644 --- a/src/api/repo/record/read.rs +++ b/src/api/repo/record/read.rs @@ -11,7 +11,7 @@ use cid::Cid; use ipld_core::ipld::Ipld; use jacquard_repo::storage::BlockStore; use serde::{Deserialize, Serialize}; -use serde_json::{json, Map, Value}; +use serde_json::{Map, Value, json}; use std::collections::HashMap; use std::str::FromStr; use tracing::{error, info}; @@ -37,10 +37,8 @@ fn ipld_to_json(ipld: Ipld) -> Value { } Ipld::List(arr) => Value::Array(arr.into_iter().map(ipld_to_json).collect()), Ipld::Map(map) => { - let obj: Map = map - .into_iter() - .map(|(k, v)| (k, ipld_to_json(v))) - .collect(); + let obj: Map = + map.into_iter().map(|(k, v)| (k, ipld_to_json(v))).collect(); Value::Object(obj) } Ipld::Link(cid) => json!({ "$link": cid.to_string() }), diff --git a/src/api/repo/record/utils.rs b/src/api/repo/record/utils.rs index 52e2a6c..bbb0805 100644 --- a/src/api/repo/record/utils.rs +++ b/src/api/repo/record/utils.rs @@ -5,7 +5,7 @@ use jacquard::types::{integer::LimitedU32, string::Tid}; use jacquard_repo::commit::Commit; use jacquard_repo::storage::BlockStore; use k256::ecdsa::SigningKey; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use std::str::FromStr; use uuid::Uuid; @@ -18,12 +18,11 @@ pub fn extract_blob_cids(record: &Value) -> Vec { fn extract_blob_cids_recursive(value: &Value, blobs: &mut Vec) { match value { Value::Object(map) => { - if map.get("$type").and_then(|v| v.as_str()) == Some("blob") { - if let Some(ref_obj) = map.get("ref") { - if let Some(link) = ref_obj.get("$link").and_then(|v| v.as_str()) { - blobs.push(link.to_string()); - } - } + if map.get("$type").and_then(|v| v.as_str()) == Some("blob") + && let Some(ref_obj) = map.get("ref") + && let Some(link) = ref_obj.get("$link").and_then(|v| v.as_str()) + { + blobs.push(link.to_string()); } for v in map.values() { extract_blob_cids_recursive(v, blobs); diff --git a/src/api/server/account_status.rs b/src/api/server/account_status.rs index eb3a024..07aa08d 100644 --- a/src/api/server/account_status.rs +++ b/src/api/server/account_status.rs @@ -8,8 +8,8 @@ use axum::{ response::{IntoResponse, Response}, }; use bcrypt::verify; -use cid::Cid; use chrono::{Duration, Utc}; +use cid::Cid; use jacquard_repo::commit::Commit; use jacquard_repo::storage::BlockStore; use k256::ecdsa::SigningKey; @@ -216,8 +216,8 @@ async fn assert_valid_did_document_for_service( })?; if let Some(row) = user_row { - let key_bytes = - crate::config::decrypt_key(&row.key_bytes, row.encryption_version).map_err(|e| { + let key_bytes = crate::config::decrypt_key(&row.key_bytes, row.encryption_version) + .map_err(|e| { error!("Failed to decrypt user key: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, @@ -247,21 +247,22 @@ async fn assert_valid_did_document_for_service( )); } } - } else if did.starts_with("did:web:") { + } else if let Some(host_and_path) = did.strip_prefix("did:web:") { let client = reqwest::Client::new(); - let host_and_path = &did[8..]; let decoded = host_and_path.replace("%3A", ":"); let parts: Vec<&str> = decoded.split(':').collect(); - let (host, path_parts) = if parts.len() > 1 && parts[1].chars().all(|c| c.is_ascii_digit()) { + let (host, path_parts) = if parts.len() > 1 && parts[1].chars().all(|c| c.is_ascii_digit()) + { (format!("{}:{}", parts[0], parts[1]), parts[2..].to_vec()) } else { (parts[0].to_string(), parts[1..].to_vec()) }; - let scheme = if host.starts_with("localhost") || host.starts_with("127.") || host.contains(':') { - "http" - } else { - "https" - }; + let scheme = + if host.starts_with("localhost") || host.starts_with("127.") || host.contains(':') { + "http" + } else { + "https" + }; let url = if path_parts.is_empty() { format!("{}://{}/.well-known/did.json", scheme, host) } else { @@ -323,11 +324,15 @@ pub async fn activate_account( State(state): State, headers: axum::http::HeaderMap, ) -> Response { + info!("[MIGRATION] activateAccount called"); let extracted = match crate::auth::extract_auth_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, - None => return ApiError::AuthenticationRequired.into_response(), + None => { + info!("[MIGRATION] activateAccount: No auth token"); + return ApiError::AuthenticationRequired.into_response(); + } }; let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); let http_uri = format!( @@ -346,8 +351,15 @@ pub async fn activate_account( .await { Ok(user) => user, - Err(e) => return ApiError::from(e).into_response(), + Err(e) => { + info!("[MIGRATION] activateAccount: Auth failed: {:?}", e); + return ApiError::from(e).into_response(); + } }; + info!( + "[MIGRATION] activateAccount: Authenticated user did={}", + auth_user.did + ); if let Err(e) = crate::auth::scope_check::check_account_scope( auth_user.is_oauth, @@ -355,42 +367,80 @@ pub async fn activate_account( crate::oauth::scopes::AccountAttr::Repo, crate::oauth::scopes::AccountAction::Manage, ) { + info!("[MIGRATION] activateAccount: Scope check failed"); return e; } let did = auth_user.did; + info!( + "[MIGRATION] activateAccount: Validating DID document for did={}", + did + ); + let did_validation_start = std::time::Instant::now(); if let Err((status, json)) = assert_valid_did_document_for_service(&state.db, &did).await { info!( - "activateAccount rejected for {}: DID document validation failed", - did + "[MIGRATION] activateAccount: DID document validation FAILED for {} (took {:?})", + did, + did_validation_start.elapsed() ); return (status, json).into_response(); } + info!( + "[MIGRATION] activateAccount: DID document validation SUCCESS for {} (took {:?})", + did, + did_validation_start.elapsed() + ); let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did) .fetch_optional(&state.db) .await .ok() .flatten(); + info!( + "[MIGRATION] activateAccount: Activating account did={} handle={:?}", + did, handle + ); let result = sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did) .execute(&state.db) .await; match result { Ok(_) => { + info!( + "[MIGRATION] activateAccount: DB update success for did={}", + did + ); if let Some(ref h) = handle { let _ = state.cache.delete(&format!("handle:{}", h)).await; } + info!( + "[MIGRATION] activateAccount: Sequencing account event (active=true) for did={}", + did + ); if let Err(e) = crate::api::repo::record::sequence_account_event(&state, &did, true, None).await { - warn!("Failed to sequence account activation event: {}", e); + warn!( + "[MIGRATION] activateAccount: Failed to sequence account activation event: {}", + e + ); + } else { + info!("[MIGRATION] activateAccount: Account event sequenced successfully"); } + info!( + "[MIGRATION] activateAccount: Sequencing identity event for did={} handle={:?}", + did, handle + ); if let Err(e) = crate::api::repo::record::sequence_identity_event(&state, &did, handle.as_deref()) .await { - warn!("Failed to sequence identity event for activation: {}", e); + warn!( + "[MIGRATION] activateAccount: Failed to sequence identity event for activation: {}", + e + ); + } else { + info!("[MIGRATION] activateAccount: Identity event sequenced successfully"); } let repo_root = sqlx::query_scalar!( "SELECT r.repo_root_cid FROM repos r JOIN users u ON r.user_id = u.id WHERE u.did = $1", @@ -401,6 +451,10 @@ pub async fn activate_account( .ok() .flatten(); if let Some(root_cid) = repo_root { + info!( + "[MIGRATION] activateAccount: Sequencing sync event for did={} root_cid={}", + did, root_cid + ); let rev = if let Ok(cid) = Cid::from_str(&root_cid) { if let Ok(Some(block)) = state.block_store.get(&cid).await { Commit::from_cbor(&block).ok().map(|c| c.rev().to_string()) @@ -410,16 +464,35 @@ pub async fn activate_account( } else { None }; - if let Err(e) = - crate::api::repo::record::sequence_sync_event(&state, &did, &root_cid, rev.as_deref()).await + if let Err(e) = crate::api::repo::record::sequence_sync_event( + &state, + &did, + &root_cid, + rev.as_deref(), + ) + .await { - warn!("Failed to sequence sync event for activation: {}", e); + warn!( + "[MIGRATION] activateAccount: Failed to sequence sync event for activation: {}", + e + ); + } else { + info!("[MIGRATION] activateAccount: Sync event sequenced successfully"); } + } else { + warn!( + "[MIGRATION] activateAccount: No repo root found for did={}", + did + ); } + info!("[MIGRATION] activateAccount: SUCCESS for did={}", did); (StatusCode::OK, Json(json!({}))).into_response() } Err(e) => { - error!("DB error activating account: {:?}", e); + error!( + "[MIGRATION] activateAccount: DB error activating account: {:?}", + e + ); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), diff --git a/src/api/server/mod.rs b/src/api/server/mod.rs index 7a1e41b..54cb913 100644 --- a/src/api/server/mod.rs +++ b/src/api/server/mod.rs @@ -26,6 +26,9 @@ pub use email::{confirm_email, request_email_update, update_email}; pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; pub use logo::get_logo; pub use meta::{describe_server, health, robots_txt}; +pub use migration::{ + clear_migration_forwarding, get_migration_status, update_migration_forwarding, +}; pub use passkey_account::{ complete_passkey_setup, create_passkey_account, recover_passkey_account, request_passkey_recovery, start_passkey_registration_for_setup, @@ -57,8 +60,5 @@ pub use trusted_devices::{ extend_device_trust, is_device_trusted, list_trusted_devices, revoke_trusted_device, trust_device, update_trusted_device, }; -pub use migration::{ - clear_migration_forwarding, get_migration_status, update_migration_forwarding, -}; pub use verify_email::{resend_migration_verification, verify_migration_email}; pub use verify_token::{VerifyTokenInput, VerifyTokenOutput, verify_token, verify_token_internal}; diff --git a/src/api/server/verify_token.rs b/src/api/server/verify_token.rs index d36e629..64ed235 100644 --- a/src/api/server/verify_token.rs +++ b/src/api/server/verify_token.rs @@ -1,8 +1,4 @@ -use axum::{ - Json, - extract::State, - http::StatusCode, -}; +use axum::{Json, extract::State, http::StatusCode}; use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::{error, info, warn}; diff --git a/src/auth/mod.rs b/src/auth/mod.rs index ddb823f..7c0c644 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -229,24 +229,21 @@ async fn validate_bearer_token_with_options_internal( .ok() .flatten(); - match session_row { - Some(row) => { - if row.access_expires_at > chrono::Utc::now() { - session_valid = true; - if let Some(c) = cache { - let _ = c - .set( - &session_cache_key, - "1", - Duration::from_secs(SESSION_CACHE_TTL_SECS), - ) - .await; - } - } else { - return Err(TokenValidationError::TokenExpired); + if let Some(row) = session_row { + if row.access_expires_at > chrono::Utc::now() { + session_valid = true; + if let Some(c) = cache { + let _ = c + .set( + &session_cache_key, + "1", + Duration::from_secs(SESSION_CACHE_TTL_SECS), + ) + .await; } + } else { + return Err(TokenValidationError::TokenExpired); } - None => {} } } diff --git a/src/comms/sender.rs b/src/comms/sender.rs index a1dc67c..d71f8f0 100644 --- a/src/comms/sender.rs +++ b/src/comms/sender.rs @@ -1,5 +1,5 @@ use async_trait::async_trait; -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use reqwest::Client; use serde_json::json; use std::process::Stdio; @@ -103,8 +103,7 @@ impl EmailSender { } pub fn format_email(&self, notification: &QueuedComms) -> String { - let subject = - mime_encode_header(notification.subject.as_deref().unwrap_or("Notification")); + let subject = mime_encode_header(notification.subject.as_deref().unwrap_or("Notification")); let recipient = sanitize_header_value(¬ification.recipient); let from_header = if self.from_name.is_empty() { self.from_address.clone() diff --git a/src/delegation/scopes.rs b/src/delegation/scopes.rs index 5df7926..4064f97 100644 --- a/src/delegation/scopes.rs +++ b/src/delegation/scopes.rs @@ -75,12 +75,10 @@ pub fn intersect_scopes(requested: &str, granted: &str) -> String { } fn find_matching_scope<'a>(requested: &str, granted: &HashSet<&'a str>) -> Option<&'a str> { - for granted_scope in granted { - if scopes_compatible(granted_scope, requested) { - return Some(granted_scope); - } - } - None + granted + .iter() + .find(|&granted_scope| scopes_compatible(granted_scope, requested)) + .map(|v| v as _) } fn scopes_compatible(granted: &str, requested: &str) -> bool { @@ -97,11 +95,11 @@ fn scopes_compatible(granted: &str, requested: &str) -> bool { return true; } - if granted_base.ends_with(".*") { - let prefix = &granted_base[..granted_base.len() - 2]; - if requested_base.starts_with(prefix) && requested_base.len() > prefix.len() { - return true; - } + if let Some(prefix) = granted_base.strip_suffix(".*") + && requested_base.starts_with(prefix) + && requested_base.len() > prefix.len() + { + return true; } false diff --git a/src/moderation/mod.rs b/src/moderation/mod.rs index 8a621bd..e73b663 100644 --- a/src/moderation/mod.rs +++ b/src/moderation/mod.rs @@ -1,16 +1,16 @@ /* - * CONTENT WARNING - * - * This file contains explicit slurs and hateful language. We're sorry you have to see them. - * - * These words exist here for one reason: to ensure our moderation system correctly blocks them. - * We can't verify the filter catches the n-word without testing against the actual word. - * Euphemisms wouldn't prove the protection works. - * - * If reading this file has caused you distress, please know: - * - you are valued and welcome in this community - * - these words do not reflect the views of this project or its contributors - * - we maintain this code precisely because we believe everyone deserves an experience on the web that is free from this kinda language + * CONTENT WARNING + * + * This file contains explicit slurs and hateful language. We're sorry you have to see them. + * + * These words exist here for one reason: to ensure our moderation system correctly blocks them. + * We can't verify the filter catches the n-word without testing against the actual word. + * Euphemisms wouldn't prove the protection works. + * + * If reading this file has caused you distress, please know: + * - you are valued and welcome in this community + * - these words do not reflect the views of this project or its contributors + * - we maintain this code precisely because we believe everyone deserves an experience on the web that is free from this kinda language */ use regex::Regex; @@ -70,7 +70,7 @@ pub fn has_explicit_slur(text: &str) -> bool { fn has_explicit_slur_with_extra_words(text: &str, extra_words: &[String]) -> bool { let text_lower = text.to_lowercase(); - let normalized = text_lower.replace('.', "").replace('-', "").replace('_', ""); + let normalized = text_lower.replace(['.', '-', '_'], ""); let stripped = strip_trailing_digits(&text_lower); let normalized_stripped = strip_trailing_digits(&normalized); @@ -104,81 +104,87 @@ fn has_explicit_slur_with_extra_words(text: &str, extra_words: &[String]) -> boo #[cfg(test)] mod tests { use super::*; + use base64::Engine; + + fn d(b64: &str) -> String { + let bytes = base64::engine::general_purpose::STANDARD.decode(b64).unwrap(); + String::from_utf8(bytes).unwrap() + } #[test] fn test_chink_pattern() { - assert!(has_explicit_slur("chink")); - assert!(has_explicit_slur("chinks")); - assert!(has_explicit_slur("CHINK")); - assert!(has_explicit_slur("Chinks")); + assert!(has_explicit_slur(&d("Y2hpbms="))); + assert!(has_explicit_slur(&d("Y2hpbmtz"))); + assert!(has_explicit_slur(&d("Q0hJTks="))); + assert!(has_explicit_slur(&d("Q2hpbmtz"))); } #[test] fn test_coon_pattern() { - assert!(has_explicit_slur("coon")); - assert!(has_explicit_slur("coons")); - assert!(has_explicit_slur("COON")); + assert!(has_explicit_slur(&d("Y29vbg=="))); + assert!(has_explicit_slur(&d("Y29vbnM="))); + assert!(has_explicit_slur(&d("Q09PTg=="))); } #[test] fn test_fag_pattern() { - assert!(has_explicit_slur("fag")); - assert!(has_explicit_slur("fags")); - assert!(has_explicit_slur("faggot")); - assert!(has_explicit_slur("faggots")); - assert!(has_explicit_slur("faggotry")); + assert!(has_explicit_slur(&d("ZmFn"))); + assert!(has_explicit_slur(&d("ZmFncw=="))); + assert!(has_explicit_slur(&d("ZmFnZ290"))); + assert!(has_explicit_slur(&d("ZmFnZ290cw=="))); + assert!(has_explicit_slur(&d("ZmFnZ290cnk="))); } #[test] fn test_kike_pattern() { - assert!(has_explicit_slur("kike")); - assert!(has_explicit_slur("kikes")); - assert!(has_explicit_slur("KIKE")); - assert!(has_explicit_slur("kikery")); + assert!(has_explicit_slur(&d("a2lrZQ=="))); + assert!(has_explicit_slur(&d("a2lrZXM="))); + assert!(has_explicit_slur(&d("S0lLRQ=="))); + assert!(has_explicit_slur(&d("a2lrZXJ5"))); } #[test] fn test_nigger_pattern() { - assert!(has_explicit_slur("nigger")); - assert!(has_explicit_slur("niggers")); - assert!(has_explicit_slur("NIGGER")); - assert!(has_explicit_slur("nigga")); - assert!(has_explicit_slur("niggas")); + assert!(has_explicit_slur(&d("bmlnZ2Vy"))); + assert!(has_explicit_slur(&d("bmlnZ2Vycw=="))); + assert!(has_explicit_slur(&d("TklHR0VS"))); + assert!(has_explicit_slur(&d("bmlnZ2E="))); + assert!(has_explicit_slur(&d("bmlnZ2Fz"))); } #[test] fn test_tranny_pattern() { - assert!(has_explicit_slur("tranny")); - assert!(has_explicit_slur("trannies")); - assert!(has_explicit_slur("TRANNY")); + assert!(has_explicit_slur(&d("dHJhbm55"))); + assert!(has_explicit_slur(&d("dHJhbm5pZXM="))); + assert!(has_explicit_slur(&d("VFJBTk5Z"))); } #[test] fn test_normalization_bypass() { - assert!(has_explicit_slur("n.i.g.g.e.r")); - assert!(has_explicit_slur("n-i-g-g-e-r")); - assert!(has_explicit_slur("n_i_g_g_e_r")); - assert!(has_explicit_slur("f.a.g")); - assert!(has_explicit_slur("f-a-g")); - assert!(has_explicit_slur("c.h.i.n.k")); - assert!(has_explicit_slur("k_i_k_e")); + assert!(has_explicit_slur(&d("bi5pLmcuZy5lLnI="))); + assert!(has_explicit_slur(&d("bi1pLWctZy1lLXI="))); + assert!(has_explicit_slur(&d("bl9pX2dfZ19lX3I="))); + assert!(has_explicit_slur(&d("Zi5hLmc="))); + assert!(has_explicit_slur(&d("Zi1hLWc="))); + assert!(has_explicit_slur(&d("Yy5oLmkubi5r"))); + assert!(has_explicit_slur(&d("a19pX2tfZQ=="))); } #[test] fn test_trailing_digits_bypass() { - assert!(has_explicit_slur("faggot123")); - assert!(has_explicit_slur("nigger69")); - assert!(has_explicit_slur("chink420")); - assert!(has_explicit_slur("fag1")); - assert!(has_explicit_slur("kike2024")); - assert!(has_explicit_slur("n_i_g_g_e_r123")); + assert!(has_explicit_slur(&d("ZmFnZ290MTIz"))); + assert!(has_explicit_slur(&d("bmlnZ2VyNjk="))); + assert!(has_explicit_slur(&d("Y2hpbms0MjA="))); + assert!(has_explicit_slur(&d("ZmFnMQ=="))); + assert!(has_explicit_slur(&d("a2lrZTIwMjQ="))); + assert!(has_explicit_slur(&d("bl9pX2dfZ19lX3IxMjM="))); } #[test] fn test_embedded_in_sentence() { - assert!(has_explicit_slur("you are a faggot")); - assert!(has_explicit_slur("stupid nigger")); - assert!(has_explicit_slur("go away chink")); + assert!(has_explicit_slur(&d("eW91IGFyZSBhIGZhZ2dvdA=="))); + assert!(has_explicit_slur(&d("c3R1cGlkIG5pZ2dlcg=="))); + assert!(has_explicit_slur(&d("Z28gYXdheSBjaGluaw=="))); } #[test] @@ -210,22 +216,22 @@ mod tests { #[test] fn test_case_insensitive() { - assert!(has_explicit_slur("NIGGER")); - assert!(has_explicit_slur("Nigger")); - assert!(has_explicit_slur("NiGgEr")); - assert!(has_explicit_slur("FAGGOT")); - assert!(has_explicit_slur("Faggot")); + assert!(has_explicit_slur(&d("TklHR0VS"))); + assert!(has_explicit_slur(&d("TmlnZ2Vy"))); + assert!(has_explicit_slur(&d("TmlHZ0Vy"))); + assert!(has_explicit_slur(&d("RkFHR09U"))); + assert!(has_explicit_slur(&d("RmFnZ290"))); } #[test] fn test_leetspeak_bypass() { - assert!(has_explicit_slur("f4ggot")); - assert!(has_explicit_slur("f4gg0t")); - assert!(has_explicit_slur("n1gger")); - assert!(has_explicit_slur("n1gg3r")); - assert!(has_explicit_slur("k1ke")); - assert!(has_explicit_slur("ch1nk")); - assert!(has_explicit_slur("tr4nny")); + assert!(has_explicit_slur(&d("ZjRnZ290"))); + assert!(has_explicit_slur(&d("ZjRnZzB0"))); + assert!(has_explicit_slur(&d("bjFnZ2Vy"))); + assert!(has_explicit_slur(&d("bjFnZzNy"))); + assert!(has_explicit_slur(&d("azFrZQ=="))); + assert!(has_explicit_slur(&d("Y2gxbms="))); + assert!(has_explicit_slur(&d("dHI0bm55"))); } #[test] @@ -253,7 +259,10 @@ mod tests { assert!(has_explicit_slur_with_extra_words("b4dw0rd", &extra)); assert!(has_explicit_slur_with_extra_words("b4dw0rd789", &extra)); assert!(has_explicit_slur_with_extra_words("b.4.d.w.0.r.d", &extra)); - assert!(has_explicit_slur_with_extra_words("this contains badword here", &extra)); + assert!(has_explicit_slur_with_extra_words( + "this contains badword here", + &extra + )); assert!(has_explicit_slur_with_extra_words("0ff3n$1v3", &extra)); assert!(!has_explicit_slur_with_extra_words("goodword", &extra)); diff --git a/src/oauth/endpoints/delegation.rs b/src/oauth/endpoints/delegation.rs index 29d7d78..8e8974d 100644 --- a/src/oauth/endpoints/delegation.rs +++ b/src/oauth/endpoints/delegation.rs @@ -88,7 +88,10 @@ pub async fn delegation_auth( } }; - if let Err(_) = db::set_request_did(&state.db, &form.request_uri, &delegated_did).await { + if db::set_request_did(&state.db, &form.request_uri, &delegated_did) + .await + .is_err() + { tracing::warn!("Failed to set delegated DID on authorization request"); } @@ -168,13 +171,11 @@ pub async fn delegation_auth( .into_response(); } - let password_valid = match &controller.password_hash { - Some(hash) => match bcrypt::verify(&form.password, hash) { - Ok(valid) => valid, - Err(_) => false, - }, - None => false, - }; + let password_valid = controller + .password_hash + .as_ref() + .map(|hash| bcrypt::verify(&form.password, hash).unwrap_or_default()) + .unwrap_or_default(); if !password_valid { return Json(DelegationAuthResponse { @@ -186,7 +187,9 @@ pub async fn delegation_auth( .into_response(); } - if let Err(_) = db::set_controller_did(&state.db, &form.request_uri, &form.controller_did).await + if db::set_controller_did(&state.db, &form.request_uri, &form.controller_did) + .await + .is_err() { return Json(DelegationAuthResponse { success: false, diff --git a/src/sync/import.rs b/src/sync/import.rs index b8fbc3b..4e6ed2c 100644 --- a/src/sync/import.rs +++ b/src/sync/import.rs @@ -189,13 +189,16 @@ fn walk_mst_node( if let Some(Ipld::List(entries)) = obj.get("e") { for entry in entries { if let Ipld::Map(entry_obj) = entry { - let prefix_len = entry_obj.get("p").and_then(|p| { - if let Ipld::Integer(n) = p { - Some(*n as usize) - } else { - None - } - }).unwrap_or(0); + let prefix_len = entry_obj + .get("p") + .and_then(|p| { + if let Ipld::Integer(n) = p { + Some(*n as usize) + } else { + None + } + }) + .unwrap_or(0); let key_suffix = entry_obj.get("k").and_then(|k| { if let Ipld::Bytes(b) = k { @@ -222,25 +225,23 @@ fn walk_mst_node( } }); - if let Some(record_cid) = record_cid { - if let Ok(full_key) = String::from_utf8(current_key.clone()) { - if let Some(record_block) = blocks.get(&record_cid) - && let Ok(record_value) = - serde_ipld_dagcbor::from_slice::(record_block) - { - let blob_refs = find_blob_refs_ipld(&record_value, 0); - let parts: Vec<&str> = full_key.split('/').collect(); - if parts.len() >= 2 { - let collection = parts[..parts.len() - 1].join("/"); - let rkey = parts[parts.len() - 1].to_string(); - records.push(ImportedRecord { - collection, - rkey, - cid: record_cid, - blob_refs, - }); - } - } + if let Some(record_cid) = record_cid + && let Ok(full_key) = String::from_utf8(current_key.clone()) + && let Some(record_block) = blocks.get(&record_cid) + && let Ok(record_value) = + serde_ipld_dagcbor::from_slice::(record_block) + { + let blob_refs = find_blob_refs_ipld(&record_value, 0); + let parts: Vec<&str> = full_key.split('/').collect(); + if parts.len() >= 2 { + let collection = parts[..parts.len() - 1].join("/"); + let rkey = parts[parts.len() - 1].to_string(); + records.push(ImportedRecord { + collection, + rkey, + cid: record_cid, + blob_refs, + }); } } } diff --git a/src/validation/mod.rs b/src/validation/mod.rs index ed0acf8..15d9fa6 100644 --- a/src/validation/mod.rs +++ b/src/validation/mod.rs @@ -161,14 +161,13 @@ impl RecordValidator { .get("$type") .and_then(|v| v.as_str()) .is_some_and(|t| t == "app.bsky.richtext.facet#tag"); - if is_tag { - if let Some(tag) = feature.get("tag").and_then(|v| v.as_str()) { - if crate::moderation::has_explicit_slur(tag) { - return Err(ValidationError::BannedContent { - path: format!("facets/{}/features/{}/tag", i, j), - }); - } - } + if is_tag + && let Some(tag) = feature.get("tag").and_then(|v| v.as_str()) + && crate::moderation::has_explicit_slur(tag) + { + return Err(ValidationError::BannedContent { + path: format!("facets/{}/features/{}/tag", i, j), + }); } } } @@ -332,12 +331,12 @@ impl RecordValidator { if !obj.contains_key("createdAt") { return Err(ValidationError::MissingField("createdAt".to_string())); } - if let Some(rkey) = rkey { - if crate::moderation::has_explicit_slur(rkey) { - return Err(ValidationError::BannedContent { - path: "rkey".to_string(), - }); - } + if let Some(rkey) = rkey + && crate::moderation::has_explicit_slur(rkey) + { + return Err(ValidationError::BannedContent { + path: "rkey".to_string(), + }); } if let Some(display_name) = obj.get("displayName").and_then(|v| v.as_str()) { if display_name.is_empty() || display_name.len() > 240 { diff --git a/tests/banned_words.rs b/tests/banned_words.rs index 66e192b..4d5663f 100644 --- a/tests/banned_words.rs +++ b/tests/banned_words.rs @@ -1,17 +1,17 @@ /* - * CONTENT WARNING - * - * This file contains explicit slurs and hateful language. We're sorry you have to see them. - * - * These words exist here for one reason: to ensure our moderation system correctly blocks them. - * We can't verify the filter catches the n-word without testing against the actual word. - * Euphemisms wouldn't prove the protection works. - * - * If reading this file has caused you distress, please know: - * - you are valued and welcome in this community - * - these words do not reflect the views of this project or its contributors - * - we maintain this code precisely because we believe everyone deserves an experience on the web that is free from this kinda language -*/ + * CONTENT WARNING + * + * This file contains explicit slurs and hateful language. We're sorry you have to see them. + * + * These words exist here for one reason: to ensure our moderation system correctly blocks them. + * We can't verify the filter catches the n-word without testing against the actual word. + * Euphemisms wouldn't prove the protection works. + * + * If reading this file has caused you distress, please know: + * - you are valued and welcome in this community + * - these words do not reflect the views of this project or its contributors + * - we maintain this code precisely because we believe everyone deserves an experience on the web that is free from this kinda language + */ mod common; mod helpers; @@ -20,11 +20,20 @@ use helpers::*; use reqwest::StatusCode; use serde_json::json; +fn decode(b64: &str) -> String { + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(b64) + .expect("invalid base64 in test"); + String::from_utf8(bytes).expect("invalid utf8 in test") +} + #[tokio::test] async fn test_handle_with_slur_rejected() { let client = client(); let timestamp = chrono::Utc::now().timestamp_millis(); - let offensive_handle = format!("nigger{}", timestamp); + let slur = decode("bmlnZ2Vy"); + let offensive_handle = format!("{}{}", slur, timestamp); let create_payload = json!({ "handle": offensive_handle, @@ -45,17 +54,20 @@ async fn test_handle_with_slur_rejected() { assert_eq!(res.status(), StatusCode::BAD_REQUEST); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["error"], "InvalidHandle"); - assert!(body["message"] - .as_str() - .unwrap_or("") - .contains("Inappropriate language")); + assert!( + body["message"] + .as_str() + .unwrap_or("") + .contains("Inappropriate language") + ); } #[tokio::test] async fn test_handle_with_normalized_slur_rejected() { let client = client(); let timestamp = chrono::Utc::now().timestamp_millis(); - let offensive_handle = format!("n-i-g-g-e-r{}", timestamp); + let slur = decode("bi1pLWctZy1lLXI="); + let offensive_handle = format!("{}{}", slur, timestamp); let create_payload = json!({ "handle": offensive_handle, @@ -84,7 +96,7 @@ async fn test_handle_update_with_slur_rejected() { let (_, jwt) = setup_new_user("handleupdate").await; let update_payload = json!({ - "handle": "faggots" + "handle": decode("ZmFnZ290cw==") }); let res = client @@ -114,7 +126,7 @@ async fn test_profile_displayname_with_slur_rejected() { "rkey": "self", "record": { "$type": "app.bsky.actor.profile", - "displayName": "I am a kike" + "displayName": decode("SSBhbSBhIGtpa2U=") } }); @@ -146,7 +158,7 @@ async fn test_profile_description_with_slur_rejected() { "record": { "$type": "app.bsky.actor.profile", "displayName": "Normal Name", - "description": "I hate all chinks" + "description": decode("SSBoYXRlIGFsbCBjaGlua3M=") } }); diff --git a/tests/firehose_validation.rs b/tests/firehose_validation.rs index 8a4ae75..7cb7214 100644 --- a/tests/firehose_validation.rs +++ b/tests/firehose_validation.rs @@ -364,12 +364,6 @@ async fn test_firehose_update_has_prev_field() { let client = client(); let (token, did) = create_account_and_login(&client).await; - let url = format!( - "ws://127.0.0.1:{}/xrpc/com.atproto.sync.subscribeRepos", - app_port() - ); - let (mut ws_stream, _) = connect_async(&url).await.expect("Failed to connect"); - let profile_payload = json!({ "repo": did, "collection": "app.bsky.actor.profile", @@ -393,22 +387,11 @@ async fn test_firehose_update_has_prev_field() { let first_profile: Value = res.json().await.unwrap(); let first_cid = first_profile["cid"].as_str().unwrap(); - let timeout = tokio::time::timeout(std::time::Duration::from_secs(5), async { - loop { - let msg = ws_stream.next().await.unwrap().unwrap(); - let raw_bytes = match msg { - tungstenite::Message::Binary(bin) => bin, - _ => continue, - }; - if let Ok((_, f)) = parse_frame(&raw_bytes) { - if f.repo == did { - break; - } - } - } - }) - .await; - assert!(timeout.is_ok(), "Timed out waiting for first commit"); + let url = format!( + "ws://127.0.0.1:{}/xrpc/com.atproto.sync.subscribeRepos", + app_port() + ); + let (mut ws_stream, _) = connect_async(&url).await.expect("Failed to connect"); let update_payload = json!({ "repo": did, @@ -432,9 +415,12 @@ async fn test_firehose_update_has_prev_field() { assert_eq!(res.status(), StatusCode::OK); let mut frame_opt: Option = None; - let timeout = tokio::time::timeout(std::time::Duration::from_secs(5), async { + let timeout = tokio::time::timeout(std::time::Duration::from_secs(15), async { loop { - let msg = ws_stream.next().await.unwrap().unwrap(); + let msg = match ws_stream.next().await { + Some(Ok(m)) => m, + _ => continue, + }; let raw_bytes = match msg { tungstenite::Message::Binary(bin) => bin, _ => continue, diff --git a/tests/oauth_security.rs b/tests/oauth_security.rs index 51e8477..96e3ecf 100644 --- a/tests/oauth_security.rs +++ b/tests/oauth_security.rs @@ -1116,7 +1116,10 @@ async fn test_delegation_viewer_scope_cannot_write() { let delegated_handle = format!("deleg-{}", ts); let delegated_res = http_client - .post(format!("{}/xrpc/com.tranquil.delegation.createDelegatedAccount", url)) + .post(format!( + "{}/xrpc/com.tranquil.delegation.createDelegatedAccount", + url + )) .bearer_auth(controller_jwt) .json(&json!({ "handle": delegated_handle, @@ -1174,7 +1177,11 @@ async fn test_delegation_viewer_scope_cannot_write() { panic!("Delegation auth failed: {}", error_body); } let auth_body: Value = auth_res.json().await.unwrap(); - assert!(auth_body["success"].as_bool().unwrap_or(false), "Delegation auth should succeed: {:?}", auth_body); + assert!( + auth_body["success"].as_bool().unwrap_or(false), + "Delegation auth should succeed: {:?}", + auth_body + ); let consent_res = http_client .post(format!("{}/oauth/authorize/consent", url))