at main 519 lines 16 kB view raw
1use std::{ 2 collections::HashMap, 3 fmt::{Debug, Display}, 4 marker::PhantomData, 5 str::FromStr, 6}; 7 8use axum::{ 9 Router, 10 extract::{self}, 11 response::IntoResponse, 12 routing::get, 13}; 14use ecsdb::{Component, Entity, EntityId}; 15use http::StatusCode; 16use serde::{Deserialize, Serialize}; 17use serde_json::json; 18use tracing::info; 19 20pub mod auth; 21 22mod htmx; 23pub use htmx::*; 24 25pub mod map; 26 27pub const USER_SESSION_KEY: &str = "checkpoints.user.session"; 28 29#[derive(Debug, Serialize, Deserialize, Clone)] 30pub struct UserSession { 31 pub athlete_id: AthleteId, 32} 33 34#[derive(Debug, Serialize, Deserialize, Component, Clone)] 35pub struct ApiToken(pub String); 36 37impl ApiToken { 38 pub fn random() -> Self { 39 let token = uuid::Uuid::new_v4().to_string(); 40 Self(token) 41 } 42} 43 44pub fn routes() -> Router<AppState<'static>> { 45 let geojson_io_routes = Router::new() 46 .route("/locations.geojson", get(locations_geojson)) 47 .route( 48 "/athlete/{athlete_id}/locations.geojson", 49 get(athlete_locations_geojson), 50 ) 51 .route("/location/{location_id}/geojson", get(location_geojson)) 52 .route_layer( 53 tower_http::cors::CorsLayer::permissive() 54 .allow_origin(tower_http::cors::AllowOrigin::mirror_request()), 55 ); 56 57 Router::new() 58 .route("/", get(index)) 59 .route("/athlete/{athlete_id}", get(athlete)) 60 .route("/location/{eid}", get(location)) 61 .merge(geojson_io_routes) 62 .route("/login", get(login)) 63 .route("/oauth/redirect", get(auth::redirect)) 64 .route("/oauth/callback", get(auth::callback)) 65 .nest_service("/static", axum_embed::ServeEmbed::<Assets>::new()) 66 // .merge(secured_routes()) 67} 68 69pub fn authenticated_routes() -> Router<AppState<'static>> { 70 Router::<AppState<'_>>::new().nest("/map", map::routes()) 71} 72 73pub async fn login(user_session: Option<UserSession>) -> impl IntoResponse { 74 match user_session { 75 Some(session) => { 76 info!(%session.athlete_id, 77 "already logged in"); 78 axum::response::Redirect::to("/").into_response() 79 } 80 None => HtmxTemplate { 81 template_name: "login.html", 82 context: json!({ 83 "links": { 84 "login": "/oauth/redirect" 85 } 86 }), 87 } 88 .into_response(), 89 } 90} 91 92#[derive(Serialize)] 93struct RankingAthlete { 94 eid: ecsdb::EntityId, 95 name: String, 96 strava_profile_url: url::Url, 97 url: String, 98 points: u64, 99 last_activity: Option<Activity>, 100 visited_locations: VisitedLocations, 101} 102 103impl RankingAthlete { 104 fn load(athlete: Entity) -> Option<Self> { 105 let db = athlete.db(); 106 let strava_athlete = athlete.component::<strava::Athlete>()?; 107 108 let last_activity = db 109 .query_filtered::<Activity, ()>(ActivityOwner(athlete.id())) 110 .max_by_key(|a| a.start_date_local); 111 112 let visited_locations = athlete.component::<VisitedLocations>().unwrap_or_default(); 113 let points = visited_locations 114 .0 115 .iter() 116 .filter_map(|l| db.entity(*l).component::<Location>()) 117 .map(|l| l.properties.points) 118 .sum(); 119 120 Some(RankingAthlete { 121 eid: athlete.id(), 122 name: strava_athlete.format_name(), 123 strava_profile_url: strava_athlete.profile_url(), 124 url: format!("/athlete/{}", athlete.id()), 125 visited_locations, 126 points, 127 last_activity, 128 }) 129 } 130} 131 132#[derive(Debug, Serialize)] 133struct RankingLocation { 134 eid: ecsdb::EntityId, 135 #[serde(flatten)] 136 properties: LocationProperties, 137 visitors: Visitors, 138 unique_visitors: u64, 139 links: serde_json::Value, 140} 141 142impl RankingLocation { 143 fn load(location: Entity) -> Option<Self> { 144 let visitors = location.component::<Visitors>().unwrap_or_default(); 145 let unique_visitors = visitors.athletes.len() as u64; 146 147 let properties = location.component::<Location>()?.properties; 148 Some(RankingLocation { 149 eid: location.id(), 150 properties, 151 visitors, 152 unique_visitors, 153 links: json!({ 154 "self": format!("/location/{}", location.id()) 155 }), 156 }) 157 } 158} 159 160pub async fn index(state: extract::State<AppState<'_>>) -> impl IntoResponse { 161 let db = state.acquire_db().await; 162 163 let athletes = db 164 .query::<ecsdb::Entity, Athlete>() 165 .flat_map(|entity| RankingAthlete::load(entity)) 166 .collect::<Vec<_>>(); 167 168 let locations = db 169 .query::<ecsdb::Entity, Location>() 170 .filter_map(|l| RankingLocation::load(l)) 171 .collect::<Vec<_>>(); 172 173 let contest_daterange = state.config.contest.start_date..state.config.contest.end_date; 174 let geojson_link = "/locations.geojson".to_string(); 175 176 HtmxTemplate { 177 template_name: "index.html", 178 context: json!({ 179 "contest": {"period": contest_daterange}, 180 "links": { 181 "login": "/oauth/redirect", 182 "locations": { 183 "geojson": geojson_link, 184 "editor": state.config.http.base_url.join("/locations.geojson").unwrap() 185 }, 186 }, 187 "athletes": athletes, 188 "locations": locations 189 }), 190 } 191} 192 193pub async fn athlete( 194 state: extract::State<AppState<'_>>, 195 athlete_id: extract::Path<EntityId>, 196) -> impl IntoResponse { 197 let db = state.acquire_db().await; 198 199 let Some(athlete) = RankingAthlete::load(db.entity(athlete_id.0)) else { 200 return HtmxTemplate { 201 template_name: "not_found.html", 202 context: json!({}), 203 }; 204 }; 205 206 let locations = db 207 .query::<ecsdb::Entity, Location>() 208 .filter(|l| athlete.visited_locations.0.contains(&l.id())) 209 .filter_map(|l| RankingLocation::load(l)) 210 .collect::<Vec<_>>(); 211 212 let contest_daterange = state.config.contest.start_date..state.config.contest.end_date; 213 let geojson_link = format!("/athlete/{}/locations.geojson", athlete.eid); 214 215 HtmxTemplate { 216 template_name: "athlete.html", 217 context: json!({ 218 "contest": {"period": contest_daterange}, 219 "links": { 220 "login": "/oauth/redirect", 221 "locations": { 222 "geojson": geojson_link, 223 "editor": state.config.http.base_url.join("/locations.geojson").unwrap() 224 }, 225 }, 226 "athlete": athlete, 227 "locations": locations 228 }), 229 } 230} 231 232pub async fn location( 233 state: extract::State<AppState<'_>>, 234 location_id: extract::Path<EntityId>, 235) -> impl IntoResponse { 236 let db = state.acquire_db().await; 237 238 let Some(location) = RankingLocation::load(db.entity(location_id.0)) else { 239 return HtmxTemplate { 240 template_name: "not_found.html", 241 context: json!({}), 242 }; 243 }; 244 245 let rides = location 246 .visitors 247 .rides 248 .iter() 249 .filter_map(|eid| { 250 let entity = db.entity(*eid); 251 entity.component::<Activity>() 252 }) 253 .collect::<Vec<_>>(); 254 255 let athletes = rides 256 .iter() 257 .filter_map(|ride| { 258 let entity = db.find(ride.athlete.id).next()?; 259 let athlete = RankingAthlete::load(entity)?; 260 Some((ride.athlete.id, athlete)) 261 }) 262 .collect::<HashMap<AthleteId, RankingAthlete>>(); 263 264 let contest_daterange = state.config.contest.start_date..state.config.contest.end_date; 265 266 HtmxTemplate { 267 template_name: "location.html", 268 context: json!({ 269 "contest": {"period": contest_daterange}, 270 "links": { 271 "login": "/oauth/redirect", 272 "locations": { 273 "geojson": format!("/location/{}/geojson", location.eid), 274 }, 275 }, 276 "rides": rides, 277 "athletes": athletes, 278 "location": location 279 }), 280 } 281} 282 283// Locations 284 285fn build_feature(entity: Entity, location: &Location) -> geojson::Feature { 286 let mut feature: geojson::Feature = location.clone().into(); 287 feature.id = Some(geojson::feature::Id::Number(entity.id().into())); 288 feature.set_property("id", json!(entity.id())); 289 feature.set_property( 290 "tooltip", 291 json!(format!( 292 "{} ({}, {} Points)", 293 location.properties.title, location.properties.surface, location.properties.points 294 )), 295 ); 296 feature.set_property( 297 "links", 298 json!({ 299 "self": format!("/location/{}", entity.id()), 300 }), 301 ); 302 feature 303} 304 305pub async fn location_geojson( 306 state: extract::State<AppState<'_>>, 307 location_id: extract::Path<EntityId>, 308) -> impl IntoResponse { 309 let db = state.acquire_db().await; 310 311 let entity = db.entity(location_id.0); 312 let Some(location) = entity.component::<Location>() else { 313 return axum::Json(serde_json::Value::Null); 314 }; 315 316 let feature = build_feature(entity, &location); 317 axum::Json(serde_json::to_value(feature).unwrap()) 318} 319 320pub async fn locations_geojson(state: extract::State<AppState<'_>>) -> impl IntoResponse { 321 let db = state.acquire_db().await; 322 323 let locations = db 324 .query::<(ecsdb::Entity, Location), ()>() 325 .map(|(e, l)| build_feature(e, &l)); 326 327 let feature_collection = geojson::FeatureCollection::from_iter(locations); 328 329 axum::Json(serde_json::to_value(feature_collection).unwrap()) 330} 331 332pub async fn locations_popup( 333 state: extract::State<AppState<'_>>, 334 id: extract::Path<ecsdb::EntityId>, 335) -> impl IntoResponse { 336 let db = state.acquire_db().await; 337 338 let Some((_entity, location)) = db 339 .query_filtered::<(ecsdb::Entity, Location), ()>(id.0) 340 .next() 341 else { 342 return StatusCode::NOT_FOUND.into_response(); 343 }; 344 345 HtmxTemplate::new("location_popup.html", json!(location)).into_response() 346} 347 348// Athlete 349 350pub async fn athlete_locations_geojson( 351 state: extract::State<AppState<'_>>, 352 path_params: extract::Path<(EntityId,)>, 353) -> impl IntoResponse { 354 let db = state.acquire_db().await; 355 356 let extract::Path((athlete_id,)) = path_params; 357 358 let athlete = db.entity(athlete_id); 359 let visited_locations = athlete.component::<VisitedLocations>().unwrap_or_default(); 360 361 let locations = db 362 .query::<(ecsdb::Entity, Location), ()>() 363 .filter(|(e, _l)| visited_locations.0.contains(&e.id())) 364 .map(|(e, l)| { 365 let mut feature: geojson::Feature = l.clone().into(); 366 feature.id = Some(geojson::feature::Id::Number(e.id().into())); 367 feature.set_property("id", json!(e.id())); 368 feature.set_property( 369 "tooltip", 370 json!(format!("{} ({})", l.properties.title, l.properties.surface)), 371 ); 372 feature.set_property( 373 "links", 374 json!({ 375 "self": format!("#eid:{}", e.id()), 376 "popup": format!("/locations/{}/popup", e.id()) 377 }), 378 ); 379 feature 380 }); 381 382 let feature_collection = geojson::FeatureCollection::from_iter(locations); 383 384 axum::Json(serde_json::to_value(feature_collection).unwrap()) 385} 386 387#[derive(rust_embed::Embed, Debug, Copy, Clone)] 388#[folder = "src/templates/static/"] 389pub struct Assets; 390 391#[allow(dead_code)] 392fn deserialize_comma_separated_string<'de, V, T, D>(deserializer: D) -> Result<V, D::Error> 393where 394 V: FromIterator<T>, 395 T: FromStr, 396 T::Err: Display, 397 D: serde::de::Deserializer<'de>, 398{ 399 struct CommaSeparated<V, T>(PhantomData<V>, PhantomData<T>); 400 401 impl<V, T> serde::de::Visitor<'_> for CommaSeparated<V, T> 402 where 403 V: FromIterator<T>, 404 T: FromStr, 405 T::Err: Display, 406 { 407 type Value = V; 408 409 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 410 formatter.write_str("string containing comma-separated elements") 411 } 412 413 fn visit_str<E>(self, s: &str) -> Result<Self::Value, E> 414 where 415 E: serde::de::Error, 416 { 417 let iter = s 418 .split(",") 419 .filter(|s| !s.is_empty()) 420 .map(FromStr::from_str); 421 Result::from_iter(iter).map_err(serde::de::Error::custom) 422 } 423 } 424 425 let visitor = CommaSeparated(PhantomData, PhantomData); 426 deserializer.deserialize_str(visitor) 427} 428 429use axum::extract::FromRequestParts; 430 431use crate::{ 432 AppState, AthleteId, VisitedLocations, Visitors, 433 locations::{Location, LocationProperties}, 434 strava::{self, Activity, ActivityOwner, Athlete}, 435}; 436 437impl axum::extract::FromRequestParts<Self> for &AppState<'_> { 438 type Rejection = <axum::extract::State<Self> as FromRequestParts<Self>>::Rejection; 439 440 #[tracing::instrument(level = "trace", skip_all)] 441 async fn from_request_parts( 442 _req: &mut http::request::Parts, 443 state: &Self, 444 ) -> Result<Self, Self::Rejection> { 445 // let app_state = axum::extract::State::<Self>::from_request_parts(req, state).await?; 446 // Ok(app_state) 447 Ok(state) 448 } 449} 450 451impl axum::extract::FromRequestParts<AppState<'_>> for UserSession { 452 type Rejection = (http::StatusCode, &'static str); 453 454 #[tracing::instrument(level = "trace", skip_all)] 455 async fn from_request_parts( 456 req: &mut http::request::Parts, 457 state: &AppState<'_>, 458 ) -> Result<Self, Self::Rejection> { 459 Ok(Option::<Self>::from_request_parts(req, state) 460 .await? 461 .expect("UserSession")) 462 } 463} 464 465impl axum::extract::OptionalFromRequestParts<AppState<'_>> for UserSession { 466 type Rejection = (http::StatusCode, &'static str); 467 468 #[tracing::instrument(level = "trace", skip_all)] 469 async fn from_request_parts( 470 req: &mut http::request::Parts, 471 state: &AppState<'_>, 472 ) -> Result<Option<Self>, Self::Rejection> { 473 Ok( 474 <extract::Extension<UserSession> as extract::FromRequestParts<_>>::from_request_parts( 475 req, state, 476 ) 477 .await 478 .map(|ext| ext.0) 479 .ok(), 480 ) 481 } 482} 483 484impl axum::extract::FromRequestParts<AppState<'_>> for strava::AccessToken { 485 type Rejection = (http::StatusCode, &'static str); 486 487 #[tracing::instrument(level = "trace", skip_all)] 488 async fn from_request_parts( 489 req: &mut http::request::Parts, 490 state: &AppState<'_>, 491 ) -> Result<Self, Self::Rejection> { 492 Ok( 493 <Self as extract::OptionalFromRequestParts<_>>::from_request_parts(req, state) 494 .await? 495 .expect("AccessToken component"), 496 ) 497 } 498} 499 500impl axum::extract::OptionalFromRequestParts<AppState<'_>> for strava::AccessToken { 501 type Rejection = (http::StatusCode, &'static str); 502 503 #[tracing::instrument(level = "trace", skip_all)] 504 async fn from_request_parts( 505 req: &mut http::request::Parts, 506 state: &AppState<'_>, 507 ) -> Result<Option<Self>, Self::Rejection> { 508 let Some(session) = Option::<UserSession>::from_request_parts(req, state).await? else { 509 return Ok(None); 510 }; 511 512 let db = state.acquire_db().await; 513 let Some(account_entity) = db.find(session.athlete_id).next() else { 514 return Ok(None); 515 }; 516 517 Ok(account_entity.component::<Self>()) 518 } 519}