···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
···0000000000000000000000000000
···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
···000000000000000000000000000000
···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+}
···174 };
175176 // 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())?;
179180 // Parse facet limit configuration
181 let facets_mentions_max = default_env("FACETS_MENTIONS_MAX", "5")
···313 type Error = anyhow::Error;
314 fn try_from(value: String) -> Result<Self, Self::Error> {
315 // Decode hex string to bytes
316- let decoded = hex::decode(&value)
317- .map_err(|err| ConfigError::EmailSecretKeyDecodeFailed(err))?;
318319 // Require at least 32 bytes (256 bits) for security
320 if decoded.len() < 32 {
···174 };
175176 // Parse email secret key (required for email confirmation tokens)
177+ let email_secret_key: EmailSecretKey =
178+ require_env("EMAIL_SECRET_KEY").and_then(|value| value.try_into())?;
179180 // Parse facet limit configuration
181 let facets_mentions_max = default_env("FACETS_MENTIONS_MAX", "5")
···313 type Error = anyhow::Error;
314 fn try_from(value: String) -> Result<Self, Self::Error> {
315 // Decode hex string to bytes
316+ let decoded = hex::decode(&value).map_err(ConfigError::EmailSecretKeyDecodeFailed)?;
0317318 // Require at least 32 bytes (256 bits) for security
319 if decoded.len() < 32 {
+3-1
src/config_errors.rs
···162 ///
163 /// This error occurs when the decoded EMAIL_SECRET_KEY is less than
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")]
00166 EmailSecretKeyTooShort(usize),
167}
···162 ///
163 /// This error occurs when the decoded EMAIL_SECRET_KEY is less than
164 /// 32 bytes (256 bits), which is the minimum required for security.
165+ #[error(
166+ "error-smokesignal-config-22 EMAIL_SECRET_KEY must be at least 32 bytes, got {0} bytes"
167+ )]
168 EmailSecretKeyTooShort(usize),
169}
···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+ ¤t_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 .is_some_and(|value| value == "suppress-bounce");
43 if is_permanent && is_suppress_bounce {
44 // Clear confirmation for ALL users with this email address
45- if let Err(err) = notification_unconfirm_email(&web_context.pool, &email).await {
46 tracing::error!(?err, recipient = %email, "Failed to unconfirm email after rejected event");
47 } else {
48 tracing::debug!(recipient = %email, "Email unconfirmed after rejected event");
···51 }
5253 "unsubscribed" => {
54- if let Err(err) = notification_unconfirm_email(&web_context.pool, &email).await {
55 tracing::error!(?err, recipient = %email, "Failed to unconfirm email after unsubscribe");
56 } else {
57 tracing::info!(recipient = %email, "Email unconfirmed after unsubscribe");
···5960 let reason = format!("{} unsubscribed via mailgun webhook", email);
61 if let Err(err) =
62- denylist_add_or_update(&web_context.pool, Cow::Borrowed(&email), Cow::Owned(reason))
63 .await
64 {
65 tracing::error!(?err, recipient = %email, "Failed to add email to denylist after unsubscribe");
···71 "complained" => {
72 // User marked email as spam
73 // Disable notifications for ALL users with this email address
74- if let Err(err) = notification_unconfirm_email(&web_context.pool, &email).await {
75 tracing::error!(?err, recipient = %email, "Failed to unconfirm email after complaint");
76 } else {
77 tracing::info!(recipient = %email, "Email unconfirmed after complaint");
···7980 let reason = format!("{} complained via mailgun webhook", email);
81 if let Err(err) =
82- denylist_add_or_update(&web_context.pool, Cow::Borrowed(&email), Cow::Owned(reason))
83 .await
84 {
85 tracing::error!(?err, recipient = %email, "Failed to add email to denylist after complaint");
···42 .is_some_and(|value| value == "suppress-bounce");
43 if is_permanent && is_suppress_bounce {
44 // Clear confirmation for ALL users with this email address
45+ if let Err(err) = notification_unconfirm_email(&web_context.pool, email).await {
46 tracing::error!(?err, recipient = %email, "Failed to unconfirm email after rejected event");
47 } else {
48 tracing::debug!(recipient = %email, "Email unconfirmed after rejected event");
···51 }
5253 "unsubscribed" => {
54+ if let Err(err) = notification_unconfirm_email(&web_context.pool, email).await {
55 tracing::error!(?err, recipient = %email, "Failed to unconfirm email after unsubscribe");
56 } else {
57 tracing::info!(recipient = %email, "Email unconfirmed after unsubscribe");
···5960 let reason = format!("{} unsubscribed via mailgun webhook", email);
61 if let Err(err) =
62+ denylist_add_or_update(&web_context.pool, Cow::Borrowed(email), Cow::Owned(reason))
63 .await
64 {
65 tracing::error!(?err, recipient = %email, "Failed to add email to denylist after unsubscribe");
···71 "complained" => {
72 // User marked email as spam
73 // Disable notifications for ALL users with this email address
74+ if let Err(err) = notification_unconfirm_email(&web_context.pool, email).await {
75 tracing::error!(?err, recipient = %email, "Failed to unconfirm email after complaint");
76 } else {
77 tracing::info!(recipient = %email, "Email unconfirmed after complaint");
···7980 let reason = format!("{} complained via mailgun webhook", email);
81 if let Err(err) =
82+ denylist_add_or_update(&web_context.pool, Cow::Borrowed(email), Cow::Owned(reason))
83 .await
84 {
85 tracing::error!(?err, recipient = %email, "Failed to add email to denylist after complaint");
+12-15
src/http/handle_oauth_aip_login.rs
···104 state: state.clone(),
105 nonce: nonce.clone(),
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(),
108 };
109110 // Get AIP server configuration - config validation ensures these are set when oauth_backend is AIP
···188 return contextual_error!(web_context, language, error_template, default_context, err);
189 }
190191- 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- }
201 }
202 }
203···219 stringify(oauth_args)
220 );
221222- 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- }
226 }
227228 return Ok(Redirect::temporary(destination.as_str()).into_response());
···104 state: state.clone(),
105 nonce: nonce.clone(),
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 repo:events.smokesignal.calendar.acceptance".to_string(),
108 };
109110 // Get AIP server configuration - config validation ensures these are set when oauth_backend is AIP
···188 return contextual_error!(web_context, language, error_template, default_context, err);
189 }
190191+ 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
0200 }
201 }
202···218 stringify(oauth_args)
219 );
220221+ if hx_request && let Ok(hx_redirect) = HxRedirect::try_from(destination.as_str()) {
222+ return Ok((StatusCode::OK, hx_redirect, "").into_response());
00223 }
224225 return Ok(Redirect::temporary(destination.as_str()).into_response());
+12-13
src/http/handle_oauth_login.rs
···309 }
310311 // 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- }
325 }
326 }
327
···309 }
310311 // Store destination if provided and not "/"
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
0324 }
325 }
326
+4-8
src/http/handle_profile.rs
···123 let display_name = prof_rec.display_name.clone();
124125 // 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- };
134135 let description_html = prof_data
136 .as_ref()
···123 let display_name = prof_rec.display_name.clone();
124125 // Parse the record JSON to get full profile data
126+ let prof_data = serde_json::from_value::<crate::atproto::lexicon::profile::Profile>(
127+ prof_rec.record.0.clone(),
128+ )
129+ .ok();
0000130131 let description_html = prof_data
132 .as_ref()
+4-5
src/http/handle_set_language.rs
···64 }
65 let found = found.unwrap();
6667- if let Some(handle) = auth.profile() {
68- if let Err(err) = handle_update_field(
69 &web_context.pool,
70 &handle.did,
71 HandleField::Language(Cow::Owned(found.to_string())),
72 )
73 .await
74- {
75- tracing::error!(error = ?err, "Failed to update language");
76- }
77 }
7879 let mut cookie = Cookie::new(COOKIE_LANG, found.to_string());
···64 }
65 let found = found.unwrap();
6667+ if let Some(handle) = auth.profile()
68+ && let Err(err) = handle_update_field(
69 &web_context.pool,
70 &handle.did,
71 HandleField::Language(Cow::Owned(found.to_string())),
72 )
73 .await
74+ {
75+ tracing::error!(error = ?err, "Failed to update language");
076 }
7778 let mut cookie = Cookie::new(COOKIE_LANG, found.to_string());
+61-183
src/http/handle_settings.rs
···54}
5556#[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)]
67pub(crate) struct WebhookForm {
68 service: String,
69}
···415 .into_response())
416}
417418-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- ¤t_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, ¤t_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- ¤t_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, ¤t_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-534pub(crate) async fn handle_add_webhook(
535 State(web_context): State<WebContext>,
536 identity_resolver: State<Arc<dyn IdentityResolver>>,
···846 });
847848 // 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- }
00859 }
860861 // Get existing profile from storage to get CID for swap record (CAS operation)
···1034 tracing::error!(error = err.error_message(), "Failed to update profile");
1035 let error_msg = format!("{:?}", err.error_message());
1036 if error_msg.contains("InvalidSwap") {
1037- return contextual_error!(
1038 web_context,
1039 language,
1040 error_template,
1041 default_context,
1042 "Your recent profile changes are still syncing. Please wait a moment and try again."
1043- );
1044 } else {
1045- return contextual_error!(
1046 web_context,
1047 language,
1048 error_template,
1049 default_context,
1050 format!("Failed to update profile: {:?}", err.error_message())
1051- );
1052 }
1053 }
1054 Err(err) => {
1055 tracing::error!(?err, "Failed to update profile");
1056 let error_msg = err.to_string();
1057 if error_msg.contains("InvalidSwap") {
1058- return contextual_error!(
1059 web_context,
1060 language,
1061 error_template,
1062 default_context,
1063 "Your recent profile changes are still syncing. Please wait a moment and try again."
1064- );
1065 } else {
1066- return contextual_error!(
1067 web_context,
1068 language,
1069 error_template,
1070 default_context,
1071 format!("Failed to update profile: {}", err)
1072- );
1073 }
1074 }
1075 }
···1102 .filter(|e| !e.is_empty());
11031104 // 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- }
1115 }
11161117 // Get current notification settings to check if email has changed
1118- let current_notification_settings = notification_get(&web_context.pool, ¤t_handle.did).await?;
01119 let current_email = current_notification_settings
1120 .as_ref()
1121- .and_then(|settings| settings.email.as_ref().map(|e| e.as_str()));
11221123 // Check if email is unchanged - if so, skip update (no-op)
1124 let email_unchanged = match (email, current_email) {
···1142 }
11431144 // Reset email confirmation in notification settings since email changed
1145- if let Err(err) = notification_reset_confirmation(&web_context.pool, ¤t_handle.did).await
01146 {
1147 tracing::error!(?err, "Failed to reset notification confirmation");
1148 // Don't fail the request if this update fails, just log it
1149 }
11501151 // 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- ¤t_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- );
011671168- 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- }
1176 }
1177- Err(err) => {
1178- tracing::error!(?err, "Failed to generate confirmation token");
1179- }
1180 }
1181 }
1182 }
···54}
5556#[derive(Deserialize, Clone, Debug)]
000000000057pub(crate) struct WebhookForm {
58 service: String,
59}
···405 .into_response())
406}
40700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000408pub(crate) async fn handle_add_webhook(
409 State(web_context): State<WebContext>,
410 identity_resolver: State<Arc<dyn IdentityResolver>>,
···720 });
721722 // Validate profile_host if provided
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+ );
735 }
736737 // Get existing profile from storage to get CID for swap record (CAS operation)
···910 tracing::error!(error = err.error_message(), "Failed to update profile");
911 let error_msg = format!("{:?}", err.error_message());
912 if error_msg.contains("InvalidSwap") {
913+ contextual_error!(
914 web_context,
915 language,
916 error_template,
917 default_context,
918 "Your recent profile changes are still syncing. Please wait a moment and try again."
919+ )
920 } else {
921+ contextual_error!(
922 web_context,
923 language,
924 error_template,
925 default_context,
926 format!("Failed to update profile: {:?}", err.error_message())
927+ )
928 }
929 }
930 Err(err) => {
931 tracing::error!(?err, "Failed to update profile");
932 let error_msg = err.to_string();
933 if error_msg.contains("InvalidSwap") {
934+ contextual_error!(
935 web_context,
936 language,
937 error_template,
938 default_context,
939 "Your recent profile changes are still syncing. Please wait a moment and try again."
940+ )
941 } else {
942+ contextual_error!(
943 web_context,
944 language,
945 error_template,
946 default_context,
947 format!("Failed to update profile: {}", err)
948+ )
949 }
950 }
951 }
···978 .filter(|e| !e.is_empty());
979980 // Validate email format if provided
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+ );
991 }
992993 // Get current notification settings to check if email has changed
994+ let current_notification_settings =
995+ notification_get(&web_context.pool, ¤t_handle.did).await?;
996 let current_email = current_notification_settings
997 .as_ref()
998+ .and_then(|settings| settings.email.as_deref());
9991000 // Check if email is unchanged - if so, skip update (no-op)
1001 let email_unchanged = match (email, current_email) {
···1019 }
10201021 // Reset email confirmation in notification settings since email changed
1022+ if let Err(err) =
1023+ notification_reset_confirmation(&web_context.pool, ¤t_handle.did).await
1024 {
1025 tracing::error!(?err, "Failed to reset notification confirmation");
1026 // Don't fail the request if this update fails, just log it
1027 }
10281029 // Automatically send confirmation email if email was set (not cleared)
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+ ¤t_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+ );
10461047+ 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;
01054 }
1055+ }
1056+ Err(err) => {
1057+ tracing::error!(?err, "Failed to generate confirmation token");
1058 }
1059 }
1060 }
···22use crate::http::utils::url_from_aturi;
23use crate::select_template;
24use crate::storage::StoragePool;
0025use crate::storage::event::count_event_rsvps;
26use crate::storage::event::event_exists;
27use crate::storage::event::event_get;
28-use crate::storage::event::get_event_rsvps;
29use crate::storage::event::get_user_rsvp_with_email_shared;
30use crate::storage::identity_profile::handle_for_did;
31use crate::storage::identity_profile::handle_for_handle;
···192 profile.did, event_rkey
193 );
194195- 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- }
206 }
207 }
208···298 None
299 };
30000000000000000000000301 // Get counts for all RSVP statuses
302 let going_count = count_event_rsvps(&ctx.web_context.pool, &aturi, "going")
303 .await
···311 .await
312 .unwrap_or_default();
313314- // Only get handles for the active tab
315- let active_tab_handles = {
316 let tab_status = match tab {
317 RSVPTab::Going => "going",
318 RSVPTab::Interested => "interested",
319 RSVPTab::NotGoing => "notgoing",
320 };
321322- let rsvps = get_event_rsvps(&ctx.web_context.pool, &aturi, Some(tab_status))
323- .await
324- .unwrap_or_default();
0325326 // Extract DIDs for batch lookup
327 let dids: Vec<String> = rsvps.iter().map(|(did, _)| did.clone()).collect();
···331 .await
332 .unwrap_or_default();
333334- // 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- }
000340 }
341- handles
342 };
343344 // Set counts on event
···366 event => event_with_counts,
367 is_self,
368 can_edit,
369- active_tab_handles,
370 active_tab => tab_name,
371 user_rsvp_status,
0372 handle_slug,
373 event_rkey,
374 collection => collection.clone(),
···22use crate::http::utils::url_from_aturi;
23use crate::select_template;
24use crate::storage::StoragePool;
25+use crate::storage::acceptance::acceptance_ticket_get_by_event_and_rsvp_did;
26+use crate::storage::event::RsvpDisplayData;
27use crate::storage::event::count_event_rsvps;
28use crate::storage::event::event_exists;
29use crate::storage::event::event_get;
30+use crate::storage::event::get_event_rsvps_with_validation;
31use crate::storage::event::get_user_rsvp_with_email_shared;
32use crate::storage::identity_profile::handle_for_did;
33use crate::storage::identity_profile::handle_for_handle;
···194 profile.did, event_rkey
195 );
196197+ 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+ );
208 }
209 }
210···300 None
301 };
302303+ // 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+ ¤t_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+323 // Get counts for all RSVP statuses
324 let going_count = count_event_rsvps(&ctx.web_context.pool, &aturi, "going")
325 .await
···333 .await
334 .unwrap_or_default();
335336+ // Get RSVPs with validation data for the active tab
337+ let active_tab_rsvps = {
338 let tab_status = match tab {
339 RSVPTab::Going => "going",
340 RSVPTab::Interested => "interested",
341 RSVPTab::NotGoing => "notgoing",
342 };
343344+ let rsvps =
345+ get_event_rsvps_with_validation(&ctx.web_context.pool, &aturi, Some(tab_status))
346+ .await
347+ .unwrap_or_default();
348349 // Extract DIDs for batch lookup
350 let dids: Vec<String> = rsvps.iter().map(|(did, _)| did.clone()).collect();
···354 .await
355 .unwrap_or_default();
356357+ // 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+ });
366 }
367+ rsvp_display_data
368 };
369370 // Set counts on event
···392 event => event_with_counts,
393 is_self,
394 can_edit,
395+ active_tab_rsvps,
396 active_tab => tab_name,
397 user_rsvp_status,
398+ pending_acceptance,
399 handle_slug,
400 event_rkey,
401 collection => collection.clone(),
+8-9
src/http/handle_xrpc_search_events.rs
···152 };
153154 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- }
164 }
165 }
166 }
···152 };
153154 let mut event_ids = Vec::new();
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());
0163 }
164 }
165 }
+5-5
src/http/middleware_i18n.rs
···119 let auth: Auth = Cached::<Auth>::from_request_parts(parts, context).await?.0;
120121 // 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- }
127 }
128129 // 2. Try to get language from cookies
···119 let auth: Auth = Cached::<Auth>::from_request_parts(parts, context).await?.0;
120121 // 1. Try to get language from user's profile settings
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 }
128129 // 2. Try to get language from cookies
+3
src/http/mod.rs
···01pub mod auth_utils;
2pub mod cache_countries;
3pub mod context;
4pub mod errors;
5pub mod event_form;
6pub mod event_view;
07pub mod handle_admin_denylist;
8pub mod handle_admin_event;
9pub mod handle_admin_events;
···22pub mod handle_email_confirm;
23pub mod handle_export_ics;
24pub mod handle_export_rsvps;
025pub mod handle_health;
26pub mod handle_host_meta;
27pub mod handle_import;
···1+pub mod acceptance_utils;
2pub mod auth_utils;
3pub mod cache_countries;
4pub mod context;
5pub mod errors;
6pub mod event_form;
7pub mod event_view;
8+pub mod handle_accept_rsvp;
9pub mod handle_admin_denylist;
10pub mod handle_admin_event;
11pub mod handle_admin_events;
···24pub mod handle_email_confirm;
25pub mod handle_export_ics;
26pub mod handle_export_rsvps;
27+pub mod handle_finalize_acceptance;
28pub mod handle_health;
29pub mod handle_host_meta;
30pub mod handle_import;
···69 /// In-memory LRU cache
70 memory_cache: Arc<RwLock<LruCache<String, CachedDocument>>>,
7172- handle_cache: Arc<RwLock<LruCache<String, String>>>,
73-74 /// Cache configuration
75 config: CacheConfig,
76}
···94 base_resolver,
95 storage,
96 memory_cache: Arc::new(RwLock::new(LruCache::new(cache_size))),
97- handle_cache: Arc::new(RwLock::new(LruCache::new(cache_size))),
98 config,
99 }
100 }
···170 // Store in database
171 if let Err(e) = self.storage.as_ref().store_document(document.clone()).await {
172 warn!("Failed to store document in database cache: {}", e);
173- } else {
174 }
175176 // Also store by handle if the subject was a handle
···69 /// In-memory LRU cache
70 memory_cache: Arc<RwLock<LruCache<String, CachedDocument>>>,
710072 /// Cache configuration
73 config: CacheConfig,
74}
···92 base_resolver,
93 storage,
94 memory_cache: Arc::new(RwLock::new(LruCache::new(cache_size))),
095 config,
96 }
97 }
···167 // Store in database
168 if let Err(e) = self.storage.as_ref().store_document(document.clone()).await {
169 warn!("Failed to store document in database cache: {}", e);
0170 }
171172 // Also store by handle if the subject was a handle
+1
src/lib.rs
···15pub mod key_provider;
16pub mod processor;
17pub mod processor_errors;
018pub mod refresh_tokens_errors;
19pub mod service;
20pub mod storage;
···15pub mod key_provider;
16pub mod processor;
17pub mod processor_errors;
18+pub mod record_resolver;
19pub mod refresh_tokens_errors;
20pub mod service;
21pub mod storage;
+100-33
src/processor.rs
···1use anyhow::Result;
02use atproto_client::com::atproto::repo::get_blob;
03use atproto_identity::model::Document;
4use atproto_identity::resolve::IdentityResolver;
5use atproto_identity::traits::DidDocumentStorage;
···8use serde_json::Value;
9use std::sync::Arc;
10011use crate::atproto::lexicon::profile::{NSID as ProfileNSID, Profile};
12use crate::consumer::SmokeSignalEvent;
13use crate::consumer::SmokeSignalEventReceiver;
14use crate::processor_errors::ProcessorError;
15use crate::storage::StoragePool;
00016use crate::storage::content::ContentStorage;
17use crate::storage::denylist::denylist_exists;
18use crate::storage::event::RsvpInsertParams;
···21use crate::storage::event::event_insert_with_metadata;
22use crate::storage::event::rsvp_delete;
23use 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;
26use crate::storage::profile::profile_delete;
27use crate::storage::profile::profile_insert;
28use atproto_record::lexicon::community::lexicon::calendar::event::{
···38 identity_resolver: Arc<dyn IdentityResolver>,
39 document_storage: Arc<dyn DidDocumentStorage + Send + Sync>,
40 http_client: reqwest::Client,
0041}
4243impl ContentFetcher {
···47 identity_resolver: Arc<dyn IdentityResolver>,
48 document_storage: Arc<dyn DidDocumentStorage + Send + Sync>,
49 http_client: reqwest::Client,
0050 ) -> Self {
51 Self {
52 pool,
···54 identity_resolver,
55 document_storage,
56 http_client,
0057 }
58 }
59···95 }
96 "events.smokesignal.profile" => {
97 self.handle_profile_commit(did, rkey, cid, record).await
00098 }
99 _ => Ok(()),
100 };
···116 self.handle_rsvp_delete(did, rkey).await
117 }
118 "events.smokesignal.profile" => self.handle_profile_delete(did, rkey).await,
000119 _ => Ok(()),
120 };
121 if let Err(e) = result {
···137 record: &Value,
138 ) -> Result<()> {
139 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- }
148149 let aturi = format!("at://{did}/{LexiconCommunityEventNSID}/{rkey}");
150···186 record: &Value,
187 ) -> Result<()> {
188 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- }
195196 let aturi = format!("at://{did}/{LexiconCommunityRSVPNSID}/{rkey}");
197···228 )
229 .await?;
2300000000000000000000000000000000231 Ok(())
232 }
233···288 let pds_endpoints = document.pds_endpoints();
289 if let Some(pds_endpoint) = pds_endpoints.first() {
290 // 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- }
299 }
300301 // 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- }
310 }
311 } else {
312 tracing::debug!(did = %did, "No PDS endpoint found for profile blob download");
···318 async fn handle_profile_delete(&self, did: &str, rkey: &str) -> Result<()> {
319 let aturi = format!("at://{did}/{ProfileNSID}/{rkey}");
320 profile_delete(&self.pool, &aturi).await?;
0000000000000000000000000000000000321 Ok(())
322 }
323···554 return Ok(());
555 }
556557- let image_bytes = get_blob(&self.http_client, pds, did, &blob_ref).await?;
558559 const MAX_SIZE: usize = 3 * 1024 * 1024;
560 if image_bytes.len() > MAX_SIZE {
···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+}
···01pub mod atproto;
2pub mod cache;
3pub mod content;
···1+pub mod acceptance;
2pub mod atproto;
3pub mod cache;
4pub mod content;
+6-6
src/storage/oauth.rs
···92 }
9394 // 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- }
101 }
102103 let mut tx = pool
···92 }
9394 // If did is provided, validate it's not empty
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 }
102103 let mut tx = pool
+1-1
src/throttle_redis.rs
···1use async_trait::async_trait;
2-use deadpool_redis::{redis::AsyncCommands, Pool as RedisPool};
3use std::time::{SystemTime, UNIX_EPOCH};
45use crate::throttle::{Throttle, ThrottleError};
···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
···67 {% include 'en-us/create_event.partial.html' %}
80000000000000000000000000000000000000000000000000009 <div class="box has-background-primary-light">
10 <h2 class="title is-4">Export RSVPs</h2>
11 <p class="mb-4">Download a CSV file of identities that have RSVP'd to the event.</p>