+1
Cargo.toml
+1
Cargo.toml
+1
i18n/en-us/actions.ftl
+1
i18n/en-us/actions.ftl
+8
i18n/en-us/common.ftl
+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
+1
i18n/fr-ca/actions.ftl
+8
i18n/fr-ca/common.ftl
+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
+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
+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
+1
src/http/mod.rs
+2
src/http/server.rs
+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
+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
+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" %}