···11+{
22+ "lexicon": 1,
33+ "id": "events.smokesignal.profile",
44+ "defs": {
55+ "main": {
66+ "type": "record",
77+ "description": "A user profile for Smoke Signal",
88+ "key": "literal:self",
99+ "record": {
1010+ "type": "object",
1111+ "properties": {
1212+ "displayName": {
1313+ "type": "string",
1414+ "description": "The display name of the identity.",
1515+ "maxGraphemes": 200,
1616+ "maxLength": 200
1717+ },
1818+ "profile_host": {
1919+ "type": "string",
2020+ "description": "The service used for profile links",
2121+ "knownValues": [
2222+ "bsky.app",
2323+ "blacksky.community"
2424+ ]
2525+ },
2626+ "description": {
2727+ "type": "string",
2828+ "description": "A free text description of the identity.",
2929+ "maxGraphemes": 2000,
3030+ "maxLength": 2000
3131+ },
3232+ "facets": {
3333+ "type": "array",
3434+ "description": "Annotations of text (mentions, URLs, hashtags, etc) in the description.",
3535+ "items": {
3636+ "type": "ref",
3737+ "ref": "app.bsky.richtext.facet"
3838+ }
3939+ },
4040+ "avatar": {
4141+ "type": "blob",
4242+ "description": "Small image to be displayed next to events. AKA, 'profile picture'",
4343+ "accept": ["image/png", "image/jpeg"],
4444+ "maxSize": 1000000
4545+ },
4646+ "banner": {
4747+ "type": "blob",
4848+ "description": "Larger horizontal image to display behind profile view.",
4949+ "accept": ["image/png", "image/jpeg"],
5050+ "maxSize": 1000000
5151+ }
5252+ }
5353+ }
5454+ },
5555+ "hiring": {
5656+ "type": "token",
5757+ "description": "Indicates the identity is actively hiring"
5858+ },
5959+ "forhire": {
6060+ "type": "token",
6161+ "description": "Indicates the identity is available for hire"
6262+ }
6363+ }
6464+}
+10
migrations/20251024000000_profile_storage.sql
···11+CREATE TABLE profiles (
22+ aturi VARCHAR(1024) PRIMARY KEY,
33+ cid VARCHAR(256) NOT NULL,
44+ did VARCHAR(256) NOT NULL,
55+ display_name VARCHAR(1024) NOT NULL,
66+ record JSON NOT NULL,
77+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW ()
88+);
99+1010+CREATE INDEX idx_profiles_did ON profiles (did);
···3030 /// or appears to be corrupted or tampered with.
3131 #[error("error-smokesignal-common-4 Invalid event format or corrupted data")]
3232 InvalidEventFormat,
3333+3434+ /// Error when an image file has an invalid format.
3535+ ///
3636+ /// This error occurs when an uploaded image file is not in a supported
3737+ /// format (PNG or JPEG).
3838+ #[error("error-smokesignal-common-5 Invalid image format: only PNG and JPEG are supported")]
3939+ InvalidImageFormat,
4040+4141+ /// Error when a record update fails due to a concurrent modification.
4242+ ///
4343+ /// This error occurs when attempting to update a record but the record
4444+ /// has been modified by another request since it was last read (CAS failure).
4545+ #[error("error-smokesignal-common-6 The record has been modified by another request. Please refresh and try again.")]
4646+ InvalidSwap,
3347}
···1414 State(web_context): State<WebContext>,
1515 Path(cid): Path<String>,
1616) -> Result<impl IntoResponse, WebError> {
1717+ // Strip file extension if present (e.g., ".png" from URLs like /content/{cid}.png)
1818+ // The content is stored with just the CID, but templates use URLs with extensions
1919+ let cid_without_ext = cid
2020+ .strip_suffix(".png")
2121+ .or_else(|| cid.strip_suffix(".jpg"))
2222+ .or_else(|| cid.strip_suffix(".jpeg"))
2323+ .unwrap_or(&cid);
2424+1725 // Check if content exists
1818- let exists = match web_context.content_storage.content_exists(&cid).await {
2626+ let exists = match web_context.content_storage.content_exists(cid_without_ext).await {
1927 Ok(exists) => exists,
2028 Err(_) => return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()),
2129 };
···2533 }
26342735 // Read the content data
2828- let content_data = match web_context.content_storage.read_content(&cid).await {
3636+ let content_data = match web_context.content_storage.read_content(cid_without_ext).await {
2937 Ok(data) => data,
3038 Err(_) => return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()),
3139 };
32404141+ // Detect content type from the original path extension
4242+ let content_type = if cid.ends_with(".png") {
4343+ "image/png"
4444+ } else if cid.ends_with(".jpg") || cid.ends_with(".jpeg") {
4545+ "image/jpeg"
4646+ } else {
4747+ "application/octet-stream"
4848+ };
4949+3350 // Return the content with appropriate headers
3451 Ok(Response::builder()
3552 .status(StatusCode::OK)
3636- .header(header::CONTENT_TYPE, "application/octet-stream")
5353+ .header(header::CONTENT_TYPE, content_type)
3754 .header(header::CACHE_CONTROL, "public, max-age=86400") // Cache for 1 day
3855 .body(Body::from(content_data))
3956 .unwrap()
+1-1
src/http/handle_oauth_aip_login.rs
···104104 state: state.clone(),
105105 nonce: nonce.clone(),
106106 code_challenge,
107107- scope: "openid email profile atproto account:email blob:image/* repo:community.lexicon.calendar.event repo:community.lexicon.calendar.rsvp".to_string(),
107107+ scope: "openid email profile atproto account:email blob:image/* repo:community.lexicon.calendar.event repo:community.lexicon.calendar.rsvp repo:events.smokesignal.profile".to_string(),
108108 };
109109110110 // Get AIP server configuration - config validation ensures these are set when oauth_backend is AIP
+3-1
src/http/handle_oauth_callback.rs
···171171 tracing::error!(error = ?err, "Unable to remove oauth_request");
172172 }
173173174174+ let did = token_response.sub.clone().unwrap();
175175+174176 // For standard OAuth, create a PDS session
175177 let cookie_value: String = WebSession::Pds {
176176- did: token_response.sub.clone().unwrap(),
178178+ did: did.clone(),
177179 session_group: "".to_string(), // Simplified for initial pass
178180 }
179181 .try_into()?;
+25
src/http/handle_profile.rs
···2626 errors::StorageError,
2727 event::{activity_list_recent_for_did, event_list_did_by_start_time, model::EventWithRole},
2828 identity_profile::{handle_for_did, handle_for_handle, handles_by_did},
2929+ profile::profile_get_by_did,
2930 },
3031};
3132···113114 .clone()
114115 .is_some_and(|inner_current_entity| inner_current_entity.did == profile.did);
115116117117+ // Fetch the profile record from the profiles table
118118+ let profile_record_data = profile_get_by_did(&ctx.web_context.pool, &profile.did).await?;
119119+120120+ // Extract profile information and render description with facets
121121+ let (profile_record, profile_display_name, profile_description_html) = if let Some(prof_rec) = &profile_record_data {
122122+ let display_name = prof_rec.display_name.clone();
123123+124124+ // Parse the record JSON to get full profile data
125125+ let prof_data = if let Ok(prof_data) = serde_json::from_value::<crate::atproto::lexicon::profile::Profile>(prof_rec.record.0.clone()) {
126126+ Some(prof_data)
127127+ } else {
128128+ None
129129+ };
130130+131131+ let description_html = prof_data.as_ref().and_then(|pd| pd.render_description_html().filter(|s| !s.is_empty()));
132132+133133+ (prof_data, Some(display_name), description_html)
134134+ } else {
135135+ (None, None, None)
136136+ };
137137+116138 let default_context = template_context! {
117139 current_handle => ctx.current_handle,
118140 language => ctx.language.to_string(),
119141 canonical_url => format!("https://{}/{}", ctx.web_context.config.external_base, profile.did),
120142 profile,
143143+ profile_record,
144144+ profile_display_name,
145145+ profile_description_html,
121146 is_self,
122147 };
123148
+292
src/http/handle_settings.rs
···11use anyhow::Result;
22+use atproto_client::com::atproto::repo::{PutRecordRequest, PutRecordResponse, put_record};
23use atproto_identity::resolve::IdentityResolver;
34use axum::{extract::State, response::IntoResponse};
45use axum_extra::extract::{Cached, Form};
···910use serde::Deserialize;
1011use std::{borrow::Cow, sync::Arc};
1112use unic_langid::LanguageIdentifier;
1313+use crate::atproto::auth::{
1414+ create_dpop_auth_from_aip_session, create_dpop_auth_from_oauth_session,
1515+};
1616+use crate::atproto::lexicon::profile::{Profile as ProfileRecord, NSID as ProfileNSID};
1717+use crate::config::OAuthBackendConfig;
12181319use crate::{
1420 contextual_error,
···2127 identity_profile::{
2228 HandleField, handle_for_did, handle_update_field, identity_profile_set_email,
2329 },
3030+ profile::profile_get_by_did,
2431 webhook::{webhook_delete, webhook_list_by_did, webhook_toggle_enabled, webhook_upsert},
2532 },
2633 task_webhooks::TaskWork,
···5764 service: String,
5865}
59666767+#[derive(Deserialize, Clone, Debug)]
6868+pub(crate) struct ProfileForm {
6969+ display_name: Option<String>,
7070+ description: Option<String>,
7171+ profile_host: Option<String>,
7272+}
7373+6074pub(crate) async fn handle_settings(
6175 State(web_context): State<WebContext>,
6276 Language(language): Language,
···92106 vec![]
93107 };
94108109109+ // Get profile data if it exists
110110+ let profile_record = profile_get_by_did(&web_context.pool, ¤t_handle.did).await?;
111111+ let (profile, profile_display_name, profile_description, profile_host) = if let Some(prof_rec) = &profile_record {
112112+ // Parse the record JSON to get full profile data
113113+ if let Ok(prof_data) = serde_json::from_value::<crate::atproto::lexicon::profile::Profile>(prof_rec.record.0.clone()) {
114114+ let display_name = prof_data.display_name.clone();
115115+ let description = prof_data.description.clone();
116116+ let host = prof_data.profile_host.clone();
117117+ (Some(prof_data), display_name, description, host)
118118+ } else {
119119+ (None, None, None, None)
120120+ }
121121+ } else {
122122+ (None, None, None, None)
123123+ };
124124+95125 // Render the form
96126 Ok((
97127 StatusCode::OK,
···103133 languages => supported_languages,
104134 webhooks => webhooks,
105135 webhooks_enabled => web_context.config.enable_webhooks,
136136+ profile,
137137+ profile_display_name,
138138+ profile_description,
139139+ profile_host,
106140 ..default_context,
107141 },
108142 ),
···718752 )
719753 .into_response())
720754}
755755+756756+#[tracing::instrument(skip_all, err)]
757757+pub(crate) async fn handle_profile_update(
758758+ State(web_context): State<WebContext>,
759759+ Language(language): Language,
760760+ identity_resolver: State<Arc<dyn IdentityResolver>>,
761761+ Cached(auth): Cached<Auth>,
762762+ Form(profile_form): Form<ProfileForm>,
763763+) -> Result<impl IntoResponse, WebError> {
764764+ let current_handle = auth.require_flat()?;
765765+766766+ let default_context = template_context! {
767767+ current_handle => current_handle.clone(),
768768+ language => language.to_string(),
769769+ };
770770+771771+ let error_template = select_template!(false, true, language);
772772+ let render_template = format!("{}/settings.profile.html", language.to_string().to_lowercase());
773773+774774+ // Clean and validate the input
775775+ let display_name = profile_form
776776+ .display_name
777777+ .clone()
778778+ .filter(|s| !s.trim().is_empty())
779779+ .or_else(|| Some(current_handle.handle.clone()));
780780+781781+ let description = profile_form.description.clone().filter(|s| !s.trim().is_empty());
782782+783783+ let profile_host = profile_form.profile_host.clone().and_then(|host| {
784784+ let trimmed = host.trim();
785785+ if trimmed.is_empty() {
786786+ None
787787+ } else {
788788+ Some(trimmed.to_string())
789789+ }
790790+ });
791791+792792+ // Validate profile_host if provided
793793+ if let Some(ref host) = profile_host {
794794+ if host != "bsky.app" && host != "blacksky.community" && host != "smokesignal.events" {
795795+ return contextual_error!(
796796+ web_context,
797797+ language,
798798+ error_template,
799799+ default_context,
800800+ "Invalid profile host value"
801801+ );
802802+ }
803803+ }
804804+805805+ // Get existing profile from storage to get CID for swap record (CAS operation)
806806+ let profile_aturi = format!("at://{}/{}/self", current_handle.did, ProfileNSID);
807807+ let existing_profile = crate::storage::profile::profile_get_by_aturi(&web_context.pool, &profile_aturi).await.ok().flatten();
808808+809809+ // Start with existing profile or create new one
810810+ let (mut profile, swap_record_cid) = if let Some(ref existing) = existing_profile {
811811+ // Deserialize existing profile and keep all existing data
812812+ let mut existing_record: ProfileRecord = match serde_json::from_value(existing.record.0.clone()) {
813813+ Ok(record) => record,
814814+ Err(err) => {
815815+ return contextual_error!(
816816+ web_context,
817817+ language,
818818+ error_template,
819819+ default_context,
820820+ format!("Failed to parse existing profile: {}", err)
821821+ );
822822+ }
823823+ };
824824+825825+ // Update only the fields being modified
826826+ existing_record.display_name = display_name.clone();
827827+ existing_record.description = description.clone();
828828+ existing_record.profile_host = profile_host.clone();
829829+ existing_record.facets = None; // Will be re-parsed below if needed
830830+831831+ let existing_cid = Some(existing.cid.clone()).filter(|value| !value.is_empty());
832832+833833+ (existing_record, existing_cid)
834834+ } else {
835835+ // No existing profile, create new one
836836+ (
837837+ ProfileRecord {
838838+ display_name: display_name.clone(),
839839+ description: description.clone(),
840840+ profile_host: profile_host.clone(),
841841+ facets: None,
842842+ avatar: None,
843843+ banner: None,
844844+ extra: std::collections::HashMap::new(),
845845+ },
846846+ None,
847847+ )
848848+ };
849849+850850+ // Parse facets from description if present
851851+ if description.is_some() {
852852+ profile
853853+ .parse_facets(
854854+ identity_resolver.as_ref(),
855855+ web_context.config.facets_max,
856856+ )
857857+ .await;
858858+ }
859859+860860+ // Validate the profile
861861+ if let Err(e) = profile.validate() {
862862+ return contextual_error!(
863863+ web_context,
864864+ language,
865865+ error_template,
866866+ default_context,
867867+ format!("Invalid profile: {}", e)
868868+ );
869869+ }
870870+871871+ // Create DPoP authentication based on backend type
872872+ let dpop_auth = match (&auth, &web_context.config.oauth_backend) {
873873+ (Auth::Pds { session, .. }, OAuthBackendConfig::ATProtocol { .. }) => {
874874+ match create_dpop_auth_from_oauth_session(session) {
875875+ Ok(auth) => auth,
876876+ Err(err) => {
877877+ return contextual_error!(
878878+ web_context,
879879+ language,
880880+ error_template,
881881+ default_context,
882882+ format!("Failed to create authentication: {}", err)
883883+ );
884884+ }
885885+ }
886886+ }
887887+ (Auth::Aip { access_token, .. }, OAuthBackendConfig::AIP { hostname, .. }) => {
888888+ match create_dpop_auth_from_aip_session(&web_context.http_client, hostname, access_token).await {
889889+ Ok(auth) => auth,
890890+ Err(err) => {
891891+ return contextual_error!(
892892+ web_context,
893893+ language,
894894+ error_template,
895895+ default_context,
896896+ format!("Failed to create authentication: {}", err)
897897+ );
898898+ }
899899+ }
900900+ }
901901+ _ => {
902902+ return contextual_error!(
903903+ web_context,
904904+ language,
905905+ error_template,
906906+ default_context,
907907+ "Invalid authentication configuration"
908908+ );
909909+ }
910910+ };
911911+912912+ // Create PutRecord request with swap record for atomic update
913913+ let record_value = match serde_json::to_value(&profile) {
914914+ Ok(val) => val,
915915+ Err(err) => {
916916+ return contextual_error!(
917917+ web_context,
918918+ language,
919919+ error_template,
920920+ default_context,
921921+ format!("Failed to serialize profile: {}", err)
922922+ );
923923+ }
924924+ };
925925+926926+ let put_record_request = PutRecordRequest {
927927+ repo: current_handle.did.clone(),
928928+ collection: ProfileNSID.to_string(),
929929+ record_key: "self".to_string(),
930930+ record: record_value,
931931+ swap_record: swap_record_cid,
932932+ swap_commit: None,
933933+ validate: false,
934934+ };
935935+936936+ // Update the record in the user's PDS
937937+ let update_result = put_record(
938938+ &web_context.http_client,
939939+ &atproto_client::client::Auth::DPoP(dpop_auth),
940940+ ¤t_handle.pds,
941941+ put_record_request,
942942+ )
943943+ .await;
944944+945945+ match update_result {
946946+ Ok(PutRecordResponse::StrongRef { uri, cid, .. }) => {
947947+ tracing::info!("Profile updated successfully: {} {}", uri, cid);
948948+949949+ // The profile will be picked up by Jetstream and stored in our database
950950+951951+ // Return updated profile section
952952+ Ok((
953953+ StatusCode::OK,
954954+ RenderHtml(
955955+ &render_template,
956956+ web_context.engine.clone(),
957957+ template_context! {
958958+ current_handle => current_handle.clone(),
959959+ language => language.to_string(),
960960+ profile => profile,
961961+ profile_display_name => profile.display_name,
962962+ profile_description => profile.description,
963963+ profile_host => profile.profile_host,
964964+ },
965965+ ),
966966+ )
967967+ .into_response())
968968+ }
969969+ Ok(PutRecordResponse::Error(err)) => {
970970+ tracing::error!(error = err.error_message(), "Failed to update profile");
971971+ let error_msg = format!("{:?}", err.error_message());
972972+ if error_msg.contains("InvalidSwap") {
973973+ return contextual_error!(
974974+ web_context,
975975+ language,
976976+ error_template,
977977+ default_context,
978978+ "Your recent profile changes are still syncing. Please wait a moment and try again."
979979+ );
980980+ } else {
981981+ return contextual_error!(
982982+ web_context,
983983+ language,
984984+ error_template,
985985+ default_context,
986986+ format!("Failed to update profile: {:?}", err.error_message())
987987+ );
988988+ }
989989+ }
990990+ Err(err) => {
991991+ tracing::error!(?err, "Failed to update profile");
992992+ let error_msg = err.to_string();
993993+ if error_msg.contains("InvalidSwap") {
994994+ return contextual_error!(
995995+ web_context,
996996+ language,
997997+ error_template,
998998+ default_context,
999999+ "Your recent profile changes are still syncing. Please wait a moment and try again."
10001000+ );
10011001+ } else {
10021002+ return contextual_error!(
10031003+ web_context,
10041004+ language,
10051005+ error_template,
10061006+ default_context,
10071007+ format!("Failed to update profile: {}", err)
10081008+ );
10091009+ }
10101010+ }
10111011+ }
10121012+}
+1
src/http/mod.rs
···11pub mod auth_utils;
22pub mod cache_countries;
33pub mod context;
44+pub mod handle_blob;
45pub mod errors;
56pub mod event_form;
67pub mod event_view;