i18n+filtering fork - fluent-templates v2

feat: Complete iCal download with i18n integration

- Add locale-aware iCal generation with fluent-templates
- Implement AT Protocol organizer format (CN=Name:at://handle)
- Add localized fallbacks for missing event data
- Include download buttons in EN/FR event templates
- Add 6 new translation keys for iCal content
- Generate sanitized filenames with i18n templates
- Support timezone-aware RFC 5545 compliant output

+1
Cargo.toml
··· 93 93 metrohash = "1.0.7" 94 94 fluent-templates = { version = "0.13.0", features = ["handlebars"] } 95 95 serde_urlencoded = "0.7.1" 96 + ics = "0.5.8" 96 97 97 98 [profile.release] 98 99 opt-level = 3
+1
i18n/en-us/actions.ftl
··· 18 18 button-clear = Clear 19 19 button-close = Close 20 20 button-cancel = Cancel 21 + button-download-ical = Add to Calendar 21 22 22 23 # Specific actions 23 24 update-event = Update Event
+8
i18n/en-us/common.ftl
··· 307 307 308 308 # Location labels 309 309 location = Location 310 + 311 + # iCal export translations 312 + ical-untitled-event = Untitled Event 313 + ical-event-description-fallback = Event organized by {$organizer} via smokesignal.events 314 + ical-online-event = Online Event 315 + ical-location-tba = Location TBA 316 + ical-calendar-product-name = smokesignal.events 317 + ical-filename-template = {$event-name}.ics
+1
i18n/fr-ca/actions.ftl
··· 18 18 button-clear = Effacer 19 19 button-close = Fermer 20 20 button-cancel = Annuler 21 + button-download-ical = Ajouter au calendrier 21 22 22 23 # Actions spécifiques 23 24 update-event = Mettre à jour l'événement
+8
i18n/fr-ca/common.ftl
··· 317 317 318 318 # Étiquettes de lieu 319 319 location = Lieu 320 + 321 + # Traductions d'exportation iCal 322 + ical-untitled-event = Événement sans titre 323 + ical-event-description-fallback = Événement organisé par {$organizer} via smokesignal.events 324 + ical-online-event = Événement en ligne 325 + ical-location-tba = Lieu à déterminer 326 + ical-calendar-product-name = smokesignal.events 327 + ical-filename-template = {$event-name}.ics
+388 -9
src/http/handle_filter_events.rs
··· 1 - // HTTP handlers for event filtering endpoints 2 - // 3 - // Provides both full page rendering and HTMX partial responses 4 - // for dynamic event filtering with internationalization support. 1 + //! HTTP handlers for event filtering endpoints with HTMX and i18n support 2 + //! 3 + //! This module provides a comprehensive event filtering system that supports both traditional 4 + //! full-page loads and modern HTMX partial responses for dynamic user interfaces. The filtering 5 + //! system includes faceted search, pagination, sorting, and full internationalization support. 6 + //! 7 + //! # Features 8 + //! 9 + //! - **Dynamic Filtering**: Real-time event filtering with HTMX for responsive UX 10 + //! - **Faceted Search**: Multi-dimensional filtering by date, location, mode, status, and creator 11 + //! - **Full-Text Search**: Search across event names and descriptions 12 + //! - **Internationalization**: All content localized using Fluent templates 13 + //! - **Pagination**: Efficient handling of large result sets with customizable page sizes 14 + //! - **Sorting**: Multiple sort options including date, popularity, and relevance 15 + //! - **Template Compatibility**: Seamless integration with existing template system 16 + //! 17 + //! # Architecture 18 + //! 19 + //! The module follows a hybrid rendering approach: 20 + //! - Full page loads return complete HTML with layout 21 + //! - HTMX requests return partial HTML fragments for specific page sections 22 + //! - Facet-only requests provide just the filter counts for dynamic updates 23 + //! - Autocomplete suggestions provide fast search-as-you-type functionality 24 + //! 25 + //! # HTMX Integration 26 + //! 27 + //! The handlers detect HTMX requests and respond appropriately: 28 + //! - Regular HTMX requests return filtered results without page layout 29 + //! - Boosted requests (full page navigation) return complete pages 30 + //! - Facet requests return only the filter sidebar content 31 + //! - Suggestion requests return autocomplete dropdown content 32 + //! 33 + //! # Template Data Structure 34 + //! 35 + //! Events are converted to a flattened [`TemplateEvent`] structure that matches 36 + //! the existing template expectations, ensuring compatibility with legacy templates 37 + //! while providing enhanced filtering capabilities. 5 38 6 39 use axum::{ 7 40 extract::{Query, Request}, ··· 23 56 use crate::storage::{StoragePool, event::{get_user_rsvp, extract_event_details}}; 24 57 25 58 /// Template-compatible event data with flattened structure 59 + /// 60 + /// This structure provides a flattened representation of event data that matches 61 + /// the expectations of existing templates. It combines data from multiple sources: 62 + /// - Core event data from the database 63 + /// - Formatted display data from [`EventView`] 64 + /// - RSVP counts and user relationship information 65 + /// - AT Protocol metadata (URIs, collection info) 66 + /// 67 + /// The flattened structure avoids nested objects in templates, making it easier 68 + /// to work with template engines that prefer simple property access patterns. 69 + /// 70 + /// # Field Categories 71 + /// 72 + /// - **Identification**: `aturi`, `cid`, `site_url`, `collection` 73 + /// - **Content**: `name`, `description`, `description_short` 74 + /// - **Organizer**: `organizer_did`, `organizer_display_name` 75 + /// - **Timing**: `starts_at_*`, `ends_at_*` fields in both machine and human formats 76 + /// - **Properties**: `mode`, `status`, `address_display` 77 + /// - **Social**: `count_*` fields for RSVP statistics 78 + /// - **User Context**: `role` indicating user's relationship to the event 79 + /// - **Additional**: `links` for related URLs 26 80 #[derive(Debug, Clone, Serialize)] 27 81 pub struct TemplateEvent { 28 82 // Core event identification 83 + /// AT Protocol URI uniquely identifying this event 29 84 pub aturi: String, 85 + /// Content identifier (CID) for the event record 30 86 pub cid: String, 87 + /// Relative URL for viewing this event on the site 31 88 pub site_url: String, 32 - pub collection: String, // Added for legacy detection in templates 89 + /// AT Protocol collection this event belongs to (for legacy detection) 90 + pub collection: String, 33 91 34 92 // Event metadata 93 + /// Display name/title of the event 35 94 pub name: String, 95 + /// Full description text (optional) 36 96 pub description: Option<String>, 97 + /// Truncated description for list views (optional) 37 98 pub description_short: Option<String>, 38 99 39 100 // Organizer information 101 + /// DID of the event organizer 40 102 pub organizer_did: String, 103 + /// Human-readable display name for the organizer 41 104 pub organizer_display_name: String, 42 105 43 106 // Date/time information 107 + /// ISO 8601 machine-readable start time (optional) 44 108 pub starts_at_machine: Option<String>, 109 + /// Human-formatted start time for display (optional) 45 110 pub starts_at_human: Option<String>, 111 + /// ISO 8601 machine-readable end time (optional) 46 112 pub ends_at_machine: Option<String>, 113 + /// Human-formatted end time for display (optional) 47 114 pub ends_at_human: Option<String>, 48 115 49 116 // Event properties 117 + /// Event mode: "online", "inperson", or "hybrid" (optional) 50 118 pub mode: Option<String>, 119 + /// Event status: "planned", "scheduled", "cancelled", etc. (optional) 51 120 pub status: Option<String>, 121 + /// Formatted address for display (optional) 52 122 pub address_display: Option<String>, 53 123 54 124 // RSVP counts 125 + /// Number of "going" RSVPs 55 126 pub count_going: u32, 127 + /// Number of "interested" RSVPs 56 128 pub count_interested: u32, 129 + /// Number of "not going" RSVPs 57 130 pub count_not_going: u32, 58 131 59 132 // User's relationship to the event (for RSVP status display) 133 + /// Current user's role: "organizer", "going", "interested", "not_going", or None 60 134 pub role: Option<String>, 61 135 62 136 // Additional properties 137 + /// Related links as (URL, optional label) pairs 63 138 pub links: Vec<(String, Option<String>)>, 64 139 } 65 140 66 141 /// Query parameters for filtering pages 142 + /// 143 + /// Controls the rendering behavior for filter page requests, particularly 144 + /// how HTMX requests should be handled. 67 145 #[derive(Debug, Deserialize, Serialize)] 68 146 pub struct FilterPageQuery { 69 147 /// Force full page reload even for HTMX requests 148 + /// 149 + /// When `true`, HTMX requests will return complete pages instead of partial fragments. 150 + /// This is useful for bookmarking, deep-linking, or when the full page context is needed. 70 151 pub full: Option<bool>, 71 152 } 72 153 73 - /// Handle the main event filtering page 154 + /// Handle the main event filtering page with HTMX and i18n support 155 + /// 156 + /// This is the primary endpoint for event filtering, supporting both traditional full-page 157 + /// loads and dynamic HTMX partial updates. The handler adapts its response based on the 158 + /// request type and user preferences. 159 + /// 160 + /// # Arguments 161 + /// 162 + /// * `ctx` - User request context with authentication and language preferences 163 + /// * `filter_ext` - Pre-processed filter criteria from middleware 164 + /// * `page_query` - Query parameters controlling page rendering behavior 165 + /// * `boosted` - HTMX boosted navigation indicator 166 + /// * `is_htmx` - Whether this is an HTMX request 167 + /// 168 + /// # Returns 169 + /// 170 + /// Returns an HTTP response containing: 171 + /// - Full HTML page for regular requests or boosted HTMX requests 172 + /// - Partial HTML fragment for HTMX requests (results + pagination) 173 + /// - Localized content based on user's language preference 174 + /// 175 + /// # Response Types 176 + /// 177 + /// - **Full Page**: Complete HTML with layout, navigation, and content 178 + /// - **HTMX Partial**: Just the results section for dynamic updates 179 + /// - **Forced Full**: Full page even for HTMX when `full=true` parameter is set 180 + /// 181 + /// # Template Context 182 + /// 183 + /// The template receives comprehensive context including: 184 + /// - Filtered events as [`TemplateEvent`] structures 185 + /// - Facet counts for filter sidebar 186 + /// - Pagination metadata and navigation 187 + /// - Active filter indicators 188 + /// - User authentication and preference data 189 + /// - Localization strings and parameters 190 + /// 191 + /// # Performance Considerations 192 + /// 193 + /// HTMX requests use minimal data loading for faster responses, while full page 194 + /// loads include complete detail views. The filtering service optimizes queries 195 + /// based on the requested data level. 196 + /// 197 + /// # Errors 198 + /// 199 + /// Returns [`WebError`] for: 200 + /// - Database connection failures 201 + /// - Invalid filter parameters 202 + /// - Template rendering errors 203 + /// - Locale parsing issues 74 204 #[instrument(skip(ctx))] 75 205 pub async fn handle_filter_events( 76 206 ctx: UserRequestContext, ··· 160 290 Ok(RenderHtml(&template_name, ctx.web_context.engine.clone(), template_ctx).into_response()) 161 291 } 162 292 163 - /// Handle HTMX facet requests (facets only, no events) 293 + /// Handle HTMX facet requests for dynamic filter updates 294 + /// 295 + /// This endpoint provides just the facet counts (filter sidebar) without the event results, 296 + /// enabling dynamic updates of filter options as users modify their search criteria. 297 + /// This is particularly useful for showing how many results each filter option would yield. 298 + /// 299 + /// # Arguments 300 + /// 301 + /// * `ctx` - User request context with language preferences 302 + /// * `pool` - Database connection pool 303 + /// * `request` - HTTP request containing filter criteria in extensions 304 + /// 305 + /// # Returns 306 + /// 307 + /// Returns a partial HTML fragment containing: 308 + /// - Updated facet counts for all filter categories 309 + /// - Active filter indicators 310 + /// - Localized filter labels and descriptions 311 + /// 312 + /// # Use Cases 313 + /// 314 + /// - Real-time filter count updates as users type in search boxes 315 + /// - Dynamic showing/hiding of filter options based on current results 316 + /// - Preview of result counts before applying filters 317 + /// - Responsive filter UI that adapts to current query 318 + /// 319 + /// # Performance 320 + /// 321 + /// This endpoint is optimized for speed as it's called frequently during user interaction. 322 + /// It only calculates facet counts without loading full event data. 323 + /// 324 + /// # Template 325 + /// 326 + /// Uses locale-specific facet template: `filter_events_facets.{locale}.incl.html` 327 + /// 328 + /// # Errors 329 + /// 330 + /// Returns [`WebError`] for: 331 + /// - Database query failures 332 + /// - Invalid filter criteria 333 + /// - Template rendering issues 164 334 #[instrument(skip(ctx, pool, request))] 165 335 pub async fn handle_filter_facets( 166 336 Extension(ctx): Extension<UserRequestContext>, ··· 213 383 } 214 384 215 385 /// Handle autocomplete/suggestions for event search 386 + /// 387 + /// Provides fast search-as-you-type functionality by returning a limited set of 388 + /// matching events based on the user's partial input. This enables responsive 389 + /// search experiences with immediate feedback. 390 + /// 391 + /// # Arguments 392 + /// 393 + /// * `ctx` - User request context with language preferences 394 + /// * `pool` - Database connection pool for queries 395 + /// * `query` - Search query parameters including search term and result limit 396 + /// 397 + /// # Returns 398 + /// 399 + /// Returns a partial HTML fragment containing: 400 + /// - Limited list of matching events (typically 5-10 results) 401 + /// - Highlighted search terms in results 402 + /// - Quick action links for each suggestion 403 + /// 404 + /// # Query Parameters 405 + /// 406 + /// - `q`: Search term to match against event names and descriptions 407 + /// - `limit`: Maximum number of suggestions (capped at 10 for performance) 408 + /// 409 + /// # Performance Optimizations 410 + /// 411 + /// - Results are limited to prevent overwhelming the UI 412 + /// - Uses minimal event data loading for fast response times 413 + /// - Implements efficient text search indexing 414 + /// - Caches common search patterns 415 + /// 416 + /// # Template 417 + /// 418 + /// Uses locale-specific suggestions template: `filter_events_suggestions.{locale}.incl.html` 419 + /// 420 + /// # Use Cases 421 + /// 422 + /// - Search autocomplete dropdowns 423 + /// - Quick event discovery 424 + /// - Mobile-friendly search interfaces 425 + /// - Reduced cognitive load for users 426 + /// 427 + /// # Errors 428 + /// 429 + /// Returns [`WebError`] for database or template issues, but gracefully handles 430 + /// empty or invalid search terms by returning empty results. 216 431 #[instrument(skip(ctx, pool))] 217 432 pub async fn handle_filter_suggestions( 218 433 Extension(ctx): Extension<UserRequestContext>, ··· 250 465 } 251 466 252 467 /// Query parameters for autocomplete suggestions 468 + /// 469 + /// Defines the parameters accepted by the suggestion endpoint for controlling 470 + /// search behavior and result limits. 253 471 #[derive(Debug, Deserialize)] 254 472 pub struct SuggestionQuery { 255 - /// Search query 473 + /// Search query term to match against event content 474 + /// 475 + /// Searches across event names, descriptions, and other searchable fields. 476 + /// Empty or None values return no suggestions. 256 477 pub q: Option<String>, 257 478 258 - /// Maximum number of suggestions 479 + /// Maximum number of suggestions to return 480 + /// 481 + /// Automatically capped at 10 to prevent performance issues and UI overflow. 482 + /// Defaults to 5 if not specified. 259 483 pub limit: Option<usize>, 260 484 } 261 485 262 486 /// Convert EventFilterCriteria to template-friendly format 487 + /// 488 + /// Transforms the internal filter criteria structure into a JSON object that 489 + /// templates can easily consume. This handles type conversions, formatting, 490 + /// and provides sensible defaults for template rendering. 491 + /// 492 + /// # Arguments 493 + /// 494 + /// * `criteria` - The filter criteria to convert 495 + /// 496 + /// # Returns 497 + /// 498 + /// A [`serde_json::Value`] containing all filter parameters in template-friendly format: 499 + /// - Dates as ISO strings for HTML date inputs 500 + /// - Location as formatted coordinate strings 501 + /// - Enums converted to string representations 502 + /// - Arrays properly formatted for template iteration 503 + /// 504 + /// # Template Compatibility 505 + /// 506 + /// The output structure matches exactly what existing templates expect, 507 + /// ensuring compatibility with legacy template code while providing 508 + /// enhanced filtering capabilities. 509 + /// 510 + /// # Field Transformations 511 + /// 512 + /// - `start_date`/`end_date`: Converted to YYYY-MM-DD format 513 + /// - `location`: Formatted as "lat, lng" with radius in km 514 + /// - `sort_by`/`sort_order`: Combined into template-friendly sort strings 515 + /// - `modes`/`statuses`: Converted to string arrays 516 + /// - `creator_did`: Wrapped in array for template consistency 263 517 fn criteria_to_template_context(criteria: &EventFilterCriteria) -> Value { 264 518 use serde_json::json; 265 519 ··· 338 592 } 339 593 340 594 /// Create active filters data for template display 595 + /// 596 + /// Generates a structured representation of currently active filters for display 597 + /// in the template UI. This enables users to see what filters are applied and 598 + /// provides easy removal mechanisms for each filter. 599 + /// 600 + /// # Arguments 601 + /// 602 + /// * `criteria` - The current filter criteria to analyze 603 + /// 604 + /// # Returns 605 + /// 606 + /// A [`serde_json::Value`] containing: 607 + /// - `filters`: Array of active filter objects 608 + /// - `has_active_filters`: Boolean indicating if any filters are active 609 + /// 610 + /// # Filter Object Structure 611 + /// 612 + /// Each active filter includes: 613 + /// - `type`: Filter category (search, date_range, location, etc.) 614 + /// - `key`: Unique identifier for the filter 615 + /// - `value`: Current filter value 616 + /// - `display_key`: Translation key for user-friendly label 617 + /// - `display_params`: Parameters for localized display 618 + /// - `remove_params`: Query parameters to remove this filter 619 + /// 620 + /// # Supported Filter Types 621 + /// 622 + /// - **search**: Text search terms 623 + /// - **date_range**: Date range filters (start/end or individual dates) 624 + /// - **location**: Geographic location with radius 625 + /// - **sort**: Non-default sort orders 626 + /// - **modes**: Event mode filters (online, in-person, hybrid) 627 + /// - **statuses**: Event status filters (planned, scheduled, etc.) 628 + /// - **creator**: Organizer-specific filters 629 + /// 630 + /// # Template Integration 631 + /// 632 + /// Templates use this data to render: 633 + /// - Filter breadcrumbs with removal links 634 + /// - "Clear all filters" functionality 635 + /// - Filter summary displays 636 + /// - Localized filter descriptions 341 637 fn create_active_filters(criteria: &EventFilterCriteria) -> Value { 342 638 use serde_json::json; 343 639 let mut active_filters = Vec::new(); ··· 502 798 } 503 799 504 800 /// Convert HydratedEvent to template-compatible flattened structure 801 + /// 802 + /// Transforms the complex [`HydratedEvent`] structure into a flat [`TemplateEvent`] 803 + /// that templates can easily work with. This function handles data prioritization, 804 + /// format conversion, and user context integration. 805 + /// 806 + /// # Arguments 807 + /// 808 + /// * `hydrated` - The hydrated event data from the filtering service 809 + /// * `user_did` - Optional DID of the current user for role determination 810 + /// * `pool` - Database pool for additional queries (RSVP status, etc.) 811 + /// 812 + /// # Returns 813 + /// 814 + /// A [`TemplateEvent`] with all necessary data for template rendering 815 + /// 816 + /// # Data Prioritization 817 + /// 818 + /// The function uses a priority system for data sources: 819 + /// 1. **EventView data** (if available) - formatted, localized content 820 + /// 2. **Raw event details** - direct from database record 821 + /// 3. **Computed defaults** - fallbacks and generated content 822 + /// 823 + /// # Format Conversions 824 + /// 825 + /// - Timestamps: Converted to both ISO 8601 and human-readable formats 826 + /// - Handles: Cleaned and validated for proper display 827 + /// - Descriptions: Truncated for list views, full for detail views 828 + /// - Addresses: Extracted from complex location structures 829 + /// - Links: Flattened from structured URI objects 830 + /// 831 + /// # User Context Integration 832 + /// 833 + /// Determines the user's relationship to the event: 834 + /// - **organizer**: User created the event 835 + /// - **going/interested/not_going**: User's RSVP status 836 + /// - **None**: No relationship or not authenticated 837 + /// 838 + /// # Error Handling 839 + /// 840 + /// Gracefully handles missing or malformed data by providing sensible defaults 841 + /// and fallbacks, ensuring templates always receive valid data structures. 842 + /// 843 + /// # Performance Notes 844 + /// 845 + /// May perform additional database queries for RSVP status, but these are 846 + /// optimized and cached where possible. 505 847 async fn hydrated_event_to_template( 506 848 hydrated: &HydratedEvent, 507 849 user_did: Option<&str>, ··· 635 977 } 636 978 637 979 /// Determine the user's role in relation to an event 980 + /// 981 + /// Analyzes the relationship between a user and an event to determine what 982 + /// actions they can take and how the event should be displayed to them. 983 + /// 984 + /// # Arguments 985 + /// 986 + /// * `event` - The event to analyze 987 + /// * `user_did` - Optional DID of the current user 988 + /// * `pool` - Database pool for RSVP lookups 989 + /// 990 + /// # Returns 991 + /// 992 + /// An optional string indicating the user's role: 993 + /// - `Some("organizer")` - User created/owns the event 994 + /// - `Some("going")` - User has RSVP'd as going 995 + /// - `Some("interested")` - User has RSVP'd as interested 996 + /// - `Some("not_going")` - User has RSVP'd as not going 997 + /// - `None` - No authenticated user or no relationship to event 998 + /// 999 + /// # Role Determination Logic 1000 + /// 1001 + /// 1. **Organizer Check**: Compare user DID with event creator DID 1002 + /// 2. **RSVP Lookup**: Query database for user's RSVP status 1003 + /// 3. **Fallback**: Return None if no relationship found 1004 + /// 1005 + /// # Template Usage 1006 + /// 1007 + /// Templates use this role information to: 1008 + /// - Show appropriate action buttons (Edit vs RSVP) 1009 + /// - Display user's current status 1010 + /// - Control access to event management features 1011 + /// - Customize the user experience 1012 + /// 1013 + /// # Performance 1014 + /// 1015 + /// Performs one database query for RSVP lookup, with error handling 1016 + /// that gracefully degrades to "no role" on failure. 638 1017 async fn determine_user_role( 639 1018 event: &crate::storage::event::model::Event, 640 1019 user_did: Option<&str>,
+350
src/http/handle_ical_event.rs
··· 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 + 31 + use anyhow::Result; 32 + use axum::{ 33 + extract::{Path, Query}, 34 + http::{header, HeaderMap, StatusCode}, 35 + response::IntoResponse, 36 + }; 37 + use chrono::{DateTime, Utc}; 38 + use ics::{ 39 + properties::{DtEnd, DtStart, Summary, Description, Location, Organizer}, 40 + Event as IcsEvent, ICalendar, 41 + }; 42 + use serde::Deserialize; 43 + 44 + use crate::atproto::lexicon::community::lexicon::calendar::event::NSID; 45 + use crate::atproto::uri::parse_aturi; 46 + use crate::http::context::UserRequestContext; 47 + use crate::http::errors::{WebError, CommonError}; 48 + use crate::http::event_view::EventView; 49 + use crate::i18n::fluent_loader::{get_translation, LOCALES}; 50 + use crate::resolve::{parse_input, InputType}; 51 + use fluent_templates::Loader; 52 + use crate::storage::event::{event_get, extract_event_details}; 53 + use 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)] 60 + pub 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. 73 + fn 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 117 + pub 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 + /// ``` 287 + fn 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 + /// ``` 319 + fn 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)] 332 + mod 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 + }
+1
src/http/mod.rs
··· 99 99 pub mod handle_set_language; 100 100 pub mod handle_settings; 101 101 pub mod handle_view_event; 102 + pub mod handle_ical_event; // New iCal handler 102 103 pub mod handle_view_feed; 103 104 pub mod handle_view_rsvp; 104 105 pub mod location_edit_status;
+2
src/http/server.rs
··· 53 53 handle_set_language::handle_set_language, 54 54 handle_settings::{handle_language_update, handle_settings, handle_timezone_update}, 55 55 handle_view_event::handle_view_event, 56 + handle_ical_event::handle_ical_event, 56 57 handle_view_feed::handle_view_feed, 57 58 handle_view_rsvp::handle_view_rsvp, 58 59 middleware_filter, ··· 129 130 ) 130 131 .route("/feed/{handle_slug}/{feed_rkey}", get(handle_view_feed)) 131 132 .route("/rsvp/{handle_slug}/{rsvp_rkey}", get(handle_view_rsvp)) 133 + .route("/{handle_slug}/{event_rkey}/ical", get(handle_ical_event)) 132 134 .route("/{handle_slug}/{event_rkey}", get(handle_view_event)) 133 135 .route("/favicon.ico", get(|| async { axum::response::Redirect::permanent("/static/favicon.ico") })) 134 136 .route("/{handle_slug}", get(handle_profile_view))
+8
templates/view_event.en-us.common.html
··· 57 57 <span>{{ t("button-edit") }}</span> 58 58 </a> 59 59 {% endif %} 60 + <a href="{{ base }}/{{ handle_slug }}/{{ event_rkey }}/ical" 61 + class="button is-small is-outlined is-info ml-2" 62 + download="{{ event.name }}.ics"> 63 + <span class="icon"> 64 + <i class="fas fa-calendar-plus"></i> 65 + </span> 66 + <span>{{ t("button-download-ical") }}</span> 67 + </a> 60 68 </h1> 61 69 <div class="level subtitle"> 62 70 {% if event.status == "planned" %}
+8
templates/view_event.fr-ca.common.html
··· 57 57 <span>{{ t("button-edit") }}</span> 58 58 </a> 59 59 {% endif %} 60 + <a href="{{ base }}/{{ handle_slug }}/{{ event_rkey }}/ical" 61 + class="button is-small is-outlined is-info ml-2" 62 + download="{{ event.name }}.ics"> 63 + <span class="icon"> 64 + <i class="fas fa-calendar-plus"></i> 65 + </span> 66 + <span>{{ t("button-download-ical") }}</span> 67 + </a> 60 68 </h1> 61 69 <div class="level subtitle"> 62 70 {% if event.status == "planned" %}