The smokesignal.events web application

feature: import bluesky profile on first login

+530 -1
+30
src/atproto/lexicon/bluesky_profile.rs
··· 1 + //! Bluesky profile lexicon definition for importing profiles. 2 + //! 3 + //! This module defines the `app.bsky.actor.profile` record type used to 4 + //! deserialize Bluesky profiles when importing them to create Smokesignal profiles. 5 + 6 + use atproto_record::lexicon::TypedBlob; 7 + use serde::Deserialize; 8 + 9 + /// The NSID for Bluesky actor profiles 10 + pub const NSID: &str = "app.bsky.actor.profile"; 11 + 12 + /// Bluesky actor profile record structure. 13 + /// 14 + /// This is used to deserialize `app.bsky.actor.profile` records from a user's PDS 15 + /// when importing their profile data to create a Smokesignal profile. 16 + #[derive(Debug, Deserialize)] 17 + #[serde(rename_all = "camelCase")] 18 + pub struct BlueskyProfile { 19 + /// Display name shown on the profile 20 + pub display_name: Option<String>, 21 + 22 + /// Profile bio/description text 23 + pub description: Option<String>, 24 + 25 + /// Avatar image blob (1:1 square aspect ratio) 26 + pub avatar: Option<TypedBlob>, 27 + 28 + /// Banner image blob (3:1 aspect ratio - NOT imported to Smokesignal which uses 16:9) 29 + pub banner: Option<TypedBlob>, 30 + }
+1
src/atproto/lexicon/mod.rs
··· 1 1 pub mod acceptance; 2 + pub mod bluesky_profile; 2 3 pub mod profile;
+2
src/http/errors/mod.rs
··· 9 9 pub mod event_view_errors; 10 10 pub mod import_error; 11 11 pub mod login_error; 12 + pub mod profile_import_error; 12 13 pub mod middleware_errors; 13 14 pub mod url_error; 14 15 pub mod view_event_error; ··· 21 22 pub(crate) use event_view_errors::EventViewError; 22 23 pub(crate) use import_error::ImportError; 23 24 pub(crate) use login_error::LoginError; 25 + pub(crate) use profile_import_error::ProfileImportError; 24 26 pub(crate) use middleware_errors::WebSessionError; 25 27 pub(crate) use url_error::UrlError; 26 28 pub(crate) use view_event_error::ViewEventError;
+44
src/http/errors/profile_import_error.rs
··· 1 + use thiserror::Error; 2 + 3 + /// Represents errors that can occur during Bluesky profile import operations. 4 + /// 5 + /// These errors typically happen when attempting to import a user's Bluesky profile 6 + /// (`app.bsky.actor.profile`) to create a Smokesignal profile on first login. 7 + #[derive(Debug, Error)] 8 + pub(crate) enum ProfileImportError { 9 + /// User already has a Smokesignal profile, import skipped. 10 + #[error("error-smokesignal-profile-import-1 User already has profile")] 11 + ProfileAlreadyExists, 12 + 13 + /// Failed to fetch the Bluesky profile from the user's PDS. 14 + #[error("error-smokesignal-profile-import-2 Failed to fetch Bluesky profile: {0}")] 15 + FetchFailed(String), 16 + 17 + /// Failed to parse the Bluesky profile record. 18 + #[error("error-smokesignal-profile-import-3 Failed to parse Bluesky profile: {0}")] 19 + ParseFailed(String), 20 + 21 + /// Failed to download the avatar blob from PDS. 22 + #[error("error-smokesignal-profile-import-4 Failed to download avatar: {0}")] 23 + AvatarDownloadFailed(String), 24 + 25 + /// Failed to process the avatar image through the image pipeline. 26 + #[error("error-smokesignal-profile-import-5 Failed to process avatar: {0}")] 27 + AvatarProcessFailed(String), 28 + 29 + /// Failed to upload the processed avatar to the user's PDS. 30 + #[error("error-smokesignal-profile-import-6 Failed to upload avatar: {0}")] 31 + AvatarUploadFailed(String), 32 + 33 + /// Failed to write the new Smokesignal profile to the user's PDS. 34 + #[error("error-smokesignal-profile-import-7 Failed to write profile to PDS: {0}")] 35 + PdsWriteFailed(String), 36 + 37 + /// Failed to store the profile locally in the database. 38 + #[error("error-smokesignal-profile-import-8 Failed to store profile locally: {0}")] 39 + StorageFailed(String), 40 + 41 + /// No Bluesky profile exists for this user. 42 + #[error("error-smokesignal-profile-import-9 No Bluesky profile found")] 43 + NoBlueskyProfile, 44 + }
+47
src/http/handle_oauth_aip_callback.rs
··· 1 1 use std::{collections::HashMap, sync::Arc}; 2 2 3 3 use crate::{ 4 + atproto::auth::create_dpop_auth_from_aip_session, 4 5 config::OAuthBackendConfig, contextual_error, 6 + facets::FacetLimits, 5 7 http::handle_email_confirm::send_confirmation_email, select_template, 6 8 storage::identity_profile::handle_warm_up, 7 9 }; ··· 24 26 errors::{LoginError, WebError}, 25 27 middleware_auth::{AUTH_COOKIE_NAME, WebSession}, 26 28 middleware_i18n::Language, 29 + profile_import::import_bluesky_profile, 27 30 }; 28 31 29 32 #[derive(Deserialize, Serialize)] ··· 209 212 { 210 213 // Log the error but don't fail the authentication flow 211 214 tracing::warn!(?err, "Failed to send confirmation email to new user"); 215 + } 216 + 217 + // Import Bluesky profile for new users 218 + if is_new_user { 219 + // Get DPoP credentials for writing to user's PDS 220 + match create_dpop_auth_from_aip_session( 221 + &web_context.http_client, 222 + hostname, 223 + &token_response.access_token, 224 + ) 225 + .await 226 + { 227 + Ok(dpop_auth) => { 228 + let facet_limits = FacetLimits::default(); 229 + match import_bluesky_profile( 230 + &web_context.http_client, 231 + &web_context.content_storage, 232 + &web_context.pool, 233 + &identity_resolver, 234 + &dpop_auth, 235 + &pds, 236 + &document.id, 237 + handle, 238 + &facet_limits, 239 + ) 240 + .await 241 + { 242 + Ok(true) => { 243 + tracing::info!(did = %document.id, "Successfully imported Bluesky profile"); 244 + } 245 + Ok(false) => { 246 + tracing::debug!(did = %document.id, "Bluesky profile import skipped"); 247 + } 248 + Err(err) => { 249 + // Log but don't fail authentication - user can set up profile manually 250 + tracing::warn!(did = %document.id, error = %err, "Failed to import Bluesky profile"); 251 + } 252 + } 253 + } 254 + Err(err) => { 255 + // Log but don't fail authentication 256 + tracing::warn!(did = %document.id, error = %err, "Failed to get DPoP auth for profile import"); 257 + } 258 + } 212 259 } 213 260 214 261 let cookie_value: String = WebSession::Aip {
+99 -1
src/http/handle_oauth_callback.rs
··· 1 1 use std::sync::Arc; 2 2 3 3 use anyhow::Result; 4 + use atproto_client::client::DPoPAuth; 4 5 use atproto_identity::key::identify_key; 6 + use atproto_identity::resolve::IdentityResolver; 5 7 use atproto_identity::traits::KeyResolver; 6 8 use atproto_oauth::{ 7 9 resources::oauth_authorization_server, ··· 18 20 use minijinja::context as template_context; 19 21 use serde::{Deserialize, Serialize}; 20 22 21 - use crate::{contextual_error, select_template}; 23 + use crate::{contextual_error, facets::FacetLimits, select_template, storage::identity_profile::handle_warm_up}; 22 24 23 25 use super::{ 24 26 context::WebContext, 25 27 errors::{LoginError, WebError}, 26 28 middleware_auth::{AUTH_COOKIE_NAME, WebSession}, 27 29 middleware_i18n::Language, 30 + profile_import::import_bluesky_profile, 28 31 }; 29 32 30 33 #[derive(Deserialize, Serialize)] ··· 37 40 pub(crate) async fn handle_oauth_callback( 38 41 State(web_context): State<WebContext>, 39 42 key_provider: State<Arc<dyn KeyResolver>>, 43 + identity_resolver: State<Arc<dyn IdentityResolver>>, 40 44 Language(language): Language, 41 45 jar: PrivateCookieJar, 42 46 Form(callback_form): Form<OAuthCallbackForm>, ··· 172 176 } 173 177 174 178 let did = token_response.sub.clone().unwrap(); 179 + 180 + // Resolve DID to get handle and PDS endpoint 181 + let document = match identity_resolver.resolve(&did).await { 182 + Ok(value) => value, 183 + Err(err) => { 184 + return contextual_error!(web_context, language, error_template, default_context, err); 185 + } 186 + }; 187 + 188 + let handle = match document 189 + .handles() 190 + .ok_or(WebError::Login(LoginError::NoHandle)) 191 + { 192 + Ok(value) => value, 193 + Err(err) => { 194 + tracing::error!(?err, "handles"); 195 + return contextual_error!(web_context, language, error_template, default_context, err); 196 + } 197 + }; 198 + 199 + let pds = match document 200 + .pds_endpoints() 201 + .first() 202 + .cloned() 203 + .ok_or(WebError::Login(LoginError::NoPDS)) 204 + { 205 + Ok(value) => value, 206 + Err(err) => { 207 + tracing::error!(?err, "pds_endpoints first"); 208 + return contextual_error!(web_context, language, error_template, default_context, err); 209 + } 210 + }; 211 + 212 + // Store the DID document 213 + if let Err(err) = web_context 214 + .document_storage 215 + .store_document(document.clone()) 216 + .await 217 + { 218 + tracing::error!(?err, "store_document"); 219 + return contextual_error!(web_context, language, error_template, default_context, err); 220 + } 221 + 222 + // Insert the handle if it doesn't exist 223 + let is_new_user = match handle_warm_up( 224 + &web_context.pool, 225 + &document.id, 226 + handle, 227 + pds, 228 + None, // PDS OAuth doesn't provide email 229 + ) 230 + .await 231 + { 232 + Ok(is_new) => is_new, 233 + Err(err) => { 234 + tracing::error!(?err, "handle_warm_up"); 235 + return contextual_error!(web_context, language, error_template, default_context, err); 236 + } 237 + }; 238 + 239 + // Import Bluesky profile for new users 240 + if is_new_user { 241 + // Create DPoP auth from the existing key data and token 242 + let dpop_auth = DPoPAuth { 243 + dpop_private_key_data: dpop_key_data.clone(), 244 + oauth_access_token: token_response.access_token.clone(), 245 + }; 246 + 247 + let facet_limits = FacetLimits::default(); 248 + match import_bluesky_profile( 249 + &web_context.http_client, 250 + &web_context.content_storage, 251 + &web_context.pool, 252 + &identity_resolver, 253 + &dpop_auth, 254 + &pds, 255 + &document.id, 256 + handle, 257 + &facet_limits, 258 + ) 259 + .await 260 + { 261 + Ok(true) => { 262 + tracing::info!(did = %document.id, "Successfully imported Bluesky profile"); 263 + } 264 + Ok(false) => { 265 + tracing::debug!(did = %document.id, "Bluesky profile import skipped"); 266 + } 267 + Err(err) => { 268 + // Log but don't fail authentication - user can set up profile manually 269 + tracing::warn!(did = %document.id, error = %err, "Failed to import Bluesky profile"); 270 + } 271 + } 272 + } 175 273 176 274 // For standard OAuth, create a PDS session 177 275 let cookie_value: String = WebSession::Pds {
+1
src/http/mod.rs
··· 52 52 pub mod handle_preview_description; 53 53 pub mod handle_profile; 54 54 pub mod handle_quick_event; 55 + pub mod profile_import; 55 56 pub mod handle_search; 56 57 pub mod handle_set_language; 57 58 pub mod handle_settings;
+306
src/http/profile_import.rs
··· 1 + //! Bluesky profile import functionality. 2 + //! 3 + //! This module imports a user's Bluesky profile (`app.bsky.actor.profile`) to create 4 + //! a Smokesignal profile (`events.smokesignal.profile`) on first login. 5 + 6 + use std::sync::Arc; 7 + 8 + use atproto_client::{ 9 + client::{Auth, DPoPAuth, post_dpop_bytes_with_headers}, 10 + com::atproto::repo::{PutRecordRequest, PutRecordResponse, get_blob, get_record, put_record}, 11 + }; 12 + use atproto_identity::resolve::IdentityResolver; 13 + use atproto_record::lexicon::TypedBlob; 14 + use bytes::Bytes; 15 + use http::header::CONTENT_TYPE; 16 + use serde::Deserialize; 17 + 18 + use crate::{ 19 + atproto::lexicon::{ 20 + bluesky_profile::{BlueskyProfile, NSID as BLUESKY_PROFILE_NSID}, 21 + profile::{Profile as SmokesignalProfile, NSID as SMOKESIGNAL_PROFILE_NSID}, 22 + }, 23 + facets::{FacetLimits, parse_facets_from_text}, 24 + storage::{StoragePool, content::ContentStorage, profile::{profile_get_by_did, profile_insert}}, 25 + }; 26 + 27 + use super::errors::ProfileImportError; 28 + 29 + /// Maximum length for display name (Smokesignal limit) 30 + const MAX_DISPLAY_NAME_LENGTH: usize = 200; 31 + 32 + /// Maximum length for description (Smokesignal limit) 33 + const MAX_DESCRIPTION_LENGTH: usize = 5000; 34 + 35 + /// Import a user's Bluesky profile to create a Smokesignal profile. 36 + /// 37 + /// This function is called on first login to provide seamless onboarding. 38 + /// If the user already has a Smokesignal profile, this is a no-op. 39 + /// 40 + /// # Arguments 41 + /// * `http_client` - HTTP client for making requests 42 + /// * `content_storage` - Storage for avatar images 43 + /// * `pool` - Database pool 44 + /// * `identity_resolver` - Resolver for parsing facets (mentions) 45 + /// * `dpop_auth` - DPoP authentication for PDS writes 46 + /// * `pds_endpoint` - User's PDS endpoint 47 + /// * `did` - User's DID 48 + /// * `handle` - User's handle (for display_name fallback) 49 + /// * `facet_limits` - Limits for facet parsing 50 + /// 51 + /// # Returns 52 + /// * `Ok(true)` - Profile was successfully imported 53 + /// * `Ok(false)` - Import was skipped (profile exists or no Bluesky profile) 54 + /// * `Err(...)` - Import failed 55 + pub(crate) async fn import_bluesky_profile( 56 + http_client: &reqwest::Client, 57 + content_storage: &Arc<dyn ContentStorage>, 58 + pool: &StoragePool, 59 + identity_resolver: &Arc<dyn IdentityResolver>, 60 + dpop_auth: &DPoPAuth, 61 + pds_endpoint: &str, 62 + did: &str, 63 + handle: &str, 64 + facet_limits: &FacetLimits, 65 + ) -> Result<bool, ProfileImportError> { 66 + // Check if user already has a Smokesignal profile 67 + if let Ok(Some(_)) = profile_get_by_did(pool, did).await { 68 + tracing::debug!(did = %did, "User already has Smokesignal profile, skipping import"); 69 + return Ok(false); 70 + } 71 + 72 + // Fetch Bluesky profile from user's PDS (public read, no auth needed) 73 + let record_response = get_record( 74 + http_client, 75 + &Auth::None, 76 + pds_endpoint, 77 + did, 78 + BLUESKY_PROFILE_NSID, 79 + "self", 80 + None, 81 + ) 82 + .await 83 + .map_err(|e| { 84 + tracing::warn!(did = %did, error = %e, "Failed to fetch Bluesky profile"); 85 + ProfileImportError::FetchFailed(e.to_string()) 86 + })?; 87 + 88 + let bluesky_profile: BlueskyProfile = match record_response { 89 + atproto_client::com::atproto::repo::GetRecordResponse::Record { value, .. } => { 90 + serde_json::from_value(value).map_err(|e| { 91 + tracing::warn!(did = %did, error = %e, "Failed to parse Bluesky profile"); 92 + ProfileImportError::ParseFailed(e.to_string()) 93 + })? 94 + } 95 + atproto_client::com::atproto::repo::GetRecordResponse::Error(e) => { 96 + if e.error.as_deref() == Some("RecordNotFound") { 97 + tracing::debug!(did = %did, "No Bluesky profile found, skipping import"); 98 + return Ok(false); 99 + } 100 + tracing::warn!(did = %did, error = ?e.error, "PDS error fetching Bluesky profile"); 101 + return Err(ProfileImportError::FetchFailed(e.error_message())); 102 + } 103 + }; 104 + 105 + // Build Smokesignal profile from Bluesky profile 106 + let mut smokesignal_profile = SmokesignalProfile { 107 + display_name: None, 108 + description: None, 109 + profile_host: Some("bsky.app".to_string()), // Default to Bluesky 110 + facets: None, 111 + avatar: None, 112 + banner: None, // Don't import banner (different aspect ratios) 113 + extra: std::collections::HashMap::new(), 114 + }; 115 + 116 + // Copy display_name (truncate if needed) 117 + if let Some(display_name) = &bluesky_profile.display_name { 118 + let truncated = if display_name.len() > MAX_DISPLAY_NAME_LENGTH { 119 + display_name[..MAX_DISPLAY_NAME_LENGTH].to_string() 120 + } else { 121 + display_name.clone() 122 + }; 123 + smokesignal_profile.display_name = Some(truncated); 124 + } 125 + 126 + // Copy description and parse facets (truncate if needed) 127 + if let Some(description) = &bluesky_profile.description { 128 + let truncated = if description.len() > MAX_DESCRIPTION_LENGTH { 129 + description[..MAX_DESCRIPTION_LENGTH].to_string() 130 + } else { 131 + description.clone() 132 + }; 133 + 134 + // Parse facets from description 135 + if let Some(facets) = parse_facets_from_text(&truncated, identity_resolver.as_ref(), facet_limits).await { 136 + smokesignal_profile.facets = Some(facets); 137 + } 138 + 139 + smokesignal_profile.description = Some(truncated); 140 + } 141 + 142 + // Import avatar if present 143 + if let Some(ref avatar_blob) = bluesky_profile.avatar { 144 + match import_avatar( 145 + http_client, 146 + content_storage, 147 + dpop_auth, 148 + pds_endpoint, 149 + did, 150 + avatar_blob, 151 + ) 152 + .await 153 + { 154 + Ok(Some(new_blob)) => { 155 + smokesignal_profile.avatar = Some(new_blob); 156 + tracing::debug!(did = %did, "Successfully imported avatar"); 157 + } 158 + Ok(None) => { 159 + tracing::debug!(did = %did, "Avatar import returned None, skipping avatar"); 160 + } 161 + Err(e) => { 162 + // Log but don't fail - profile can still be created without avatar 163 + tracing::warn!(did = %did, error = %e, "Failed to import avatar, continuing without it"); 164 + } 165 + } 166 + } 167 + 168 + // Build typed profile for PDS 169 + let typed_profile = TypedSmokesignalProfile { 170 + r#type: SMOKESIGNAL_PROFILE_NSID.to_string(), 171 + profile: smokesignal_profile.clone(), 172 + }; 173 + 174 + // Write profile to user's PDS 175 + let put_request = PutRecordRequest { 176 + repo: did.to_string(), 177 + collection: SMOKESIGNAL_PROFILE_NSID.to_string(), 178 + record_key: "self".to_string(), 179 + record: typed_profile.clone(), 180 + validate: false, 181 + swap_record: None, 182 + swap_commit: None, 183 + }; 184 + 185 + let put_response = put_record(http_client, &Auth::DPoP(dpop_auth.clone()), pds_endpoint, put_request) 186 + .await 187 + .map_err(|e| ProfileImportError::PdsWriteFailed(e.to_string()))?; 188 + 189 + match put_response { 190 + PutRecordResponse::StrongRef { uri, cid, .. } => { 191 + // Determine display name for local storage 192 + let display_name_for_db = smokesignal_profile 193 + .display_name 194 + .as_ref() 195 + .filter(|s| !s.trim().is_empty()) 196 + .map(|s| s.as_str()) 197 + .unwrap_or(handle); 198 + 199 + // Store profile locally 200 + if let Err(e) = profile_insert(pool, &uri, &cid, did, display_name_for_db, &smokesignal_profile).await { 201 + tracing::error!(did = %did, error = %e, "Failed to store imported profile locally"); 202 + return Err(ProfileImportError::StorageFailed(e.to_string())); 203 + } 204 + 205 + tracing::info!(did = %did, uri = %uri, "Successfully imported Bluesky profile to Smokesignal"); 206 + Ok(true) 207 + } 208 + PutRecordResponse::Error(e) => { 209 + tracing::error!(did = %did, error = ?e.error, "PDS returned error for profile import"); 210 + Err(ProfileImportError::PdsWriteFailed(e.error_message())) 211 + } 212 + } 213 + } 214 + 215 + /// Import avatar from Bluesky profile. 216 + /// 217 + /// Downloads the avatar from the user's PDS, processes it through the image pipeline, 218 + /// re-uploads it to the user's PDS, and stores it locally. 219 + async fn import_avatar( 220 + http_client: &reqwest::Client, 221 + content_storage: &Arc<dyn ContentStorage>, 222 + dpop_auth: &DPoPAuth, 223 + pds_endpoint: &str, 224 + did: &str, 225 + avatar_blob: &TypedBlob, 226 + ) -> Result<Option<TypedBlob>, ProfileImportError> { 227 + let blob_cid = &avatar_blob.inner.ref_.link; 228 + 229 + // Validate size (max 3MB) 230 + if avatar_blob.inner.size > 3_000_000 { 231 + tracing::debug!(did = %did, size = avatar_blob.inner.size, "Avatar exceeds max size, skipping"); 232 + return Ok(None); 233 + } 234 + 235 + // Download blob from PDS (public, no auth needed) 236 + let image_bytes = get_blob(http_client, pds_endpoint, did, blob_cid) 237 + .await 238 + .map_err(|e| ProfileImportError::AvatarDownloadFailed(e.to_string()))?; 239 + 240 + // Process avatar through image pipeline (validates and converts to 400x400 PNG) 241 + let processed = crate::image::process_avatar(&image_bytes) 242 + .map_err(|e| ProfileImportError::AvatarProcessFailed(e.to_string()))?; 243 + 244 + // Upload processed avatar to user's PDS 245 + let new_blob = upload_blob_to_pds(http_client, dpop_auth, pds_endpoint, &processed, "image/png") 246 + .await 247 + .map_err(|e| ProfileImportError::AvatarUploadFailed(e.to_string()))?; 248 + 249 + // Store avatar locally in content storage 250 + let image_path = format!("{}.png", new_blob.inner.ref_.link); 251 + if let Err(e) = content_storage.write_content(&image_path, &processed).await { 252 + tracing::warn!( 253 + did = %did, 254 + cid = %new_blob.inner.ref_.link, 255 + error = %e, 256 + "Failed to store avatar in content storage, continuing anyway" 257 + ); 258 + // Don't fail - the PDS upload succeeded 259 + } 260 + 261 + Ok(Some(new_blob)) 262 + } 263 + 264 + /// Upload a blob to the user's PDS using DPoP authentication. 265 + async fn upload_blob_to_pds( 266 + http_client: &reqwest::Client, 267 + dpop_auth: &DPoPAuth, 268 + pds_endpoint: &str, 269 + data: &[u8], 270 + mime_type: &str, 271 + ) -> Result<TypedBlob, String> { 272 + let upload_url = format!("{}/xrpc/com.atproto.repo.uploadBlob", pds_endpoint); 273 + 274 + let mut headers = http::HeaderMap::default(); 275 + headers.insert(CONTENT_TYPE, mime_type.parse().unwrap()); 276 + 277 + let blob_response = post_dpop_bytes_with_headers( 278 + http_client, 279 + dpop_auth, 280 + &upload_url, 281 + Bytes::copy_from_slice(data), 282 + &headers, 283 + ) 284 + .await 285 + .map_err(|e| e.to_string())?; 286 + 287 + serde_json::from_value::<CreateBlobResponse>(blob_response) 288 + .map(|r| r.blob) 289 + .map_err(|e| e.to_string()) 290 + } 291 + 292 + /// Response from com.atproto.repo.uploadBlob 293 + #[derive(Deserialize)] 294 + struct CreateBlobResponse { 295 + blob: TypedBlob, 296 + } 297 + 298 + /// Typed Smokesignal profile with $type field for PDS storage 299 + #[derive(Clone, serde::Serialize, serde::Deserialize)] 300 + struct TypedSmokesignalProfile { 301 + #[serde(rename = "$type")] 302 + r#type: String, 303 + 304 + #[serde(flatten)] 305 + profile: SmokesignalProfile, 306 + }