this repo has no description
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}