forked from
smokesignal.events/smokesignal
i18n+filtering fork - fluent-templates v2
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}