use std::hash::Hasher; use std::str::FromStr; use anyhow::Result; use atproto_record::aturi::ATURI; use atproto_record::lexicon::community::lexicon::location::LocationOrRef; use axum::{extract::Path, response::IntoResponse}; use http::{HeaderValue, StatusCode, header::CONTENT_DISPOSITION}; use metrohash::MetroHash64; use crate::http::context::UserRequestContext; use crate::http::errors::WebError; use crate::http::utils::url_from_aturi; use crate::ics_helpers::generate_event_ics; use crate::storage::event::event_get; use atproto_record::lexicon::community::lexicon::calendar::event::{Event as EventLexicon, NSID}; pub(crate) async fn handle_export_ics( ctx: UserRequestContext, Path(aturi_path): Path, ) -> Result { // Parse the ATURI let aturi = match ATURI::from_str(&aturi_path) { Ok(aturi) => aturi, Err(_) => return Ok((StatusCode::NOT_FOUND).into_response()), }; // Check if the collection matches the event NSID if aturi.collection != NSID { return Ok((StatusCode::NOT_FOUND).into_response()); } // Reconstruct the full ATURI string let full_aturi = aturi.to_string(); // Get the event from storage let event = match event_get(&ctx.web_context.pool, &full_aturi).await { Ok(event) => event, Err(_) => return Ok((StatusCode::NOT_FOUND).into_response()), }; // Parse the record to get event details let event_record: EventLexicon = match serde_json::from_value(event.record.0.clone()) { Ok(record) => record, Err(_) => return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()), }; // Check for start time let starts_at = match event_record.starts_at { Some(start) => start, None => return Ok((StatusCode::BAD_REQUEST).into_response()), }; // Generate UID from ATURI let mut h = MetroHash64::default(); h.write(event.aturi.clone().as_bytes()); let uid = crockford::encode(h.finish()); // Generate event URL let event_url = match url_from_aturi(&ctx.web_context.config.external_base, &event.aturi) { Ok(url) => url, Err(_) => return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()), }; // Build description with URL let description = format!("More at {}", event_url); // Generate ICS content using shared helper // Extract location name from the first location if it's an InlineAddress let location_name = event_record.locations.first().and_then(|loc| match loc { LocationOrRef::InlineAddress(typed_address) => typed_address.inner.name.as_deref(), _ => None, }); let ics_content = match generate_event_ics( &uid, &event_record.name, Some(&description), location_name, starts_at, event_record.ends_at, Some(&event_url), ) { Ok(content) => content, Err(_) => return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()), }; // Generate filename let filename = format!("{}-{}.ics", aturi.authority, aturi.record_key); // Create response with proper headers for file download let content_disposition = format!("attachment; filename=\"{}\"", filename); let response = ( [ ( http::header::CONTENT_TYPE, HeaderValue::from_static("text/calendar; charset=utf-8"), ), ( CONTENT_DISPOSITION, HeaderValue::from_str(&content_disposition).unwrap(), ), ], ics_content, ); Ok(response.into_response()) }