The smokesignal.events web application
at main 386 lines 12 kB view raw
1use anyhow::Result; 2use axum::extract::Query; 3use axum::extract::State; 4use axum::response::IntoResponse; 5use axum::{Json, http::StatusCode}; 6use axum_extra::extract::Cached; 7use axum_htmx::HxBoosted; 8use axum_htmx::HxRequest; 9use axum_template::RenderHtml; 10use chrono::Utc; 11use minijinja::context as template_context; 12use serde::Deserialize; 13use serde_json::json; 14use std::collections::HashMap; 15 16use crate::atproto::auth::create_dpop_auth_from_session; 17use crate::http::context::WebContext; 18use crate::http::errors::CommonError; 19use crate::http::errors::WebError; 20use crate::http::event_form::{EventConfiguration, ManageEvent, ManageEventLink}; 21use crate::http::event_validation::{ 22 parse_ends_at, parse_starts_at, validate_end_requires_start, validate_event_request, 23}; 24use crate::http::middleware_auth::Auth; 25use crate::http::middleware_i18n::Language; 26use crate::http::timezones::supported_timezones; 27use crate::http::utils::url_from_aturi; 28use crate::select_template; 29use crate::storage::event::{EventInsertParams, event_insert_with_metadata}; 30use atproto_client::com::atproto::repo::{ 31 CreateRecordRequest, CreateRecordResponse, create_record, 32}; 33use atproto_record::lexicon::community::lexicon::calendar::event::{Event, NSID}; 34 35use super::cache_countries::{other_countries, popular_countries}; 36use super::handle_blob::store_event_header_from_pds; 37 38/// Query parameters for pre-populating the create event form 39#[derive(Debug, Default, Deserialize)] 40pub(crate) struct CreateEventQuery { 41 pub name: Option<String>, 42 pub description: Option<String>, 43 #[serde(default = "default_mode")] 44 pub mode: String, 45 pub starts_at: Option<String>, 46 pub ends_at: Option<String>, 47 pub link: Option<String>, 48 pub link_name: Option<String>, 49} 50 51fn default_mode() -> String { 52 "inperson".to_string() 53} 54 55impl CreateEventQuery { 56 /// Build a redirect URL that preserves query parameters for login redirects. 57 /// This prevents data loss when an unauthenticated user visits a pre-populated 58 /// create event form. 59 fn to_redirect_url(&self) -> String { 60 // RFC 3986 unreserved characters that don't need encoding 61 fn percent_encode(s: &str) -> String { 62 let mut result = String::new(); 63 for byte in s.bytes() { 64 match byte { 65 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { 66 result.push(byte as char); 67 } 68 _ => { 69 result.push_str(&format!("%{:02X}", byte)); 70 } 71 } 72 } 73 result 74 } 75 76 let mut params = Vec::new(); 77 78 if let Some(ref name) = self.name { 79 if !name.is_empty() { 80 params.push(format!("name={}", percent_encode(name))); 81 } 82 } 83 if let Some(ref description) = self.description { 84 if !description.is_empty() { 85 params.push(format!("description={}", percent_encode(description))); 86 } 87 } 88 if self.mode != "inperson" { 89 params.push(format!("mode={}", percent_encode(&self.mode))); 90 } 91 if let Some(ref starts_at) = self.starts_at { 92 if !starts_at.is_empty() { 93 params.push(format!("starts_at={}", percent_encode(starts_at))); 94 } 95 } 96 if let Some(ref ends_at) = self.ends_at { 97 if !ends_at.is_empty() { 98 params.push(format!("ends_at={}", percent_encode(ends_at))); 99 } 100 } 101 if let Some(ref link) = self.link { 102 if !link.is_empty() { 103 params.push(format!("link={}", percent_encode(link))); 104 } 105 } 106 if let Some(ref link_name) = self.link_name { 107 if !link_name.is_empty() { 108 params.push(format!("link_name={}", percent_encode(link_name))); 109 } 110 } 111 112 if params.is_empty() { 113 "/event".to_string() 114 } else { 115 format!("/event?{}", params.join("&")) 116 } 117 } 118} 119 120pub(crate) async fn handle_create_event( 121 State(web_context): State<WebContext>, 122 Language(language): Language, 123 Cached(auth): Cached<Auth>, 124 HxRequest(hx_request): HxRequest, 125 HxBoosted(hx_boosted): HxBoosted, 126 Query(query): Query<CreateEventQuery>, 127) -> Result<impl IntoResponse, WebError> { 128 let current_handle = auth.require(&query.to_redirect_url())?; 129 130 let is_development = cfg!(debug_assertions); 131 let render_template = select_template!("create_event", hx_boosted, hx_request, language); 132 let (default_tz, timezones) = supported_timezones(auth.profile()); 133 134 // Build form from query parameters 135 let mut name = query.name.unwrap_or_default(); 136 let mut description = query.description.unwrap_or_default(); 137 138 #[cfg(debug_assertions)] 139 { 140 if name.is_empty() { 141 name = "My awesome event".to_string(); 142 } 143 if description.is_empty() { 144 description = "A really great event.".to_string(); 145 } 146 } 147 148 // Calculate default start time (6:00 PM in user's timezone) if not provided 149 let starts_at = if query.starts_at.is_some() { 150 query.starts_at.clone() 151 } else { 152 let now = Utc::now(); 153 let parsed_tz = default_tz 154 .parse::<chrono_tz::Tz>() 155 .unwrap_or(chrono_tz::UTC); 156 let local_date = now.with_timezone(&parsed_tz).date_naive(); 157 158 local_date 159 .and_hms_opt(18, 0, 0) 160 .and_then(|naive_dt| naive_dt.and_local_timezone(parsed_tz).single()) 161 .map(|local_dt| local_dt.with_timezone(&Utc).to_rfc3339()) 162 }; 163 164 // Build links from query parameters 165 let links = match (&query.link, &query.link_name) { 166 (Some(url), label) if !url.is_empty() => vec![ManageEventLink { 167 url: url.clone(), 168 label: label.clone(), 169 }], 170 _ => vec![], 171 }; 172 173 // Construct ManageEvent - the unified struct for form data 174 let event_data = ManageEvent { 175 name, 176 description, 177 status: "scheduled".to_string(), 178 mode: query.mode.clone(), 179 tz: default_tz.to_string(), 180 starts_at, 181 ends_at: query.ends_at.clone(), 182 links, 183 locations: vec![], 184 header_cid: None, 185 header_alt: None, 186 header_size: None, 187 thumbnail_cid: None, 188 thumbnail_alt: None, 189 config: EventConfiguration::default(), 190 }; 191 192 // Get country lists for location form dropdown 193 let popular = popular_countries().unwrap_or_default(); 194 let others = other_countries().unwrap_or_default(); 195 196 Ok(RenderHtml( 197 &render_template, 198 web_context.engine.clone(), 199 template_context! { 200 current_handle, 201 language => language.to_string(), 202 canonical_url => format!("https://{}/event", web_context.config.external_base), 203 is_development, 204 create_event => true, 205 submit_url => "/event", 206 event_data, 207 popular_countries => popular, 208 other_countries => others, 209 timezones, 210 default_tz, 211 }, 212 ) 213 .into_response()) 214} 215 216/// JSON API handler for creating events 217pub(crate) async fn handle_create_event_json( 218 State(web_context): State<WebContext>, 219 Language(_language): Language, 220 Cached(auth): Cached<Auth>, 221 Json(request): Json<ManageEvent>, 222) -> Result<impl IntoResponse, WebError> { 223 let current_handle = auth.require("/event")?; 224 let session = auth.session().ok_or(CommonError::NotAuthorized)?; 225 226 // For create, validate that end time requires start time 227 let starts_at = match parse_starts_at(request.starts_at.as_deref()) { 228 Ok(v) => v, 229 Err(e) => return Ok(e.into_response()), 230 }; 231 let ends_at = match parse_ends_at(request.ends_at.as_deref()) { 232 Ok(v) => v, 233 Err(e) => return Ok(e.into_response()), 234 }; 235 236 if let Err(e) = validate_end_requires_start(starts_at, ends_at) { 237 return Ok(e.into_response()); 238 } 239 240 // Validate and parse the complete request 241 let facet_limits = crate::facets::FacetLimits { 242 mentions_max: web_context.config.facets_mentions_max, 243 tags_max: web_context.config.facets_tags_max, 244 links_max: web_context.config.facets_links_max, 245 max: web_context.config.facets_max, 246 }; 247 248 let validated = validate_event_request( 249 &request, 250 web_context.identity_resolver.as_ref(), 251 &facet_limits, 252 ) 253 .await; 254 255 let validated = match validated { 256 Ok(v) => v, 257 Err(e) => return Ok(e.into_response()), 258 }; 259 260 // Create DPoP auth from session 261 let dpop_auth = create_dpop_auth_from_session(session)?; 262 263 let now = Utc::now(); 264 265 // Create the event record using validated data 266 let the_record = Event { 267 name: validated.name.clone(), 268 description: validated.description.clone(), 269 created_at: now, 270 starts_at: validated.starts_at, 271 ends_at: validated.ends_at, 272 mode: validated.mode, 273 status: validated.status, 274 locations: validated.locations, 275 uris: validated.uris, 276 media: validated.media, 277 facets: validated.facets, 278 extra: HashMap::default(), 279 }; 280 281 // Submit to AT Protocol 282 let event_record = CreateRecordRequest { 283 repo: current_handle.did.clone(), 284 collection: NSID.to_string(), 285 validate: false, 286 record_key: None, 287 record: the_record.clone(), 288 swap_commit: None, 289 }; 290 291 let create_record_result = create_record( 292 &web_context.http_client, 293 &atproto_client::client::Auth::DPoP(dpop_auth), 294 &current_handle.pds, 295 event_record, 296 ) 297 .await; 298 299 let create_record_response = match create_record_result { 300 Ok(CreateRecordResponse::StrongRef { uri, cid, .. }) => { 301 atproto_record::lexicon::com::atproto::repo::StrongRef { uri, cid } 302 } 303 Ok(CreateRecordResponse::Error(err)) => { 304 return Ok(( 305 StatusCode::INTERNAL_SERVER_ERROR, 306 Json(json!({ 307 "error": err.error_message() 308 })), 309 ) 310 .into_response()); 311 } 312 Err(err) => { 313 return Ok(( 314 StatusCode::INTERNAL_SERVER_ERROR, 315 Json(json!({ 316 "error": err.to_string() 317 })), 318 ) 319 .into_response()); 320 } 321 }; 322 323 // Insert into database 324 let event_insert_result = event_insert_with_metadata( 325 &web_context.pool, 326 EventInsertParams { 327 aturi: &create_record_response.uri, 328 cid: &create_record_response.cid, 329 did: &current_handle.did, 330 lexicon: NSID, 331 record: &the_record, 332 name: &the_record.name, 333 require_confirmed_email: request.config.require_confirmed_email, 334 disable_direct_rsvp: request.config.disable_direct_rsvp, 335 rsvp_redirect_url: request.config.rsvp_redirect_url.as_deref(), 336 }, 337 ) 338 .await; 339 340 if let Err(err) = event_insert_result { 341 return Ok(( 342 StatusCode::INTERNAL_SERVER_ERROR, 343 Json(json!({ 344 "error": err.to_string() 345 })), 346 ) 347 .into_response()); 348 } 349 350 // Download and store header image from PDS if one was uploaded 351 if let Some(ref header_cid) = request.header_cid 352 && let Err(err) = store_event_header_from_pds( 353 &web_context.http_client, 354 &web_context.content_storage, 355 &current_handle.pds, 356 &current_handle.did, 357 header_cid, 358 ) 359 .await 360 { 361 tracing::warn!( 362 ?err, 363 cid = %header_cid, 364 "Failed to store event header image from PDS" 365 ); 366 // Don't fail the request - the event was created successfully 367 // The header can be fetched later via jetstream or admin import 368 } 369 370 let event_url = url_from_aturi( 371 &web_context.config.external_base, 372 &create_record_response.uri, 373 )?; 374 375 // Return success response 376 Ok(( 377 StatusCode::CREATED, 378 Json(json!({ 379 "success": true, 380 "uri": create_record_response.uri, 381 "cid": create_record_response.cid, 382 "url": event_url 383 })), 384 ) 385 .into_response()) 386}