The smokesignal.events web application
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 ¤t_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: ¤t_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 ¤t_handle.pds,
356 ¤t_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}