···11+//! Bluesky profile lexicon definition for importing profiles.
22+//!
33+//! This module defines the `app.bsky.actor.profile` record type used to
44+//! deserialize Bluesky profiles when importing them to create Smokesignal profiles.
55+66+use atproto_record::lexicon::TypedBlob;
77+use serde::Deserialize;
88+99+/// The NSID for Bluesky actor profiles
1010+pub const NSID: &str = "app.bsky.actor.profile";
1111+1212+/// Bluesky actor profile record structure.
1313+///
1414+/// This is used to deserialize `app.bsky.actor.profile` records from a user's PDS
1515+/// when importing their profile data to create a Smokesignal profile.
1616+#[derive(Debug, Deserialize)]
1717+#[serde(rename_all = "camelCase")]
1818+pub struct BlueskyProfile {
1919+ /// Display name shown on the profile
2020+ pub display_name: Option<String>,
2121+2222+ /// Profile bio/description text
2323+ pub description: Option<String>,
2424+2525+ /// Avatar image blob (1:1 square aspect ratio)
2626+ pub avatar: Option<TypedBlob>,
2727+2828+ /// Banner image blob (3:1 aspect ratio - NOT imported to Smokesignal which uses 16:9)
2929+ pub banner: Option<TypedBlob>,
3030+}
+1
src/atproto/lexicon/mod.rs
···11pub mod acceptance;
22+pub mod bluesky_profile;
23pub mod profile;
+2
src/http/errors/mod.rs
···99pub mod event_view_errors;
1010pub mod import_error;
1111pub mod login_error;
1212+pub mod profile_import_error;
1213pub mod middleware_errors;
1314pub mod url_error;
1415pub mod view_event_error;
···2122pub(crate) use event_view_errors::EventViewError;
2223pub(crate) use import_error::ImportError;
2324pub(crate) use login_error::LoginError;
2525+pub(crate) use profile_import_error::ProfileImportError;
2426pub(crate) use middleware_errors::WebSessionError;
2527pub(crate) use url_error::UrlError;
2628pub(crate) use view_event_error::ViewEventError;
+44
src/http/errors/profile_import_error.rs
···11+use thiserror::Error;
22+33+/// Represents errors that can occur during Bluesky profile import operations.
44+///
55+/// These errors typically happen when attempting to import a user's Bluesky profile
66+/// (`app.bsky.actor.profile`) to create a Smokesignal profile on first login.
77+#[derive(Debug, Error)]
88+pub(crate) enum ProfileImportError {
99+ /// User already has a Smokesignal profile, import skipped.
1010+ #[error("error-smokesignal-profile-import-1 User already has profile")]
1111+ ProfileAlreadyExists,
1212+1313+ /// Failed to fetch the Bluesky profile from the user's PDS.
1414+ #[error("error-smokesignal-profile-import-2 Failed to fetch Bluesky profile: {0}")]
1515+ FetchFailed(String),
1616+1717+ /// Failed to parse the Bluesky profile record.
1818+ #[error("error-smokesignal-profile-import-3 Failed to parse Bluesky profile: {0}")]
1919+ ParseFailed(String),
2020+2121+ /// Failed to download the avatar blob from PDS.
2222+ #[error("error-smokesignal-profile-import-4 Failed to download avatar: {0}")]
2323+ AvatarDownloadFailed(String),
2424+2525+ /// Failed to process the avatar image through the image pipeline.
2626+ #[error("error-smokesignal-profile-import-5 Failed to process avatar: {0}")]
2727+ AvatarProcessFailed(String),
2828+2929+ /// Failed to upload the processed avatar to the user's PDS.
3030+ #[error("error-smokesignal-profile-import-6 Failed to upload avatar: {0}")]
3131+ AvatarUploadFailed(String),
3232+3333+ /// Failed to write the new Smokesignal profile to the user's PDS.
3434+ #[error("error-smokesignal-profile-import-7 Failed to write profile to PDS: {0}")]
3535+ PdsWriteFailed(String),
3636+3737+ /// Failed to store the profile locally in the database.
3838+ #[error("error-smokesignal-profile-import-8 Failed to store profile locally: {0}")]
3939+ StorageFailed(String),
4040+4141+ /// No Bluesky profile exists for this user.
4242+ #[error("error-smokesignal-profile-import-9 No Bluesky profile found")]
4343+ NoBlueskyProfile,
4444+}
+47
src/http/handle_oauth_aip_callback.rs
···11use std::{collections::HashMap, sync::Arc};
2233use crate::{
44+ atproto::auth::create_dpop_auth_from_aip_session,
45 config::OAuthBackendConfig, contextual_error,
66+ facets::FacetLimits,
57 http::handle_email_confirm::send_confirmation_email, select_template,
68 storage::identity_profile::handle_warm_up,
79};
···2426 errors::{LoginError, WebError},
2527 middleware_auth::{AUTH_COOKIE_NAME, WebSession},
2628 middleware_i18n::Language,
2929+ profile_import::import_bluesky_profile,
2730};
28312932#[derive(Deserialize, Serialize)]
···209212 {
210213 // Log the error but don't fail the authentication flow
211214 tracing::warn!(?err, "Failed to send confirmation email to new user");
215215+ }
216216+217217+ // Import Bluesky profile for new users
218218+ if is_new_user {
219219+ // Get DPoP credentials for writing to user's PDS
220220+ match create_dpop_auth_from_aip_session(
221221+ &web_context.http_client,
222222+ hostname,
223223+ &token_response.access_token,
224224+ )
225225+ .await
226226+ {
227227+ Ok(dpop_auth) => {
228228+ let facet_limits = FacetLimits::default();
229229+ match import_bluesky_profile(
230230+ &web_context.http_client,
231231+ &web_context.content_storage,
232232+ &web_context.pool,
233233+ &identity_resolver,
234234+ &dpop_auth,
235235+ &pds,
236236+ &document.id,
237237+ handle,
238238+ &facet_limits,
239239+ )
240240+ .await
241241+ {
242242+ Ok(true) => {
243243+ tracing::info!(did = %document.id, "Successfully imported Bluesky profile");
244244+ }
245245+ Ok(false) => {
246246+ tracing::debug!(did = %document.id, "Bluesky profile import skipped");
247247+ }
248248+ Err(err) => {
249249+ // Log but don't fail authentication - user can set up profile manually
250250+ tracing::warn!(did = %document.id, error = %err, "Failed to import Bluesky profile");
251251+ }
252252+ }
253253+ }
254254+ Err(err) => {
255255+ // Log but don't fail authentication
256256+ tracing::warn!(did = %document.id, error = %err, "Failed to get DPoP auth for profile import");
257257+ }
258258+ }
212259 }
213260214261 let cookie_value: String = WebSession::Aip {
+99-1
src/http/handle_oauth_callback.rs
···11use std::sync::Arc;
2233use anyhow::Result;
44+use atproto_client::client::DPoPAuth;
45use atproto_identity::key::identify_key;
66+use atproto_identity::resolve::IdentityResolver;
57use atproto_identity::traits::KeyResolver;
68use atproto_oauth::{
79 resources::oauth_authorization_server,
···1820use minijinja::context as template_context;
1921use serde::{Deserialize, Serialize};
20222121-use crate::{contextual_error, select_template};
2323+use crate::{contextual_error, facets::FacetLimits, select_template, storage::identity_profile::handle_warm_up};
22242325use super::{
2426 context::WebContext,
2527 errors::{LoginError, WebError},
2628 middleware_auth::{AUTH_COOKIE_NAME, WebSession},
2729 middleware_i18n::Language,
3030+ profile_import::import_bluesky_profile,
2831};
29323033#[derive(Deserialize, Serialize)]
···3740pub(crate) async fn handle_oauth_callback(
3841 State(web_context): State<WebContext>,
3942 key_provider: State<Arc<dyn KeyResolver>>,
4343+ identity_resolver: State<Arc<dyn IdentityResolver>>,
4044 Language(language): Language,
4145 jar: PrivateCookieJar,
4246 Form(callback_form): Form<OAuthCallbackForm>,
···172176 }
173177174178 let did = token_response.sub.clone().unwrap();
179179+180180+ // Resolve DID to get handle and PDS endpoint
181181+ let document = match identity_resolver.resolve(&did).await {
182182+ Ok(value) => value,
183183+ Err(err) => {
184184+ return contextual_error!(web_context, language, error_template, default_context, err);
185185+ }
186186+ };
187187+188188+ let handle = match document
189189+ .handles()
190190+ .ok_or(WebError::Login(LoginError::NoHandle))
191191+ {
192192+ Ok(value) => value,
193193+ Err(err) => {
194194+ tracing::error!(?err, "handles");
195195+ return contextual_error!(web_context, language, error_template, default_context, err);
196196+ }
197197+ };
198198+199199+ let pds = match document
200200+ .pds_endpoints()
201201+ .first()
202202+ .cloned()
203203+ .ok_or(WebError::Login(LoginError::NoPDS))
204204+ {
205205+ Ok(value) => value,
206206+ Err(err) => {
207207+ tracing::error!(?err, "pds_endpoints first");
208208+ return contextual_error!(web_context, language, error_template, default_context, err);
209209+ }
210210+ };
211211+212212+ // Store the DID document
213213+ if let Err(err) = web_context
214214+ .document_storage
215215+ .store_document(document.clone())
216216+ .await
217217+ {
218218+ tracing::error!(?err, "store_document");
219219+ return contextual_error!(web_context, language, error_template, default_context, err);
220220+ }
221221+222222+ // Insert the handle if it doesn't exist
223223+ let is_new_user = match handle_warm_up(
224224+ &web_context.pool,
225225+ &document.id,
226226+ handle,
227227+ pds,
228228+ None, // PDS OAuth doesn't provide email
229229+ )
230230+ .await
231231+ {
232232+ Ok(is_new) => is_new,
233233+ Err(err) => {
234234+ tracing::error!(?err, "handle_warm_up");
235235+ return contextual_error!(web_context, language, error_template, default_context, err);
236236+ }
237237+ };
238238+239239+ // Import Bluesky profile for new users
240240+ if is_new_user {
241241+ // Create DPoP auth from the existing key data and token
242242+ let dpop_auth = DPoPAuth {
243243+ dpop_private_key_data: dpop_key_data.clone(),
244244+ oauth_access_token: token_response.access_token.clone(),
245245+ };
246246+247247+ let facet_limits = FacetLimits::default();
248248+ match import_bluesky_profile(
249249+ &web_context.http_client,
250250+ &web_context.content_storage,
251251+ &web_context.pool,
252252+ &identity_resolver,
253253+ &dpop_auth,
254254+ &pds,
255255+ &document.id,
256256+ handle,
257257+ &facet_limits,
258258+ )
259259+ .await
260260+ {
261261+ Ok(true) => {
262262+ tracing::info!(did = %document.id, "Successfully imported Bluesky profile");
263263+ }
264264+ Ok(false) => {
265265+ tracing::debug!(did = %document.id, "Bluesky profile import skipped");
266266+ }
267267+ Err(err) => {
268268+ // Log but don't fail authentication - user can set up profile manually
269269+ tracing::warn!(did = %document.id, error = %err, "Failed to import Bluesky profile");
270270+ }
271271+ }
272272+ }
175273176274 // For standard OAuth, create a PDS session
177275 let cookie_value: String = WebSession::Pds {
+1
src/http/mod.rs
···5252pub mod handle_preview_description;
5353pub mod handle_profile;
5454pub mod handle_quick_event;
5555+pub mod profile_import;
5556pub mod handle_search;
5657pub mod handle_set_language;
5758pub mod handle_settings;
+306
src/http/profile_import.rs
···11+//! Bluesky profile import functionality.
22+//!
33+//! This module imports a user's Bluesky profile (`app.bsky.actor.profile`) to create
44+//! a Smokesignal profile (`events.smokesignal.profile`) on first login.
55+66+use std::sync::Arc;
77+88+use atproto_client::{
99+ client::{Auth, DPoPAuth, post_dpop_bytes_with_headers},
1010+ com::atproto::repo::{PutRecordRequest, PutRecordResponse, get_blob, get_record, put_record},
1111+};
1212+use atproto_identity::resolve::IdentityResolver;
1313+use atproto_record::lexicon::TypedBlob;
1414+use bytes::Bytes;
1515+use http::header::CONTENT_TYPE;
1616+use serde::Deserialize;
1717+1818+use crate::{
1919+ atproto::lexicon::{
2020+ bluesky_profile::{BlueskyProfile, NSID as BLUESKY_PROFILE_NSID},
2121+ profile::{Profile as SmokesignalProfile, NSID as SMOKESIGNAL_PROFILE_NSID},
2222+ },
2323+ facets::{FacetLimits, parse_facets_from_text},
2424+ storage::{StoragePool, content::ContentStorage, profile::{profile_get_by_did, profile_insert}},
2525+};
2626+2727+use super::errors::ProfileImportError;
2828+2929+/// Maximum length for display name (Smokesignal limit)
3030+const MAX_DISPLAY_NAME_LENGTH: usize = 200;
3131+3232+/// Maximum length for description (Smokesignal limit)
3333+const MAX_DESCRIPTION_LENGTH: usize = 5000;
3434+3535+/// Import a user's Bluesky profile to create a Smokesignal profile.
3636+///
3737+/// This function is called on first login to provide seamless onboarding.
3838+/// If the user already has a Smokesignal profile, this is a no-op.
3939+///
4040+/// # Arguments
4141+/// * `http_client` - HTTP client for making requests
4242+/// * `content_storage` - Storage for avatar images
4343+/// * `pool` - Database pool
4444+/// * `identity_resolver` - Resolver for parsing facets (mentions)
4545+/// * `dpop_auth` - DPoP authentication for PDS writes
4646+/// * `pds_endpoint` - User's PDS endpoint
4747+/// * `did` - User's DID
4848+/// * `handle` - User's handle (for display_name fallback)
4949+/// * `facet_limits` - Limits for facet parsing
5050+///
5151+/// # Returns
5252+/// * `Ok(true)` - Profile was successfully imported
5353+/// * `Ok(false)` - Import was skipped (profile exists or no Bluesky profile)
5454+/// * `Err(...)` - Import failed
5555+pub(crate) async fn import_bluesky_profile(
5656+ http_client: &reqwest::Client,
5757+ content_storage: &Arc<dyn ContentStorage>,
5858+ pool: &StoragePool,
5959+ identity_resolver: &Arc<dyn IdentityResolver>,
6060+ dpop_auth: &DPoPAuth,
6161+ pds_endpoint: &str,
6262+ did: &str,
6363+ handle: &str,
6464+ facet_limits: &FacetLimits,
6565+) -> Result<bool, ProfileImportError> {
6666+ // Check if user already has a Smokesignal profile
6767+ if let Ok(Some(_)) = profile_get_by_did(pool, did).await {
6868+ tracing::debug!(did = %did, "User already has Smokesignal profile, skipping import");
6969+ return Ok(false);
7070+ }
7171+7272+ // Fetch Bluesky profile from user's PDS (public read, no auth needed)
7373+ let record_response = get_record(
7474+ http_client,
7575+ &Auth::None,
7676+ pds_endpoint,
7777+ did,
7878+ BLUESKY_PROFILE_NSID,
7979+ "self",
8080+ None,
8181+ )
8282+ .await
8383+ .map_err(|e| {
8484+ tracing::warn!(did = %did, error = %e, "Failed to fetch Bluesky profile");
8585+ ProfileImportError::FetchFailed(e.to_string())
8686+ })?;
8787+8888+ let bluesky_profile: BlueskyProfile = match record_response {
8989+ atproto_client::com::atproto::repo::GetRecordResponse::Record { value, .. } => {
9090+ serde_json::from_value(value).map_err(|e| {
9191+ tracing::warn!(did = %did, error = %e, "Failed to parse Bluesky profile");
9292+ ProfileImportError::ParseFailed(e.to_string())
9393+ })?
9494+ }
9595+ atproto_client::com::atproto::repo::GetRecordResponse::Error(e) => {
9696+ if e.error.as_deref() == Some("RecordNotFound") {
9797+ tracing::debug!(did = %did, "No Bluesky profile found, skipping import");
9898+ return Ok(false);
9999+ }
100100+ tracing::warn!(did = %did, error = ?e.error, "PDS error fetching Bluesky profile");
101101+ return Err(ProfileImportError::FetchFailed(e.error_message()));
102102+ }
103103+ };
104104+105105+ // Build Smokesignal profile from Bluesky profile
106106+ let mut smokesignal_profile = SmokesignalProfile {
107107+ display_name: None,
108108+ description: None,
109109+ profile_host: Some("bsky.app".to_string()), // Default to Bluesky
110110+ facets: None,
111111+ avatar: None,
112112+ banner: None, // Don't import banner (different aspect ratios)
113113+ extra: std::collections::HashMap::new(),
114114+ };
115115+116116+ // Copy display_name (truncate if needed)
117117+ if let Some(display_name) = &bluesky_profile.display_name {
118118+ let truncated = if display_name.len() > MAX_DISPLAY_NAME_LENGTH {
119119+ display_name[..MAX_DISPLAY_NAME_LENGTH].to_string()
120120+ } else {
121121+ display_name.clone()
122122+ };
123123+ smokesignal_profile.display_name = Some(truncated);
124124+ }
125125+126126+ // Copy description and parse facets (truncate if needed)
127127+ if let Some(description) = &bluesky_profile.description {
128128+ let truncated = if description.len() > MAX_DESCRIPTION_LENGTH {
129129+ description[..MAX_DESCRIPTION_LENGTH].to_string()
130130+ } else {
131131+ description.clone()
132132+ };
133133+134134+ // Parse facets from description
135135+ if let Some(facets) = parse_facets_from_text(&truncated, identity_resolver.as_ref(), facet_limits).await {
136136+ smokesignal_profile.facets = Some(facets);
137137+ }
138138+139139+ smokesignal_profile.description = Some(truncated);
140140+ }
141141+142142+ // Import avatar if present
143143+ if let Some(ref avatar_blob) = bluesky_profile.avatar {
144144+ match import_avatar(
145145+ http_client,
146146+ content_storage,
147147+ dpop_auth,
148148+ pds_endpoint,
149149+ did,
150150+ avatar_blob,
151151+ )
152152+ .await
153153+ {
154154+ Ok(Some(new_blob)) => {
155155+ smokesignal_profile.avatar = Some(new_blob);
156156+ tracing::debug!(did = %did, "Successfully imported avatar");
157157+ }
158158+ Ok(None) => {
159159+ tracing::debug!(did = %did, "Avatar import returned None, skipping avatar");
160160+ }
161161+ Err(e) => {
162162+ // Log but don't fail - profile can still be created without avatar
163163+ tracing::warn!(did = %did, error = %e, "Failed to import avatar, continuing without it");
164164+ }
165165+ }
166166+ }
167167+168168+ // Build typed profile for PDS
169169+ let typed_profile = TypedSmokesignalProfile {
170170+ r#type: SMOKESIGNAL_PROFILE_NSID.to_string(),
171171+ profile: smokesignal_profile.clone(),
172172+ };
173173+174174+ // Write profile to user's PDS
175175+ let put_request = PutRecordRequest {
176176+ repo: did.to_string(),
177177+ collection: SMOKESIGNAL_PROFILE_NSID.to_string(),
178178+ record_key: "self".to_string(),
179179+ record: typed_profile.clone(),
180180+ validate: false,
181181+ swap_record: None,
182182+ swap_commit: None,
183183+ };
184184+185185+ let put_response = put_record(http_client, &Auth::DPoP(dpop_auth.clone()), pds_endpoint, put_request)
186186+ .await
187187+ .map_err(|e| ProfileImportError::PdsWriteFailed(e.to_string()))?;
188188+189189+ match put_response {
190190+ PutRecordResponse::StrongRef { uri, cid, .. } => {
191191+ // Determine display name for local storage
192192+ let display_name_for_db = smokesignal_profile
193193+ .display_name
194194+ .as_ref()
195195+ .filter(|s| !s.trim().is_empty())
196196+ .map(|s| s.as_str())
197197+ .unwrap_or(handle);
198198+199199+ // Store profile locally
200200+ if let Err(e) = profile_insert(pool, &uri, &cid, did, display_name_for_db, &smokesignal_profile).await {
201201+ tracing::error!(did = %did, error = %e, "Failed to store imported profile locally");
202202+ return Err(ProfileImportError::StorageFailed(e.to_string()));
203203+ }
204204+205205+ tracing::info!(did = %did, uri = %uri, "Successfully imported Bluesky profile to Smokesignal");
206206+ Ok(true)
207207+ }
208208+ PutRecordResponse::Error(e) => {
209209+ tracing::error!(did = %did, error = ?e.error, "PDS returned error for profile import");
210210+ Err(ProfileImportError::PdsWriteFailed(e.error_message()))
211211+ }
212212+ }
213213+}
214214+215215+/// Import avatar from Bluesky profile.
216216+///
217217+/// Downloads the avatar from the user's PDS, processes it through the image pipeline,
218218+/// re-uploads it to the user's PDS, and stores it locally.
219219+async fn import_avatar(
220220+ http_client: &reqwest::Client,
221221+ content_storage: &Arc<dyn ContentStorage>,
222222+ dpop_auth: &DPoPAuth,
223223+ pds_endpoint: &str,
224224+ did: &str,
225225+ avatar_blob: &TypedBlob,
226226+) -> Result<Option<TypedBlob>, ProfileImportError> {
227227+ let blob_cid = &avatar_blob.inner.ref_.link;
228228+229229+ // Validate size (max 3MB)
230230+ if avatar_blob.inner.size > 3_000_000 {
231231+ tracing::debug!(did = %did, size = avatar_blob.inner.size, "Avatar exceeds max size, skipping");
232232+ return Ok(None);
233233+ }
234234+235235+ // Download blob from PDS (public, no auth needed)
236236+ let image_bytes = get_blob(http_client, pds_endpoint, did, blob_cid)
237237+ .await
238238+ .map_err(|e| ProfileImportError::AvatarDownloadFailed(e.to_string()))?;
239239+240240+ // Process avatar through image pipeline (validates and converts to 400x400 PNG)
241241+ let processed = crate::image::process_avatar(&image_bytes)
242242+ .map_err(|e| ProfileImportError::AvatarProcessFailed(e.to_string()))?;
243243+244244+ // Upload processed avatar to user's PDS
245245+ let new_blob = upload_blob_to_pds(http_client, dpop_auth, pds_endpoint, &processed, "image/png")
246246+ .await
247247+ .map_err(|e| ProfileImportError::AvatarUploadFailed(e.to_string()))?;
248248+249249+ // Store avatar locally in content storage
250250+ let image_path = format!("{}.png", new_blob.inner.ref_.link);
251251+ if let Err(e) = content_storage.write_content(&image_path, &processed).await {
252252+ tracing::warn!(
253253+ did = %did,
254254+ cid = %new_blob.inner.ref_.link,
255255+ error = %e,
256256+ "Failed to store avatar in content storage, continuing anyway"
257257+ );
258258+ // Don't fail - the PDS upload succeeded
259259+ }
260260+261261+ Ok(Some(new_blob))
262262+}
263263+264264+/// Upload a blob to the user's PDS using DPoP authentication.
265265+async fn upload_blob_to_pds(
266266+ http_client: &reqwest::Client,
267267+ dpop_auth: &DPoPAuth,
268268+ pds_endpoint: &str,
269269+ data: &[u8],
270270+ mime_type: &str,
271271+) -> Result<TypedBlob, String> {
272272+ let upload_url = format!("{}/xrpc/com.atproto.repo.uploadBlob", pds_endpoint);
273273+274274+ let mut headers = http::HeaderMap::default();
275275+ headers.insert(CONTENT_TYPE, mime_type.parse().unwrap());
276276+277277+ let blob_response = post_dpop_bytes_with_headers(
278278+ http_client,
279279+ dpop_auth,
280280+ &upload_url,
281281+ Bytes::copy_from_slice(data),
282282+ &headers,
283283+ )
284284+ .await
285285+ .map_err(|e| e.to_string())?;
286286+287287+ serde_json::from_value::<CreateBlobResponse>(blob_response)
288288+ .map(|r| r.blob)
289289+ .map_err(|e| e.to_string())
290290+}
291291+292292+/// Response from com.atproto.repo.uploadBlob
293293+#[derive(Deserialize)]
294294+struct CreateBlobResponse {
295295+ blob: TypedBlob,
296296+}
297297+298298+/// Typed Smokesignal profile with $type field for PDS storage
299299+#[derive(Clone, serde::Serialize, serde::Deserialize)]
300300+struct TypedSmokesignalProfile {
301301+ #[serde(rename = "$type")]
302302+ r#type: String,
303303+304304+ #[serde(flatten)]
305305+ profile: SmokesignalProfile,
306306+}