i18n+filtering fork - fluent-templates v2
at main 350 lines 13 kB view raw
1//! iCal event download handler with full i18n support 2//! 3//! This module generates RFC 5545 compliant iCalendar (.ics) files for events with comprehensive 4//! internationalization support. The implementation follows web standards for calendar downloads 5//! and integrates seamlessly with the AT Protocol ecosystem. 6//! 7//! # Features 8//! 9//! - **AT Protocol Integration**: Uses AT Protocol handle format for organizer fields instead of mailto 10//! - **Locale-Aware Content**: All user-facing content is translated using Fluent templates 11//! - **Intelligent Fallbacks**: Graceful handling of missing event data with localized fallbacks 12//! - **Safe File Downloads**: Sanitized filenames prevent path traversal and filesystem issues 13//! - **RFC 5545 Compliance**: Generates valid iCalendar files compatible with all major calendar apps 14//! - **Timezone Support**: Proper UTC timestamp formatting for cross-timezone compatibility 15//! 16//! # Usage 17//! 18//! The main entry point is [`handle_ical_event`] which processes HTTP requests for iCal downloads. 19//! The route is typically mounted at `/{handle_slug}/{event_rkey}/ical`. 20//! 21//! # Translation Keys 22//! 23//! This module requires the following translation keys to be defined in the Fluent files: 24//! - `ical-untitled-event`: Fallback for events without titles 25//! - `ical-event-description-fallback`: Template for events without descriptions 26//! - `ical-online-event`: Location text for online events 27//! - `ical-location-tba`: Location text when venue is not specified 28//! - `ical-calendar-product-name`: Product identifier for the calendar 29//! - `ical-filename-template`: Template for generated filenames 30 31use anyhow::Result; 32use axum::{ 33 extract::{Path, Query}, 34 http::{header, HeaderMap, StatusCode}, 35 response::IntoResponse, 36}; 37use chrono::{DateTime, Utc}; 38use ics::{ 39 properties::{DtEnd, DtStart, Summary, Description, Location, Organizer}, 40 Event as IcsEvent, ICalendar, 41}; 42use serde::Deserialize; 43 44use crate::atproto::lexicon::community::lexicon::calendar::event::NSID; 45use crate::atproto::uri::parse_aturi; 46use crate::http::context::UserRequestContext; 47use crate::http::errors::{WebError, CommonError}; 48use crate::http::event_view::EventView; 49use crate::i18n::fluent_loader::{get_translation, LOCALES}; 50use crate::resolve::{parse_input, InputType}; 51use fluent_templates::Loader; 52use crate::storage::event::{event_get, extract_event_details}; 53use crate::storage::handle::{handle_for_did, handle_for_handle}; 54 55/// Query parameters for iCal collection specification 56/// 57/// Allows clients to specify which AT Protocol collection to use when looking up events. 58/// Defaults to the standard community calendar event collection if not specified. 59#[derive(Debug, Deserialize)] 60pub struct ICalCollectionParam { 61 /// The AT Protocol collection identifier (NSID) to use for event lookup 62 /// 63 /// Defaults to the standard community calendar event collection NSID 64 /// when not provided in the query parameters. 65 #[serde(default = "default_collection")] 66 collection: String, 67} 68 69/// Returns the default collection NSID for community calendar events 70/// 71/// This function provides the default AT Protocol collection identifier 72/// used when no explicit collection is specified in the request. 73fn default_collection() -> String { 74 NSID.to_string() 75} 76 77/// Generate iCal content for an event with full internationalization support 78/// 79/// This is the main HTTP handler for iCal event downloads. It processes requests for 80/// `.ics` files and returns RFC 5545 compliant iCalendar data with proper HTTP headers 81/// for download handling. 82/// 83/// # Arguments 84/// 85/// * `ctx` - User request context containing language preferences and web configuration 86/// * `handle_slug` - AT Protocol handle or DID identifying the event organizer 87/// * `event_rkey` - Record key identifying the specific event within the collection 88/// * `collection_param` - Optional query parameter specifying the AT Protocol collection 89/// 90/// # Returns 91/// 92/// Returns an HTTP response with: 93/// - `Content-Type: text/calendar; charset=utf-8` header 94/// - `Content-Disposition: attachment; filename="..."` header with localized filename 95/// - RFC 5545 compliant iCalendar content in the response body 96/// 97/// # Errors 98/// 99/// Returns [`WebError`] in the following cases: 100/// - Invalid handle slug format 101/// - Event not found in the database 102/// - Invalid AT Protocol URI format 103/// - Failed to parse event data 104/// 105/// # AT Protocol Integration 106/// 107/// The organizer field uses AT Protocol handle format (`CN=Name:at://handle`) instead 108/// of the traditional mailto format, making it compatible with decentralized identity systems. 109/// 110/// # Internationalization 111/// 112/// All user-facing content is localized based on the user's language preference: 113/// - Event titles fall back to translated "untitled event" text 114/// - Descriptions use translated templates when missing 115/// - Location displays localized "online" or "TBA" text for missing venues 116/// - Filenames are generated using translated templates 117pub async fn handle_ical_event( 118 ctx: UserRequestContext, 119 Path((handle_slug, event_rkey)): Path<(String, String)>, 120 collection_param: Query<ICalCollectionParam>, 121) -> Result<impl IntoResponse, WebError> { 122 // Parse the handle slug to get the profile 123 let profile = match parse_input(&handle_slug) { 124 Ok(InputType::Plc(did) | InputType::Web(did)) => { 125 handle_for_did(&ctx.web_context.pool, &did).await 126 } 127 Ok(InputType::Handle(handle)) => { 128 handle_for_handle(&ctx.web_context.pool, &handle).await 129 } 130 _ => return Err(WebError::Common(CommonError::InvalidHandleSlug)), 131 } 132 .map_err(|_| WebError::Common(CommonError::RecordNotFound))?; 133 134 // Use the provided collection parameter 135 let collection = &collection_param.0.collection; 136 let lookup_aturi = format!("at://{}/{}/{}", profile.did, collection, event_rkey); 137 138 // Get the event from the database 139 let event = event_get(&ctx.web_context.pool, &lookup_aturi) 140 .await 141 .map_err(|_| WebError::Common(CommonError::RecordNotFound))?; 142 143 // Get organizer handle for additional info 144 let organizer_handle = handle_for_did(&ctx.web_context.pool, &event.did) 145 .await 146 .ok(); 147 148 // Convert to EventView to get formatted data 149 let event_view = EventView::try_from_with_locale( 150 (None, organizer_handle.as_ref(), &event), 151 Some(&ctx.language.0), 152 ) 153 .map_err(|_| WebError::Common(CommonError::FailedToParse))?; 154 155 // Extract event details for raw datetime access 156 let details = extract_event_details(&event); 157 158 // Parse aturi to get rkey for unique ID 159 let (_, _, rkey) = parse_aturi(&event.aturi) 160 .map_err(|_| WebError::Common(CommonError::InvalidAtUri))?; 161 162 // Create iCal event with localized content 163 let mut ical_event = IcsEvent::new( 164 format!("{}@smokesignal.events", rkey), 165 format_timestamp(&Utc::now()), 166 ); 167 168 // Set event summary (title) - use localized fallback if needed 169 let event_title = if event_view.name.trim().is_empty() { 170 // Fallback to localized "untitled event" 171 LOCALES.lookup(&ctx.language.0, "ical-untitled-event") 172 } else { 173 event_view.name.clone() 174 }; 175 ical_event.push(Summary::new(event_title)); 176 177 // Set description with i18n awareness 178 let event_description = match event_view.description { 179 Some(desc) if !desc.trim().is_empty() => desc, 180 _ => { 181 // Fallback to localized description 182 let mut args = std::collections::HashMap::new(); 183 args.insert( 184 "organizer".into(), 185 fluent_templates::fluent_bundle::FluentValue::String( 186 std::borrow::Cow::Owned(event_view.organizer_display_name.clone()) 187 ) 188 ); 189 get_translation(&ctx.language.0, "ical-event-description-fallback", Some(args)) 190 } 191 }; 192 ical_event.push(Description::new(event_description)); 193 194 // Set start time if available 195 if let Some(starts_at) = details.starts_at { 196 ical_event.push(DtStart::new(format_timestamp(&starts_at))); 197 } 198 199 // Set end time if available 200 if let Some(ends_at) = details.ends_at { 201 ical_event.push(DtEnd::new(format_timestamp(&ends_at))); 202 } 203 204 // Set location with i18n awareness 205 let event_location = match event_view.address_display { 206 Some(address) if !address.trim().is_empty() => address, 207 _ => { 208 // Fallback to localized "online event" or "location TBA" 209 match event_view.mode.as_deref() { 210 Some("online") => LOCALES.lookup(&ctx.language.0, "ical-online-event"), 211 _ => LOCALES.lookup(&ctx.language.0, "ical-location-tba"), 212 } 213 } 214 }; 215 ical_event.push(Location::new(event_location)); 216 217 // Set organizer with AT Protocol handle format (not mailto) 218 let organizer_handle = organizer_handle 219 .as_ref() 220 .map(|h| h.handle.clone()) 221 .unwrap_or_else(|| "unknown".to_string()); 222 223 // Use AT Protocol handle format instead of mailto: 224 // CN=Display Name:at://handle.domain 225 let organizer_field = format!( 226 "CN={}:at://{}", 227 event_view.organizer_display_name, 228 organizer_handle 229 ); 230 ical_event.push(Organizer::new(organizer_field)); 231 232 // Create calendar with localized product identifier 233 let calendar_name = LOCALES.lookup(&ctx.language.0, "ical-calendar-product-name"); 234 let mut calendar = ICalendar::new("2.0", &calendar_name); 235 calendar.add_event(ical_event); 236 237 // Generate iCal content 238 let ical_content = calendar.to_string(); 239 240 // Create localized filename with proper sanitization 241 let sanitized_name = sanitize_filename(&event_view.name); 242 let mut filename_args = std::collections::HashMap::new(); 243 filename_args.insert( 244 "event-name".into(), 245 fluent_templates::fluent_bundle::FluentValue::String( 246 std::borrow::Cow::Owned(sanitized_name.clone()) 247 ) 248 ); 249 let filename = get_translation(&ctx.language.0, "ical-filename-template", Some(filename_args)); 250 251 // Create response with appropriate headers 252 let mut headers = HeaderMap::new(); 253 headers.insert( 254 header::CONTENT_TYPE, 255 "text/calendar; charset=utf-8".parse().unwrap(), 256 ); 257 headers.insert( 258 header::CONTENT_DISPOSITION, 259 format!("attachment; filename=\"{}\"", filename) 260 .parse() 261 .unwrap(), 262 ); 263 264 Ok((StatusCode::OK, headers, ical_content)) 265} 266 267/// Format a timestamp for iCal format (YYYYMMDDTHHMMSSZ) 268/// 269/// Converts a UTC DateTime to the iCalendar specification format required by RFC 5545. 270/// The output format is `YYYYMMDDTHHMMSSZ` where the trailing 'Z' indicates UTC timezone. 271/// 272/// # Arguments 273/// 274/// * `dt` - A UTC DateTime to format 275/// 276/// # Returns 277/// 278/// A string formatted as `YYYYMMDDTHHMMSSZ` suitable for use in iCalendar DTSTART and DTEND properties 279/// 280/// # Examples 281/// 282/// ``` 283/// use chrono::{DateTime, Utc}; 284/// let dt = DateTime::parse_from_rfc3339("2025-06-04T14:30:00Z").unwrap().with_timezone(&Utc); 285/// assert_eq!(format_timestamp(&dt), "20250604T143000Z"); 286/// ``` 287fn format_timestamp(dt: &DateTime<Utc>) -> String { 288 dt.format("%Y%m%dT%H%M%SZ").to_string() 289} 290 291/// Sanitize filename by removing or replacing invalid characters 292/// 293/// Ensures that event names can be safely used as filenames by replacing characters 294/// that are invalid or problematic in filesystem paths. This prevents path traversal 295/// attacks and ensures cross-platform compatibility. 296/// 297/// # Arguments 298/// 299/// * `name` - The raw event name to sanitize 300/// 301/// # Returns 302/// 303/// A sanitized string safe for use as a filename component 304/// 305/// # Sanitization Rules 306/// 307/// - Path separators (`/`, `\`) are replaced with underscores 308/// - Reserved characters (`:`, `*`, `?`, `"`, `<`, `>`, `|`) are replaced with underscores 309/// - Control characters are replaced with underscores 310/// - Leading and trailing whitespace is trimmed 311/// 312/// # Examples 313/// 314/// ``` 315/// assert_eq!(sanitize_filename("Hello World"), "Hello World"); 316/// assert_eq!(sanitize_filename("Hello/World"), "Hello_World"); 317/// assert_eq!(sanitize_filename("Test: Event?"), "Test_ Event_"); 318/// ``` 319fn sanitize_filename(name: &str) -> String { 320 name.chars() 321 .map(|c| match c { 322 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', 323 c if c.is_control() => '_', 324 c => c, 325 }) 326 .collect::<String>() 327 .trim() 328 .to_string() 329} 330 331#[cfg(test)] 332mod tests { 333 use super::*; 334 335 #[test] 336 fn test_sanitize_filename() { 337 assert_eq!(sanitize_filename("Hello World"), "Hello World"); 338 assert_eq!(sanitize_filename("Hello/World"), "Hello_World"); 339 assert_eq!(sanitize_filename("Test: Event?"), "Test_ Event_"); 340 assert_eq!(sanitize_filename("Event<>Name"), "Event__Name"); 341 } 342 343 #[test] 344 fn test_format_timestamp() { 345 let dt = DateTime::parse_from_rfc3339("2025-06-04T14:30:00Z") 346 .unwrap() 347 .with_timezone(&Utc); 348 assert_eq!(format_timestamp(&dt), "20250604T143000Z"); 349 } 350}