The smokesignal.events web application

feature: identity email address

Signed-off-by: Nick Gerakines <nick.gerakines@gmail.com>

+194 -7
+2
migrations/20250706120000_add_email_to_identity_profiles.sql
··· 1 + -- Add email column to identity_profiles table 2 + ALTER TABLE identity_profiles ADD COLUMN email TEXT DEFAULT NULL;
+62 -4
src/http/handle_oauth_aip_callback.rs
··· 1 - use crate::{config::OAuthBackendConfig, contextual_error, select_template}; 2 - use anyhow::Result; 1 + use std::collections::HashMap; 2 + 3 + use crate::{config::OAuthBackendConfig, contextual_error, select_template, storage::identity_profile::{handle_for_did, identity_profile_set_email}}; 4 + use anyhow::{Context, Result, anyhow}; 3 5 use axum::{ 4 6 extract::State, 5 7 response::{IntoResponse, Redirect}, 6 8 }; 7 9 use axum_extra::extract::{ 8 - cookie::{Cookie, SameSite}, 9 10 Form, PrivateCookieJar, 11 + cookie::{Cookie, SameSite}, 10 12 }; 11 13 use minijinja::context as template_context; 12 14 use serde::{Deserialize, Serialize}; ··· 14 16 use super::{ 15 17 context::WebContext, 16 18 errors::{LoginError, WebError}, 17 - middleware_auth::{WebSession, AUTH_COOKIE_NAME}, 19 + middleware_auth::{AUTH_COOKIE_NAME, WebSession}, 18 20 middleware_i18n::Language, 19 21 }; 20 22 ··· 122 124 123 125 let token_response = token_response.unwrap(); 124 126 127 + let identity_profile = handle_for_did(&web_context.pool, &oauth_request.did).await?; 128 + 129 + let maybe_email = get_email_from_userinfo(&web_context.http_client, hostname, &identity_profile.did, &token_response.access_token).await; 130 + let maybe_email = match maybe_email { 131 + Ok(value) => value, 132 + Err(err) => { 133 + tracing::error!(error = ?err, "error getting AIP userinfo"); 134 + None 135 + } 136 + }; 137 + if let Some(email) = maybe_email { 138 + // Write the email address to the database if it already isn't in the database. 139 + // Only set if the identity_profile's email field is None (not even an empty string) 140 + if identity_profile.email.is_none() { 141 + if let Err(err) = identity_profile_set_email(&web_context.pool, &oauth_request.did, Some(&email)).await { 142 + tracing::error!(error = ?err, "Failed to set email from OAuth userinfo"); 143 + } 144 + } 145 + } 146 + 125 147 let cookie_value: String = WebSession::Aip { 126 148 did: oauth_request.did.clone(), 127 149 access_token: token_response.access_token.clone(), ··· 147 169 148 170 Ok((updated_jar, Redirect::to("/")).into_response()) 149 171 } 172 + 173 + #[derive(Clone, Deserialize)] 174 + pub struct OpenIDClaims { 175 + pub sub: String, 176 + 177 + #[serde(skip_serializing_if = "Option::is_none")] 178 + pub email: Option<String>, 179 + 180 + #[serde(flatten)] 181 + pub additional_claims: HashMap<String, serde_json::Value>, 182 + } 183 + 184 + async fn get_email_from_userinfo( 185 + http_client: &reqwest::Client, 186 + aip_server: &str, 187 + did: &str, 188 + aip_access_token: &str, 189 + ) -> Result<Option<String>> { 190 + let userinfo_endpoint = format!("{}/oauth/userinfo", aip_server); 191 + 192 + let response: OpenIDClaims = http_client 193 + .get(userinfo_endpoint) 194 + .bearer_auth(aip_access_token) 195 + .send() 196 + .await 197 + .context(anyhow!("HTTP request for userinfo failed"))? 198 + .json() 199 + .await 200 + .context(anyhow!("Parsing HTTP response for userinfo failed"))?; 201 + 202 + if response.sub != did { 203 + return Err(anyhow!("DID does not match userinfo subject")); 204 + } 205 + 206 + Ok(response.email) 207 + }
+1 -1
src/http/handle_oauth_aip_login.rs
··· 158 158 state: state.clone(), 159 159 nonce: nonce.clone(), 160 160 code_challenge, 161 - scope: "atproto:atproto atproto:transition:generic".to_string(), 161 + scope: "openid profile email atproto:atproto atproto:transition:generic atproto:transition:email".to_string(), 162 162 }; 163 163 164 164 // Get AIP server configuration - config validation ensures these are set when oauth_backend is AIP
+63 -1
src/http/handle_settings.rs
··· 16 16 timezones::supported_timezones, 17 17 }, 18 18 select_template, 19 - storage::identity_profile::{handle_for_did, handle_update_field, HandleField}, 19 + storage::identity_profile::{handle_for_did, handle_update_field, identity_profile_set_email, HandleField}, 20 20 }; 21 21 22 22 #[derive(Deserialize, Clone, Debug)] ··· 27 27 #[derive(Deserialize, Clone, Debug)] 28 28 pub(crate) struct LanguageForm { 29 29 language: String, 30 + } 31 + 32 + #[derive(Deserialize, Clone, Debug)] 33 + pub(crate) struct EmailForm { 34 + email: Option<String>, 30 35 } 31 36 32 37 pub(crate) async fn handle_settings( ··· 230 235 ) 231 236 .into_response()) 232 237 } 238 + 239 + #[tracing::instrument(skip_all, err)] 240 + pub(crate) async fn handle_email_update( 241 + State(web_context): State<WebContext>, 242 + Language(language): Language, 243 + Cached(auth): Cached<Auth>, 244 + Form(email_form): Form<EmailForm>, 245 + ) -> Result<impl IntoResponse, WebError> { 246 + let current_handle = auth.require_flat()?; 247 + 248 + let default_context = template_context! { 249 + current_handle => current_handle.clone(), 250 + language => language.to_string(), 251 + }; 252 + 253 + let error_template = select_template!(false, true, language); 254 + let render_template = format!("settings.{}.email.html", language.to_string().to_lowercase()); 255 + 256 + // Update the email in the database 257 + let update_result = match email_form.email { 258 + Some(email) if email.is_empty() => { 259 + identity_profile_set_email(&web_context.pool, &current_handle.did, Some("")).await 260 + } 261 + Some(email) => { 262 + identity_profile_set_email(&web_context.pool, &current_handle.did, Some(&email)).await 263 + } 264 + None => { 265 + identity_profile_set_email(&web_context.pool, &current_handle.did, Some("")).await 266 + } 267 + }; 268 + 269 + if let Err(err) = update_result { 270 + return contextual_error!(web_context, language, error_template, default_context, err); 271 + } 272 + 273 + // Refresh the current handle to get the updated email 274 + let current_handle = match handle_for_did(&web_context.pool, &current_handle.did).await { 275 + Ok(value) => value, 276 + Err(err) => { 277 + return contextual_error!(web_context, language, error_template, default_context, err); 278 + } 279 + }; 280 + 281 + Ok(( 282 + StatusCode::OK, 283 + RenderHtml( 284 + &render_template, 285 + web_context.engine.clone(), 286 + template_context! { 287 + current_handle, 288 + email_updated => true, 289 + ..default_context 290 + }, 291 + ), 292 + ) 293 + .into_response()) 294 + }
+2 -1
src/http/server.rs
··· 47 47 }, 48 48 handle_profile::handle_profile_view, 49 49 handle_set_language::handle_set_language, 50 - handle_settings::{handle_language_update, handle_settings, handle_timezone_update}, 50 + handle_settings::{handle_email_update, handle_language_update, handle_settings, handle_timezone_update}, 51 51 handle_view_event::handle_view_event, 52 52 handle_view_feed::handle_view_feed, 53 53 handle_view_rsvp::handle_view_rsvp, ··· 110 110 .route("/settings", get(handle_settings)) 111 111 .route("/settings/timezone", post(handle_timezone_update)) 112 112 .route("/settings/language", post(handle_language_update)) 113 + .route("/settings/email", post(handle_email_update)) 113 114 .route("/import", get(handle_import)) 114 115 .route("/import", post(handle_import_submit)) 115 116 .route("/event", get(handle_create_event))
+33
src/storage/identity_profile.rs
··· 22 22 23 23 pub language: String, 24 24 pub tz: String, 25 + pub email: Option<String>, 25 26 26 27 pub created_at: DateTime<Utc>, 27 28 pub updated_at: DateTime<Utc>, ··· 133 134 } 134 135 135 136 query_builder 137 + .bind(now) 138 + .bind(did) 139 + .execute(tx.as_mut()) 140 + .await 141 + .map_err(StorageError::UnableToExecuteQuery)?; 142 + 143 + tx.commit() 144 + .await 145 + .map_err(StorageError::CannotCommitDatabaseTransaction) 146 + } 147 + 148 + pub async fn identity_profile_set_email( 149 + pool: &StoragePool, 150 + did: &str, 151 + email: Option<&str>, 152 + ) -> Result<(), StorageError> { 153 + // Validate DID is not empty 154 + if did.trim().is_empty() { 155 + return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 156 + "DID cannot be empty".into(), 157 + ))); 158 + } 159 + 160 + let mut tx = pool 161 + .begin() 162 + .await 163 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 164 + 165 + let now = Utc::now(); 166 + 167 + sqlx::query("UPDATE identity_profiles SET email = $1, updated_at = $2 WHERE did = $3") 168 + .bind(email) 136 169 .bind(now) 137 170 .bind(did) 138 171 .execute(tx.as_mut())
+4
templates/settings.en-us.common.html
··· 38 38 <div id="timezone-form"> 39 39 {% include "settings.en-us.tz.html" %} 40 40 </div> 41 + 42 + <div id="email-form" class="mt-4"> 43 + {% include "settings.en-us.email.html" %} 44 + </div> 41 45 </div> 42 46 </div> 43 47 </div>
+27
templates/settings.en-us.email.html
··· 1 + <div class="field"> 2 + <label class="label">Email Address</label> 3 + <form hx-post="/settings/email" hx-target="#email-form" hx-swap="innerHTML"> 4 + <div class="field has-addons"> 5 + <div class="control is-expanded"> 6 + <input class="input" type="email" name="email" placeholder="Enter your email address" 7 + value="{% if current_handle.email and current_handle.email != '' %}{{ current_handle.email }}{% endif %}" 8 + data-loading-disable data-loading-aria-busy> 9 + </div> 10 + <div class="control"> 11 + <button type="submit" class="button is-primary" data-loading-disable data-loading-aria-busy> 12 + Update Email 13 + </button> 14 + </div> 15 + </div> 16 + </form> 17 + <form hx-post="/settings/email" hx-target="#email-form" hx-swap="innerHTML" class="mt-2"> 18 + <div class="control"> 19 + <button type="submit" class="button is-danger is-light" data-loading-disable data-loading-aria-busy> 20 + Clear Email 21 + </button> 22 + </div> 23 + </form> 24 + {% if email_updated %} 25 + <p class="help is-success mt-2">Email address updated successfully.</p> 26 + {% endif %} 27 + </div>