use std::{ collections::HashMap, fmt::{Debug, Display}, marker::PhantomData, str::FromStr, }; use axum::{ Router, extract::{self}, response::IntoResponse, routing::get, }; use ecsdb::{Component, Entity, EntityId}; use http::StatusCode; use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::info; pub mod auth; mod htmx; pub use htmx::*; pub mod map; pub const USER_SESSION_KEY: &str = "checkpoints.user.session"; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct UserSession { pub athlete_id: AthleteId, } #[derive(Debug, Serialize, Deserialize, Component, Clone)] pub struct ApiToken(pub String); impl ApiToken { pub fn random() -> Self { let token = uuid::Uuid::new_v4().to_string(); Self(token) } } pub fn routes() -> Router> { let geojson_io_routes = Router::new() .route("/locations.geojson", get(locations_geojson)) .route( "/athlete/{athlete_id}/locations.geojson", get(athlete_locations_geojson), ) .route("/location/{location_id}/geojson", get(location_geojson)) .route_layer( tower_http::cors::CorsLayer::permissive() .allow_origin(tower_http::cors::AllowOrigin::mirror_request()), ); Router::new() .route("/", get(index)) .route("/athlete/{athlete_id}", get(athlete)) .route("/location/{eid}", get(location)) .merge(geojson_io_routes) .route("/login", get(login)) .route("/oauth/redirect", get(auth::redirect)) .route("/oauth/callback", get(auth::callback)) .nest_service("/static", axum_embed::ServeEmbed::::new()) // .merge(secured_routes()) } pub fn authenticated_routes() -> Router> { Router::>::new().nest("/map", map::routes()) } pub async fn login(user_session: Option) -> impl IntoResponse { match user_session { Some(session) => { info!(%session.athlete_id, "already logged in"); axum::response::Redirect::to("/").into_response() } None => HtmxTemplate { template_name: "login.html", context: json!({ "links": { "login": "/oauth/redirect" } }), } .into_response(), } } #[derive(Serialize)] struct RankingAthlete { eid: ecsdb::EntityId, name: String, strava_profile_url: url::Url, url: String, points: u64, last_activity: Option, visited_locations: VisitedLocations, } impl RankingAthlete { fn load(athlete: Entity) -> Option { let db = athlete.db(); let strava_athlete = athlete.component::()?; let last_activity = db .query_filtered::(ActivityOwner(athlete.id())) .max_by_key(|a| a.start_date_local); let visited_locations = athlete.component::().unwrap_or_default(); let points = visited_locations .0 .iter() .filter_map(|l| db.entity(*l).component::()) .map(|l| l.properties.points) .sum(); Some(RankingAthlete { eid: athlete.id(), name: strava_athlete.format_name(), strava_profile_url: strava_athlete.profile_url(), url: format!("/athlete/{}", athlete.id()), visited_locations, points, last_activity, }) } } #[derive(Debug, Serialize)] struct RankingLocation { eid: ecsdb::EntityId, #[serde(flatten)] properties: LocationProperties, visitors: Visitors, unique_visitors: u64, links: serde_json::Value, } impl RankingLocation { fn load(location: Entity) -> Option { let visitors = location.component::().unwrap_or_default(); let unique_visitors = visitors.athletes.len() as u64; let properties = location.component::()?.properties; Some(RankingLocation { eid: location.id(), properties, visitors, unique_visitors, links: json!({ "self": format!("/location/{}", location.id()) }), }) } } pub async fn index(state: extract::State>) -> impl IntoResponse { let db = state.acquire_db().await; let athletes = db .query::() .flat_map(|entity| RankingAthlete::load(entity)) .collect::>(); let locations = db .query::() .filter_map(|l| RankingLocation::load(l)) .collect::>(); let contest_daterange = state.config.contest.start_date..state.config.contest.end_date; let geojson_link = "/locations.geojson".to_string(); HtmxTemplate { template_name: "index.html", context: json!({ "contest": {"period": contest_daterange}, "links": { "login": "/oauth/redirect", "locations": { "geojson": geojson_link, "editor": state.config.http.base_url.join("/locations.geojson").unwrap() }, }, "athletes": athletes, "locations": locations }), } } pub async fn athlete( state: extract::State>, athlete_id: extract::Path, ) -> impl IntoResponse { let db = state.acquire_db().await; let Some(athlete) = RankingAthlete::load(db.entity(athlete_id.0)) else { return HtmxTemplate { template_name: "not_found.html", context: json!({}), }; }; let locations = db .query::() .filter(|l| athlete.visited_locations.0.contains(&l.id())) .filter_map(|l| RankingLocation::load(l)) .collect::>(); let contest_daterange = state.config.contest.start_date..state.config.contest.end_date; let geojson_link = format!("/athlete/{}/locations.geojson", athlete.eid); HtmxTemplate { template_name: "athlete.html", context: json!({ "contest": {"period": contest_daterange}, "links": { "login": "/oauth/redirect", "locations": { "geojson": geojson_link, "editor": state.config.http.base_url.join("/locations.geojson").unwrap() }, }, "athlete": athlete, "locations": locations }), } } pub async fn location( state: extract::State>, location_id: extract::Path, ) -> impl IntoResponse { let db = state.acquire_db().await; let Some(location) = RankingLocation::load(db.entity(location_id.0)) else { return HtmxTemplate { template_name: "not_found.html", context: json!({}), }; }; let rides = location .visitors .rides .iter() .filter_map(|eid| { let entity = db.entity(*eid); entity.component::() }) .collect::>(); let athletes = rides .iter() .filter_map(|ride| { let entity = db.find(ride.athlete.id).next()?; let athlete = RankingAthlete::load(entity)?; Some((ride.athlete.id, athlete)) }) .collect::>(); let contest_daterange = state.config.contest.start_date..state.config.contest.end_date; HtmxTemplate { template_name: "location.html", context: json!({ "contest": {"period": contest_daterange}, "links": { "login": "/oauth/redirect", "locations": { "geojson": format!("/location/{}/geojson", location.eid), }, }, "rides": rides, "athletes": athletes, "location": location }), } } // Locations fn build_feature(entity: Entity, location: &Location) -> geojson::Feature { let mut feature: geojson::Feature = location.clone().into(); feature.id = Some(geojson::feature::Id::Number(entity.id().into())); feature.set_property("id", json!(entity.id())); feature.set_property( "tooltip", json!(format!( "{} ({}, {} Points)", location.properties.title, location.properties.surface, location.properties.points )), ); feature.set_property( "links", json!({ "self": format!("/location/{}", entity.id()), }), ); feature } pub async fn location_geojson( state: extract::State>, location_id: extract::Path, ) -> impl IntoResponse { let db = state.acquire_db().await; let entity = db.entity(location_id.0); let Some(location) = entity.component::() else { return axum::Json(serde_json::Value::Null); }; let feature = build_feature(entity, &location); axum::Json(serde_json::to_value(feature).unwrap()) } pub async fn locations_geojson(state: extract::State>) -> impl IntoResponse { let db = state.acquire_db().await; let locations = db .query::<(ecsdb::Entity, Location), ()>() .map(|(e, l)| build_feature(e, &l)); let feature_collection = geojson::FeatureCollection::from_iter(locations); axum::Json(serde_json::to_value(feature_collection).unwrap()) } pub async fn locations_popup( state: extract::State>, id: extract::Path, ) -> impl IntoResponse { let db = state.acquire_db().await; let Some((_entity, location)) = db .query_filtered::<(ecsdb::Entity, Location), ()>(id.0) .next() else { return StatusCode::NOT_FOUND.into_response(); }; HtmxTemplate::new("location_popup.html", json!(location)).into_response() } // Athlete pub async fn athlete_locations_geojson( state: extract::State>, path_params: extract::Path<(EntityId,)>, ) -> impl IntoResponse { let db = state.acquire_db().await; let extract::Path((athlete_id,)) = path_params; let athlete = db.entity(athlete_id); let visited_locations = athlete.component::().unwrap_or_default(); let locations = db .query::<(ecsdb::Entity, Location), ()>() .filter(|(e, _l)| visited_locations.0.contains(&e.id())) .map(|(e, l)| { let mut feature: geojson::Feature = l.clone().into(); feature.id = Some(geojson::feature::Id::Number(e.id().into())); feature.set_property("id", json!(e.id())); feature.set_property( "tooltip", json!(format!("{} ({})", l.properties.title, l.properties.surface)), ); feature.set_property( "links", json!({ "self": format!("#eid:{}", e.id()), "popup": format!("/locations/{}/popup", e.id()) }), ); feature }); let feature_collection = geojson::FeatureCollection::from_iter(locations); axum::Json(serde_json::to_value(feature_collection).unwrap()) } #[derive(rust_embed::Embed, Debug, Copy, Clone)] #[folder = "src/templates/static/"] pub struct Assets; #[allow(dead_code)] fn deserialize_comma_separated_string<'de, V, T, D>(deserializer: D) -> Result where V: FromIterator, T: FromStr, T::Err: Display, D: serde::de::Deserializer<'de>, { struct CommaSeparated(PhantomData, PhantomData); impl serde::de::Visitor<'_> for CommaSeparated where V: FromIterator, T: FromStr, T::Err: Display, { type Value = V; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("string containing comma-separated elements") } fn visit_str(self, s: &str) -> Result where E: serde::de::Error, { let iter = s .split(",") .filter(|s| !s.is_empty()) .map(FromStr::from_str); Result::from_iter(iter).map_err(serde::de::Error::custom) } } let visitor = CommaSeparated(PhantomData, PhantomData); deserializer.deserialize_str(visitor) } use axum::extract::FromRequestParts; use crate::{ AppState, AthleteId, VisitedLocations, Visitors, locations::{Location, LocationProperties}, strava::{self, Activity, ActivityOwner, Athlete}, }; impl axum::extract::FromRequestParts for &AppState<'_> { type Rejection = as FromRequestParts>::Rejection; #[tracing::instrument(level = "trace", skip_all)] async fn from_request_parts( _req: &mut http::request::Parts, state: &Self, ) -> Result { // let app_state = axum::extract::State::::from_request_parts(req, state).await?; // Ok(app_state) Ok(state) } } impl axum::extract::FromRequestParts> for UserSession { type Rejection = (http::StatusCode, &'static str); #[tracing::instrument(level = "trace", skip_all)] async fn from_request_parts( req: &mut http::request::Parts, state: &AppState<'_>, ) -> Result { Ok(Option::::from_request_parts(req, state) .await? .expect("UserSession")) } } impl axum::extract::OptionalFromRequestParts> for UserSession { type Rejection = (http::StatusCode, &'static str); #[tracing::instrument(level = "trace", skip_all)] async fn from_request_parts( req: &mut http::request::Parts, state: &AppState<'_>, ) -> Result, Self::Rejection> { Ok( as extract::FromRequestParts<_>>::from_request_parts( req, state, ) .await .map(|ext| ext.0) .ok(), ) } } impl axum::extract::FromRequestParts> for strava::AccessToken { type Rejection = (http::StatusCode, &'static str); #[tracing::instrument(level = "trace", skip_all)] async fn from_request_parts( req: &mut http::request::Parts, state: &AppState<'_>, ) -> Result { Ok( >::from_request_parts(req, state) .await? .expect("AccessToken component"), ) } } impl axum::extract::OptionalFromRequestParts> for strava::AccessToken { type Rejection = (http::StatusCode, &'static str); #[tracing::instrument(level = "trace", skip_all)] async fn from_request_parts( req: &mut http::request::Parts, state: &AppState<'_>, ) -> Result, Self::Rejection> { let Some(session) = Option::::from_request_parts(req, state).await? else { return Ok(None); }; let db = state.acquire_db().await; let Some(account_entity) = db.find(session.athlete_id).next() else { return Ok(None); }; Ok(account_entity.component::()) } }