The smokesignal.events web application
at main 401 lines 13 kB view raw
1//! Shared validation logic for event creation and editing. 2//! 3//! This module extracts common validation code used by both 4//! `handle_create_event_json` and `handle_edit_event_json`. 5 6use axum::Json; 7use axum::response::IntoResponse; 8use chrono::{DateTime, Utc}; 9use http::StatusCode; 10use serde_json::json; 11use thiserror::Error; 12 13use atproto_identity::resolve::IdentityResolver; 14use atproto_record::lexicon::TypedBlob; 15use atproto_record::lexicon::app::bsky::richtext::facet::Facet; 16use atproto_record::lexicon::community::lexicon::calendar::event::{ 17 AspectRatio, EventLink, Media, Mode, Status, TypedEventLink, 18}; 19use atproto_record::lexicon::community::lexicon::location::{Address, Geo, LocationOrRef}; 20use atproto_record::typed::TypedLexicon; 21 22use crate::atproto::utils::{location_from_address, location_from_geo}; 23use crate::facets::{FacetLimits, parse_facets_from_text}; 24use crate::http::event_form::{ManageEvent, ManageEventLink, ManageEventLocation}; 25 26/// Validation error for event requests 27#[derive(Debug, Error)] 28pub(crate) enum EventValidationError { 29 #[error("Name must be between 10 and 500 characters")] 30 InvalidNameLength, 31 32 #[error("Description must be between 10 and 3000 characters")] 33 InvalidDescriptionLength, 34 35 #[error("Invalid start datetime format")] 36 InvalidStartDateTime, 37 38 #[error("Invalid end datetime format")] 39 InvalidEndDateTime, 40 41 #[error("Cannot set end time without start time")] 42 EndWithoutStart, 43 44 #[error("End time must be after start time")] 45 EndBeforeStart, 46 47 #[error("Invalid mode. Must be 'inperson', 'virtual', or 'hybrid'")] 48 InvalidMode, 49 50 #[error("Invalid status")] 51 InvalidStatus, 52 53 #[error("{0}")] 54 InvalidGeoLocation(&'static str), 55 56 #[error("Invalid header blob: {0}")] 57 InvalidHeaderBlob(String), 58 59 #[error("Invalid thumbnail blob: {0}")] 60 InvalidThumbnailBlob(String), 61} 62 63impl EventValidationError { 64 /// Convert validation error to HTTP response 65 pub(crate) fn into_response(self) -> axum::response::Response { 66 ( 67 StatusCode::BAD_REQUEST, 68 Json(json!({ "error": self.to_string() })), 69 ) 70 .into_response() 71 } 72} 73 74/// Validated and parsed event data ready for AT Protocol submission 75pub(crate) struct ValidatedEventData { 76 pub name: String, 77 pub description: String, 78 pub starts_at: Option<DateTime<Utc>>, 79 pub ends_at: Option<DateTime<Utc>>, 80 pub mode: Option<Mode>, 81 pub status: Option<Status>, 82 pub locations: Vec<LocationOrRef>, 83 pub uris: Vec<TypedEventLink>, 84 pub media: Vec<TypedLexicon<Media>>, 85 pub facets: Option<Vec<Facet>>, 86} 87 88/// Validate name length (10-500 characters) 89pub(crate) fn validate_name(name: &str) -> Result<String, EventValidationError> { 90 let trimmed = name.trim(); 91 if trimmed.len() < 10 || trimmed.len() > 500 { 92 return Err(EventValidationError::InvalidNameLength); 93 } 94 Ok(trimmed.to_string()) 95} 96 97/// Validate description length (10-3000 characters) 98pub(crate) fn validate_description(description: &str) -> Result<String, EventValidationError> { 99 let trimmed = description.trim(); 100 if trimmed.len() < 10 || trimmed.len() > 3000 { 101 return Err(EventValidationError::InvalidDescriptionLength); 102 } 103 Ok(trimmed.to_string()) 104} 105 106/// Parse and validate start datetime 107pub(crate) fn parse_starts_at( 108 starts_str: Option<&str>, 109) -> Result<Option<DateTime<Utc>>, EventValidationError> { 110 match starts_str { 111 Some(s) => { 112 let dt = s 113 .parse::<DateTime<Utc>>() 114 .map_err(|_| EventValidationError::InvalidStartDateTime)?; 115 Ok(Some(dt)) 116 } 117 None => Ok(None), 118 } 119} 120 121/// Parse and validate end datetime (empty strings treated as None) 122pub(crate) fn parse_ends_at( 123 ends_str: Option<&str>, 124) -> Result<Option<DateTime<Utc>>, EventValidationError> { 125 match ends_str { 126 Some(s) if !s.trim().is_empty() => { 127 let dt = s 128 .parse::<DateTime<Utc>>() 129 .map_err(|_| EventValidationError::InvalidEndDateTime)?; 130 Ok(Some(dt)) 131 } 132 _ => Ok(None), 133 } 134} 135 136/// Validate that end is after start (if both are set) 137pub(crate) fn validate_time_range( 138 starts_at: Option<DateTime<Utc>>, 139 ends_at: Option<DateTime<Utc>>, 140) -> Result<(), EventValidationError> { 141 if let (Some(start), Some(end)) = (starts_at, ends_at) 142 && end <= start 143 { 144 return Err(EventValidationError::EndBeforeStart); 145 } 146 Ok(()) 147} 148 149/// Validate that end time requires start time (for create only) 150pub(crate) fn validate_end_requires_start( 151 starts_at: Option<DateTime<Utc>>, 152 ends_at: Option<DateTime<Utc>>, 153) -> Result<(), EventValidationError> { 154 if ends_at.is_some() && starts_at.is_none() { 155 return Err(EventValidationError::EndWithoutStart); 156 } 157 Ok(()) 158} 159 160/// Parse mode string to Mode enum 161pub(crate) fn parse_mode(mode_str: &str) -> Result<Option<Mode>, EventValidationError> { 162 match mode_str { 163 "inperson" => Ok(Some(Mode::InPerson)), 164 "virtual" => Ok(Some(Mode::Virtual)), 165 "hybrid" => Ok(Some(Mode::Hybrid)), 166 _ => Err(EventValidationError::InvalidMode), 167 } 168} 169 170/// Parse status string to Status enum 171pub(crate) fn parse_status(status_str: &str) -> Result<Option<Status>, EventValidationError> { 172 match status_str { 173 "planned" => Ok(Some(Status::Planned)), 174 "scheduled" => Ok(Some(Status::Scheduled)), 175 "cancelled" => Ok(Some(Status::Cancelled)), 176 "postponed" => Ok(Some(Status::Postponed)), 177 "rescheduled" => Ok(Some(Status::Rescheduled)), 178 _ => Err(EventValidationError::InvalidStatus), 179 } 180} 181 182/// Validate and convert locations from ManageEventLocation enum to AT Protocol format. 183/// 184/// This function handles both Address and Geo variants in a single pass, 185/// validating geo coordinates and converting all locations to LocationOrRef. 186pub(crate) fn validate_and_convert_locations( 187 locations: &[ManageEventLocation], 188) -> Result<Vec<LocationOrRef>, EventValidationError> { 189 let mut result = Vec::with_capacity(locations.len()); 190 191 for location in locations { 192 match location { 193 ManageEventLocation::Address { 194 country, 195 postal_code, 196 region, 197 locality, 198 street, 199 name, 200 } => { 201 result.push(location_from_address(Address { 202 country: country.clone(), 203 postal_code: postal_code.clone(), 204 region: region.clone(), 205 locality: locality.clone(), 206 street: street.clone(), 207 name: name.clone(), 208 })); 209 } 210 ManageEventLocation::Geo { 211 latitude, 212 longitude, 213 name, 214 } => { 215 // Validate latitude 216 let lat: f64 = latitude.parse().map_err(|_| { 217 EventValidationError::InvalidGeoLocation("Invalid latitude format") 218 })?; 219 if !(-90.0..=90.0).contains(&lat) { 220 return Err(EventValidationError::InvalidGeoLocation( 221 "Latitude must be between -90 and 90", 222 )); 223 } 224 225 // Validate longitude 226 let lon: f64 = longitude.parse().map_err(|_| { 227 EventValidationError::InvalidGeoLocation("Invalid longitude format") 228 })?; 229 if !(-180.0..=180.0).contains(&lon) { 230 return Err(EventValidationError::InvalidGeoLocation( 231 "Longitude must be between -180 and 180", 232 )); 233 } 234 235 result.push(location_from_geo(Geo { 236 latitude: latitude.clone(), 237 longitude: longitude.clone(), 238 name: name.clone(), 239 })); 240 } 241 } 242 } 243 244 Ok(result) 245} 246 247/// Convert links to AT Protocol format 248pub(crate) fn convert_links(links: &[ManageEventLink]) -> Vec<TypedEventLink> { 249 links 250 .iter() 251 .map(|link| { 252 TypedLexicon::new(EventLink { 253 uri: link.url.clone(), 254 name: link.label.clone(), 255 }) 256 }) 257 .collect() 258} 259 260/// Build media array from header and thumbnail CIDs 261pub(crate) fn build_media( 262 header_cid: Option<&str>, 263 header_alt: Option<&str>, 264 header_size: Option<i64>, 265 thumbnail_cid: Option<&str>, 266 thumbnail_alt: Option<&str>, 267) -> Result<Vec<TypedLexicon<Media>>, EventValidationError> { 268 let mut media = Vec::new(); 269 270 if let Some(cid) = header_cid { 271 let blob_json = serde_json::json!({ 272 "$type": "blob", 273 "ref": { 274 "$link": cid 275 }, 276 "mimeType": "image/png", 277 "size": header_size.unwrap_or(0) 278 }); 279 280 let typed_blob: TypedBlob = serde_json::from_value(blob_json) 281 .map_err(|e| EventValidationError::InvalidHeaderBlob(e.to_string()))?; 282 283 media.push(TypedLexicon::new(Media { 284 role: "header".to_string(), 285 alt: header_alt.unwrap_or_default().to_string(), 286 content: typed_blob, 287 aspect_ratio: Some(AspectRatio { 288 width: 1500, 289 height: 500, 290 }), 291 })); 292 } 293 294 if let Some(cid) = thumbnail_cid { 295 let blob_json = serde_json::json!({ 296 "$type": "blob", 297 "ref": { 298 "$link": cid 299 }, 300 "mimeType": "image/png", 301 "size": 0 302 }); 303 304 let typed_blob: TypedBlob = serde_json::from_value(blob_json) 305 .map_err(|e| EventValidationError::InvalidThumbnailBlob(e.to_string()))?; 306 307 media.push(TypedLexicon::new(Media { 308 role: "thumbnail".to_string(), 309 alt: thumbnail_alt.unwrap_or_default().to_string(), 310 content: typed_blob, 311 aspect_ratio: Some(AspectRatio { 312 width: 1024, 313 height: 1024, 314 }), 315 })); 316 } 317 318 Ok(media) 319} 320 321/// Validate and parse a complete event request. 322/// 323/// This function validates all fields and converts them to AT Protocol types. 324/// Used by both create and edit event handlers. 325pub(crate) async fn validate_event_request( 326 request: &ManageEvent, 327 identity_resolver: &dyn IdentityResolver, 328 facet_limits: &FacetLimits, 329) -> Result<ValidatedEventData, EventValidationError> { 330 // Validate basic fields 331 let name = validate_name(&request.name)?; 332 let description = validate_description(&request.description)?; 333 334 // Parse and validate datetimes 335 let starts_at = parse_starts_at(request.starts_at.as_deref())?; 336 let ends_at = parse_ends_at(request.ends_at.as_deref())?; 337 validate_time_range(starts_at, ends_at)?; 338 339 // Parse mode and status 340 let mode = parse_mode(&request.mode)?; 341 let status = parse_status(&request.status)?; 342 343 // Validate and convert locations (handles both Address and Geo variants) 344 let locations = validate_and_convert_locations(&request.locations)?; 345 346 // Convert links 347 let uris = convert_links(&request.links); 348 349 // Build media 350 let media = build_media( 351 request.header_cid.as_deref(), 352 request.header_alt.as_deref(), 353 request.header_size, 354 request.thumbnail_cid.as_deref(), 355 request.thumbnail_alt.as_deref(), 356 )?; 357 358 // Parse facets from description 359 let facets = if !description.is_empty() { 360 parse_facets_from_text(&description, identity_resolver, facet_limits).await 361 } else { 362 None 363 }; 364 365 Ok(ValidatedEventData { 366 name, 367 description, 368 starts_at, 369 ends_at, 370 mode, 371 status, 372 locations, 373 uris, 374 media, 375 facets, 376 }) 377} 378 379/// Convert status enum to string for form data 380pub(crate) fn status_to_string(status: &Option<Status>) -> String { 381 match status { 382 Some(Status::Planned) => "planned", 383 Some(Status::Scheduled) => "scheduled", 384 Some(Status::Cancelled) => "cancelled", 385 Some(Status::Postponed) => "postponed", 386 Some(Status::Rescheduled) => "rescheduled", 387 None => "scheduled", 388 } 389 .to_string() 390} 391 392/// Convert mode enum to string for form data 393pub(crate) fn mode_to_string(mode: &Option<Mode>) -> String { 394 match mode { 395 Some(Mode::InPerson) => "inperson", 396 Some(Mode::Virtual) => "virtual", 397 Some(Mode::Hybrid) => "hybrid", 398 None => "inperson", 399 } 400 .to_string() 401}