The smokesignal.events web application

feature: rsvp acceptance

+2324 -794
+24
lexicon/events.smokesignal.calendar.acceptance.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "events.smokesignal.calendar.acceptance", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A cryptographic proof record that contains RSVP acceptance data.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "cid" 13 + ], 14 + "properties": { 15 + "cid": { 16 + "type": "string", 17 + "format": "cid", 18 + "description": "The CID (Content Identifier) of the rsvp that this proof validates." 19 + } 20 + } 21 + } 22 + } 23 + } 24 + }
+28
migrations/20251105000000_rsvp_acceptance.sql
··· 1 + -- Add validated_at column to RSVPs table 2 + ALTER TABLE rsvps ADD COLUMN validated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL; 3 + 4 + -- Create acceptance_tickets table for storing RSVP acceptance tickets 5 + CREATE TABLE acceptance_tickets ( 6 + aturi VARCHAR(1024) PRIMARY KEY, 7 + did VARCHAR(256) NOT NULL, 8 + rsvp_did VARCHAR(256) NOT NULL, 9 + event_aturi VARCHAR(1024) NOT NULL, 10 + record JSON NOT NULL, 11 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() 12 + ); 13 + 14 + CREATE INDEX idx_acceptance_tickets_did ON acceptance_tickets (did); 15 + CREATE INDEX idx_acceptance_tickets_rsvp_did ON acceptance_tickets (rsvp_did); 16 + CREATE INDEX idx_acceptance_tickets_event_aturi ON acceptance_tickets (event_aturi); 17 + 18 + -- Create acceptance_records table for storing RSVP acceptance records 19 + CREATE TABLE acceptance_records ( 20 + aturi VARCHAR(1024) PRIMARY KEY, 21 + cid VARCHAR(256) NOT NULL, 22 + did VARCHAR(256) NOT NULL, 23 + record JSON NOT NULL, 24 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), 25 + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() 26 + ); 27 + 28 + CREATE INDEX idx_acceptance_records_did ON acceptance_records (did);
+30
src/atproto/lexicon/acceptance.rs
··· 1 + use atproto_record::typed::{LexiconType, TypedLexicon}; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + pub const NSID: &str = "events.smokesignal.calendar.acceptance"; 5 + 6 + /// RSVP acceptance proof record 7 + #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] 8 + #[serde(rename_all = "camelCase")] 9 + pub struct Acceptance { 10 + /// The CID (Content Identifier) of the RSVP that this proof validates 11 + pub cid: String, 12 + } 13 + 14 + pub type TypedAcceptance = TypedLexicon<Acceptance>; 15 + 16 + impl LexiconType for Acceptance { 17 + fn lexicon_type() -> &'static str { 18 + NSID 19 + } 20 + } 21 + 22 + impl Acceptance { 23 + /// Validates the acceptance record 24 + pub fn validate(&self) -> Result<(), String> { 25 + if self.cid.trim().is_empty() { 26 + return Err("CID cannot be empty".to_string()); 27 + } 28 + Ok(()) 29 + } 30 + }
+1
src/atproto/lexicon/mod.rs
··· 1 + pub mod acceptance; 1 2 pub mod profile;
+6 -7
src/atproto/lexicon/profile.rs
··· 43 43 return Err("Display name must be 200 characters or less".to_string()); 44 44 } 45 45 46 - if let Some(description) = &self.description { 47 - if description.len() > 5000 { 48 - return Err("Description must be 5000 characters or less".to_string()); 49 - } 46 + if let Some(description) = &self.description 47 + && description.len() > 5000 48 + { 49 + return Err("Description must be 5000 characters or less".to_string()); 50 50 } 51 51 52 52 if let Some(profile_host) = &self.profile_host ··· 370 370 while let Some(ch) = chars.next() { 371 371 if ch == '<' 372 372 && let Some(&next_ch) = chars.peek() 373 + && (next_ch.is_ascii_alphabetic() || next_ch == '/' || next_ch == '!') 373 374 { 374 - if next_ch.is_ascii_alphabetic() || next_ch == '/' || next_ch == '!' { 375 - return true; 376 - } 375 + return true; 377 376 } 378 377 } 379 378 false
+30 -15
src/bin/smokesignal.rs
··· 1 1 use anyhow::Result; 2 - use atproto_identity::key::{identify_key, to_public}; 2 + use atproto_identity::key::{IdentityDocumentKeyResolver, identify_key, to_public}; 3 3 use atproto_identity::resolve::{ 4 4 HickoryDnsResolver, InnerIdentityResolver, SharedIdentityResolver, 5 5 }; ··· 199 199 }; 200 200 201 201 // Initialize email throttle (15 emails per 15 minutes using 5-minute windows) 202 - let email_throttle = Arc::new(smokesignal::throttle_redis::RedisRollingWindowThrottle::new( 203 - cache_pool.clone(), 204 - "throttle:email".to_string(), 205 - 300, // 5-minute windows 206 - 3, // Check 3 windows (15 minutes total) 207 - 15, // Max 15 emails 208 - )); 202 + let email_throttle = Arc::new( 203 + smokesignal::throttle_redis::RedisRollingWindowThrottle::new( 204 + cache_pool.clone(), 205 + "throttle:email".to_string(), 206 + 300, // 5-minute windows 207 + 3, // Check 3 windows (15 minutes total) 208 + 15, // Max 15 emails 209 + ), 210 + ); 209 211 210 212 // Initialize emailer if SMTP is configured 211 213 let emailer: Option<Arc<dyn smokesignal::emailer::Emailer>> = ··· 218 220 email_throttle, 219 221 ) { 220 222 Ok(lettre_emailer) => { 221 - tracing::info!("Email notifications enabled with throttling (max 15 emails per 15 minutes)"); 223 + tracing::info!( 224 + "Email notifications enabled with throttling (max 15 emails per 15 minutes)" 225 + ); 222 226 Some(Arc::new(lettre_emailer)) 223 227 } 224 228 Err(err) => { ··· 337 341 (None, None) 338 342 }; 339 343 344 + // Create resolvers for signature verification 345 + let record_resolver = Arc::new( 346 + smokesignal::record_resolver::StorageBackedRecordResolver::new( 347 + http_client.clone(), 348 + identity_resolver.clone(), 349 + pool.clone(), 350 + ), 351 + ); 352 + let key_resolver = IdentityDocumentKeyResolver::new(identity_resolver.clone()); 353 + 340 354 let content_fetcher = ContentFetcher::new( 341 355 pool.clone(), 342 356 content_storage.clone(), 343 357 identity_resolver.clone(), 344 358 document_storage.clone(), 345 359 http_client.clone(), 360 + record_resolver, 361 + key_resolver, 346 362 ); 347 363 348 364 let inner_token = token.clone(); ··· 390 406 } 391 407 392 408 // Register search indexer handler if enabled 393 - if let Some(search_handler) = &search_indexer_handler { 394 - if let Err(err) = jetstream_consumer.register_handler(search_handler.clone()).await { 395 - tracing::error!("Failed to register search indexer handler: {}", err); 396 - inner_token.cancel(); 397 - break; 398 - } 409 + if let Some(search_handler) = &search_indexer_handler 410 + && let Err(err) = jetstream_consumer.register_handler(search_handler.clone()).await { 411 + tracing::error!("Failed to register search indexer handler: {}", err); 412 + inner_token.cancel(); 413 + break; 399 414 } 400 415 401 416 tokio::select! {
+3 -4
src/config.rs
··· 174 174 }; 175 175 176 176 // Parse email secret key (required for email confirmation tokens) 177 - let email_secret_key: EmailSecretKey = require_env("EMAIL_SECRET_KEY") 178 - .and_then(|value| value.try_into())?; 177 + let email_secret_key: EmailSecretKey = 178 + require_env("EMAIL_SECRET_KEY").and_then(|value| value.try_into())?; 179 179 180 180 // Parse facet limit configuration 181 181 let facets_mentions_max = default_env("FACETS_MENTIONS_MAX", "5") ··· 313 313 type Error = anyhow::Error; 314 314 fn try_from(value: String) -> Result<Self, Self::Error> { 315 315 // Decode hex string to bytes 316 - let decoded = hex::decode(&value) 317 - .map_err(|err| ConfigError::EmailSecretKeyDecodeFailed(err))?; 316 + let decoded = hex::decode(&value).map_err(ConfigError::EmailSecretKeyDecodeFailed)?; 318 317 319 318 // Require at least 32 bytes (256 bits) for security 320 319 if decoded.len() < 32 {
+3 -1
src/config_errors.rs
··· 162 162 /// 163 163 /// This error occurs when the decoded EMAIL_SECRET_KEY is less than 164 164 /// 32 bytes (256 bits), which is the minimum required for security. 165 - #[error("error-smokesignal-config-22 EMAIL_SECRET_KEY must be at least 32 bytes, got {0} bytes")] 165 + #[error( 166 + "error-smokesignal-config-22 EMAIL_SECRET_KEY must be at least 32 bytes, got {0} bytes" 167 + )] 166 168 EmailSecretKeyTooShort(usize), 167 169 }
+3 -1
src/consumer.rs
··· 4 4 use std::sync::Arc; 5 5 use tokio::sync::mpsc; 6 6 7 - use crate::atproto::lexicon::profile::NSID as PROFILE_NSID; 7 + use crate::atproto::lexicon::{acceptance::NSID as ACCEPTANCE_NSID, profile::NSID as PROFILE_NSID}; 8 8 use atproto_record::lexicon::community::lexicon::{ 9 9 calendar::event::NSID as EVENT_NSID, calendar::rsvp::NSID as RSVP_NSID, 10 10 }; ··· 49 49 if commit.collection != RSVP_NSID 50 50 && commit.collection != EVENT_NSID 51 51 && commit.collection != PROFILE_NSID 52 + && commit.collection != ACCEPTANCE_NSID 52 53 { 53 54 return Ok(()); 54 55 } ··· 65 66 if commit.collection != RSVP_NSID 66 67 && commit.collection != EVENT_NSID 67 68 && commit.collection != PROFILE_NSID 69 + && commit.collection != ACCEPTANCE_NSID 68 70 { 69 71 return Ok(()); 70 72 }
+18 -6
src/email_templates.rs
··· 39 39 .map_err(|e| anyhow!("Invalid UTF-8 in template {}: {}", file_name, e))?; 40 40 41 41 // Convert to static strings by leaking (acceptable for embedded templates loaded once) 42 - let name_static: &'static str = Box::leak(file_name.to_string().into_boxed_str()); 43 - let content_static: &'static str = Box::leak(content.to_string().into_boxed_str()); 42 + let name_static: &'static str = 43 + Box::leak(file_name.to_string().into_boxed_str()); 44 + let content_static: &'static str = 45 + Box::leak(content.to_string().into_boxed_str()); 44 46 45 47 env.add_template(name_static, content_static) 46 48 .map_err(|e| anyhow!("Failed to add template {}: {}", file_name, e))?; ··· 184 186 } 185 187 186 188 /// Context for email confirmation 187 - pub fn confirmation( 188 - confirmation_url: &str, 189 - unsubscribe_url: Option<&str>, 190 - ) -> minijinja::Value { 189 + pub fn confirmation(confirmation_url: &str, unsubscribe_url: Option<&str>) -> minijinja::Value { 191 190 context! { 192 191 confirmation_url => confirmation_url, 193 192 unsubscribe_url => unsubscribe_url, ··· 225 224 event_location => event_location, 226 225 event_start_time => event_start_time, 227 226 event_end_time => event_end_time, 227 + event_url => event_url, 228 + unsubscribe_url => unsubscribe_url, 229 + } 230 + } 231 + 232 + /// Context for RSVP acceptance notification 233 + pub fn rsvp_accepted( 234 + event_name: &str, 235 + event_url: &str, 236 + unsubscribe_url: &str, 237 + ) -> minijinja::Value { 238 + context! { 239 + event_name => event_name, 228 240 event_url => event_url, 229 241 unsubscribe_url => unsubscribe_url, 230 242 }
+91 -9
src/emailer.rs
··· 33 33 const FROM_EMAIL_NAME: &str = "Smoke Signal Events"; 34 34 35 35 const EMAIL_RSVP_GOING_SUBJECT: &str = "Someone is going to your event!"; 36 + const EMAIL_RSVP_ACCEPTED_SUBJECT: &str = "Your RSVP has been accepted"; 36 37 const EMAIL_EVENT_CHANGED_SUBJECT: &str = "An event you're going to has changed"; 37 38 const EMAIL_CONFIRMATION_SUBJECT: &str = "Confirm your email address"; 38 39 const EMAIL_REMINDER_24H_SUBJECT: &str = "Reminder: Your event is starting soon"; ··· 66 67 event_url: &str, 67 68 ) -> Result<()>; 68 69 70 + /// Send notification that an RSVP has been accepted 71 + async fn notify_rsvp_accepted( 72 + &self, 73 + receiver_email: &str, 74 + receiver_did: &str, 75 + event_name: &str, 76 + event_url: &str, 77 + ) -> Result<()>; 78 + 69 79 /// Send notification that an event has been changed 70 80 async fn notify_event_changed( 71 81 &self, ··· 97 107 /// 98 108 /// The `ics_attachment` parameter should be generated using 99 109 /// `crate::ics_helpers::generate_event_ics()` and converted to bytes. 110 + #[allow(clippy::too_many_arguments)] 100 111 async fn send_event_summary( 101 112 &self, 102 113 receiver_email: &str, ··· 228 239 Ok(false) => { 229 240 warn!( 230 241 recipient_email, 231 - subject, "Email with attachments throttled - recipient has received too many emails recently" 242 + subject, 243 + "Email with attachments throttled - recipient has received too many emails recently" 232 244 ); 233 245 return Ok(()); 234 246 } 235 247 Err(err) => { 236 248 warn!( 237 249 ?err, 238 - recipient_email, subject, "Throttle check failed for email with attachments, allowing email to proceed" 250 + recipient_email, 251 + subject, 252 + "Throttle check failed for email with attachments, allowing email to proceed" 239 253 ); 240 254 // Continue anyway - don't let throttle errors block email delivery 241 255 } ··· 334 348 receiver: receiver_did.to_string(), 335 349 }; 336 350 let unsubscribe_token = generate_token(&unsubscribe_action, &self.email_secret_key)?; 337 - let unsubscribe_url = format!("https://{}/unsubscribe/{}", self.external_base, unsubscribe_token); 351 + let unsubscribe_url = format!( 352 + "https://{}/unsubscribe/{}", 353 + self.external_base, unsubscribe_token 354 + ); 338 355 339 356 // Render email templates 340 357 let ctx = contexts::rsvp_going(rsvp_identity, event_name, event_url, &unsubscribe_url); ··· 352 369 .await 353 370 } 354 371 372 + async fn notify_rsvp_accepted( 373 + &self, 374 + receiver_email: &str, 375 + receiver_did: &str, 376 + event_name: &str, 377 + event_url: &str, 378 + ) -> Result<()> { 379 + // Check if we should send the email (denylist checks) 380 + if !self 381 + .should_send_email(None, receiver_did, receiver_email) 382 + .await? 383 + { 384 + info!( 385 + receiver_did, 386 + "Skipping RSVP accepted notification due to denylist" 387 + ); 388 + return Ok(()); 389 + } 390 + 391 + // Generate unsubscribe token for all notifications 392 + let unsubscribe_action = UnsubscribeAction::DisableRsvpNotifications { 393 + receiver: receiver_did.to_string(), 394 + }; 395 + let unsubscribe_token = generate_token(&unsubscribe_action, &self.email_secret_key)?; 396 + let unsubscribe_url = format!( 397 + "https://{}/unsubscribe/{}", 398 + self.external_base, unsubscribe_token 399 + ); 400 + 401 + // Render email templates 402 + let ctx = contexts::rsvp_accepted(event_name, event_url, &unsubscribe_url); 403 + let plain_body = self 404 + .template_engine 405 + .render_text("rsvp_accepted", ctx.clone())?; 406 + let html_body = self.template_engine.render_html("rsvp_accepted", ctx)?; 407 + 408 + self.send_email( 409 + receiver_email, 410 + EMAIL_RSVP_ACCEPTED_SUBJECT, 411 + plain_body, 412 + html_body, 413 + ) 414 + .await 415 + } 416 + 355 417 async fn notify_event_changed( 356 418 &self, 357 419 receiver_email: &str, ··· 389 451 receiver: receiver_did.to_string(), 390 452 }; 391 453 let unsubscribe_token = generate_token(&unsubscribe_action, &self.email_secret_key)?; 392 - let unsubscribe_url = format!("https://{}/unsubscribe/{}", self.external_base, unsubscribe_token); 454 + let unsubscribe_url = format!( 455 + "https://{}/unsubscribe/{}", 456 + self.external_base, unsubscribe_token 457 + ); 393 458 394 459 // Render email templates 395 460 let ctx = contexts::event_changed(event_name, event_url, &unsubscribe_url); ··· 462 527 receiver: receiver_did.to_string(), 463 528 }; 464 529 let unsubscribe_token = generate_token(&unsubscribe_action, &self.email_secret_key)?; 465 - let unsubscribe_url = format!("https://{}/unsubscribe/{}", self.external_base, unsubscribe_token); 530 + let unsubscribe_url = format!( 531 + "https://{}/unsubscribe/{}", 532 + self.external_base, unsubscribe_token 533 + ); 466 534 467 535 // Render email templates 468 - let ctx = contexts::event_reminder_24h(event_name, event_start_time, event_url, &unsubscribe_url); 536 + let ctx = 537 + contexts::event_reminder_24h(event_name, event_start_time, event_url, &unsubscribe_url); 469 538 let plain_body = self 470 539 .template_engine 471 540 .render_text("event_reminder_24h", ctx.clone())?; ··· 522 591 receiver: receiver_did.to_string(), 523 592 }; 524 593 let unsubscribe_token = generate_token(&unsubscribe_action, &self.email_secret_key)?; 525 - let unsubscribe_url = format!("https://{}/unsubscribe/{}", self.external_base, unsubscribe_token); 594 + let unsubscribe_url = format!( 595 + "https://{}/unsubscribe/{}", 596 + self.external_base, unsubscribe_token 597 + ); 526 598 527 599 // Render email templates 528 600 let ctx = contexts::event_summary( ··· 588 660 if denylist_exists(&self.storage_pool, &[&unsubscribe_sender_pattern]).await? { 589 661 info!( 590 662 sender_did = sender, 591 - receiver_did, 592 - "Receiver has unsubscribed from notifications from this sender" 663 + receiver_did, "Receiver has unsubscribed from notifications from this sender" 593 664 ); 594 665 return Ok(false); 595 666 } ··· 664 735 _ics_attachment: Vec<u8>, 665 736 ) -> Result<()> { 666 737 info!("NoOpEmailer: Would send event summary with ICS attachment"); 738 + Ok(()) 739 + } 740 + 741 + async fn notify_rsvp_accepted( 742 + &self, 743 + _receiver_email: &str, 744 + _receiver_did: &str, 745 + _event_name: &str, 746 + _event_url: &str, 747 + ) -> Result<()> { 748 + info!("NoOpEmailer: Would send RSVP accepted notification"); 667 749 Ok(()) 668 750 } 669 751
+18 -25
src/facets.rs
··· 361 361 } 362 362 363 363 // Check facet type and apply per-type limits 364 - let should_include = facet 365 - .features 366 - .first() 367 - .map_or(false, |feature| match feature { 368 - FacetFeature::Mention(_) if mention_count < limits.mentions_max => { 369 - mention_count += 1; 370 - true 371 - } 372 - FacetFeature::Link(_) if link_count < limits.links_max => { 373 - link_count += 1; 374 - true 375 - } 376 - FacetFeature::Tag(_) if tag_count < limits.tags_max => { 377 - tag_count += 1; 378 - true 379 - } 380 - _ => false, 381 - }); 364 + let should_include = facet.features.first().is_some_and(|feature| match feature { 365 + FacetFeature::Mention(_) if mention_count < limits.mentions_max => { 366 + mention_count += 1; 367 + true 368 + } 369 + FacetFeature::Link(_) if link_count < limits.links_max => { 370 + link_count += 1; 371 + true 372 + } 373 + FacetFeature::Tag(_) if tag_count < limits.tags_max => { 374 + tag_count += 1; 375 + true 376 + } 377 + _ => false, 378 + }); 382 379 383 380 if should_include { 384 381 total_count += 1; ··· 547 544 #[async_trait] 548 545 impl IdentityResolver for MockIdentityResolver { 549 546 async fn resolve(&self, handle: &str) -> anyhow::Result<Document> { 550 - let handle_key = if handle.starts_with("at://") { 551 - handle.to_string() 552 - } else { 553 - handle.to_string() 554 - }; 547 + let handle_key = handle.to_string(); 555 548 556 549 if let Some(did) = self.handles_to_dids.get(&handle_key) { 557 550 Ok(Document { ··· 902 895 url_spans.len() 903 896 ); 904 897 905 - if url_spans.len() >= 1 { 898 + if !url_spans.is_empty() { 906 899 assert_eq!(url_spans[0].url, "https://www.ietf.org/meeting/124/"); 907 900 } 908 901 ··· 945 938 url_spans.len() 946 939 ); 947 940 948 - if url_spans.len() >= 1 { 941 + if !url_spans.is_empty() { 949 942 assert_eq!(url_spans[0].url, "https://www.ietf.org/meeting/124/"); 950 943 } 951 944
+131
src/http/acceptance_utils.rs
··· 1 + use anyhow::{Result, anyhow}; 2 + 3 + use crate::{ 4 + http::{ 5 + context::WebContext, 6 + errors::{CommonError, WebError}, 7 + utils::url_from_aturi, 8 + }, 9 + storage::{ 10 + event::{Event, event_get}, 11 + identity_profile::handle_for_did, 12 + }, 13 + }; 14 + 15 + /// Verify that the current user is the organizer of the specified event. 16 + /// Returns the Event if authorized, or an error if not found or not authorized. 17 + pub(crate) async fn verify_event_organizer_authorization( 18 + web_context: &WebContext, 19 + event_aturi: &str, 20 + organizer_did: &str, 21 + ) -> Result<Event, WebError> { 22 + // Get the event from storage 23 + let event = event_get(&web_context.pool, event_aturi) 24 + .await 25 + .map_err(|e| anyhow!("Failed to get event: {}", e))?; 26 + 27 + // Verify the current user is the event organizer 28 + if event.did != organizer_did { 29 + return Err(CommonError::NotAuthorized.into()); 30 + } 31 + 32 + Ok(event) 33 + } 34 + 35 + /// Send an email notification to the subject about their RSVP acceptance. 36 + /// This function never fails - errors are logged but the function always returns successfully. 37 + pub async fn send_acceptance_email_notification( 38 + web_context: &WebContext, 39 + subject_did: &str, 40 + event_name: &str, 41 + event_aturi: &str, 42 + ) { 43 + // Get the subject's profile for email notification 44 + let subject_profile = match handle_for_did(&web_context.pool, subject_did).await { 45 + Ok(profile) => profile, 46 + Err(e) => { 47 + tracing::warn!( 48 + "Failed to get profile for DID {} to send acceptance notification: {:?}", 49 + subject_did, 50 + e 51 + ); 52 + return; 53 + } 54 + }; 55 + 56 + // Generate event URL 57 + let event_url = match url_from_aturi(&web_context.config.external_base, event_aturi) { 58 + Ok(url) => url, 59 + Err(e) => { 60 + tracing::error!( 61 + "Failed to generate event URL from AT-URI {}: {:?}", 62 + event_aturi, 63 + e 64 + ); 65 + return; 66 + } 67 + }; 68 + 69 + // Send email notification if email is available 70 + if let Some(email) = &subject_profile.email 71 + && let Some(emailer) = &web_context.emailer 72 + { 73 + if let Err(e) = emailer 74 + .notify_rsvp_accepted(email, subject_did, event_name, &event_url) 75 + .await 76 + { 77 + tracing::error!("Failed to send RSVP accepted email to {}: {:?}", email, e); 78 + } else { 79 + tracing::info!( 80 + "Sent RSVP acceptance notification to {} for event {}", 81 + email, 82 + event_name 83 + ); 84 + } 85 + } 86 + } 87 + 88 + /// Format an error message as an HTML notification for HTMX responses. 89 + pub fn format_error_html(title: &str, message: &str, details: Option<&str>) -> String { 90 + if let Some(details) = details { 91 + format!( 92 + r#"<div class="notification is-danger"> 93 + <p><strong>Error!</strong> {}</p> 94 + <p>{}</p> 95 + <p class="is-size-7 mt-2">Details: {}</p> 96 + </div>"#, 97 + title, message, details 98 + ) 99 + } else { 100 + format!( 101 + r#"<div class="notification is-danger"> 102 + <p><strong>Error!</strong> {}</p> 103 + <p>{}</p> 104 + </div>"#, 105 + title, message 106 + ) 107 + } 108 + } 109 + 110 + /// Format a success message as an HTML notification for HTMX responses. 111 + pub fn format_success_html( 112 + title: &str, 113 + message: &str, 114 + additional_info: Option<Vec<String>>, 115 + ) -> String { 116 + let mut html = format!( 117 + r#"<div class="notification is-success"> 118 + <p><strong>Success!</strong> {}</p> 119 + <p>{}</p>"#, 120 + title, message 121 + ); 122 + 123 + if let Some(info) = additional_info { 124 + for line in info { 125 + html.push_str(&format!("\n <p>{}</p>", line)); 126 + } 127 + } 128 + 129 + html.push_str("\n </div>"); 130 + html 131 + }
+214
src/http/handle_accept_rsvp.rs
··· 1 + use anyhow::Result; 2 + use atproto_attestation::cid::create_attestation_cid; 3 + use atproto_client::com::atproto::repo::{PutRecordRequest, PutRecordResponse, put_record}; 4 + use atproto_record::{ 5 + lexicon::community::lexicon::calendar::rsvp::{Rsvp, TypedRsvp}, 6 + tid::Tid, 7 + typed::TypedLexicon, 8 + }; 9 + use axum::{extract::State, response::IntoResponse}; 10 + use axum_extra::extract::{Cached, Form}; 11 + use http::StatusCode; 12 + use serde::Deserialize; 13 + 14 + use crate::{ 15 + atproto::{ 16 + auth::{create_dpop_auth_from_aip_session, create_dpop_auth_from_oauth_session}, 17 + lexicon::acceptance::{Acceptance, NSID as ACCEPTANCE_NSID, TypedAcceptance}, 18 + }, 19 + config::OAuthBackendConfig, 20 + http::{ 21 + acceptance_utils::{ 22 + format_error_html, format_success_html, send_acceptance_email_notification, 23 + verify_event_organizer_authorization, 24 + }, 25 + context::WebContext, 26 + errors::{CommonError, WebError}, 27 + middleware_auth::Auth, 28 + middleware_i18n::Language, 29 + }, 30 + storage::{acceptance::acceptance_ticket_upsert, event::rsvp_get}, 31 + }; 32 + 33 + #[derive(Debug, Deserialize)] 34 + pub struct AcceptRsvpForm { 35 + /// The AT-URI of the RSVP to accept 36 + rsvp_aturi: String, 37 + } 38 + 39 + pub(crate) async fn handle_accept_rsvp( 40 + State(web_context): State<WebContext>, 41 + Language(_language): Language, 42 + Cached(auth): Cached<Auth>, 43 + Form(form): Form<AcceptRsvpForm>, 44 + ) -> Result<impl IntoResponse, WebError> { 45 + let current_handle = auth.require("/accept_rsvp")?; 46 + 47 + // Get the RSVP from storage 48 + let rsvp = match rsvp_get(&web_context.pool, &form.rsvp_aturi).await { 49 + Ok(Some(rsvp)) => rsvp, 50 + Ok(None) => { 51 + return Ok(( 52 + StatusCode::NOT_FOUND, 53 + format_error_html( 54 + "RSVP not found", 55 + &format!("No RSVP found with URI: {}", form.rsvp_aturi), 56 + None, 57 + ), 58 + ) 59 + .into_response()); 60 + } 61 + Err(e) => { 62 + return Ok(( 63 + StatusCode::INTERNAL_SERVER_ERROR, 64 + format_error_html( 65 + "Failed to retrieve RSVP", 66 + "Could not fetch the RSVP from the database.", 67 + Some(&e.to_string()), 68 + ), 69 + ) 70 + .into_response()); 71 + } 72 + }; 73 + 74 + let rsvp_record = serde_json::from_value::<Rsvp>(rsvp.record.0) 75 + .map_err(|_| anyhow::anyhow!("unable to deserialize rsvp record"))?; 76 + 77 + let typed_rsvp: TypedRsvp = TypedLexicon::new(rsvp_record); 78 + 79 + // Verify the current user is the event organizer 80 + let event = match verify_event_organizer_authorization( 81 + &web_context, 82 + &rsvp.event_aturi, 83 + &current_handle.did, 84 + ) 85 + .await 86 + { 87 + Ok(event) => event, 88 + Err(e) => { 89 + return Ok(( 90 + StatusCode::FORBIDDEN, 91 + format_error_html( 92 + "Not authorized", 93 + "You must be the event organizer to accept RSVPs.", 94 + Some(&e.to_string()), 95 + ), 96 + ) 97 + .into_response()); 98 + } 99 + }; 100 + 101 + let content_cid = create_attestation_cid( 102 + typed_rsvp.into(), 103 + serde_json::json!({ "$type": ACCEPTANCE_NSID }).into(), 104 + &current_handle.did, 105 + ) 106 + .map_err(|e| anyhow::anyhow!("Failed to create remote attestation proof: {}", e))?; 107 + 108 + // Create the acceptance record 109 + let acceptance = Acceptance { 110 + cid: content_cid.to_string(), 111 + }; 112 + 113 + let record_key = Tid::new().to_string(); 114 + 115 + // Create DPoP auth based on OAuth backend type 116 + let dpop_auth = match (&auth, &web_context.config.oauth_backend) { 117 + (Auth::Pds { session, .. }, OAuthBackendConfig::ATProtocol { .. }) => { 118 + create_dpop_auth_from_oauth_session(session)? 119 + } 120 + (Auth::Aip { access_token, .. }, OAuthBackendConfig::AIP { hostname, .. }) => { 121 + create_dpop_auth_from_aip_session(&web_context.http_client, hostname, access_token) 122 + .await? 123 + } 124 + _ => return Err(CommonError::NotAuthorized.into()), 125 + }; 126 + 127 + let typed_acceptance = TypedAcceptance::new(acceptance.clone()); 128 + 129 + // Write the acceptance record to the current identity's PDS 130 + let put_request = PutRecordRequest { 131 + repo: current_handle.did.clone(), 132 + collection: ACCEPTANCE_NSID.to_string(), 133 + validate: false, 134 + record_key: record_key.clone(), 135 + record: typed_acceptance.clone(), 136 + swap_commit: None, 137 + swap_record: None, 138 + }; 139 + 140 + let put_result = put_record( 141 + &web_context.http_client, 142 + &atproto_client::client::Auth::DPoP(dpop_auth), 143 + &current_handle.pds, 144 + put_request, 145 + ) 146 + .await; 147 + 148 + let published_acceptance = match put_result { 149 + Ok(PutRecordResponse::StrongRef { uri, cid, .. }) => { 150 + atproto_record::lexicon::com::atproto::repo::StrongRef { uri, cid } 151 + } 152 + Ok(PutRecordResponse::Error(err)) => { 153 + return Ok(( 154 + StatusCode::BAD_REQUEST, 155 + format_error_html( 156 + "Failed to create acceptance record", 157 + "The AT Protocol server rejected the acceptance record creation.", 158 + Some(&err.error_message()), 159 + ), 160 + ) 161 + .into_response()); 162 + } 163 + Err(err) => { 164 + return Ok(( 165 + StatusCode::INTERNAL_SERVER_ERROR, 166 + format_error_html( 167 + "Failed to publish acceptance", 168 + "Could not publish the acceptance record to the AT Protocol network.", 169 + Some(&err.to_string()), 170 + ), 171 + ) 172 + .into_response()); 173 + } 174 + }; 175 + 176 + // Store the acceptance ticket using individual fields 177 + if let Err(e) = acceptance_ticket_upsert( 178 + &web_context.pool, 179 + &published_acceptance.uri, 180 + &current_handle.did, 181 + &rsvp.did, 182 + &event.aturi, 183 + &acceptance, 184 + ) 185 + .await 186 + { 187 + return Ok(( 188 + StatusCode::INTERNAL_SERVER_ERROR, 189 + format_error_html( 190 + "Failed to store acceptance", 191 + "Could not save the acceptance ticket to the database.", 192 + Some(&e.to_string()), 193 + ), 194 + ) 195 + .into_response()); 196 + } 197 + 198 + // Send email notification to the RSVP creator (async, never fails) 199 + send_acceptance_email_notification(&web_context, &rsvp.did, &event.name, &event.aturi).await; 200 + 201 + // Return success with HTMX-compatible HTML 202 + Ok(( 203 + StatusCode::OK, 204 + format_success_html( 205 + "RSVP accepted successfully", 206 + &format!("The RSVP from {} has been accepted.", rsvp.did), 207 + Some(vec![ 208 + format!("Acceptance record published: {}", published_acceptance.uri), 209 + "The user has been notified via email.".to_string(), 210 + ]), 211 + ), 212 + ) 213 + .into_response()) 214 + }
+23 -24
src/http/handle_create_event.rs
··· 343 343 } 344 344 345 345 // Send webhooks if enabled 346 - if web_context.config.enable_webhooks { 347 - if let Some(webhook_sender) = &web_context.webhook_sender { 348 - // Get all enabled webhooks for the user 349 - if let Ok(webhooks) = 350 - webhook_list_enabled_by_did(&web_context.pool, &current_handle.did) 351 - .await 352 - { 353 - // Prepare context with email if shared 354 - let context = json!({}); 346 + if web_context.config.enable_webhooks 347 + && let Some(webhook_sender) = &web_context.webhook_sender 348 + { 349 + // Get all enabled webhooks for the user 350 + if let Ok(webhooks) = 351 + webhook_list_enabled_by_did(&web_context.pool, &current_handle.did).await 352 + { 353 + // Prepare context with email if shared 354 + let context = json!({}); 355 355 356 - let record_json = json!({ 357 - "uri": &create_record_response.uri, 358 - "cit": &create_record_response.cid, 359 - }); 356 + let record_json = json!({ 357 + "uri": &create_record_response.uri, 358 + "cit": &create_record_response.cid, 359 + }); 360 360 361 - // Send webhook for each enabled webhook 362 - for webhook in webhooks { 363 - let _ = webhook_sender 364 - .send(TaskWork::EventCreated { 365 - identity: current_handle.did.clone(), 366 - service: webhook.service, 367 - record: record_json.clone(), 368 - context: context.clone(), 369 - }) 370 - .await; 371 - } 361 + // Send webhook for each enabled webhook 362 + for webhook in webhooks { 363 + let _ = webhook_sender 364 + .send(TaskWork::EventCreated { 365 + identity: current_handle.did.clone(), 366 + service: webhook.service, 367 + record: record_json.clone(), 368 + context: context.clone(), 369 + }) 370 + .await; 372 371 } 373 372 } 374 373 }
+143 -146
src/http/handle_create_rsvp.rs
··· 232 232 } 233 233 234 234 // Send email notification if RSVP is "going" and emailer is enabled 235 - if is_going_rsvp { 236 - if let Some(ref emailer) = web_context.emailer { 237 - // Extract event creator DID from event aturi 238 - if let Ok(event_aturi_parsed) = 239 - ATURI::from_str(build_rsvp_form.subject_aturi.as_ref().unwrap()) 240 - { 241 - let event_creator_did = event_aturi_parsed.authority; 235 + if is_going_rsvp && let Some(ref emailer) = web_context.emailer { 236 + // Extract event creator DID from event aturi 237 + if let Ok(event_aturi_parsed) = 238 + ATURI::from_str(build_rsvp_form.subject_aturi.as_ref().unwrap()) 239 + { 240 + let event_creator_did = event_aturi_parsed.authority; 241 + 242 + // Get event URL (used by both notifications) 243 + let event_url = url_from_aturi( 244 + &web_context.config.external_base, 245 + build_rsvp_form.subject_aturi.as_ref().unwrap(), 246 + ) 247 + .unwrap_or_else(|_| { 248 + format!("https://{}/event", web_context.config.external_base) 249 + }); 242 250 243 - // Get event URL (used by both notifications) 244 - let event_url = url_from_aturi( 245 - &web_context.config.external_base, 246 - build_rsvp_form.subject_aturi.as_ref().unwrap(), 247 - ) 248 - .unwrap_or_else(|_| { 249 - format!("https://{}/event", web_context.config.external_base) 250 - }); 251 + // Get full event details from database (used by both notifications) 252 + let event_result = crate::storage::event::event_get( 253 + &web_context.pool, 254 + build_rsvp_form.subject_aturi.as_ref().unwrap(), 255 + ) 256 + .await; 251 257 252 - // Get full event details from database (used by both notifications) 253 - let event_result = crate::storage::event::event_get( 258 + // Send notification to event creator 259 + if let Ok(event_creator_profile) = 260 + crate::storage::identity_profile::handle_for_did( 254 261 &web_context.pool, 255 - build_rsvp_form.subject_aturi.as_ref().unwrap(), 262 + &event_creator_did, 256 263 ) 257 - .await; 264 + .await 265 + && let Some(creator_email) = &event_creator_profile.email 266 + && !creator_email.is_empty() 267 + { 268 + let event_name = if let Ok(ref event) = event_result { 269 + if event.name.is_empty() { 270 + "Untitled Event" 271 + } else { 272 + &event.name 273 + } 274 + } else { 275 + "Untitled Event" 276 + }; 258 277 259 - // Send notification to event creator 260 - if let Ok(event_creator_profile) = 261 - crate::storage::identity_profile::handle_for_did( 262 - &web_context.pool, 278 + if let Err(err) = emailer 279 + .notify_rsvp_going( 280 + creator_email, 263 281 &event_creator_did, 282 + &current_handle.did, 283 + &current_handle.handle, 284 + event_name, 285 + &event_url, 264 286 ) 265 287 .await 266 288 { 267 - if let Some(creator_email) = &event_creator_profile.email { 268 - if !creator_email.is_empty() { 269 - let event_name = if let Ok(ref event) = event_result { 270 - if event.name.is_empty() { 271 - "Untitled Event" 272 - } else { 273 - &event.name 274 - } 275 - } else { 276 - "Untitled Event" 277 - }; 278 - 279 - if let Err(err) = emailer 280 - .notify_rsvp_going( 281 - creator_email, 282 - &event_creator_did, 283 - &current_handle.did, 284 - &current_handle.handle, 285 - event_name, 286 - &event_url, 287 - ) 288 - .await 289 - { 290 - tracing::warn!( 291 - ?err, 292 - "Failed to send RSVP notification email to event creator" 293 - ); 294 - } 295 - } 296 - } 289 + tracing::warn!( 290 + ?err, 291 + "Failed to send RSVP notification email to event creator" 292 + ); 297 293 } 294 + } 298 295 299 - // Send event summary to RSVPer (current user) 300 - if let Ok(event) = event_result { 301 - // Check if user has a confirmed email 302 - if let Ok(Some(rsvper_email)) = 303 - crate::storage::notification::notification_get_confirmed_email( 304 - &web_context.pool, 305 - &current_handle.did, 306 - ) 307 - .await 308 - { 309 - // Extract event details from the record 310 - let event_details = 311 - crate::storage::event::extract_event_details(&event); 296 + // Send event summary to RSVPer (current user) 297 + if let Ok(event) = event_result { 298 + // Check if user has a confirmed email 299 + if let Ok(Some(rsvper_email)) = 300 + crate::storage::notification::notification_get_confirmed_email( 301 + &web_context.pool, 302 + &current_handle.did, 303 + ) 304 + .await 305 + { 306 + // Extract event details from the record 307 + let event_details = 308 + crate::storage::event::extract_event_details(&event); 312 309 313 - // Only send if event has start time 314 - if let Some(starts_at) = event_details.starts_at { 315 - // Extract location string from locations array 316 - let location_opt = event_details 310 + // Only send if event has start time 311 + if let Some(starts_at) = event_details.starts_at { 312 + // Extract location string from locations array 313 + let location_opt = event_details 317 314 .locations 318 315 .iter() 319 316 .filter_map(|loc| { ··· 325 322 }) 326 323 .next(); // Take the first address found 327 324 328 - // Generate ICS file 329 - let ics_content = crate::ics_helpers::generate_event_ics( 330 - &event.aturi, 331 - &event_details.name, 332 - if event_details.description.is_empty() { 333 - None 334 - } else { 335 - Some(event_details.description.as_ref()) 336 - }, 337 - location_opt.as_deref(), 338 - starts_at, 339 - event_details.ends_at, 340 - Some(&event_url), 341 - ); 325 + // Generate ICS file 326 + let ics_content = crate::ics_helpers::generate_event_ics( 327 + &event.aturi, 328 + &event_details.name, 329 + if event_details.description.is_empty() { 330 + None 331 + } else { 332 + Some(event_details.description.as_ref()) 333 + }, 334 + location_opt.as_deref(), 335 + starts_at, 336 + event_details.ends_at, 337 + Some(&event_url), 338 + ); 342 339 343 - if let Ok(ics_string) = ics_content { 344 - let ics_bytes = ics_string.into_bytes(); 340 + if let Ok(ics_string) = ics_content { 341 + let ics_bytes = ics_string.into_bytes(); 345 342 346 - // Format times for email display 347 - let event_start_time = 348 - starts_at.format("%B %e, %Y at %l:%M %p %Z").to_string(); 349 - let event_end_time = event_details 350 - .ends_at 351 - .map(|t| t.format("%B %e, %Y at %l:%M %p %Z").to_string()); 343 + // Format times for email display 344 + let event_start_time = starts_at 345 + .format("%B %e, %Y at %l:%M %p %Z") 346 + .to_string(); 347 + let event_end_time = event_details.ends_at.map(|t| { 348 + t.format("%B %e, %Y at %l:%M %p %Z").to_string() 349 + }); 352 350 353 - // Send event summary email with ICS attachment 354 - if let Err(err) = emailer 355 - .send_event_summary( 356 - &rsvper_email, 357 - &current_handle.did, 358 - &event_details.name, 359 - if event_details.description.is_empty() { 360 - None 361 - } else { 362 - Some(event_details.description.as_ref()) 363 - }, 364 - location_opt.as_deref(), 365 - &event_start_time, 366 - event_end_time.as_deref(), 367 - &event_url, 368 - ics_bytes, 369 - ) 370 - .await 371 - { 372 - tracing::warn!( 373 - ?err, 374 - "Failed to send event summary email to RSVPer" 375 - ); 376 - } 377 - } else { 351 + // Send event summary email with ICS attachment 352 + if let Err(err) = emailer 353 + .send_event_summary( 354 + &rsvper_email, 355 + &current_handle.did, 356 + &event_details.name, 357 + if event_details.description.is_empty() { 358 + None 359 + } else { 360 + Some(event_details.description.as_ref()) 361 + }, 362 + location_opt.as_deref(), 363 + &event_start_time, 364 + event_end_time.as_deref(), 365 + &event_url, 366 + ics_bytes, 367 + ) 368 + .await 369 + { 378 370 tracing::warn!( 379 - "Failed to generate ICS file for event summary" 371 + ?err, 372 + "Failed to send event summary email to RSVPer" 380 373 ); 381 374 } 375 + } else { 376 + tracing::warn!( 377 + "Failed to generate ICS file for event summary" 378 + ); 382 379 } 383 380 } 384 381 } ··· 387 384 } 388 385 389 386 // Send webhooks if enabled 390 - if web_context.config.enable_webhooks { 391 - if let Some(webhook_sender) = &web_context.webhook_sender { 392 - let webhook_identity = 393 - ATURI::from_str(build_rsvp_form.subject_aturi.as_ref().unwrap()) 394 - .map(|value| value.authority) 395 - .unwrap_or_default(); 387 + if web_context.config.enable_webhooks 388 + && let Some(webhook_sender) = &web_context.webhook_sender 389 + { 390 + let webhook_identity = 391 + ATURI::from_str(build_rsvp_form.subject_aturi.as_ref().unwrap()) 392 + .map(|value| value.authority) 393 + .unwrap_or_default(); 396 394 397 - // Get all enabled webhooks for the user 398 - if let Ok(webhooks) = 399 - webhook_list_enabled_by_did(&web_context.pool, &webhook_identity).await 400 - { 401 - // Prepare context (empty - email sharing removed) 402 - let context = serde_json::json!({}); 395 + // Get all enabled webhooks for the user 396 + if let Ok(webhooks) = 397 + webhook_list_enabled_by_did(&web_context.pool, &webhook_identity).await 398 + { 399 + // Prepare context (empty - email sharing removed) 400 + let context = serde_json::json!({}); 403 401 404 - // Convert the RSVP record to JSON 405 - let record_json = serde_json::json!({ 406 - "uri": &create_record_result.uri, 407 - "cit": &create_record_result.cid, 408 - }); 402 + // Convert the RSVP record to JSON 403 + let record_json = serde_json::json!({ 404 + "uri": &create_record_result.uri, 405 + "cit": &create_record_result.cid, 406 + }); 409 407 410 - // Send webhook for each enabled webhook 411 - for webhook in webhooks { 412 - let _ = webhook_sender 413 - .send(TaskWork::RSVPCreated { 414 - identity: current_handle.did.clone(), 415 - service: webhook.service, 416 - record: record_json.clone(), 417 - context: context.clone(), 418 - }) 419 - .await; 420 - } 408 + // Send webhook for each enabled webhook 409 + for webhook in webhooks { 410 + let _ = webhook_sender 411 + .send(TaskWork::RSVPCreated { 412 + identity: current_handle.did.clone(), 413 + service: webhook.service, 414 + record: record_json.clone(), 415 + context: context.clone(), 416 + }) 417 + .await; 421 418 } 422 419 } 423 420 }
+68 -48
src/http/handle_edit_event.rs
··· 25 25 http::utils::url_from_aturi, 26 26 select_template, 27 27 storage::{ 28 - event::{event_get, event_update_with_metadata}, 28 + event::{event_get, event_update_with_metadata, get_event_rsvps_for_export}, 29 29 identity_profile::{handle_for_did, handle_for_handle}, 30 30 }, 31 31 }; ··· 105 105 106 106 let event = event.unwrap(); 107 107 108 + // Fetch RSVPs for the event 109 + let mut rsvps = get_event_rsvps_for_export(&ctx.web_context.pool, &lookup_aturi) 110 + .await 111 + .unwrap_or_else(|err| { 112 + tracing::warn!(?err, "Failed to fetch RSVPs for event"); 113 + Vec::new() 114 + }); 115 + 116 + // Sort RSVPs alphabetically by handle (None values go at the end) 117 + rsvps.sort_by(|a, b| match (&a.handle, &b.handle) { 118 + (Some(h_a), Some(h_b)) => h_a.to_lowercase().cmp(&h_b.to_lowercase()), 119 + (Some(_), None) => std::cmp::Ordering::Less, 120 + (None, Some(_)) => std::cmp::Ordering::Greater, 121 + (None, None) => a.did.cmp(&b.did), 122 + }); 123 + 108 124 // Check if this is a community calendar event (we only support editing those) 109 125 if event.lexicon != LexiconCommunityEventNSID { 110 126 return contextual_error!( ··· 353 369 location_form, 354 370 event_rkey, 355 371 handle_slug, 372 + event, 373 + rsvps, 356 374 timezones, 357 375 is_development, 358 376 locations_editable, ··· 635 653 } 636 654 637 655 // Send email notifications to RSVP holders if checkbox is checked and emailer is enabled 638 - if build_event_form.send_notifications.unwrap_or(false) { 639 - if let Some(ref emailer) = ctx.web_context.emailer { 640 - // Get all "going" RSVPs for this event 641 - if let Ok(rsvps) = crate::storage::event::get_event_rsvps( 642 - &ctx.web_context.pool, 643 - &lookup_aturi, 644 - Some("going"), 645 - ) 646 - .await 647 - { 648 - let event_url = 649 - url_from_aturi(&ctx.web_context.config.external_base, &lookup_aturi) 650 - .unwrap_or_else(|_| { 651 - format!( 652 - "https://{}/event", 653 - ctx.web_context.config.external_base 654 - ) 655 - }); 656 + if build_event_form.send_notifications.unwrap_or(false) 657 + && let Some(ref emailer) = ctx.web_context.emailer 658 + { 659 + // Get all "going" RSVPs for this event 660 + if let Ok(rsvps) = crate::storage::event::get_event_rsvps( 661 + &ctx.web_context.pool, 662 + &lookup_aturi, 663 + Some("going"), 664 + ) 665 + .await 666 + { 667 + let event_url = 668 + url_from_aturi(&ctx.web_context.config.external_base, &lookup_aturi) 669 + .unwrap_or_else(|_| { 670 + format!( 671 + "https://{}/event", 672 + ctx.web_context.config.external_base 673 + ) 674 + }); 656 675 657 - // Get the event updater's DID from current_handle 658 - let updater_did = ctx 659 - .current_handle 660 - .as_ref() 661 - .map(|h| h.did.as_str()) 662 - .unwrap_or(""); 676 + // Get the event updater's DID from current_handle 677 + let updater_did = ctx 678 + .current_handle 679 + .as_ref() 680 + .map(|h| h.did.as_str()) 681 + .unwrap_or(""); 663 682 664 - // Send notification to each RSVP holder with "going" status 665 - for (rsvp_did, _status) in rsvps { 666 - // Get the RSVP holder's profile (for email) 667 - if let Ok(rsvp_holder_profile) = 668 - crate::storage::identity_profile::handle_for_did( 669 - &ctx.web_context.pool, 683 + // Send notification to each RSVP holder with "going" status 684 + for (rsvp_did, _status) in rsvps { 685 + // Get the RSVP holder's profile (for email) 686 + if let Ok(rsvp_holder_profile) = 687 + crate::storage::identity_profile::handle_for_did( 688 + &ctx.web_context.pool, 689 + &rsvp_did, 690 + ) 691 + .await 692 + && let Some(rsvp_holder_email) = &rsvp_holder_profile.email 693 + && !rsvp_holder_email.is_empty() 694 + { 695 + // Send notification 696 + if let Err(err) = emailer 697 + .notify_event_changed( 698 + rsvp_holder_email, 670 699 &rsvp_did, 700 + updater_did, 701 + name, 702 + &event_url, 671 703 ) 672 704 .await 673 705 { 674 - if let Some(rsvp_holder_email) = &rsvp_holder_profile.email { 675 - if !rsvp_holder_email.is_empty() { 676 - // Send notification 677 - if let Err(err) = emailer 678 - .notify_event_changed( 679 - rsvp_holder_email, 680 - &rsvp_did, 681 - updater_did, 682 - &name, 683 - &event_url, 684 - ) 685 - .await 686 - { 687 - tracing::warn!(?err, rsvp_holder_did = ?rsvp_did, "Failed to send event change notification email"); 688 - } 689 - } 690 - } 706 + tracing::warn!(?err, rsvp_holder_did = ?rsvp_did, "Failed to send event change notification email"); 691 707 } 692 708 } 693 709 } ··· 710 726 event_url, 711 727 event_rkey, 712 728 handle_slug, 729 + event, 730 + rsvps, 713 731 timezones, 714 732 is_development, 715 733 locations_editable, ··· 734 752 location_form, 735 753 event_rkey, 736 754 handle_slug, 755 + event, 756 + rsvps, 737 757 timezones, 738 758 is_development, 739 759 locations_editable,
+193
src/http/handle_finalize_acceptance.rs
··· 1 + use anyhow::{Result, anyhow}; 2 + use atproto_attestation::append_remote_attestation; 3 + use atproto_record::aturi::ATURI; 4 + use atproto_record::lexicon::community::lexicon::calendar::rsvp::{NSID as RSVP_NSID, Rsvp}; 5 + use atproto_record::typed::TypedLexicon; 6 + use axum::{extract::State, response::IntoResponse}; 7 + use axum_extra::extract::{Cached, Form}; 8 + use http::StatusCode; 9 + use serde::Deserialize; 10 + use std::str::FromStr; 11 + 12 + use crate::{ 13 + atproto::auth::{create_dpop_auth_from_aip_session, create_dpop_auth_from_oauth_session}, 14 + config::OAuthBackendConfig, 15 + http::{ 16 + acceptance_utils::format_success_html, 17 + context::WebContext, 18 + errors::{CommonError, WebError}, 19 + middleware_auth::Auth, 20 + middleware_i18n::Language, 21 + }, 22 + storage::{ 23 + acceptance::{acceptance_ticket_delete, acceptance_ticket_get}, 24 + event::rsvp_get_by_event_and_did, 25 + }, 26 + }; 27 + use atproto_client::com::atproto::repo::{PutRecordRequest, PutRecordResponse, put_record}; 28 + 29 + #[derive(Debug, Deserialize)] 30 + pub struct FinalizeAcceptanceForm { 31 + /// The AT-URI of the acceptance ticket to finalize 32 + acceptance_aturi: String, 33 + } 34 + 35 + pub(crate) async fn handle_finalize_acceptance( 36 + State(web_context): State<WebContext>, 37 + Language(_language): Language, 38 + Cached(auth): Cached<Auth>, 39 + Form(form): Form<FinalizeAcceptanceForm>, 40 + ) -> Result<impl IntoResponse, WebError> { 41 + let current_handle = auth.require("/finalize_acceptance")?; 42 + 43 + // Get the acceptance ticket from storage 44 + let ticket = acceptance_ticket_get(&web_context.pool, &form.acceptance_aturi) 45 + .await 46 + .map_err(|e| anyhow!("Failed to get acceptance ticket: {}", e))? 47 + .ok_or_else(|| anyhow!("Acceptance ticket not found"))?; 48 + 49 + // Verify the current user is the RSVP creator (recipient of the acceptance) 50 + if ticket.rsvp_did != current_handle.did { 51 + return Err(CommonError::NotAuthorized.into()); 52 + } 53 + 54 + // Get the RSVP to verify it exists and get its aturi 55 + let rsvp = rsvp_get_by_event_and_did(&web_context.pool, &ticket.event_aturi, &ticket.rsvp_did) 56 + .await 57 + .map_err(|e| anyhow!("Failed to get RSVP for validation: {}", e))? 58 + .ok_or_else(|| anyhow!("RSVP not found"))?; 59 + 60 + // Parse the acceptance AT-URI to fetch it from the organizer's PDS 61 + let parsed_acceptance_aturi = 62 + ATURI::from_str(&ticket.aturi).map_err(|e| anyhow!("Invalid acceptance AT-URI: {}", e))?; 63 + 64 + // Resolve the organizer's DID to get their PDS endpoint 65 + let organizer_document = web_context 66 + .identity_resolver 67 + .resolve(&ticket.did) 68 + .await 69 + .map_err(|e| anyhow!("Failed to resolve organizer DID: {}", e))?; 70 + 71 + let organizer_pds = organizer_document 72 + .service 73 + .iter() 74 + .find(|s| s.r#type == "AtprotoPersonalDataServer") 75 + .map(|s| s.service_endpoint.as_str()) 76 + .ok_or_else(|| anyhow!("Organizer has no PDS endpoint"))?; 77 + 78 + // Fetch the acceptance record from the organizer's PDS 79 + let acceptance_record_resp = atproto_client::com::atproto::repo::get_record( 80 + &web_context.http_client, 81 + &atproto_client::client::Auth::None, 82 + organizer_pds, 83 + &parsed_acceptance_aturi.authority, 84 + &parsed_acceptance_aturi.collection, 85 + &parsed_acceptance_aturi.record_key, 86 + None, 87 + ) 88 + .await 89 + .map_err(|e| { 90 + anyhow!( 91 + "Failed to fetch acceptance record from organizer's PDS: {}", 92 + e 93 + ) 94 + })?; 95 + 96 + let acceptance_record = match acceptance_record_resp { 97 + atproto_client::com::atproto::repo::GetRecordResponse::Record { value, .. } => value, 98 + atproto_client::com::atproto::repo::GetRecordResponse::Error(error) => { 99 + return Err( 100 + anyhow!("Failed to get acceptance record: {}", error.error_message()).into(), 101 + ); 102 + } 103 + }; 104 + 105 + // Deserialize the RSVP record from storage 106 + let rsvp_record: Rsvp = serde_json::from_value(rsvp.record.0.clone()) 107 + .map_err(|e| anyhow!("Failed to deserialize RSVP record: {}", e))?; 108 + 109 + // Create a base RSVP without signatures for attestation 110 + let base_rsvp = Rsvp { 111 + subject: rsvp_record.subject.clone(), 112 + status: rsvp_record.status.clone(), 113 + created_at: rsvp_record.created_at, 114 + extra: rsvp_record.extra.clone(), 115 + signatures: vec![], // Clear signatures before appending 116 + }; 117 + let typed_base_rsvp = TypedLexicon::new(base_rsvp); 118 + 119 + // Append the remote attestation (acceptance) to the RSVP 120 + let updated_rsvp_record = append_remote_attestation( 121 + typed_base_rsvp.into(), 122 + acceptance_record.into(), // Convert to AnyInput 123 + &ticket.did, // organizer's DID (receiver of the RSVP, giver of acceptance) 124 + &ticket.aturi, // acceptance AT-URI 125 + ) 126 + .map_err(|e| anyhow!("Failed to append remote attestation: {}", e))?; 127 + 128 + // Create DPoP auth based on OAuth backend type 129 + let dpop_auth = match (&auth, &web_context.config.oauth_backend) { 130 + (Auth::Pds { session, .. }, OAuthBackendConfig::ATProtocol { .. }) => { 131 + create_dpop_auth_from_oauth_session(session)? 132 + } 133 + (Auth::Aip { access_token, .. }, OAuthBackendConfig::AIP { hostname, .. }) => { 134 + create_dpop_auth_from_aip_session(&web_context.http_client, hostname, access_token) 135 + .await? 136 + } 137 + _ => return Err(CommonError::NotAuthorized.into()), 138 + }; 139 + 140 + // Parse the RSVP AT-URI to extract the record key 141 + let parsed_rsvp_aturi = 142 + ATURI::from_str(&rsvp.aturi).map_err(|e| anyhow!("Invalid RSVP AT-URI: {}", e))?; 143 + 144 + // Update the RSVP in the user's PDS with the new signatures 145 + let put_record_request = PutRecordRequest { 146 + repo: current_handle.did.clone(), 147 + collection: RSVP_NSID.to_string(), 148 + validate: false, 149 + record_key: parsed_rsvp_aturi.record_key.clone(), 150 + record: updated_rsvp_record, 151 + swap_commit: None, 152 + swap_record: None, 153 + }; 154 + 155 + let put_record_result = put_record( 156 + &web_context.http_client, 157 + &atproto_client::client::Auth::DPoP(dpop_auth), 158 + &current_handle.pds, 159 + put_record_request, 160 + ) 161 + .await; 162 + 163 + let _updated_rsvp = match put_record_result { 164 + Ok(PutRecordResponse::StrongRef { uri, cid, .. }) => { 165 + atproto_record::lexicon::com::atproto::repo::StrongRef { uri, cid } 166 + } 167 + Ok(PutRecordResponse::Error(err)) => { 168 + return Err(anyhow!("AT Protocol error updating RSVP: {}", err.error_message()).into()); 169 + } 170 + Err(err) => { 171 + return Err(anyhow!("Failed to update RSVP: {}", err).into()); 172 + } 173 + }; 174 + 175 + // Delete the acceptance ticket from storage (cleanup) 176 + acceptance_ticket_delete(&web_context.pool, &ticket.aturi) 177 + .await 178 + .map_err(|e| anyhow!("Failed to delete acceptance ticket: {}", e))?; 179 + 180 + // Return success with HTMX-compatible HTML 181 + Ok(( 182 + StatusCode::OK, 183 + format_success_html( 184 + "Acceptance finalized successfully", 185 + "Your RSVP has been updated with the organizer's acceptance.", 186 + Some(vec![ 187 + "The acceptance signature has been added to your RSVP.".to_string(), 188 + "This RSVP is now verified.".to_string(), 189 + ]), 190 + ), 191 + ) 192 + .into_response()) 193 + }
+5 -5
src/http/handle_mailgun_webhook.rs
··· 42 42 .is_some_and(|value| value == "suppress-bounce"); 43 43 if is_permanent && is_suppress_bounce { 44 44 // Clear confirmation for ALL users with this email address 45 - if let Err(err) = notification_unconfirm_email(&web_context.pool, &email).await { 45 + if let Err(err) = notification_unconfirm_email(&web_context.pool, email).await { 46 46 tracing::error!(?err, recipient = %email, "Failed to unconfirm email after rejected event"); 47 47 } else { 48 48 tracing::debug!(recipient = %email, "Email unconfirmed after rejected event"); ··· 51 51 } 52 52 53 53 "unsubscribed" => { 54 - if let Err(err) = notification_unconfirm_email(&web_context.pool, &email).await { 54 + if let Err(err) = notification_unconfirm_email(&web_context.pool, email).await { 55 55 tracing::error!(?err, recipient = %email, "Failed to unconfirm email after unsubscribe"); 56 56 } else { 57 57 tracing::info!(recipient = %email, "Email unconfirmed after unsubscribe"); ··· 59 59 60 60 let reason = format!("{} unsubscribed via mailgun webhook", email); 61 61 if let Err(err) = 62 - denylist_add_or_update(&web_context.pool, Cow::Borrowed(&email), Cow::Owned(reason)) 62 + denylist_add_or_update(&web_context.pool, Cow::Borrowed(email), Cow::Owned(reason)) 63 63 .await 64 64 { 65 65 tracing::error!(?err, recipient = %email, "Failed to add email to denylist after unsubscribe"); ··· 71 71 "complained" => { 72 72 // User marked email as spam 73 73 // Disable notifications for ALL users with this email address 74 - if let Err(err) = notification_unconfirm_email(&web_context.pool, &email).await { 74 + if let Err(err) = notification_unconfirm_email(&web_context.pool, email).await { 75 75 tracing::error!(?err, recipient = %email, "Failed to unconfirm email after complaint"); 76 76 } else { 77 77 tracing::info!(recipient = %email, "Email unconfirmed after complaint"); ··· 79 79 80 80 let reason = format!("{} complained via mailgun webhook", email); 81 81 if let Err(err) = 82 - denylist_add_or_update(&web_context.pool, Cow::Borrowed(&email), Cow::Owned(reason)) 82 + denylist_add_or_update(&web_context.pool, Cow::Borrowed(email), Cow::Owned(reason)) 83 83 .await 84 84 { 85 85 tracing::error!(?err, recipient = %email, "Failed to add email to denylist after complaint");
+12 -15
src/http/handle_oauth_aip_login.rs
··· 104 104 state: state.clone(), 105 105 nonce: nonce.clone(), 106 106 code_challenge, 107 - 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(), 107 + scope: "openid email profile atproto account:email blob:image/* repo:community.lexicon.calendar.event repo:community.lexicon.calendar.rsvp repo:events.smokesignal.profile repo:events.smokesignal.calendar.acceptance".to_string(), 108 108 }; 109 109 110 110 // Get AIP server configuration - config validation ensures these are set when oauth_backend is AIP ··· 188 188 return contextual_error!(web_context, language, error_template, default_context, err); 189 189 } 190 190 191 - if let Some(ref dest) = login_form.destination { 192 - if dest != "/" { 193 - // Create a direct instance to access the set_destination method 194 - let postgres_storage = crate::storage::atproto::PostgresOAuthRequestStorage::new( 195 - web_context.pool.clone(), 196 - ); 197 - if let Err(err) = postgres_storage.set_destination(&state, dest).await { 198 - tracing::error!(?err, "set_destination"); 199 - // Don't fail the login flow if we can't store the destination 200 - } 191 + if let Some(ref dest) = login_form.destination 192 + && dest != "/" 193 + { 194 + // Create a direct instance to access the set_destination method 195 + let postgres_storage = 196 + crate::storage::atproto::PostgresOAuthRequestStorage::new(web_context.pool.clone()); 197 + if let Err(err) = postgres_storage.set_destination(&state, dest).await { 198 + tracing::error!(?err, "set_destination"); 199 + // Don't fail the login flow if we can't store the destination 201 200 } 202 201 } 203 202 ··· 219 218 stringify(oauth_args) 220 219 ); 221 220 222 - if hx_request { 223 - if let Ok(hx_redirect) = HxRedirect::try_from(destination.as_str()) { 224 - return Ok((StatusCode::OK, hx_redirect, "").into_response()); 225 - } 221 + if hx_request && let Ok(hx_redirect) = HxRedirect::try_from(destination.as_str()) { 222 + return Ok((StatusCode::OK, hx_redirect, "").into_response()); 226 223 } 227 224 228 225 return Ok(Redirect::temporary(destination.as_str()).into_response());
+12 -13
src/http/handle_oauth_login.rs
··· 309 309 } 310 310 311 311 // Store destination if provided and not "/" 312 - if let Some(ref dest) = destination.destination { 313 - if dest != "/" { 314 - // Create a direct instance to access the set_destination method 315 - let postgres_storage = crate::storage::atproto::PostgresOAuthRequestStorage::new( 316 - web_context.pool.clone(), 317 - ); 318 - if let Err(err) = postgres_storage 319 - .set_destination(&oauth_request_state.state, dest) 320 - .await 321 - { 322 - tracing::error!(?err, "set_destination"); 323 - // Don't fail the login flow if we can't store the destination 324 - } 312 + if let Some(ref dest) = destination.destination 313 + && dest != "/" 314 + { 315 + // Create a direct instance to access the set_destination method 316 + let postgres_storage = 317 + crate::storage::atproto::PostgresOAuthRequestStorage::new(web_context.pool.clone()); 318 + if let Err(err) = postgres_storage 319 + .set_destination(&oauth_request_state.state, dest) 320 + .await 321 + { 322 + tracing::error!(?err, "set_destination"); 323 + // Don't fail the login flow if we can't store the destination 325 324 } 326 325 } 327 326
+4 -8
src/http/handle_profile.rs
··· 123 123 let display_name = prof_rec.display_name.clone(); 124 124 125 125 // Parse the record JSON to get full profile data 126 - let prof_data = if let Ok(prof_data) = serde_json::from_value::< 127 - crate::atproto::lexicon::profile::Profile, 128 - >(prof_rec.record.0.clone()) 129 - { 130 - Some(prof_data) 131 - } else { 132 - None 133 - }; 126 + let prof_data = serde_json::from_value::<crate::atproto::lexicon::profile::Profile>( 127 + prof_rec.record.0.clone(), 128 + ) 129 + .ok(); 134 130 135 131 let description_html = prof_data 136 132 .as_ref()
+4 -5
src/http/handle_set_language.rs
··· 64 64 } 65 65 let found = found.unwrap(); 66 66 67 - if let Some(handle) = auth.profile() { 68 - if let Err(err) = handle_update_field( 67 + if let Some(handle) = auth.profile() 68 + && let Err(err) = handle_update_field( 69 69 &web_context.pool, 70 70 &handle.did, 71 71 HandleField::Language(Cow::Owned(found.to_string())), 72 72 ) 73 73 .await 74 - { 75 - tracing::error!(error = ?err, "Failed to update language"); 76 - } 74 + { 75 + tracing::error!(error = ?err, "Failed to update language"); 77 76 } 78 77 79 78 let mut cookie = Cookie::new(COOKIE_LANG, found.to_string());
+61 -183
src/http/handle_settings.rs
··· 54 54 } 55 55 56 56 #[derive(Deserialize, Clone, Debug)] 57 - pub(crate) struct DiscoverEventsForm { 58 - discover_events: Option<String>, 59 - } 60 - 61 - #[derive(Deserialize, Clone, Debug)] 62 - pub(crate) struct DiscoverRsvpsForm { 63 - discover_rsvps: Option<String>, 64 - } 65 - 66 - #[derive(Deserialize, Clone, Debug)] 67 57 pub(crate) struct WebhookForm { 68 58 service: String, 69 59 } ··· 415 405 .into_response()) 416 406 } 417 407 418 - pub(crate) async fn handle_discover_events_update( 419 - State(web_context): State<WebContext>, 420 - Language(language): Language, 421 - Cached(auth): Cached<Auth>, 422 - Form(discover_form): Form<DiscoverEventsForm>, 423 - ) -> Result<impl IntoResponse, WebError> { 424 - let current_handle = auth.require_flat()?; 425 - 426 - let default_context = template_context! { 427 - current_handle => current_handle.clone(), 428 - language => language.to_string(), 429 - }; 430 - 431 - let error_template = select_template!(false, true, language); 432 - let render_template = format!( 433 - "{}/settings.discover_events.html", 434 - language.to_string().to_lowercase() 435 - ); 436 - 437 - // Parse the boolean value from the form 438 - let discover_events = discover_form 439 - .discover_events 440 - .as_ref() 441 - .map(|s| s == "true") 442 - .unwrap_or(false); 443 - 444 - if let Err(err) = handle_update_field( 445 - &web_context.pool, 446 - &current_handle.did, 447 - HandleField::DiscoverEvents(discover_events), 448 - ) 449 - .await 450 - { 451 - return contextual_error!(web_context, language, error_template, default_context, err); 452 - } 453 - 454 - let current_handle = match handle_for_did(&web_context.pool, &current_handle.did).await { 455 - Ok(value) => value, 456 - Err(err) => { 457 - return contextual_error!(web_context, language, error_template, default_context, err); 458 - } 459 - }; 460 - 461 - Ok(( 462 - StatusCode::OK, 463 - RenderHtml( 464 - &render_template, 465 - web_context.engine.clone(), 466 - template_context! { 467 - current_handle, 468 - discover_events_updated => true, 469 - ..default_context 470 - }, 471 - ), 472 - ) 473 - .into_response()) 474 - } 475 - 476 - pub(crate) async fn handle_discover_rsvps_update( 477 - State(web_context): State<WebContext>, 478 - Language(language): Language, 479 - Cached(auth): Cached<Auth>, 480 - Form(discover_form): Form<DiscoverRsvpsForm>, 481 - ) -> Result<impl IntoResponse, WebError> { 482 - let current_handle = auth.require_flat()?; 483 - 484 - let default_context = template_context! { 485 - current_handle => current_handle.clone(), 486 - language => language.to_string(), 487 - }; 488 - 489 - let error_template = select_template!(false, true, language); 490 - let render_template = format!( 491 - "{}/settings.discover_rsvps.html", 492 - language.to_string().to_lowercase() 493 - ); 494 - 495 - // Parse the boolean value from the form 496 - let discover_rsvps = discover_form 497 - .discover_rsvps 498 - .as_ref() 499 - .map(|s| s == "true") 500 - .unwrap_or(false); 501 - 502 - if let Err(err) = handle_update_field( 503 - &web_context.pool, 504 - &current_handle.did, 505 - HandleField::DiscoverRsvps(discover_rsvps), 506 - ) 507 - .await 508 - { 509 - return contextual_error!(web_context, language, error_template, default_context, err); 510 - } 511 - 512 - let current_handle = match handle_for_did(&web_context.pool, &current_handle.did).await { 513 - Ok(value) => value, 514 - Err(err) => { 515 - return contextual_error!(web_context, language, error_template, default_context, err); 516 - } 517 - }; 518 - 519 - Ok(( 520 - StatusCode::OK, 521 - RenderHtml( 522 - &render_template, 523 - web_context.engine.clone(), 524 - template_context! { 525 - current_handle, 526 - discover_rsvps_updated => true, 527 - ..default_context 528 - }, 529 - ), 530 - ) 531 - .into_response()) 532 - } 533 - 534 408 pub(crate) async fn handle_add_webhook( 535 409 State(web_context): State<WebContext>, 536 410 identity_resolver: State<Arc<dyn IdentityResolver>>, ··· 846 720 }); 847 721 848 722 // Validate profile_host if provided 849 - if let Some(ref host) = profile_host { 850 - if host != "bsky.app" && host != "blacksky.community" && host != "smokesignal.events" { 851 - return contextual_error!( 852 - web_context, 853 - language, 854 - error_template, 855 - default_context, 856 - "Invalid profile host value" 857 - ); 858 - } 723 + if let Some(ref host) = profile_host 724 + && host != "bsky.app" 725 + && host != "blacksky.community" 726 + && host != "smokesignal.events" 727 + { 728 + return contextual_error!( 729 + web_context, 730 + language, 731 + error_template, 732 + default_context, 733 + "Invalid profile host value" 734 + ); 859 735 } 860 736 861 737 // Get existing profile from storage to get CID for swap record (CAS operation) ··· 1034 910 tracing::error!(error = err.error_message(), "Failed to update profile"); 1035 911 let error_msg = format!("{:?}", err.error_message()); 1036 912 if error_msg.contains("InvalidSwap") { 1037 - return contextual_error!( 913 + contextual_error!( 1038 914 web_context, 1039 915 language, 1040 916 error_template, 1041 917 default_context, 1042 918 "Your recent profile changes are still syncing. Please wait a moment and try again." 1043 - ); 919 + ) 1044 920 } else { 1045 - return contextual_error!( 921 + contextual_error!( 1046 922 web_context, 1047 923 language, 1048 924 error_template, 1049 925 default_context, 1050 926 format!("Failed to update profile: {:?}", err.error_message()) 1051 - ); 927 + ) 1052 928 } 1053 929 } 1054 930 Err(err) => { 1055 931 tracing::error!(?err, "Failed to update profile"); 1056 932 let error_msg = err.to_string(); 1057 933 if error_msg.contains("InvalidSwap") { 1058 - return contextual_error!( 934 + contextual_error!( 1059 935 web_context, 1060 936 language, 1061 937 error_template, 1062 938 default_context, 1063 939 "Your recent profile changes are still syncing. Please wait a moment and try again." 1064 - ); 940 + ) 1065 941 } else { 1066 - return contextual_error!( 942 + contextual_error!( 1067 943 web_context, 1068 944 language, 1069 945 error_template, 1070 946 default_context, 1071 947 format!("Failed to update profile: {}", err) 1072 - ); 948 + ) 1073 949 } 1074 950 } 1075 951 } ··· 1102 978 .filter(|e| !e.is_empty()); 1103 979 1104 980 // Validate email format if provided 1105 - if let Some(email_addr) = email { 1106 - if !email_addr.contains('@') || !email_addr.contains('.') { 1107 - return contextual_error!( 1108 - web_context, 1109 - language, 1110 - error_template, 1111 - default_context, 1112 - "error-smokesignal-email-7 Invalid format: Email address must contain @ and domain" 1113 - ); 1114 - } 981 + if let Some(email_addr) = email 982 + && (!email_addr.contains('@') || !email_addr.contains('.')) 983 + { 984 + return contextual_error!( 985 + web_context, 986 + language, 987 + error_template, 988 + default_context, 989 + "error-smokesignal-email-7 Invalid format: Email address must contain @ and domain" 990 + ); 1115 991 } 1116 992 1117 993 // Get current notification settings to check if email has changed 1118 - let current_notification_settings = notification_get(&web_context.pool, &current_handle.did).await?; 994 + let current_notification_settings = 995 + notification_get(&web_context.pool, &current_handle.did).await?; 1119 996 let current_email = current_notification_settings 1120 997 .as_ref() 1121 - .and_then(|settings| settings.email.as_ref().map(|e| e.as_str())); 998 + .and_then(|settings| settings.email.as_deref()); 1122 999 1123 1000 // Check if email is unchanged - if so, skip update (no-op) 1124 1001 let email_unchanged = match (email, current_email) { ··· 1142 1019 } 1143 1020 1144 1021 // Reset email confirmation in notification settings since email changed 1145 - if let Err(err) = notification_reset_confirmation(&web_context.pool, &current_handle.did).await 1022 + if let Err(err) = 1023 + notification_reset_confirmation(&web_context.pool, &current_handle.did).await 1146 1024 { 1147 1025 tracing::error!(?err, "Failed to reset notification confirmation"); 1148 1026 // Don't fail the request if this update fails, just log it 1149 1027 } 1150 1028 1151 1029 // Automatically send confirmation email if email was set (not cleared) 1152 - if let Some(email_addr) = email { 1153 - if let Some(ref emailer) = web_context.emailer { 1154 - // Generate signed confirmation token 1155 - let secret = web_context.config.http_cookie_key.as_ref().master(); 1156 - match crate::email_confirmation::generate_confirmation_token( 1157 - &current_handle.did, 1158 - email_addr, 1159 - secret, 1160 - ) { 1161 - Ok(token) => { 1162 - // Generate confirmation URL and send email 1163 - let confirmation_url = format!( 1164 - "https://{}/settings/confirm-email/{}", 1165 - web_context.config.external_base, token 1166 - ); 1030 + if let Some(email_addr) = email 1031 + && let Some(ref emailer) = web_context.emailer 1032 + { 1033 + // Generate signed confirmation token 1034 + let secret = web_context.config.http_cookie_key.as_ref().master(); 1035 + match crate::email_confirmation::generate_confirmation_token( 1036 + &current_handle.did, 1037 + email_addr, 1038 + secret, 1039 + ) { 1040 + Ok(token) => { 1041 + // Generate confirmation URL and send email 1042 + let confirmation_url = format!( 1043 + "https://{}/settings/confirm-email/{}", 1044 + web_context.config.external_base, token 1045 + ); 1167 1046 1168 - if let Err(err) = emailer 1169 - .send_email_confirmation(email_addr, &confirmation_url) 1170 - .await 1171 - { 1172 - tracing::error!(?err, "Failed to send confirmation email automatically"); 1173 - } else { 1174 - confirmation_sent = true; 1175 - } 1047 + if let Err(err) = emailer 1048 + .send_email_confirmation(email_addr, &confirmation_url) 1049 + .await 1050 + { 1051 + tracing::error!(?err, "Failed to send confirmation email automatically"); 1052 + } else { 1053 + confirmation_sent = true; 1176 1054 } 1177 - Err(err) => { 1178 - tracing::error!(?err, "Failed to generate confirmation token"); 1179 - } 1055 + } 1056 + Err(err) => { 1057 + tracing::error!(?err, "Failed to generate confirmation token"); 1180 1058 } 1181 1059 } 1182 1060 }
+6 -3
src/http/handle_unsubscribe.rs
··· 19 19 denylist_add_or_update, notification_unsubscribe_all_pattern, 20 20 notification_unsubscribe_sender_pattern, 21 21 }, 22 - notification::{notification_disable_preference, NotificationPreference}, 22 + notification::{NotificationPreference, notification_disable_preference}, 23 23 }, 24 - unsubscribe_token::{verify_token, UnsubscribeAction}, 24 + unsubscribe_token::{UnsubscribeAction, verify_token}, 25 25 }; 26 26 27 27 /// Handle GET request to unsubscribe from email notifications ··· 54 54 language, 55 55 error_template, 56 56 default_context, 57 - format!("error-smokesignal-unsubscribe-100 Invalid or expired unsubscribe link: {}", err) 57 + format!( 58 + "error-smokesignal-unsubscribe-100 Invalid or expired unsubscribe link: {}", 59 + err 60 + ) 58 61 ); 59 62 } 60 63 };
+52 -25
src/http/handle_view_event.rs
··· 22 22 use crate::http::utils::url_from_aturi; 23 23 use crate::select_template; 24 24 use crate::storage::StoragePool; 25 + use crate::storage::acceptance::acceptance_ticket_get_by_event_and_rsvp_did; 26 + use crate::storage::event::RsvpDisplayData; 25 27 use crate::storage::event::count_event_rsvps; 26 28 use crate::storage::event::event_exists; 27 29 use crate::storage::event::event_get; 28 - use crate::storage::event::get_event_rsvps; 30 + use crate::storage::event::get_event_rsvps_with_validation; 29 31 use crate::storage::event::get_user_rsvp_with_email_shared; 30 32 use crate::storage::identity_profile::handle_for_did; 31 33 use crate::storage::identity_profile::handle_for_handle; ··· 192 194 profile.did, event_rkey 193 195 ); 194 196 195 - if let Ok(exists) = event_exists(&ctx.web_context.pool, &legacy_aturi).await { 196 - if exists { 197 - return contextual_error!( 198 - ctx.web_context, 199 - ctx.language, 200 - error_template, 201 - default_context, 202 - ViewEventError::LegacyEventNotSupported, 203 - StatusCode::NOT_FOUND 204 - ); 205 - } 197 + if let Ok(exists) = event_exists(&ctx.web_context.pool, &legacy_aturi).await 198 + && exists 199 + { 200 + return contextual_error!( 201 + ctx.web_context, 202 + ctx.language, 203 + error_template, 204 + default_context, 205 + ViewEventError::LegacyEventNotSupported, 206 + StatusCode::NOT_FOUND 207 + ); 206 208 } 207 209 } 208 210 ··· 298 300 None 299 301 }; 300 302 303 + // Check if there's a pending acceptance ticket for the current user's RSVP 304 + let pending_acceptance = if let Some(current_entity) = &ctx.current_handle { 305 + match acceptance_ticket_get_by_event_and_rsvp_did( 306 + &ctx.web_context.pool, 307 + &aturi, 308 + &current_entity.did, 309 + ) 310 + .await 311 + { 312 + Ok(Some(ticket)) => Some(ticket.aturi), 313 + Ok(None) => None, 314 + Err(err) => { 315 + tracing::warn!("Error checking for pending acceptance: {:?}", err); 316 + None 317 + } 318 + } 319 + } else { 320 + None 321 + }; 322 + 301 323 // Get counts for all RSVP statuses 302 324 let going_count = count_event_rsvps(&ctx.web_context.pool, &aturi, "going") 303 325 .await ··· 311 333 .await 312 334 .unwrap_or_default(); 313 335 314 - // Only get handles for the active tab 315 - let active_tab_handles = { 336 + // Get RSVPs with validation data for the active tab 337 + let active_tab_rsvps = { 316 338 let tab_status = match tab { 317 339 RSVPTab::Going => "going", 318 340 RSVPTab::Interested => "interested", 319 341 RSVPTab::NotGoing => "notgoing", 320 342 }; 321 343 322 - let rsvps = get_event_rsvps(&ctx.web_context.pool, &aturi, Some(tab_status)) 323 - .await 324 - .unwrap_or_default(); 344 + let rsvps = 345 + get_event_rsvps_with_validation(&ctx.web_context.pool, &aturi, Some(tab_status)) 346 + .await 347 + .unwrap_or_default(); 325 348 326 349 // Extract DIDs for batch lookup 327 350 let dids: Vec<String> = rsvps.iter().map(|(did, _)| did.clone()).collect(); ··· 331 354 .await 332 355 .unwrap_or_default(); 333 356 334 - // Extract handle strings in the same order as the original rsvps 335 - let mut handles = Vec::new(); 336 - for (did, _) in &rsvps { 337 - if let Some(profile) = handle_profiles.get(did) { 338 - handles.push(profile.handle.clone()); 339 - } 357 + // Create RsvpDisplayData objects with handle and validation info 358 + let mut rsvp_display_data = Vec::new(); 359 + for (did, validated_at) in &rsvps { 360 + let handle = handle_profiles.get(did).map(|p| p.handle.clone()); 361 + rsvp_display_data.push(RsvpDisplayData { 362 + did: did.clone(), 363 + handle, 364 + validated_at: *validated_at, 365 + }); 340 366 } 341 - handles 367 + rsvp_display_data 342 368 }; 343 369 344 370 // Set counts on event ··· 366 392 event => event_with_counts, 367 393 is_self, 368 394 can_edit, 369 - active_tab_handles, 395 + active_tab_rsvps, 370 396 active_tab => tab_name, 371 397 user_rsvp_status, 398 + pending_acceptance, 372 399 handle_slug, 373 400 event_rkey, 374 401 collection => collection.clone(),
+8 -9
src/http/handle_xrpc_search_events.rs
··· 152 152 }; 153 153 154 154 let mut event_ids = Vec::new(); 155 - if let Some(outer_hits) = serach_results_value.get("hits") { 156 - if let Some(inner_hits) = outer_hits.get("hits") { 157 - if let Some(hits) = inner_hits.as_array() { 158 - for hit in hits { 159 - let document_id = hit.get("_id").and_then(|value| value.as_str()); 160 - if let Some(document_id_value) = document_id { 161 - event_ids.push(document_id_value.to_string()); 162 - } 163 - } 155 + if let Some(outer_hits) = serach_results_value.get("hits") 156 + && let Some(inner_hits) = outer_hits.get("hits") 157 + && let Some(hits) = inner_hits.as_array() 158 + { 159 + for hit in hits { 160 + let document_id = hit.get("_id").and_then(|value| value.as_str()); 161 + if let Some(document_id_value) = document_id { 162 + event_ids.push(document_id_value.to_string()); 164 163 } 165 164 } 166 165 }
+5 -5
src/http/middleware_i18n.rs
··· 119 119 let auth: Auth = Cached::<Auth>::from_request_parts(parts, context).await?.0; 120 120 121 121 // 1. Try to get language from user's profile settings 122 - if let Some(handle) = auth.profile() { 123 - if let Ok(auth_lang) = handle.language.parse::<LanguageIdentifier>() { 124 - debug!(language = %auth_lang, "Using language from user profile"); 125 - return Ok(Self(auth_lang)); 126 - } 122 + if let Some(handle) = auth.profile() 123 + && let Ok(auth_lang) = handle.language.parse::<LanguageIdentifier>() 124 + { 125 + debug!(language = %auth_lang, "Using language from user profile"); 126 + return Ok(Self(auth_lang)); 127 127 } 128 128 129 129 // 2. Try to get language from cookies
+3
src/http/mod.rs
··· 1 + pub mod acceptance_utils; 1 2 pub mod auth_utils; 2 3 pub mod cache_countries; 3 4 pub mod context; 4 5 pub mod errors; 5 6 pub mod event_form; 6 7 pub mod event_view; 8 + pub mod handle_accept_rsvp; 7 9 pub mod handle_admin_denylist; 8 10 pub mod handle_admin_event; 9 11 pub mod handle_admin_events; ··· 22 24 pub mod handle_email_confirm; 23 25 pub mod handle_export_ics; 24 26 pub mod handle_export_rsvps; 27 + pub mod handle_finalize_acceptance; 25 28 pub mod handle_health; 26 29 pub mod handle_host_meta; 27 30 pub mod handle_import;
+6 -11
src/http/server.rs
··· 16 16 17 17 use crate::http::{ 18 18 context::WebContext, 19 + handle_accept_rsvp::handle_accept_rsvp, 19 20 handle_admin_denylist::{ 20 21 handle_admin_denylist, handle_admin_denylist_add, handle_admin_denylist_remove, 21 22 }, ··· 41 42 handle_email_confirm::{handle_confirm_email, handle_send_email_confirmation}, 42 43 handle_export_ics::handle_export_ics, 43 44 handle_export_rsvps::handle_export_rsvps, 45 + handle_finalize_acceptance::handle_finalize_acceptance, 44 46 handle_health::{handle_alive, handle_ready, handle_started}, 45 - handle_unsubscribe::handle_unsubscribe, 46 47 handle_host_meta::handle_host_meta, 47 48 handle_import::{handle_import, handle_import_submit}, 48 49 handle_index::handle_index, ··· 55 56 handle_profile::handle_profile_view, 56 57 handle_set_language::handle_set_language, 57 58 handle_settings::{ 58 - handle_add_webhook, handle_discover_events_update, handle_discover_rsvps_update, 59 - handle_email_update, handle_language_update, handle_list_webhooks, 59 + handle_add_webhook, handle_email_update, handle_language_update, handle_list_webhooks, 60 60 handle_notification_email_update, handle_notification_preferences_update, 61 61 handle_profile_update, handle_remove_webhook, handle_settings, handle_test_webhook, 62 62 handle_timezone_update, handle_toggle_webhook, 63 63 }, 64 + handle_unsubscribe::handle_unsubscribe, 64 65 handle_view_event::handle_view_event, 65 66 handle_wellknown::handle_wellknown_did_web, 66 67 handle_xrpc_get_event::handle_xrpc_get_event, ··· 149 150 .route("/settings/timezone", post(handle_timezone_update)) 150 151 .route("/settings/language", post(handle_language_update)) 151 152 .route("/settings/email", post(handle_email_update)) 152 - .route( 153 - "/settings/discover_events", 154 - post(handle_discover_events_update), 155 - ) 156 - .route( 157 - "/settings/discover_rsvps", 158 - post(handle_discover_rsvps_update), 159 - ) 160 153 .route("/settings/profile", post(handle_profile_update)) 161 154 .route("/settings/avatar", post(upload_profile_avatar)) 162 155 .route("/settings/avatar/delete", post(delete_profile_avatar)) ··· 191 184 .route("/event", post(handle_create_event)) 192 185 .route("/rsvp", get(handle_create_rsvp)) 193 186 .route("/rsvp", post(handle_create_rsvp)) 187 + .route("/accept_rsvp", post(handle_accept_rsvp)) 188 + .route("/finalize_acceptance", post(handle_finalize_acceptance)) 194 189 .route("/event/starts", get(handle_starts_at_builder)) 195 190 .route("/event/starts", post(handle_starts_at_builder)) 196 191 .route("/event/location", get(handle_location_at_builder))
+4 -4
src/http/utils.rs
··· 140 140 None => text, 141 141 }; 142 142 143 - if ret.len() < text.len() { 144 - if let Some(suffix) = suffix { 145 - return format!("{} {}", ret, suffix.clone()); 146 - } 143 + if ret.len() < text.len() 144 + && let Some(suffix) = suffix 145 + { 146 + return format!("{} {}", ret, suffix.clone()); 147 147 } 148 148 ret.to_string() 149 149 }
-4
src/identity_cache.rs
··· 69 69 /// In-memory LRU cache 70 70 memory_cache: Arc<RwLock<LruCache<String, CachedDocument>>>, 71 71 72 - handle_cache: Arc<RwLock<LruCache<String, String>>>, 73 - 74 72 /// Cache configuration 75 73 config: CacheConfig, 76 74 } ··· 94 92 base_resolver, 95 93 storage, 96 94 memory_cache: Arc::new(RwLock::new(LruCache::new(cache_size))), 97 - handle_cache: Arc::new(RwLock::new(LruCache::new(cache_size))), 98 95 config, 99 96 } 100 97 } ··· 170 167 // Store in database 171 168 if let Err(e) = self.storage.as_ref().store_document(document.clone()).await { 172 169 warn!("Failed to store document in database cache: {}", e); 173 - } else { 174 170 } 175 171 176 172 // Also store by handle if the subject was a handle
+1
src/lib.rs
··· 15 15 pub mod key_provider; 16 16 pub mod processor; 17 17 pub mod processor_errors; 18 + pub mod record_resolver; 18 19 pub mod refresh_tokens_errors; 19 20 pub mod service; 20 21 pub mod storage;
+100 -33
src/processor.rs
··· 1 1 use anyhow::Result; 2 + use atproto_attestation::verify_record; 2 3 use atproto_client::com::atproto::repo::get_blob; 4 + use atproto_identity::key::IdentityDocumentKeyResolver; 3 5 use atproto_identity::model::Document; 4 6 use atproto_identity::resolve::IdentityResolver; 5 7 use atproto_identity::traits::DidDocumentStorage; ··· 8 10 use serde_json::Value; 9 11 use std::sync::Arc; 10 12 13 + use crate::atproto::lexicon::acceptance::{Acceptance, NSID as AcceptanceNSID}; 11 14 use crate::atproto::lexicon::profile::{NSID as ProfileNSID, Profile}; 12 15 use crate::consumer::SmokeSignalEvent; 13 16 use crate::consumer::SmokeSignalEventReceiver; 14 17 use crate::processor_errors::ProcessorError; 15 18 use crate::storage::StoragePool; 19 + use crate::storage::acceptance::{ 20 + acceptance_record_delete, acceptance_record_upsert, rsvp_update_validated_at, 21 + }; 16 22 use crate::storage::content::ContentStorage; 17 23 use crate::storage::denylist::denylist_exists; 18 24 use crate::storage::event::RsvpInsertParams; ··· 21 27 use crate::storage::event::event_insert_with_metadata; 22 28 use crate::storage::event::rsvp_delete; 23 29 use crate::storage::event::rsvp_insert_with_metadata; 24 - use crate::storage::identity_profile::identity_profile_allow_discover_events; 25 - use crate::storage::identity_profile::identity_profile_allow_discover_rsvps; 26 30 use crate::storage::profile::profile_delete; 27 31 use crate::storage::profile::profile_insert; 28 32 use atproto_record::lexicon::community::lexicon::calendar::event::{ ··· 38 42 identity_resolver: Arc<dyn IdentityResolver>, 39 43 document_storage: Arc<dyn DidDocumentStorage + Send + Sync>, 40 44 http_client: reqwest::Client, 45 + record_resolver: Arc<crate::record_resolver::StorageBackedRecordResolver>, 46 + key_resolver: IdentityDocumentKeyResolver, 41 47 } 42 48 43 49 impl ContentFetcher { ··· 47 53 identity_resolver: Arc<dyn IdentityResolver>, 48 54 document_storage: Arc<dyn DidDocumentStorage + Send + Sync>, 49 55 http_client: reqwest::Client, 56 + record_resolver: Arc<crate::record_resolver::StorageBackedRecordResolver>, 57 + key_resolver: IdentityDocumentKeyResolver, 50 58 ) -> Self { 51 59 Self { 52 60 pool, ··· 54 62 identity_resolver, 55 63 document_storage, 56 64 http_client, 65 + record_resolver, 66 + key_resolver, 57 67 } 58 68 } 59 69 ··· 95 105 } 96 106 "events.smokesignal.profile" => { 97 107 self.handle_profile_commit(did, rkey, cid, record).await 108 + } 109 + "events.smokesignal.calendar.acceptance" => { 110 + self.handle_acceptance_commit(did, rkey, cid, record).await 98 111 } 99 112 _ => Ok(()), 100 113 }; ··· 116 129 self.handle_rsvp_delete(did, rkey).await 117 130 } 118 131 "events.smokesignal.profile" => self.handle_profile_delete(did, rkey).await, 132 + "events.smokesignal.calendar.acceptance" => { 133 + self.handle_acceptance_delete(did, rkey).await 134 + } 119 135 _ => Ok(()), 120 136 }; 121 137 if let Err(e) = result { ··· 137 153 record: &Value, 138 154 ) -> Result<()> { 139 155 tracing::info!("Processing event: {} for {}", rkey, did); 140 - 141 - // Check if the user allows event discovery 142 - let allow_discover = identity_profile_allow_discover_events(&self.pool, did).await?; 143 - // Default to false if the profile doesn't exist yet 144 - if !allow_discover.unwrap_or(false) { 145 - tracing::info!("User {} has opted out of event discovery", did); 146 - return Ok(()); 147 - } 148 156 149 157 let aturi = format!("at://{did}/{LexiconCommunityEventNSID}/{rkey}"); 150 158 ··· 186 194 record: &Value, 187 195 ) -> Result<()> { 188 196 tracing::info!("Processing rsvp: {} for {}", rkey, did); 189 - 190 - let allow_discover = identity_profile_allow_discover_rsvps(&self.pool, did).await?; 191 - if !allow_discover.unwrap_or(true) { 192 - tracing::info!("User {} has opted out of event discovery", did); 193 - return Ok(()); 194 - } 195 197 196 198 let aturi = format!("at://{did}/{LexiconCommunityRSVPNSID}/{rkey}"); 197 199 ··· 228 230 ) 229 231 .await?; 230 232 233 + // Check if RSVP has signatures and verify them 234 + if !rsvp_record.signatures.is_empty() { 235 + tracing::info!( 236 + "RSVP {} has {} signature(s), verifying...", 237 + aturi, 238 + rsvp_record.signatures.len() 239 + ); 240 + 241 + let key_resolver_clone = self.key_resolver.clone(); 242 + let validated = verify_record( 243 + (&rsvp_record).into(), 244 + did, 245 + key_resolver_clone, 246 + self.record_resolver.as_ref(), 247 + ) 248 + .await 249 + .is_ok(); 250 + 251 + if validated { 252 + if let Err(e) = 253 + rsvp_update_validated_at(&self.pool, &aturi, Some(chrono::Utc::now())).await 254 + { 255 + tracing::error!("Failed to update RSVP validated_at: {:?}", e); 256 + } else { 257 + tracing::info!("RSVP {} validated with signatures", aturi); 258 + } 259 + } else { 260 + tracing::warn!("RSVP {} signature verification failed", aturi); 261 + } 262 + } 263 + 231 264 Ok(()) 232 265 } 233 266 ··· 288 321 let pds_endpoints = document.pds_endpoints(); 289 322 if let Some(pds_endpoint) = pds_endpoints.first() { 290 323 // Download avatar if present 291 - if let Some(ref avatar) = profile_record.avatar { 292 - if let Err(e) = self.download_avatar(pds_endpoint, did, avatar).await { 293 - tracing::warn!( 294 - error = ?e, 295 - did = %did, 296 - "Failed to download avatar for profile" 297 - ); 298 - } 324 + if let Some(ref avatar) = profile_record.avatar 325 + && let Err(e) = self.download_avatar(pds_endpoint, did, avatar).await 326 + { 327 + tracing::warn!( 328 + error = ?e, 329 + did = %did, 330 + "Failed to download avatar for profile" 331 + ); 299 332 } 300 333 301 334 // Download banner if present 302 - if let Some(ref banner) = profile_record.banner { 303 - if let Err(e) = self.download_banner(pds_endpoint, did, banner).await { 304 - tracing::warn!( 305 - error = ?e, 306 - did = %did, 307 - "Failed to download banner for profile" 308 - ); 309 - } 335 + if let Some(ref banner) = profile_record.banner 336 + && let Err(e) = self.download_banner(pds_endpoint, did, banner).await 337 + { 338 + tracing::warn!( 339 + error = ?e, 340 + did = %did, 341 + "Failed to download banner for profile" 342 + ); 310 343 } 311 344 } else { 312 345 tracing::debug!(did = %did, "No PDS endpoint found for profile blob download"); ··· 318 351 async fn handle_profile_delete(&self, did: &str, rkey: &str) -> Result<()> { 319 352 let aturi = format!("at://{did}/{ProfileNSID}/{rkey}"); 320 353 profile_delete(&self.pool, &aturi).await?; 354 + Ok(()) 355 + } 356 + 357 + async fn handle_acceptance_commit( 358 + &self, 359 + did: &str, 360 + rkey: &str, 361 + _cid: &str, 362 + record: &Value, 363 + ) -> Result<()> { 364 + tracing::info!("Processing acceptance: {} for {}", rkey, did); 365 + 366 + let aturi = format!("at://{did}/{AcceptanceNSID}/{rkey}"); 367 + 368 + // Deserialize and validate the acceptance record 369 + let acceptance_record: Acceptance = serde_json::from_value(record.clone())?; 370 + 371 + // Validate the acceptance record 372 + if let Err(e) = acceptance_record.validate() { 373 + tracing::warn!("Invalid acceptance record: {}", e); 374 + return Ok(()); 375 + } 376 + 377 + // Store the acceptance record 378 + acceptance_record_upsert(&self.pool, &aturi, &acceptance_record.cid, did, record).await?; 379 + 380 + tracing::info!("Acceptance stored: {}", aturi); 381 + Ok(()) 382 + } 383 + 384 + async fn handle_acceptance_delete(&self, did: &str, rkey: &str) -> Result<()> { 385 + let aturi = format!("at://{did}/{AcceptanceNSID}/{rkey}"); 386 + acceptance_record_delete(&self.pool, &aturi).await?; 387 + tracing::info!("Acceptance deleted: {}", aturi); 321 388 Ok(()) 322 389 } 323 390 ··· 554 621 return Ok(()); 555 622 } 556 623 557 - let image_bytes = get_blob(&self.http_client, pds, did, &blob_ref).await?; 624 + let image_bytes = get_blob(&self.http_client, pds, did, blob_ref).await?; 558 625 559 626 const MAX_SIZE: usize = 3 * 1024 * 1024; 560 627 if image_bytes.len() > MAX_SIZE {
+156
src/record_resolver.rs
··· 1 + //! Storage-backed record resolver for ATProto records with network fallback. 2 + //! 3 + //! This module provides a `RecordResolver` implementation that: 4 + //! 1. First checks local storage for cached acceptance records 5 + //! 2. Falls back to fetching from the network via the identity's PDS 6 + //! 3. Caches fetched records in storage for future use 7 + 8 + use crate::storage::StoragePool; 9 + use crate::storage::acceptance::{acceptance_record_get, acceptance_record_upsert}; 10 + use anyhow::{Context, anyhow}; 11 + use atproto_identity::resolve::IdentityResolver; 12 + use atproto_record::aturi::ATURI; 13 + use std::str::FromStr; 14 + use std::sync::Arc; 15 + use tracing::{debug, warn}; 16 + 17 + /// Record resolver that uses storage as a cache and falls back to network fetching. 18 + /// 19 + /// This resolver attempts to retrieve acceptance records from local storage first. 20 + /// If not found, it resolves the DID to find the PDS endpoint, fetches the record from 21 + /// the network, and stores it for future use. 22 + pub struct StorageBackedRecordResolver { 23 + http_client: reqwest::Client, 24 + identity_resolver: Arc<dyn IdentityResolver>, 25 + pool: StoragePool, 26 + } 27 + 28 + impl StorageBackedRecordResolver { 29 + /// Create a new storage-backed record resolver. 30 + pub fn new( 31 + http_client: reqwest::Client, 32 + identity_resolver: Arc<dyn IdentityResolver>, 33 + pool: StoragePool, 34 + ) -> Self { 35 + Self { 36 + http_client, 37 + identity_resolver, 38 + pool, 39 + } 40 + } 41 + } 42 + 43 + #[async_trait::async_trait] 44 + impl atproto_client::record_resolver::RecordResolver for StorageBackedRecordResolver { 45 + async fn resolve<T>(&self, aturi: &str) -> anyhow::Result<T> 46 + where 47 + T: serde::de::DeserializeOwned + Send, 48 + { 49 + // Parse the AT-URI 50 + let parsed = ATURI::from_str(aturi).map_err(|e| anyhow!("Invalid AT-URI: {}", e))?; 51 + tracing::debug!(?parsed, "RecordResolver resolve parsed"); 52 + 53 + // Try to get from storage first (only for acceptance records) 54 + if parsed.collection == "events.smokesignal.calendar.acceptance" { 55 + debug!(aturi = %aturi, "Checking storage for acceptance record"); 56 + 57 + match acceptance_record_get(&self.pool, aturi).await { 58 + Ok(Some(acceptance_record)) => { 59 + debug!(aturi = %aturi, "Found acceptance record in storage cache"); 60 + // Deserialize the stored record 61 + return serde_json::from_value(acceptance_record.record.0) 62 + .map_err(|e| anyhow!("Failed to deserialize cached record: {}", e)); 63 + } 64 + Ok(None) => { 65 + debug!(aturi = %aturi, "Acceptance record not found in storage, fetching from network"); 66 + } 67 + Err(e) => { 68 + warn!( 69 + aturi = %aturi, 70 + error = %e, 71 + "Failed to check storage for acceptance record, will fetch from network" 72 + ); 73 + } 74 + } 75 + } 76 + 77 + // Not in storage or not an acceptance record - fetch from network 78 + debug!(aturi = %aturi, authority = %parsed.authority, "Resolving DID to fetch record from PDS"); 79 + 80 + // Resolve the DID to get the PDS endpoint 81 + let document = self 82 + .identity_resolver 83 + .resolve(&parsed.authority) 84 + .await 85 + .with_context(|| format!("Failed to resolve DID: {}", parsed.authority))?; 86 + 87 + // Find the PDS endpoint 88 + let pds_endpoint = document 89 + .service 90 + .iter() 91 + .find(|s| s.r#type == "AtprotoPersonalDataServer") 92 + .map(|s| s.service_endpoint.as_str()) 93 + .ok_or_else(|| anyhow!("No PDS endpoint found for DID: {}", parsed.authority))?; 94 + 95 + debug!( 96 + aturi = %aturi, 97 + pds_endpoint = %pds_endpoint, 98 + "Fetching record from PDS" 99 + ); 100 + 101 + // Fetch the record using the XRPC client 102 + let response = atproto_client::com::atproto::repo::get_record( 103 + &self.http_client, 104 + &atproto_client::client::Auth::None, 105 + pds_endpoint, 106 + &parsed.authority, 107 + &parsed.collection, 108 + &parsed.record_key, 109 + None, 110 + ) 111 + .await 112 + .with_context(|| format!("Failed to fetch record from PDS: {}", pds_endpoint))?; 113 + 114 + match response { 115 + atproto_client::com::atproto::repo::GetRecordResponse::Record { 116 + value, cid, .. 117 + } => { 118 + // If this is an acceptance record, store it for future use 119 + if parsed.collection == "events.smokesignal.calendar.acceptance" { 120 + debug!(aturi = %aturi, "Caching acceptance record in storage"); 121 + 122 + // Store asynchronously, but don't fail if storage fails 123 + if let Err(e) = 124 + acceptance_record_upsert(&self.pool, aturi, &cid, &parsed.authority, &value) 125 + .await 126 + { 127 + warn!( 128 + aturi = %aturi, 129 + error = %e, 130 + "Failed to cache acceptance record in storage" 131 + ); 132 + } 133 + } 134 + 135 + // Deserialize and return the record 136 + serde_json::from_value(value) 137 + .map_err(|e| anyhow!("Failed to deserialize record: {}", e)) 138 + } 139 + atproto_client::com::atproto::repo::GetRecordResponse::Error(error) => { 140 + Err(anyhow!("Failed to fetch record: {}", error.error_message())) 141 + } 142 + } 143 + } 144 + } 145 + 146 + // Implement RecordResolver for &StorageBackedRecordResolver to allow passing by reference 147 + #[async_trait::async_trait] 148 + impl atproto_client::record_resolver::RecordResolver for &StorageBackedRecordResolver { 149 + async fn resolve<T>(&self, aturi: &str) -> anyhow::Result<T> 150 + where 151 + T: serde::de::DeserializeOwned + Send, 152 + { 153 + // Delegate to the implementation for StorageBackedRecordResolver 154 + (*self).resolve(aturi).await 155 + } 156 + }
+336
src/storage/acceptance.rs
··· 1 + use anyhow::Result; 2 + use chrono::{DateTime, Utc}; 3 + use serde_json::json; 4 + use sqlx::FromRow; 5 + 6 + use super::StoragePool; 7 + use super::errors::StorageError; 8 + 9 + /// Model for RSVP acceptance ticket 10 + #[derive(Clone, FromRow, Debug)] 11 + pub struct AcceptanceTicket { 12 + pub aturi: String, 13 + pub did: String, 14 + pub rsvp_did: String, 15 + pub event_aturi: String, 16 + pub record: sqlx::types::Json<serde_json::Value>, 17 + pub created_at: DateTime<Utc>, 18 + } 19 + 20 + /// Model for RSVP acceptance record 21 + #[derive(Clone, FromRow, Debug)] 22 + pub struct AcceptanceRecord { 23 + pub aturi: String, 24 + pub cid: String, 25 + pub did: String, 26 + pub record: sqlx::types::Json<serde_json::Value>, 27 + pub created_at: DateTime<Utc>, 28 + pub updated_at: DateTime<Utc>, 29 + } 30 + 31 + /// Insert or update an acceptance ticket 32 + pub async fn acceptance_ticket_upsert<T: serde::Serialize>( 33 + pool: &StoragePool, 34 + aturi: &str, 35 + did: &str, 36 + rsvp_did: &str, 37 + event_aturi: &str, 38 + record: &T, 39 + ) -> Result<(), StorageError> { 40 + let mut tx = pool 41 + .begin() 42 + .await 43 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 44 + 45 + let now = Utc::now(); 46 + 47 + sqlx::query( 48 + "INSERT INTO acceptance_tickets (aturi, did, rsvp_did, event_aturi, record, created_at) 49 + VALUES ($1, $2, $3, $4, $5, $6) 50 + ON CONFLICT (aturi) DO UPDATE 51 + SET did = $2, rsvp_did = $3, event_aturi = $4, record = $5", 52 + ) 53 + .bind(aturi) 54 + .bind(did) 55 + .bind(rsvp_did) 56 + .bind(event_aturi) 57 + .bind(json!(record)) 58 + .bind(now) 59 + .execute(tx.as_mut()) 60 + .await 61 + .map_err(StorageError::UnableToExecuteQuery)?; 62 + 63 + tx.commit() 64 + .await 65 + .map_err(StorageError::CannotCommitDatabaseTransaction) 66 + } 67 + 68 + /// Get acceptance ticket by AT-URI 69 + pub async fn acceptance_ticket_get( 70 + pool: &StoragePool, 71 + aturi: &str, 72 + ) -> Result<Option<AcceptanceTicket>, StorageError> { 73 + let mut tx = pool 74 + .begin() 75 + .await 76 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 77 + 78 + let record = 79 + sqlx::query_as::<_, AcceptanceTicket>("SELECT * FROM acceptance_tickets WHERE aturi = $1") 80 + .bind(aturi) 81 + .fetch_optional(tx.as_mut()) 82 + .await 83 + .map_err(StorageError::UnableToExecuteQuery)?; 84 + 85 + tx.commit() 86 + .await 87 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 88 + 89 + Ok(record) 90 + } 91 + 92 + /// List acceptance tickets created by a specific DID 93 + pub async fn acceptance_ticket_list_by_did( 94 + pool: &StoragePool, 95 + did: &str, 96 + ) -> Result<Vec<AcceptanceTicket>, StorageError> { 97 + let mut tx = pool 98 + .begin() 99 + .await 100 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 101 + 102 + let records = sqlx::query_as::<_, AcceptanceTicket>( 103 + "SELECT * FROM acceptance_tickets WHERE did = $1 ORDER BY created_at DESC", 104 + ) 105 + .bind(did) 106 + .fetch_all(tx.as_mut()) 107 + .await 108 + .map_err(StorageError::UnableToExecuteQuery)?; 109 + 110 + tx.commit() 111 + .await 112 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 113 + 114 + Ok(records) 115 + } 116 + 117 + /// List acceptance tickets for a specific RSVP DID (tickets created for this user) 118 + pub async fn acceptance_ticket_list_by_rsvp_did( 119 + pool: &StoragePool, 120 + rsvp_did: &str, 121 + ) -> Result<Vec<AcceptanceTicket>, StorageError> { 122 + let mut tx = pool 123 + .begin() 124 + .await 125 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 126 + 127 + let records = sqlx::query_as::<_, AcceptanceTicket>( 128 + "SELECT * FROM acceptance_tickets WHERE rsvp_did = $1 ORDER BY created_at DESC", 129 + ) 130 + .bind(rsvp_did) 131 + .fetch_all(tx.as_mut()) 132 + .await 133 + .map_err(StorageError::UnableToExecuteQuery)?; 134 + 135 + tx.commit() 136 + .await 137 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 138 + 139 + Ok(records) 140 + } 141 + 142 + /// List acceptance tickets for a specific event 143 + pub async fn acceptance_ticket_list_by_event( 144 + pool: &StoragePool, 145 + event_aturi: &str, 146 + ) -> Result<Vec<AcceptanceTicket>, StorageError> { 147 + let mut tx = pool 148 + .begin() 149 + .await 150 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 151 + 152 + let records = sqlx::query_as::<_, AcceptanceTicket>( 153 + "SELECT * FROM acceptance_tickets WHERE event_aturi = $1 ORDER BY created_at DESC", 154 + ) 155 + .bind(event_aturi) 156 + .fetch_all(tx.as_mut()) 157 + .await 158 + .map_err(StorageError::UnableToExecuteQuery)?; 159 + 160 + tx.commit() 161 + .await 162 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 163 + 164 + Ok(records) 165 + } 166 + 167 + /// Delete an acceptance ticket 168 + pub async fn acceptance_ticket_delete(pool: &StoragePool, aturi: &str) -> Result<(), StorageError> { 169 + let mut tx = pool 170 + .begin() 171 + .await 172 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 173 + 174 + sqlx::query("DELETE FROM acceptance_tickets WHERE aturi = $1") 175 + .bind(aturi) 176 + .execute(tx.as_mut()) 177 + .await 178 + .map_err(StorageError::UnableToExecuteQuery)?; 179 + 180 + tx.commit() 181 + .await 182 + .map_err(StorageError::CannotCommitDatabaseTransaction) 183 + } 184 + 185 + /// Insert or update an acceptance record 186 + pub async fn acceptance_record_upsert<T: serde::Serialize>( 187 + pool: &StoragePool, 188 + aturi: &str, 189 + cid: &str, 190 + did: &str, 191 + record: &T, 192 + ) -> Result<(), StorageError> { 193 + let mut tx = pool 194 + .begin() 195 + .await 196 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 197 + 198 + let now = Utc::now(); 199 + 200 + sqlx::query( 201 + "INSERT INTO acceptance_records (aturi, cid, did, record, created_at, updated_at) 202 + VALUES ($1, $2, $3, $4, $5, $6) 203 + ON CONFLICT (aturi) DO UPDATE 204 + SET cid = $2, did = $3, record = $4, updated_at = $6", 205 + ) 206 + .bind(aturi) 207 + .bind(cid) 208 + .bind(did) 209 + .bind(json!(record)) 210 + .bind(now) 211 + .bind(now) 212 + .execute(tx.as_mut()) 213 + .await 214 + .map_err(StorageError::UnableToExecuteQuery)?; 215 + 216 + tx.commit() 217 + .await 218 + .map_err(StorageError::CannotCommitDatabaseTransaction) 219 + } 220 + 221 + /// Get acceptance record by AT-URI 222 + pub async fn acceptance_record_get( 223 + pool: &StoragePool, 224 + aturi: &str, 225 + ) -> Result<Option<AcceptanceRecord>, StorageError> { 226 + let mut tx = pool 227 + .begin() 228 + .await 229 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 230 + 231 + let record = 232 + sqlx::query_as::<_, AcceptanceRecord>("SELECT * FROM acceptance_records WHERE aturi = $1") 233 + .bind(aturi) 234 + .fetch_optional(tx.as_mut()) 235 + .await 236 + .map_err(StorageError::UnableToExecuteQuery)?; 237 + 238 + tx.commit() 239 + .await 240 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 241 + 242 + Ok(record) 243 + } 244 + 245 + /// Get acceptance record by RSVP CID 246 + pub async fn acceptance_record_get_by_cid( 247 + pool: &StoragePool, 248 + cid: &str, 249 + ) -> Result<Option<AcceptanceRecord>, StorageError> { 250 + let mut tx = pool 251 + .begin() 252 + .await 253 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 254 + 255 + let record = sqlx::query_as::<_, AcceptanceRecord>( 256 + "SELECT * FROM acceptance_records WHERE cid = $1 LIMIT 1", 257 + ) 258 + .bind(cid) 259 + .fetch_optional(tx.as_mut()) 260 + .await 261 + .map_err(StorageError::UnableToExecuteQuery)?; 262 + 263 + tx.commit() 264 + .await 265 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 266 + 267 + Ok(record) 268 + } 269 + 270 + /// Delete an acceptance record 271 + pub async fn acceptance_record_delete(pool: &StoragePool, aturi: &str) -> Result<(), StorageError> { 272 + let mut tx = pool 273 + .begin() 274 + .await 275 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 276 + 277 + sqlx::query("DELETE FROM acceptance_records WHERE aturi = $1") 278 + .bind(aturi) 279 + .execute(tx.as_mut()) 280 + .await 281 + .map_err(StorageError::UnableToExecuteQuery)?; 282 + 283 + tx.commit() 284 + .await 285 + .map_err(StorageError::CannotCommitDatabaseTransaction) 286 + } 287 + 288 + /// Get acceptance ticket for a specific event and RSVP DID combination 289 + pub async fn acceptance_ticket_get_by_event_and_rsvp_did( 290 + pool: &StoragePool, 291 + event_aturi: &str, 292 + rsvp_did: &str, 293 + ) -> Result<Option<AcceptanceTicket>, StorageError> { 294 + let mut tx = pool 295 + .begin() 296 + .await 297 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 298 + 299 + let record = sqlx::query_as::<_, AcceptanceTicket>( 300 + "SELECT * FROM acceptance_tickets WHERE event_aturi = $1 AND rsvp_did = $2 LIMIT 1", 301 + ) 302 + .bind(event_aturi) 303 + .bind(rsvp_did) 304 + .fetch_optional(tx.as_mut()) 305 + .await 306 + .map_err(StorageError::UnableToExecuteQuery)?; 307 + 308 + tx.commit() 309 + .await 310 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 311 + 312 + Ok(record) 313 + } 314 + 315 + /// Update RSVP validated_at timestamp 316 + pub async fn rsvp_update_validated_at( 317 + pool: &StoragePool, 318 + rsvp_aturi: &str, 319 + validated_at: Option<DateTime<Utc>>, 320 + ) -> Result<(), StorageError> { 321 + let mut tx = pool 322 + .begin() 323 + .await 324 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 325 + 326 + sqlx::query("UPDATE rsvps SET validated_at = $1 WHERE aturi = $2") 327 + .bind(validated_at) 328 + .bind(rsvp_aturi) 329 + .execute(tx.as_mut()) 330 + .await 331 + .map_err(StorageError::UnableToExecuteQuery)?; 332 + 333 + tx.commit() 334 + .await 335 + .map_err(StorageError::CannotCommitDatabaseTransaction) 336 + }
+133 -30
src/storage/event.rs
··· 3 3 4 4 use anyhow::Result; 5 5 use chrono::Utc; 6 + use serde::Serialize; 6 7 use serde_json::json; 7 8 use sqlx::{Postgres, QueryBuilder}; 8 9 ··· 13 14 14 15 use super::StoragePool; 15 16 use super::errors::StorageError; 16 - use model::{ActivityItem, Event, EventWithRole, Rsvp}; 17 + use model::{ActivityItem, EventWithRole, Rsvp}; 17 18 18 - pub mod model { 19 + pub(crate) mod model { 19 20 use chrono::{DateTime, Utc}; 20 21 use serde::{Deserialize, Serialize}; 21 22 use sqlx::FromRow; 22 23 23 24 #[derive(Clone, FromRow, Deserialize, Serialize, Debug)] 24 - pub(crate) struct Event { 25 + pub struct Event { 25 26 pub aturi: String, 26 27 pub cid: String, 27 28 ··· 58 59 pub event_cid: String, 59 60 pub status: String, 60 61 pub email_shared: bool, 62 + pub validated_at: Option<DateTime<Utc>>, 61 63 62 64 pub updated_at: Option<DateTime<Utc>>, 63 65 } ··· 73 75 pub created_at: Option<DateTime<Utc>>, 74 76 } 75 77 } 78 + 79 + // Import Event for use within this module and re-export for external use 80 + pub use model::Event; 76 81 77 82 pub async fn event_insert( 78 83 pool: &StoragePool, ··· 214 219 let mut parts = Vec::new(); 215 220 216 221 // Add parts in specified order, omitting empty values 217 - if let Some(name_val) = name { 218 - if !name_val.trim().is_empty() { 219 - parts.push(name_val.clone()); 220 - } 222 + if let Some(name_val) = name 223 + && !name_val.trim().is_empty() 224 + { 225 + parts.push(name_val.clone()); 221 226 } 222 227 223 - if let Some(street_val) = street { 224 - if !street_val.trim().is_empty() { 225 - parts.push(street_val.clone()); 226 - } 228 + if let Some(street_val) = street 229 + && !street_val.trim().is_empty() 230 + { 231 + parts.push(street_val.clone()); 227 232 } 228 233 229 - if let Some(locality_val) = locality { 230 - if !locality_val.trim().is_empty() { 231 - parts.push(locality_val.clone()); 232 - } 234 + if let Some(locality_val) = locality 235 + && !locality_val.trim().is_empty() 236 + { 237 + parts.push(locality_val.clone()); 233 238 } 234 239 235 - if let Some(region_val) = region { 236 - if !region_val.trim().is_empty() { 237 - parts.push(region_val.clone()); 238 - } 240 + if let Some(region_val) = region 241 + && !region_val.trim().is_empty() 242 + { 243 + parts.push(region_val.clone()); 239 244 } 240 245 241 - if let Some(postal_val) = postal_code { 242 - if !postal_val.trim().is_empty() { 243 - parts.push(postal_val.clone()); 244 - } 246 + if let Some(postal_val) = postal_code 247 + && !postal_val.trim().is_empty() 248 + { 249 + parts.push(postal_val.clone()); 245 250 } 246 251 247 252 // Country is required so no need to check if it's empty ··· 447 452 } 448 453 449 454 // If status is provided, validate it's not empty 450 - if let Some(status_val) = status { 451 - if status_val.trim().is_empty() { 452 - return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 453 - "Status cannot be empty".into(), 454 - ))); 455 - } 455 + if let Some(status_val) = status 456 + && status_val.trim().is_empty() 457 + { 458 + return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 459 + "Status cannot be empty".into(), 460 + ))); 456 461 } 457 462 458 463 let mut tx = pool ··· 487 492 Ok(rsvps) 488 493 } 489 494 495 + pub async fn get_event_rsvps_with_validation( 496 + pool: &StoragePool, 497 + event_aturi: &str, 498 + status: Option<&str>, 499 + ) -> Result<Vec<(String, Option<chrono::DateTime<chrono::Utc>>)>, StorageError> { 500 + // Validate event_aturi is not empty 501 + if event_aturi.trim().is_empty() { 502 + return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 503 + "Event URI cannot be empty".into(), 504 + ))); 505 + } 506 + 507 + // If status is provided, validate it's not empty 508 + if let Some(status_val) = status 509 + && status_val.trim().is_empty() 510 + { 511 + return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 512 + "Status cannot be empty".into(), 513 + ))); 514 + } 515 + 516 + let mut tx = pool 517 + .begin() 518 + .await 519 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 520 + 521 + let query = if status.is_some() { 522 + "SELECT did, validated_at FROM rsvps WHERE event_aturi = $1 AND status = $2" 523 + } else { 524 + "SELECT did, validated_at FROM rsvps WHERE event_aturi = $1" 525 + }; 526 + 527 + let rsvps = if let Some(status_value) = status { 528 + sqlx::query_as::<_, (String, Option<chrono::DateTime<chrono::Utc>>)>(query) 529 + .bind(event_aturi) 530 + .bind(status_value) 531 + .fetch_all(tx.as_mut()) 532 + .await 533 + } else { 534 + sqlx::query_as::<_, (String, Option<chrono::DateTime<chrono::Utc>>)>(query) 535 + .bind(event_aturi) 536 + .fetch_all(tx.as_mut()) 537 + .await 538 + } 539 + .map_err(StorageError::UnableToExecuteQuery)?; 540 + 541 + tx.commit() 542 + .await 543 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 544 + 545 + Ok(rsvps) 546 + } 547 + 490 548 pub async fn get_user_rsvp( 491 549 pool: &StoragePool, 492 550 event_aturi: &str, ··· 565 623 .map_err(StorageError::CannotCommitDatabaseTransaction)?; 566 624 567 625 Ok(result) 626 + } 627 + 628 + pub async fn rsvp_get_by_event_and_did( 629 + pool: &StoragePool, 630 + event_aturi: &str, 631 + did: &str, 632 + ) -> Result<Option<Rsvp>, StorageError> { 633 + // Validate event_aturi is not empty 634 + if event_aturi.trim().is_empty() { 635 + return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 636 + "Event URI cannot be empty".into(), 637 + ))); 638 + } 639 + 640 + // Validate did is not empty 641 + if did.trim().is_empty() { 642 + return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 643 + "DID cannot be empty".into(), 644 + ))); 645 + } 646 + 647 + let mut tx = pool 648 + .begin() 649 + .await 650 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 651 + 652 + let rsvp = sqlx::query_as::<_, Rsvp>("SELECT * FROM rsvps WHERE event_aturi = $1 AND did = $2") 653 + .bind(event_aturi) 654 + .bind(did) 655 + .fetch_optional(tx.as_mut()) 656 + .await 657 + .map_err(StorageError::UnableToExecuteQuery)?; 658 + 659 + tx.commit() 660 + .await 661 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 662 + 663 + Ok(rsvp) 568 664 } 569 665 570 666 pub async fn rsvp_get(pool: &StoragePool, aturi: &str) -> Result<Option<Rsvp>, StorageError> { ··· 1070 1166 } 1071 1167 1072 1168 // Structure to hold RSVP export data 1073 - #[derive(Debug)] 1169 + #[derive(Debug, Serialize)] 1074 1170 pub struct RsvpExportData { 1075 1171 pub event_aturi: String, 1076 1172 pub rsvp_aturi: String, ··· 1078 1174 pub handle: Option<String>, 1079 1175 pub status: String, 1080 1176 pub created_at: Option<chrono::DateTime<chrono::Utc>>, 1177 + } 1178 + 1179 + #[derive(Debug, Serialize, Clone)] 1180 + pub struct RsvpDisplayData { 1181 + pub did: String, 1182 + pub handle: Option<String>, 1183 + pub validated_at: Option<chrono::DateTime<chrono::Utc>>, 1081 1184 } 1082 1185 1083 1186 /// Get all RSVPs for an event with detailed information for CSV export
+2 -80
src/storage/identity_profile.rs
··· 22 22 pub language: String, 23 23 pub tz: String, 24 24 pub email: Option<String>, 25 - pub discover_events: bool, 26 - pub discover_rsvps: bool, 27 25 28 26 pub created_at: DateTime<Utc>, 29 27 pub updated_at: DateTime<Utc>, ··· 124 122 Language(Cow<'static, str>), 125 123 Timezone(Cow<'static, str>), 126 124 ActiveNow, 127 - DiscoverEvents(bool), 128 - DiscoverRsvps(bool), 129 125 } 130 126 131 127 pub async fn handle_update_field( ··· 150 146 HandleField::ActiveNow => { 151 147 "UPDATE identity_profiles SET active_at = $1, updated_at = $2 WHERE did = $3" 152 148 } 153 - HandleField::DiscoverEvents(_) => { 154 - "UPDATE identity_profiles SET discover_events = $1, updated_at = $2 WHERE did = $3" 155 - } 156 - HandleField::DiscoverRsvps(_) => { 157 - "UPDATE identity_profiles SET discover_rsvps = $1, updated_at = $2 WHERE did = $3" 158 - } 159 149 }; 160 150 161 151 let mut query_builder = sqlx::query(query); ··· 170 160 HandleField::ActiveNow => { 171 161 query_builder = query_builder.bind(now); 172 162 } 173 - HandleField::DiscoverEvents(value) => { 174 - query_builder = query_builder.bind(value); 175 - } 176 - HandleField::DiscoverRsvps(value) => { 177 - query_builder = query_builder.bind(value); 178 - } 179 163 } 180 164 181 165 query_builder ··· 222 206 .map_err(StorageError::CannotCommitDatabaseTransaction) 223 207 } 224 208 225 - pub async fn identity_profile_allow_discover_events( 226 - pool: &StoragePool, 227 - did: &str, 228 - ) -> Result<Option<bool>, StorageError> { 229 - // Validate DID is not empty 230 - if did.trim().is_empty() { 231 - return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 232 - "DID cannot be empty".into(), 233 - ))); 234 - } 235 - 236 - let mut tx = pool 237 - .begin() 238 - .await 239 - .map_err(StorageError::CannotBeginDatabaseTransaction)?; 240 - 241 - let discover_events = sqlx::query_scalar::<_, bool>( 242 - "SELECT discover_events FROM identity_profiles WHERE did = $1", 243 - ) 244 - .bind(did) 245 - .fetch_optional(tx.as_mut()) 246 - .await 247 - .map_err(StorageError::UnableToExecuteQuery)?; 248 - 249 - tx.commit() 250 - .await 251 - .map_err(StorageError::CannotCommitDatabaseTransaction)?; 252 - 253 - Ok(discover_events) 254 - } 255 - 256 - pub async fn identity_profile_allow_discover_rsvps( 257 - pool: &StoragePool, 258 - did: &str, 259 - ) -> Result<Option<bool>, StorageError> { 260 - // Validate DID is not empty 261 - if did.trim().is_empty() { 262 - return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 263 - "DID cannot be empty".into(), 264 - ))); 265 - } 266 - 267 - let mut tx = pool 268 - .begin() 269 - .await 270 - .map_err(StorageError::CannotBeginDatabaseTransaction)?; 271 - 272 - let discover_rsvps = sqlx::query_scalar::<_, bool>( 273 - "SELECT discover_rsvps FROM identity_profiles WHERE did = $1", 274 - ) 275 - .bind(did) 276 - .fetch_optional(tx.as_mut()) 277 - .await 278 - .map_err(StorageError::UnableToExecuteQuery)?; 279 - 280 - tx.commit() 281 - .await 282 - .map_err(StorageError::CannotCommitDatabaseTransaction)?; 283 - 284 - Ok(discover_rsvps) 285 - } 286 - 287 209 pub async fn handle_for_did( 288 210 pool: &StoragePool, 289 211 did: &str, ··· 532 454 async fn test_handle_for_did(pool: PgPool) -> sqlx::Result<()> { 533 455 let handle = handle_for_did(&pool, "did:plc:d5c1ed6d01421a67b96f68fa").await; 534 456 println!("result {:?}", handle); 535 - assert!(!handle.is_err()); 457 + assert!(handle.is_ok()); 536 458 let handle = handle.unwrap(); 537 459 assert_eq!(handle.handle, "whole-crane.examplepds.com"); 538 460 ··· 543 465 async fn test_handle_for_handle(pool: PgPool) -> sqlx::Result<()> { 544 466 let handle = handle_for_handle(&pool, "whole-crane.examplepds.com").await; 545 467 println!("result {:?}", handle); 546 - assert!(!handle.is_err()); 468 + assert!(handle.is_ok()); 547 469 let handle = handle.unwrap(); 548 470 assert_eq!(handle.did, "did:plc:d5c1ed6d01421a67b96f68fa"); 549 471
+1
src/storage/mod.rs
··· 1 + pub mod acceptance; 1 2 pub mod atproto; 2 3 pub mod cache; 3 4 pub mod content;
+6 -6
src/storage/oauth.rs
··· 92 92 } 93 93 94 94 // If did is provided, validate it's not empty 95 - if let Some(did_value) = did { 96 - if did_value.trim().is_empty() { 97 - return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 98 - "DID cannot be empty".into(), 99 - ))); 100 - } 95 + if let Some(did_value) = did 96 + && did_value.trim().is_empty() 97 + { 98 + return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 99 + "DID cannot be empty".into(), 100 + ))); 101 101 } 102 102 103 103 let mut tx = pool
+1 -1
src/throttle_redis.rs
··· 1 1 use async_trait::async_trait; 2 - use deadpool_redis::{redis::AsyncCommands, Pool as RedisPool}; 2 + use deadpool_redis::{Pool as RedisPool, redis::AsyncCommands}; 3 3 use std::time::{SystemTime, UNIX_EPOCH}; 4 4 5 5 use crate::throttle::{Throttle, ThrottleError};
+2 -6
src/unsubscribe_token.rs
··· 83 83 Ok(UnsubscribeAction::UnsubscribeAll { 84 84 receiver: dids[0].clone(), 85 85 }) 86 - } else if remainder.starts_with(':') { 86 + } else if let Some(preference) = remainder.strip_prefix(':') { 87 87 // [did]:preference_type 88 - let preference = &remainder[1..]; 89 88 let receiver = dids[0].clone(); 90 89 91 90 match preference { ··· 131 130 } 132 131 133 132 /// Generate an unsubscribe token 134 - pub fn generate_token( 135 - action: &UnsubscribeAction, 136 - secret_key: &EmailSecretKey, 137 - ) -> Result<String> { 133 + pub fn generate_token(action: &UnsubscribeAction, secret_key: &EmailSecretKey) -> Result<String> { 138 134 let payload = action.to_payload(); 139 135 140 136 // Create HMAC-SHA256 signature
+287
templates/email/rsvp_accepted.html.jinja
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 5 + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> 6 + <title>Your RSVP has been accepted</title> 7 + <style media="all" type="text/css"> 8 + /* GLOBAL RESETS */ 9 + body { 10 + font-family: Helvetica, sans-serif; 11 + -webkit-font-smoothing: antialiased; 12 + font-size: 16px; 13 + line-height: 1.3; 14 + -ms-text-size-adjust: 100%; 15 + -webkit-text-size-adjust: 100%; 16 + } 17 + 18 + table { 19 + border-collapse: separate; 20 + mso-table-lspace: 0pt; 21 + mso-table-rspace: 0pt; 22 + width: 100%; 23 + } 24 + 25 + table td { 26 + font-family: Helvetica, sans-serif; 27 + font-size: 16px; 28 + vertical-align: top; 29 + } 30 + 31 + /* BODY & CONTAINER */ 32 + body { 33 + background-color: #f4f5f6; 34 + margin: 0; 35 + padding: 0; 36 + } 37 + 38 + .body { 39 + background-color: #f4f5f6; 40 + width: 100%; 41 + } 42 + 43 + .container { 44 + margin: 0 auto !important; 45 + max-width: 600px; 46 + padding: 0; 47 + padding-top: 24px; 48 + width: 600px; 49 + } 50 + 51 + .content { 52 + box-sizing: border-box; 53 + display: block; 54 + margin: 0 auto; 55 + max-width: 600px; 56 + padding: 0; 57 + } 58 + 59 + /* HEADER, FOOTER, MAIN */ 60 + .main { 61 + background: #ffffff; 62 + border: 1px solid #eaebed; 63 + border-radius: 16px; 64 + width: 100%; 65 + } 66 + 67 + .wrapper { 68 + box-sizing: border-box; 69 + padding: 24px; 70 + } 71 + 72 + .footer { 73 + clear: both; 74 + padding-top: 24px; 75 + text-align: center; 76 + width: 100%; 77 + } 78 + 79 + .footer td, 80 + .footer p, 81 + .footer span, 82 + .footer a { 83 + color: #9a9ea6; 84 + font-size: 16px; 85 + text-align: center; 86 + } 87 + 88 + /* TYPOGRAPHY */ 89 + p { 90 + font-family: Helvetica, sans-serif; 91 + font-size: 16px; 92 + font-weight: normal; 93 + margin: 0; 94 + margin-bottom: 16px; 95 + } 96 + 97 + a { 98 + color: #0867ec; 99 + text-decoration: underline; 100 + } 101 + 102 + /* BUTTONS */ 103 + .btn { 104 + box-sizing: border-box; 105 + min-width: 100% !important; 106 + width: 100%; 107 + } 108 + 109 + .btn > tbody > tr > td { 110 + padding-bottom: 16px; 111 + } 112 + 113 + .btn table { 114 + width: auto; 115 + } 116 + 117 + .btn table td { 118 + background-color: #ffffff; 119 + border-radius: 4px; 120 + text-align: center; 121 + } 122 + 123 + .btn a { 124 + background-color: #ffffff; 125 + border: solid 2px #0867ec; 126 + border-radius: 4px; 127 + box-sizing: border-box; 128 + color: #0867ec; 129 + cursor: pointer; 130 + display: inline-block; 131 + font-size: 16px; 132 + font-weight: bold; 133 + margin: 0; 134 + padding: 12px 24px; 135 + text-decoration: none; 136 + text-transform: capitalize; 137 + } 138 + 139 + .btn-primary table td { 140 + background-color: #0867ec; 141 + } 142 + 143 + .btn-primary a { 144 + background-color: #0867ec; 145 + border-color: #0867ec; 146 + color: #ffffff; 147 + } 148 + 149 + @media all { 150 + .btn-primary table td:hover { 151 + background-color: #ec0867 !important; 152 + } 153 + .btn-primary a:hover { 154 + background-color: #ec0867 !important; 155 + border-color: #ec0867 !important; 156 + } 157 + } 158 + 159 + /* UTILITY CLASSES */ 160 + .preheader { 161 + color: transparent; 162 + display: none; 163 + height: 0; 164 + max-height: 0; 165 + max-width: 0; 166 + opacity: 0; 167 + overflow: hidden; 168 + mso-hide: all; 169 + visibility: hidden; 170 + width: 0; 171 + } 172 + 173 + /* RESPONSIVE AND MOBILE FRIENDLY STYLES */ 174 + @media only screen and (max-width: 640px) { 175 + .main p, 176 + .main td, 177 + .main span { 178 + font-size: 16px !important; 179 + } 180 + .wrapper { 181 + padding: 8px !important; 182 + } 183 + .content { 184 + padding: 0 !important; 185 + } 186 + .container { 187 + padding: 0 !important; 188 + padding-top: 8px !important; 189 + width: 100% !important; 190 + } 191 + .main { 192 + border-left-width: 0 !important; 193 + border-radius: 0 !important; 194 + border-right-width: 0 !important; 195 + } 196 + .btn table { 197 + max-width: 100% !important; 198 + width: 100% !important; 199 + } 200 + .btn a { 201 + font-size: 16px !important; 202 + max-width: 100% !important; 203 + width: 100% !important; 204 + } 205 + } 206 + 207 + /* PRESERVE THESE STYLES IN THE HEAD */ 208 + @media all { 209 + .ExternalClass { 210 + width: 100%; 211 + } 212 + .ExternalClass, 213 + .ExternalClass p, 214 + .ExternalClass span, 215 + .ExternalClass font, 216 + .ExternalClass td, 217 + .ExternalClass div { 218 + line-height: 100%; 219 + } 220 + .apple-link a { 221 + color: inherit !important; 222 + font-family: inherit !important; 223 + font-size: inherit !important; 224 + font-weight: inherit !important; 225 + line-height: inherit !important; 226 + text-decoration: none !important; 227 + } 228 + #MessageViewBody a { 229 + color: inherit; 230 + text-decoration: none; 231 + font-size: inherit; 232 + font-family: inherit; 233 + font-weight: inherit; 234 + line-height: inherit; 235 + } 236 + } 237 + </style> 238 + </head> 239 + <body> 240 + <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body"> 241 + <tr> 242 + <td>&nbsp;</td> 243 + <td class="container"> 244 + <div class="content"> 245 + <span class="preheader">Your RSVP has been accepted for: {{ event_name }}</span> 246 + <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main"> 247 + <tr> 248 + <td class="wrapper"> 249 + <p>Hello!</p> 250 + <p>Your RSVP has been accepted for the event: <strong>{{ event_name }}</strong></p> 251 + <p>Visit the event page to finalize your RSVP and update it with the acceptance attestation.</p> 252 + <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary"> 253 + <tbody> 254 + <tr> 255 + <td align="left"> 256 + <table role="presentation" border="0" cellpadding="0" cellspacing="0"> 257 + <tbody> 258 + <tr> 259 + <td><a href="{{ event_url }}" target="_blank">View Event & Finalize RSVP</a></td> 260 + </tr> 261 + </tbody> 262 + </table> 263 + </td> 264 + </tr> 265 + </tbody> 266 + </table> 267 + </td> 268 + </tr> 269 + </table> 270 + <div class="footer"> 271 + <table role="presentation" border="0" cellpadding="0" cellspacing="0"> 272 + <tr> 273 + <td class="content-block"> 274 + <span class="apple-link">Smoke Signal Events - https://smokesignal.events/</span> 275 + <br>Update your notification settings: <a href="https://smokesignal.events/settings">Settings</a>. 276 + <br><a href="{{ unsubscribe_url }}">Unsubscribe</a> from these notifications. 277 + </td> 278 + </tr> 279 + </table> 280 + </div> 281 + </div> 282 + </td> 283 + <td>&nbsp;</td> 284 + </tr> 285 + </table> 286 + </body> 287 + </html>
+10
templates/email/rsvp_accepted.txt.jinja
··· 1 + Hello! 2 + 3 + Your RSVP has been accepted for the event: {{ event_name }} 4 + 5 + View the event and finalize your RSVP: {{ event_url }} 6 + 7 + --- 8 + 9 + Update your notification settings: https://smokesignal.events/settings 10 + Unsubscribe from these notifications: {{ unsubscribe_url }}
+51
templates/en-us/edit_event.common.html
··· 6 6 7 7 {% include 'en-us/create_event.partial.html' %} 8 8 9 + <div class="box has-background-info-light"> 10 + <h2 class="title is-4">Manage RSVPs</h2> 11 + <p class="mb-4">Accept RSVPs from attendees to confirm their attendance.</p> 12 + 13 + {% if rsvps and rsvps|length > 0 %} 14 + <table class="table is-fullwidth is-striped"> 15 + <thead> 16 + <tr> 17 + <th>Handle</th> 18 + <th>Status</th> 19 + <th>Action</th> 20 + </tr> 21 + </thead> 22 + <tbody> 23 + {% for rsvp in rsvps %} 24 + <tr> 25 + <td> 26 + {% if rsvp.handle %} 27 + <strong>@{{ rsvp.handle }}</strong> 28 + {% else %} 29 + <em>{{ rsvp.did }}</em> 30 + {% endif %} 31 + </td> 32 + <td> 33 + <span class="tag {% if rsvp.status == 'going' %}is-success{% elif rsvp.status == 'interested' %}is-info{% else %}is-light{% endif %}"> 34 + {{ rsvp.status }} 35 + </span> 36 + </td> 37 + <td> 38 + <form hx-post="/accept_rsvp" hx-target="#accept-rsvp-result-{{ loop.index }}" hx-swap="innerHTML" class="is-inline"> 39 + <input type="hidden" name="rsvp_aturi" value="{{ rsvp.rsvp_aturi }}" /> 40 + <button type="submit" class="button is-small is-primary" title="Accept this RSVP"> 41 + <span class="icon is-small"> 42 + <i class="fas fa-check"></i> 43 + </span> 44 + <span>Accept</span> 45 + </button> 46 + </form> 47 + <div id="accept-rsvp-result-{{ loop.index }}" class="mt-2"></div> 48 + </td> 49 + </tr> 50 + {% endfor %} 51 + </tbody> 52 + </table> 53 + {% else %} 54 + <p class="has-text-grey-light">No RSVPs yet.</p> 55 + {% endif %} 56 + 57 + <div id="accept-rsvp-result" class="mt-4"></div> 58 + </div> 59 + 9 60 <div class="box has-background-primary-light"> 10 61 <h2 class="title is-4">Export RSVPs</h2> 11 62 <p class="mb-4">Download a CSV file of identities that have RSVP'd to the event.</p>
-14
templates/en-us/settings.common.html
··· 54 54 55 55 <div class="columns"> 56 56 <div class="column"> 57 - <h2 class="subtitle">Discovery Settings</h2> 58 - <div id="discover-events-form" class="mb-4"> 59 - {% include "en-us/settings.discover_events.html" %} 60 - </div> 61 - <div id="discover-rsvps-form"> 62 - {% include "en-us/settings.discover_rsvps.html" %} 63 - </div> 64 - </div> 65 - </div> 66 - 67 - <hr> 68 - 69 - <div class="columns"> 70 - <div class="column"> 71 57 <h2 class="subtitle">Email & Notifications</h2> 72 58 <div id="notifications-form"> 73 59 {% include "en-us/settings.notifications.html" %}
-23
templates/en-us/settings.discover_events.html
··· 1 - <div class="field"> 2 - <label class="label">Event Discovery</label> 3 - <div class="control"> 4 - <form hx-post="/settings/discover_events" hx-target="#discover-events-form" hx-swap="innerHTML"> 5 - <label class="radio"> 6 - <input type="radio" name="discover_events" value="true" {% if current_handle.discover_events %}checked{% endif %} 7 - hx-post="/settings/discover_events" hx-target="#discover-events-form" hx-swap="innerHTML" 8 - data-loading-disable data-loading-aria-busy> 9 - Allow my events from outside of Smoke Signal to be displayed in Smoke Signal 10 - </label> 11 - <br> 12 - <label class="radio"> 13 - <input type="radio" name="discover_events" value="false" {% if not current_handle.discover_events %}checked{% endif %} 14 - hx-post="/settings/discover_events" hx-target="#discover-events-form" hx-swap="innerHTML" 15 - data-loading-disable data-loading-aria-busy> 16 - Disallow my events from outside of Smoke Signal to be displayed in Smoke Signal 17 - </label> 18 - </form> 19 - </div> 20 - {% if discover_events_updated %} 21 - <p class="help is-success mt-2">Event discovery preference updated successfully.</p> 22 - {% endif %} 23 - </div>
-23
templates/en-us/settings.discover_rsvps.html
··· 1 - <div class="field"> 2 - <label class="label">RSVP Discovery</label> 3 - <div class="control"> 4 - <form hx-post="/settings/discover_rsvps" hx-target="#discover-rsvps-form" hx-swap="innerHTML"> 5 - <label class="radio"> 6 - <input type="radio" name="discover_rsvps" value="true" {% if current_handle.discover_rsvps %}checked{% endif %} 7 - hx-post="/settings/discover_rsvps" hx-target="#discover-rsvps-form" hx-swap="innerHTML" 8 - data-loading-disable data-loading-aria-busy> 9 - Allow my RSVPs from outside of Smoke Signal to be displayed in Smoke Signal 10 - </label> 11 - <br> 12 - <label class="radio"> 13 - <input type="radio" name="discover_rsvps" value="false" {% if not current_handle.discover_rsvps %}checked{% endif %} 14 - hx-post="/settings/discover_rsvps" hx-target="#discover-rsvps-form" hx-swap="innerHTML" 15 - data-loading-disable data-loading-aria-busy> 16 - Disallow my RSVPs from outside of Smoke Signal to be displayed in Smoke Signal 17 - </label> 18 - </form> 19 - </div> 20 - {% if discover_rsvps_updated %} 21 - <p class="help is-success mt-2">RSVP discovery preference updated successfully.</p> 22 - {% endif %} 23 - </div>
+29 -2
templates/en-us/view_event.common.html
··· 191 191 {% elif user_rsvp_status == "notgoing" %} 192 192 <p>You have RSVP'd <strong>Not Goiing</strong>.</p> 193 193 {% endif %} 194 + {% if pending_acceptance %} 195 + <article class="message is-info mt-3"> 196 + <div class="message-body"> 197 + <p class="mb-2"><strong>Your RSVP has been accepted!</strong></p> 198 + <p class="mb-3">The event organizer has accepted your RSVP. Click below to finalize your acceptance.</p> 199 + <form hx-post="/finalize_acceptance" hx-target="#acceptance-result" hx-swap="innerHTML"> 200 + <input type="hidden" name="acceptance_aturi" value="{{ pending_acceptance }}" /> 201 + <button type="submit" class="button is-primary"> 202 + <span class="icon"> 203 + <i class="fas fa-check-circle"></i> 204 + </span> 205 + <span>Finalize Acceptance</span> 206 + </button> 207 + </form> 208 + <div id="acceptance-result" class="mt-3"></div> 209 + </div> 210 + </article> 211 + {% endif %} 194 212 {% if show_download_ics %} 195 213 <p> 196 214 <a class="button is-small mt-3" href="{{ base }}/ics/{{ event.aturi }}"> ··· 268 286 </ul> 269 287 </div> 270 288 <div class="grid is-col-min-12 has-text-centered"> 271 - {% for handle in active_tab_handles %} 289 + {% for rsvp in active_tab_rsvps %} 272 290 <span class="cell"> 273 - <a href="/@{{ handle }}">@{{ handle }}</a> 291 + {% if rsvp.handle %} 292 + <a href="/@{{ rsvp.handle }}">@{{ rsvp.handle }}</a> 293 + {% else %} 294 + <span class="has-text-grey">{{ rsvp.did }}</span> 295 + {% endif %} 296 + {% if rsvp.validated_at %} 297 + <span class="icon has-text-primary" title="Verified RSVP - Organizer has accepted this response"> 298 + <i class="fas fa-certificate"></i> 299 + </span> 300 + {% endif %} 274 301 </span> 275 302 {% endfor %} 276 303 </div>