dev api key

This commit is contained in:
Jonas Rabenstein 2026-08-12 14:02:12 +02:00
commit 89091a03d3
11 changed files with 791 additions and 762 deletions

View file

@ -13,7 +13,7 @@ rand = "0.9"
reqwest = { reqwest = {
version = "0.12", version = "0.12",
default-features = false, default-features = false,
features = ["json", "form", "rustls-tls"] features = ["json", "rustls-tls"]
} }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

View file

@ -80,6 +80,9 @@
mkdir -p "$PGHOST" mkdir -p "$PGHOST"
# Use an absolute path for the PostgreSQL Unix socket.
PGHOST_ABS="$(cd "$PGHOST" && pwd)"
# ============================================================ # ============================================================
# Initialise PostgreSQL cluster # Initialise PostgreSQL cluster
# ============================================================ # ============================================================
@ -101,8 +104,6 @@
# Start PostgreSQL # Start PostgreSQL
# #
# PostgreSQL is deliberately restricted to the Unix socket. # PostgreSQL is deliberately restricted to the Unix socket.
# No TCP/IP listener is opened, so there are no port conflicts
# with another local PostgreSQL instance.
# ============================================================ # ============================================================
if ! pg_ctl \ if ! pg_ctl \
@ -116,7 +117,7 @@
pg_ctl \ pg_ctl \
-D "$PGDATA" \ -D "$PGDATA" \
-l "$PGDATA/postgresql.log" \ -l "$PGDATA/postgresql.log" \
-o "-c listen_addresses= -k $PGHOST" \ -o "-c listen_addresses= -k $PGHOST_ABS" \
start \ start \
>/dev/null >/dev/null
fi fi
@ -129,7 +130,7 @@
for _ in $(seq 1 50); do for _ in $(seq 1 50); do
if pg_isready \ if pg_isready \
-h "$PGHOST" \ -h "$PGHOST_ABS" \
-U "$PGUSER" \ -U "$PGUSER" \
>/dev/null 2>&1 >/dev/null 2>&1
then then
@ -164,7 +165,7 @@
# ============================================================ # ============================================================
if ! psql \ if ! psql \
-h "$PGHOST" \ -h "$PGHOST_ABS" \
-U "$PGUSER" \ -U "$PGUSER" \
-d postgres \ -d postgres \
-tAc \ -tAc \
@ -177,7 +178,7 @@
echo "==> Creating PostgreSQL database '$PGDATABASE'" echo "==> Creating PostgreSQL database '$PGDATABASE'"
createdb \ createdb \
-h "$PGHOST" \ -h "$PGHOST_ABS" \
-U "$PGUSER" \ -U "$PGUSER" \
"$PGDATABASE" "$PGDATABASE"
fi fi
@ -190,7 +191,7 @@
echo "==> Checking PostGIS" echo "==> Checking PostGIS"
psql \ psql \
-h "$PGHOST" \ -h "$PGHOST_ABS" \
-U "$PGUSER" \ -U "$PGUSER" \
-d "$PGDATABASE" \ -d "$PGDATABASE" \
-v ON_ERROR_STOP=1 \ -v ON_ERROR_STOP=1 \
@ -198,10 +199,13 @@
>/dev/null >/dev/null
# ============================================================ # ============================================================
# Application connection string # SQLx / application connection string
#
# localhost is only the syntactic host. The host query
# parameter tells libpq/SQLx to use our Unix socket.
# ============================================================ # ============================================================
export DATABASE_URL="postgresql://$PGUSER@/$PGDATABASE?host=$PGHOST" export DATABASE_URL="postgresql://$PGUSER@localhost/$PGDATABASE?host=$PGHOST_ABS"
# ============================================================ # ============================================================
# Development information # Development information
@ -217,14 +221,14 @@
echo echo
echo "PostgreSQL:" echo "PostgreSQL:"
echo " $(psql --version)" echo " $(psql --version)"
echo " socket: $PGHOST" echo " socket: $PGHOST_ABS"
echo " database: $PGDATABASE" echo " database: $PGDATABASE"
echo " user: $PGUSER" echo " user: $PGUSER"
echo echo
echo "PostGIS:" echo "PostGIS:"
psql \ psql \
-h "$PGHOST" \ -h "$PGHOST_ABS" \
-U "$PGUSER" \ -U "$PGUSER" \
-d "$PGDATABASE" \ -d "$PGDATABASE" \
-tAc "SELECT PostGIS_Full_Version();" | -tAc "SELECT PostGIS_Full_Version();" |
@ -242,6 +246,7 @@
echo echo
echo "============================================================" echo "============================================================"
echo echo
set +e
''; '';
}; };
} }

View file

@ -9,11 +9,7 @@ use rand::{
}; };
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::{AppState, db, intervals::IntervalsClient};
AppState,
db,
intervals::IntervalsClient,
};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct OAuthCallback { pub struct OAuthCallback {
@ -22,39 +18,50 @@ pub struct OAuthCallback {
pub error: Option<String>, pub error: Option<String>,
} }
pub async fn start( pub async fn start(State(state): State<AppState>) -> impl IntoResponse {
State(state): State<AppState>, // Important: ThreadRng must not live across an await.
) -> impl IntoResponse { let oauth_state = {
let mut rng = rng(); let mut rng = rng();
Alphanumeric.sample_string(&mut rng, 48)
};
let oauth_state = if let Err(error) = db::delete_old_oauth_states(&state.db).await {
Alphanumeric.sample_string(&mut rng, 48); tracing::error!(
%error,
"cannot clean OAuth states"
);
if let Err(error) =
db::delete_old_oauth_states(&state.db).await
{
tracing::error!(%error, "cannot clean OAuth states");
return "internal error".into_response(); return "internal error".into_response();
} }
if let Err(error) = sqlx::query( if let Err(error) = sqlx::query("INSERT INTO oauth_states(state) VALUES ($1)")
"INSERT INTO oauth_states(state) VALUES ($1)", .bind(&oauth_state)
) .execute(&state.db)
.bind(&oauth_state) .await
.execute(&state.db)
.await
{ {
tracing::error!(%error, "cannot create OAuth state"); tracing::error!(
%error,
"cannot create OAuth state"
);
return "internal error".into_response(); return "internal error".into_response();
} }
let client = let client = IntervalsClient::new(state.config.clone());
IntervalsClient::new(state.config.clone());
Redirect::to( let authorize_url = match client.oauth_authorize_url(&oauth_state) {
&client.oauth_authorize_url(&oauth_state) Ok(url) => url,
) Err(error) => {
.into_response() tracing::error!(
%error,
"cannot construct OAuth authorize URL"
);
return "cannot construct OAuth URL".into_response();
}
};
Redirect::to(&authorize_url).into_response()
} }
pub async fn callback( pub async fn callback(
@ -88,32 +95,46 @@ pub async fn callback(
match state_row { match state_row {
Ok(Some(_)) => {} Ok(Some(_)) => {}
Ok(None) => { Ok(None) => {
return "invalid or expired OAuth state".into_response(); return "invalid or expired OAuth state".into_response();
} }
Err(error) => { Err(error) => {
tracing::error!(%error, "OAuth state validation failed"); tracing::error!(
%error,
"OAuth state validation failed"
);
return "internal error".into_response(); return "internal error".into_response();
} }
} }
let client = let client = IntervalsClient::new(state.config.clone());
IntervalsClient::new(state.config.clone());
let response = let response = match client.exchange_code(&code).await {
match client.exchange_code(&code).await { Ok(response) => response,
Ok(response) => response,
Err(error) => { Err(error) => {
tracing::error!(%error, "OAuth token exchange failed"); tracing::error!(
return "OAuth token exchange failed".into_response(); %error,
} "OAuth token exchange failed"
}; );
return "OAuth token exchange failed".into_response();
}
};
let (access_token, scope, athlete_id, display_name) = let (access_token, scope, athlete_id, display_name) =
match IntervalsClient::token_data(&response) { match IntervalsClient::token_data(&response) {
Ok(value) => value, Ok(value) => value,
Err(error) => { Err(error) => {
tracing::error!(%error, "invalid OAuth token response"); tracing::error!(
%error,
"invalid OAuth token response"
);
return "invalid OAuth response".into_response(); return "invalid OAuth response".into_response();
} }
}; };
@ -127,14 +148,12 @@ pub async fn callback(
scopes scopes
) )
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
ON CONFLICT (intervals_athlete_id) ON CONFLICT (intervals_athlete_id)
DO UPDATE SET DO UPDATE SET
display_name = EXCLUDED.display_name, display_name = EXCLUDED.display_name,
access_token = EXCLUDED.access_token, access_token = EXCLUDED.access_token,
scopes = EXCLUDED.scopes, scopes = EXCLUDED.scopes,
updated_at = now() updated_at = now()
RETURNING id RETURNING id
"#, "#,
) )
@ -150,71 +169,63 @@ pub async fn callback(
let athlete_db_id: i64 = match row { let athlete_db_id: i64 = match row {
Ok(row) => match row.try_get("id") { Ok(row) => match row.try_get("id") {
Ok(id) => id, Ok(id) => id,
Err(error) => { Err(error) => {
tracing::error!(%error, "invalid athlete row"); tracing::error!(
%error,
"invalid athlete row"
);
return "internal error".into_response(); return "internal error".into_response();
} }
}, },
Err(error) => { Err(error) => {
tracing::error!(%error, "cannot store athlete"); tracing::error!(
%error,
"cannot store athlete"
);
return "cannot store athlete".into_response(); return "cannot store athlete".into_response();
} }
}; };
let mut rng = rng(); let session_token = {
let mut rng = rng();
Alphanumeric.sample_string(&mut rng, 64)
};
let session_token = if let Err(error) = db::create_session(&state.db, athlete_db_id, &session_token).await {
Alphanumeric.sample_string(&mut rng, 64); tracing::error!(
%error,
"cannot create session"
);
if let Err(error) =
db::create_session(
&state.db,
athlete_db_id,
&session_token,
).await
{
tracing::error!(%error, "cannot create session");
return "cannot create session".into_response(); return "cannot create session".into_response();
} }
let cookie = Cookie::build( let cookie = Cookie::build(("county_session", session_token))
("county_session", session_token) .path("/")
) .http_only(true)
.path("/") .same_site(SameSite::Lax)
.http_only(true) .secure(state.config.cookie_secure);
.same_site(SameSite::Lax)
.secure(state.config.cookie_secure);
let jar = CookieJar::new().add(cookie); let jar = CookieJar::new().add(cookie);
( (jar, Redirect::to("/")).into_response()
jar,
Redirect::to("/"),
).into_response()
} }
pub async fn logout( pub async fn logout(State(state): State<AppState>, jar: CookieJar) -> impl IntoResponse {
State(state): State<AppState>,
jar: CookieJar,
) -> impl IntoResponse {
if let Some(cookie) = jar.get("county_session") { if let Some(cookie) = jar.get("county_session") {
let hash = db::hash_token(cookie.value()); let hash = db::hash_token(cookie.value());
let _ = sqlx::query( let _ = sqlx::query("DELETE FROM sessions WHERE token_hash = $1")
"DELETE FROM sessions WHERE token_hash = $1", .bind(&hash)
) .execute(&state.db)
.bind(hash) .await;
.execute(&state.db)
.await;
} }
let jar = jar.remove( let jar = jar.remove(Cookie::build("county_session").path("/"));
Cookie::build("county_session")
.path("/")
);
( (jar, Redirect::to("/")).into_response()
jar,
Redirect::to("/"),
)
} }

View file

@ -1,15 +1,19 @@
use anyhow::Result; use anyhow::{Context, Result};
use std::env;
#[derive(Clone)] #[derive(Clone, Debug)]
pub struct Config { pub struct Config {
pub database_url: String, pub database_url: String,
pub bind_address: String, pub bind_address: String,
pub intervals_base_url: String, pub intervals_base_url: String,
pub intervals_client_id: String, pub intervals_api_key: Option<String>,
pub intervals_client_secret: String, pub intervals_athlete_id: String,
pub intervals_redirect_uri: String,
pub intervals_webhook_secret: String, pub intervals_client_id: Option<String>,
pub intervals_client_secret: Option<String>,
pub intervals_redirect_uri: Option<String>,
pub intervals_webhook_secret: Option<String>,
pub cookie_secure: bool, pub cookie_secure: bool,
} }
@ -18,24 +22,65 @@ impl Config {
pub fn from_env() -> Result<Self> { pub fn from_env() -> Result<Self> {
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
let database_url = required("DATABASE_URL")?;
let bind_address =
env::var("BIND_ADDRESS").unwrap_or_else(|_| "127.0.0.1:8080".to_string());
let intervals_base_url =
env::var("INTERVALS_BASE_URL").unwrap_or_else(|_| "https://intervals.icu".to_string());
let intervals_api_key = optional("INTERVALS_API_KEY");
let intervals_athlete_id =
env::var("INTERVALS_ATHLETE_ID").unwrap_or_else(|_| "0".to_string());
let intervals_client_id = optional("INTERVALS_CLIENT_ID");
let intervals_client_secret = optional("INTERVALS_CLIENT_SECRET");
let intervals_redirect_uri = optional("INTERVALS_REDIRECT_URI");
let intervals_webhook_secret = optional("INTERVALS_WEBHOOK_SECRET");
let cookie_secure = env::var("COOKIE_SECURE")
.unwrap_or_else(|_| "false".to_string())
.parse::<bool>()
.context("COOKIE_SECURE must be true or false")?;
Ok(Self { Ok(Self {
database_url: std::env::var("DATABASE_URL")?, database_url,
bind_address,
bind_address: std::env::var("BIND_ADDRESS") intervals_base_url,
.unwrap_or_else(|_| "127.0.0.1:8080".into()), intervals_api_key,
intervals_athlete_id,
intervals_base_url: std::env::var("INTERVALS_BASE_URL") intervals_client_id,
.unwrap_or_else(|_| "https://intervals.icu".into()), intervals_client_secret,
intervals_redirect_uri,
intervals_webhook_secret,
intervals_client_id: std::env::var("INTERVALS_CLIENT_ID")?, cookie_secure,
intervals_client_secret: std::env::var("INTERVALS_CLIENT_SECRET")?,
intervals_redirect_uri: std::env::var("INTERVALS_REDIRECT_URI")?,
intervals_webhook_secret: std::env::var("INTERVALS_WEBHOOK_SECRET")?,
cookie_secure: std::env::var("COOKIE_SECURE")
.unwrap_or_else(|_| "true".into())
.parse()
.unwrap_or(true),
}) })
} }
pub fn require_api_key(&self) -> Result<&str> {
self.intervals_api_key
.as_deref()
.context("INTERVALS_API_KEY is not configured")
}
}
fn required(name: &str) -> Result<String> {
let value = env::var(name).with_context(|| format!("{name} is not configured"))?;
if value.trim().is_empty() {
anyhow::bail!("{name} is empty");
}
Ok(value)
}
fn optional(name: &str) -> Option<String> {
env::var(name).ok().filter(|value| !value.trim().is_empty())
} }

View file

@ -12,11 +12,7 @@ pub fn hash_token(token: &str) -> Vec<u8> {
hasher.finalize().to_vec() hasher.finalize().to_vec()
} }
pub async fn create_session( pub async fn create_session(db: &PgPool, athlete_id: i64, token: &str) -> Result<()> {
db: &PgPool,
athlete_id: i64,
token: &str,
) -> Result<()> {
let hash = hash_token(token); let hash = hash_token(token);
sqlx::query( sqlx::query(
@ -28,7 +24,7 @@ pub async fn create_session(
VALUES ($1, $2) VALUES ($1, $2)
"#, "#,
) )
.bind(hash) .bind(&hash)
.bind(athlete_id) .bind(athlete_id)
.execute(db) .execute(db)
.await?; .await?;
@ -36,10 +32,7 @@ pub async fn create_session(
Ok(()) Ok(())
} }
pub async fn athlete_for_session( pub async fn athlete_for_session(db: &PgPool, token: &str) -> Result<Option<(i64, String)>> {
db: &PgPool,
token: &str,
) -> Result<Option<(i64, String)>> {
let hash = hash_token(token); let hash = hash_token(token);
let row = sqlx::query( let row = sqlx::query(
@ -53,7 +46,7 @@ pub async fn athlete_for_session(
WHERE s.token_hash = $1 WHERE s.token_hash = $1
"#, "#,
) )
.bind(hash) .bind(&hash)
.fetch_optional(db) .fetch_optional(db)
.await?; .await?;
@ -64,9 +57,13 @@ pub async fn athlete_for_session(
let name: String = row.try_get("display_name")?; let name: String = row.try_get("display_name")?;
sqlx::query( sqlx::query(
"UPDATE sessions SET last_seen_at = now() WHERE token_hash = $1", r#"
UPDATE sessions
SET last_seen_at = now()
WHERE token_hash = $1
"#,
) )
.bind(hash) .bind(&hash)
.execute(db) .execute(db)
.await?; .await?;
@ -76,11 +73,12 @@ pub async fn athlete_for_session(
} }
} }
pub async fn delete_old_oauth_states( pub async fn delete_old_oauth_states(db: &PgPool) -> Result<()> {
db: &PgPool,
) -> Result<()> {
sqlx::query( sqlx::query(
"DELETE FROM oauth_states WHERE created_at < now() - interval '10 minutes'", r#"
DELETE FROM oauth_states
WHERE created_at < now() - interval '10 minutes'
"#,
) )
.execute(db) .execute(db)
.await?; .await?;
@ -105,7 +103,6 @@ pub async fn ensure_activity(
processed_at processed_at
) )
VALUES ($1, $2, $3, $4, NULL) VALUES ($1, $2, $3, $4, NULL)
ON CONFLICT ( ON CONFLICT (
athlete_id, athlete_id,
intervals_activity_id intervals_activity_id
@ -114,7 +111,6 @@ pub async fn ensure_activity(
start_time = EXCLUDED.start_time, start_time = EXCLUDED.start_time,
activity_json = EXCLUDED.activity_json, activity_json = EXCLUDED.activity_json,
processed_at = NULL processed_at = NULL
RETURNING id RETURNING id
"#, "#,
) )
@ -135,7 +131,10 @@ pub async fn delete_activity_crossings(
activity_id: i64, activity_id: i64,
) -> Result<()> { ) -> Result<()> {
sqlx::query( sqlx::query(
"DELETE FROM county_crossings WHERE activity_id = $1", r#"
DELETE FROM county_crossings
WHERE activity_id = $1
"#,
) )
.bind(activity_id) .bind(activity_id)
.execute(&mut **tx) .execute(&mut **tx)
@ -165,12 +164,10 @@ pub async fn save_crossing(
$1, $1,
$2, $2,
$3, $3,
ST_SetSRID( ST_SetSRID(
ST_MakePoint($4, $5), ST_MakePoint($4, $5),
4326 4326
), ),
$6, $6,
$7, $7,
$8 $8

View file

@ -4,19 +4,12 @@ use sqlx::PgPool;
use crate::model::{DetectedCrossing, TrackPoint}; use crate::model::{DetectedCrossing, TrackPoint};
pub fn interpolate_time( pub fn interpolate_time(a: DateTime<Utc>, b: DateTime<Utc>, fraction: f64) -> DateTime<Utc> {
a: DateTime<Utc>,
b: DateTime<Utc>,
fraction: f64,
) -> DateTime<Utc> {
let fraction = fraction.clamp(0.0, 1.0); let fraction = fraction.clamp(0.0, 1.0);
let duration_ms = let duration_ms = (b - a).num_milliseconds();
(b - a).num_milliseconds();
a + Duration::milliseconds( a + Duration::milliseconds((duration_ms as f64 * fraction).round() as i64)
(duration_ms as f64 * fraction).round() as i64
)
} }
/// Detects all county crossings between two consecutive GPS samples. /// Detects all county crossings between two consecutive GPS samples.
@ -132,26 +125,18 @@ pub async fn crossings_between(
continue; continue;
} }
let from_county_id: i64 = let from_county_id: i64 = row.try_get("from_id")?;
row.try_get("from_id")?;
let to_county_id: i64 = let to_county_id: i64 = row.try_get("to_id")?;
row.try_get("to_id")?;
let lon: f64 = let lon: f64 = row.try_get("lon")?;
row.try_get("lon")?;
let lat: f64 = let lat: f64 = row.try_get("lat")?;
row.try_get("lat")?;
result.push(DetectedCrossing { result.push(DetectedCrossing {
track_index: a.index, track_index: a.index,
crossing_time: interpolate_time( crossing_time: interpolate_time(a.time, b.time, fraction),
a.time,
b.time,
fraction,
),
lat, lat,
lon, lon,

View file

@ -1,6 +1,6 @@
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use chrono::{DateTime, Utc}; use chrono::{DateTime, NaiveDateTime, Utc};
use reqwest::Client; use reqwest::{Client, StatusCode};
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::config::Config; use crate::config::Config;
@ -19,274 +19,266 @@ impl IntervalsClient {
} }
} }
pub fn oauth_authorize_url(&self, state: &str) -> String { fn base_url(&self) -> &str {
self.config.intervals_base_url.trim_end_matches('/')
}
fn api_key(&self) -> Result<&str> {
self.config.require_api_key()
}
fn api_url(&self, path: &str) -> String {
format!( format!(
"{}/oauth/authorize?client_id={}&redirect_uri={}&scope={}&state={}", "{}/api/v1/{}",
self.config.intervals_base_url, self.base_url(),
urlencoding::encode(&self.config.intervals_client_id), path.trim_start_matches('/')
urlencoding::encode(&self.config.intervals_redirect_uri),
urlencoding::encode("ACTIVITY:READ"),
urlencoding::encode(state),
) )
} }
pub async fn exchange_code(&self, code: &str) -> Result<Value> { fn authenticated(&self, request: reqwest::RequestBuilder) -> Result<reqwest::RequestBuilder> {
Ok(request.basic_auth("API_KEY", Some(self.api_key()?)))
}
async fn get_json(&self, url: String) -> Result<Value> {
let response = self
.authenticated(self.client.get(url))?
.send()
.await
.context("Intervals.icu request failed")?;
let status = response.status();
let body = response
.text()
.await
.context("cannot read Intervals.icu response")?;
if !status.is_success() {
return Err(anyhow!("Intervals.icu returned HTTP {}: {}", status, body));
}
serde_json::from_str(&body)
.with_context(|| format!("invalid JSON returned by Intervals.icu: {}", body))
}
/// Get a single activity.
///
/// Uses:
/// GET /api/v1/activity/{id}
pub async fn activity(&self, activity_id: &str) -> Result<Value> {
let url = self.api_url(&format!("activity/{activity_id}"));
self.get_json(url).await
}
/// Get activity streams.
///
/// Uses:
/// GET /api/v1/activity/{id}/streams.json
///
/// The response is normally an array of stream objects:
///
/// [
/// {"type":"time","data":[...]},
/// {"type":"latlng","data":[...]},
/// ...
/// ]
pub async fn streams(&self, activity_id: &str) -> Result<Value> {
let url = self.api_url(&format!("activity/{activity_id}/streams.json"));
self.get_json(url).await
}
/// Fetch the athlete's activities.
///
/// `0` means the athlete belonging to the API key.
pub async fn activities(&self, oldest: &str, newest: &str) -> Result<Value> {
let athlete_id = &self.config.intervals_athlete_id;
let url = format!( let url = format!(
"{}/api/oauth/token", "{}?oldest={}&newest={}",
self.config.intervals_base_url self.api_url(&format!("athlete/{athlete_id}/activities")),
urlencoding::encode(oldest),
urlencoding::encode(newest),
); );
Ok(self self.get_json(url).await
.client
.post(url)
.form(&[
("client_id", self.config.intervals_client_id.as_str()),
(
"client_secret",
self.config.intervals_client_secret.as_str(),
),
("code", code),
])
.send()
.await?
.error_for_status()?
.json()
.await?)
} }
pub async fn activity( /// Return a stream's values independent of whether the API
&self, /// returned an object or an array of stream objects.
access_token: &str, pub fn stream_values<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Vec<Value>> {
activity_id: &str, // Current API format: array of streams.
) -> Result<Value> {
let url = format!(
"{}/api/v1/activity/{}",
self.config.intervals_base_url,
activity_id
);
Ok(self
.client
.get(url)
.bearer_auth(access_token)
.query(&[("intervals", "true")])
.send()
.await?
.error_for_status()
.context("Intervals.icu activity request failed")?
.json()
.await?)
}
pub async fn streams(
&self,
access_token: &str,
activity_id: &str,
) -> Result<Value> {
let url = format!(
"{}/api/v1/activity/{}/streams.json",
self.config.intervals_base_url,
activity_id
);
Ok(self
.client
.get(url)
.bearer_auth(access_token)
.query(&[("types", "time,latlng")])
.send()
.await?
.error_for_status()
.context("Intervals.icu streams request failed")?
.json()
.await?)
}
pub fn activity_url(&self, activity_id: &str) -> String {
format!(
"{}/activities/{}",
self.config.intervals_base_url,
activity_id
)
}
pub fn interval_url(
&self,
activity_id: &str,
interval_id: i64,
) -> String {
format!(
"{}/activities/{}?interval={}",
self.config.intervals_base_url,
activity_id,
interval_id
)
}
pub fn stream_values(
streams: &Value,
wanted: &str,
) -> Option<Vec<Value>> {
if let Some(array) = streams.as_array() { if let Some(array) = streams.as_array() {
for entry in array { for stream in array {
if entry.get("type") if stream.get("type").and_then(Value::as_str) == Some(stream_type) {
.and_then(Value::as_str) return stream.get("data").and_then(Value::as_array);
== Some(wanted)
{
return entry
.get("data")
.and_then(Value::as_array)
.cloned();
} }
} }
} }
if let Some(object) = streams.as_object() { // Also support a dictionary-like response:
if let Some(value) = object.get(wanted) { //
if let Some(data) = value.get("data").and_then(Value::as_array) { // {
return Some(data.clone()); // "time": [...],
} // "latlng": [...]
// }
streams.get(stream_type).and_then(Value::as_array)
}
if let Some(data) = value.as_array() { /// Extract the activity start timestamp.
return Some(data.clone()); pub fn activity_start(activity: &Value) -> Result<DateTime<Utc>> {
} let value = activity
.get("start_date")
.or_else(|| activity.get("start_date_local"))
.or_else(|| activity.get("start_time"))
.ok_or_else(|| anyhow!("activity has no start timestamp"))?;
let string = value
.as_str()
.ok_or_else(|| anyhow!("activity start timestamp is not a string"))?;
parse_datetime(string)
}
/// Try to find an Intervals.icu interval corresponding
/// to a GPS stream index.
///
/// The exact interval representation has changed over
/// time, so this deliberately supports the common forms.
pub fn matching_interval(activity: &Value, track_index: usize) -> Option<Value> {
let intervals = activity.get("icu_intervals")?;
let array = intervals.as_array()?;
for interval in array {
let start = interval
.get("start_index")
.or_else(|| interval.get("start"));
let end = interval.get("end_index").or_else(|| interval.get("end"));
let start = start.and_then(Value::as_u64)?;
let end = end.and_then(Value::as_u64)?;
if (start as usize) <= track_index && track_index <= end as usize {
return Some(interval.clone());
} }
} }
None None
} }
pub fn activity_start(activity: &Value) -> Result<DateTime<Utc>> { /// OAuth methods are intentionally retained so we can
if let Some(value) = activity /// re-enable multi-user OAuth later.
.get("start_date") pub fn oauth_authorize_url(&self, state: &str) -> Result<String> {
.and_then(Value::as_str) let client_id = self
{ .config
return Ok(value.parse()?); .intervals_client_id
.as_deref()
.context("INTERVALS_CLIENT_ID is not configured")?;
let redirect_uri = self
.config
.intervals_redirect_uri
.as_deref()
.context("INTERVALS_REDIRECT_URI is not configured")?;
Ok(format!(
"{}/oauth/authorize?client_id={}&redirect_uri={}&scope={}&state={}",
self.base_url(),
urlencoding::encode(client_id),
urlencoding::encode(redirect_uri),
urlencoding::encode("ACTIVITY:READ"),
urlencoding::encode(state),
))
}
pub async fn exchange_code(&self, code: &str) -> Result<Value> {
let client_id = self
.config
.intervals_client_id
.as_deref()
.context("INTERVALS_CLIENT_ID is not configured")?;
let client_secret = self
.config
.intervals_client_secret
.as_deref()
.context("INTERVALS_CLIENT_SECRET is not configured")?;
let response = self
.client
.post(format!("{}/api/oauth/token", self.base_url()))
.form(&[
("client_id", client_id),
("client_secret", client_secret),
("code", code),
])
.send()
.await
.context("OAuth token request failed")?;
let status = response.status();
let body = response.text().await?;
if !status.is_success() {
return Err(anyhow!(
"OAuth token exchange returned HTTP {}: {}",
status,
body
));
} }
if let Some(value) = activity Ok(serde_json::from_str(&body)?)
.get("start_date_local")
.and_then(Value::as_str)
{
let naive = chrono::NaiveDateTime::parse_from_str(
value,
"%Y-%m-%dT%H:%M:%S",
)?;
return Ok(
naive
.and_utc()
);
}
Err(anyhow!("activity contains no usable start_date"))
} }
pub fn icu_intervals(activity: &Value) -> Vec<Value> { pub fn token_data(response: &Value) -> Result<(String, String, String, String)> {
activity let access_token = response
.get("icu_intervals")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
}
pub fn matching_interval(
activity: &Value,
track_index: usize,
) -> Option<Value> {
let intervals = Self::icu_intervals(activity);
intervals
.into_iter()
.filter(|interval| {
let start = interval
.get("start_index")
.and_then(Value::as_u64);
let end = interval
.get("end_index")
.and_then(Value::as_u64);
match (start, end) {
(Some(start), Some(end)) =>
(start as usize) <= track_index
&& track_index < end as usize,
_ => false,
}
})
.min_by_key(|interval| {
let start = interval
.get("start_index")
.and_then(Value::as_u64)
.unwrap_or(u64::MAX);
let end = interval
.get("end_index")
.and_then(Value::as_u64)
.unwrap_or(u64::MAX);
end.saturating_sub(start)
})
}
pub fn interval_metrics(
interval: &Value,
) -> (Option<f64>, Option<f64>, Option<i64>) {
let watts = interval
.get("average_watts")
.and_then(Value::as_f64);
let hr = interval
.get("average_heartrate")
.and_then(Value::as_f64);
let id = interval
.get("id")
.and_then(Value::as_i64);
(watts, hr, id)
}
pub fn token_data(
response: &Value,
) -> Result<(String, String, String, String)> {
let token = response
.get("access_token") .get("access_token")
.and_then(Value::as_str) .and_then(Value::as_str)
.ok_or_else(|| anyhow!("OAuth response has no access_token"))?; .context("OAuth response has no access_token")?
.to_string();
let scope = response let scope = response
.get("scope") .get("scope")
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or("") .unwrap_or("")
.to_owned(); .to_string();
let athlete = response let athlete = response
.get("athlete") .get("athlete")
.ok_or_else(|| anyhow!("OAuth response has no athlete"))?; .context("OAuth response has no athlete")?;
let athlete_id = athlete let athlete_id = athlete
.get("id") .get("id")
.and_then(Value::as_str) .and_then(Value::as_str)
.ok_or_else(|| anyhow!("OAuth response has no athlete.id"))?; .or_else(|| athlete.get("id").and_then(Value::as_i64).map(|_| ""))
.unwrap_or("")
.to_string();
let athlete_name = athlete let display_name = athlete
.get("name") .get("name")
.or_else(|| athlete.get("display_name"))
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or(athlete_id); .unwrap_or("Intervals.icu athlete")
.to_string();
Ok(( Ok((access_token, scope, athlete_id, display_name))
token.to_owned(),
scope,
athlete_id.to_owned(),
athlete_name.to_owned(),
))
}
pub fn _json_example() -> Value {
json!({
"scope": "ACTIVITY:READ"
})
} }
} }
fn parse_datetime(value: &str) -> Result<DateTime<Utc>> {
if let Ok(value) = DateTime::parse_from_rfc3339(value) {
return Ok(value.with_timezone(&Utc));
}
if let Ok(value) = DateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%z") {
return Ok(value.with_timezone(&Utc));
}
if let Ok(value) = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S") {
return Ok(DateTime::<Utc>::from_naive_utc_and_offset(value, Utc));
}
Err(anyhow!("cannot parse activity timestamp: {}", value))
}

View file

@ -1,8 +1,5 @@
use anyhow::Result; use anyhow::Result;
use axum::{ use axum::{Json, extract::State};
extract::State,
Json,
};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde_json::Value; use serde_json::Value;
use sqlx::PgPool; use sqlx::PgPool;
@ -15,22 +12,17 @@ use crate::{
pub async fn api( pub async fn api(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Vec<LeaderboardGroup>>, (axum::http::StatusCode, String)> { ) -> Result<Json<Vec<LeaderboardGroup>>, (axum::http::StatusCode, String)> {
build(&state.db) build(&state.db).await.map(Json).map_err(|error| {
.await tracing::error!(%error, "leaderboard query failed");
.map(Json)
.map_err(|error| {
tracing::error!(%error, "leaderboard query failed");
( (
axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::http::StatusCode::INTERNAL_SERVER_ERROR,
error.to_string(), error.to_string(),
) )
}) })
} }
pub async fn build( pub async fn build(db: &PgPool) -> Result<Vec<LeaderboardGroup>> {
db: &PgPool,
) -> Result<Vec<LeaderboardGroup>> {
let rows = sqlx::query( let rows = sqlx::query(
r#" r#"
WITH bucketed AS ( WITH bucketed AS (
@ -110,21 +102,17 @@ pub async fn build(
let mut groups: Vec<LeaderboardGroup> = Vec::new(); let mut groups: Vec<LeaderboardGroup> = Vec::new();
for row in rows { for row in rows {
let bucket_start: DateTime<Utc> = let bucket_start: DateTime<Utc> = row.try_get("bucket_start")?;
row.try_get("bucket_start")?;
let from_county: String = let from_county: String = row.try_get("from_county")?;
row.try_get("from_county")?;
let to_county: String = let to_county: String = row.try_get("to_county")?;
row.try_get("to_county")?;
let group_index = let group_index = groups.iter().position(|group| {
groups.iter().position(|group| { group.bucket_start == bucket_start
group.bucket_start == bucket_start && group.from_county == from_county
&& group.from_county == from_county && group.to_county == to_county
&& group.to_county == to_county });
});
let index = match group_index { let index = match group_index {
Some(index) => index, Some(index) => index,
@ -141,35 +129,30 @@ pub async fn build(
} }
}; };
let rank: i64 = let rank: i64 = row.try_get("rank")?;
row.try_get("rank")?;
let athlete: String = let athlete: String = row.try_get("display_name")?;
row.try_get("display_name")?;
let crossing_time: DateTime<Utc> = let crossing_time: DateTime<Utc> = row.try_get("crossing_time")?;
row.try_get("crossing_time")?;
let activity_id: String = let activity_id: String = row.try_get("intervals_activity_id")?;
row.try_get("intervals_activity_id")?;
let interval: Option<Value> = let interval: Option<Value> = row.try_get("intervals_interval")?;
row.try_get("intervals_interval")?;
let interval_id = let interval_id = interval
interval.as_ref() .as_ref()
.and_then(|v| v.get("id")) .and_then(|v| v.get("id"))
.and_then(Value::as_i64); .and_then(Value::as_i64);
let average_watts = let average_watts = interval
interval.as_ref() .as_ref()
.and_then(|v| v.get("average_watts")) .and_then(|v| v.get("average_watts"))
.and_then(Value::as_f64); .and_then(Value::as_f64);
let average_heartrate = let average_heartrate = interval
interval.as_ref() .as_ref()
.and_then(|v| v.get("average_heartrate")) .and_then(|v| v.get("average_heartrate"))
.and_then(Value::as_f64); .and_then(Value::as_f64);
let row_data = LeaderboardRow { let row_data = LeaderboardRow {
rank, rank,
@ -182,16 +165,12 @@ pub async fn build(
activity_id: activity_id.clone(), activity_id: activity_id.clone(),
activity_url: format!( activity_url: format!("https://intervals.icu/activities/{}", activity_id),
"https://intervals.icu/activities/{}",
activity_id
),
interval_url: interval_id.map(|id| { interval_url: interval_id.map(|id| {
format!( format!(
"https://intervals.icu/activities/{}?interval={}", "https://intervals.icu/activities/{}?interval={}",
activity_id, activity_id, id
id
) )
}), }),

View file

@ -5,30 +5,22 @@ mod geo;
mod intervals; mod intervals;
mod leaderboard; mod leaderboard;
mod model; mod model;
mod webhook;
mod web; mod web;
mod webhook;
use std::sync::Arc;
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use axum::{ use axum::{
Router, Router,
extract::{Path, State},
routing::{get, post}, routing::{get, post},
}; };
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use serde_json::Value; use serde_json::Value;
use sqlx::PgPool; use sqlx::{PgPool, Row};
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
use tracing_subscriber::{ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
layer::SubscriberExt,
util::SubscriberInitExt,
};
use crate::{ use crate::{config::Config, intervals::IntervalsClient, model::TrackPoint};
config::Config,
intervals::IntervalsClient,
model::TrackPoint,
};
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
@ -39,19 +31,20 @@ pub struct AppState {
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
tracing_subscriber::registry() tracing_subscriber::registry()
.with( .with(tracing_subscriber::EnvFilter::from_default_env())
tracing_subscriber::EnvFilter::from_default_env()
)
.with(tracing_subscriber::fmt::layer()) .with(tracing_subscriber::fmt::layer())
.init(); .init();
let config = let config = Config::from_env()?;
Config::from_env()?;
let db = tracing::info!(
PgPool::connect(&config.database_url) database_url = %redact_database_url(&config.database_url),
.await "connecting to PostgreSQL"
.context("cannot connect to PostgreSQL")?; );
let db = PgPool::connect(&config.database_url)
.await
.context("cannot connect to PostgreSQL")?;
sqlx::migrate!() sqlx::migrate!()
.run(&db) .run(&db)
@ -69,22 +62,29 @@ async fn main() -> Result<()> {
.route("/oauth/start", get(auth::start)) .route("/oauth/start", get(auth::start))
.route("/oauth/callback", get(auth::callback)) .route("/oauth/callback", get(auth::callback))
.route("/logout", get(auth::logout)) .route("/logout", get(auth::logout))
.route( .route("/webhooks/intervals", post(webhook::receive))
"/webhooks/intervals", .route("/api/leaderboard", get(leaderboard::api))
post(webhook::receive), /*
) * Development-only endpoint.
.route( *
"/api/leaderboard", * This lets us test the complete activity-processing
get(leaderboard::api), * pipeline without having to trigger an Intervals.icu
) * webhook.
*
* Example:
*
* curl -X POST \
* http://127.0.0.1:8080/dev/process/12345678
*
* Do not expose this endpoint publicly in production.
*/
.route("/dev/process/:activity_id", post(dev_process_activity))
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.with_state(state); .with_state(state);
let listener = let listener = tokio::net::TcpListener::bind(&config.bind_address)
tokio::net::TcpListener::bind( .await
&config.bind_address .with_context(|| format!("cannot bind to {}", config.bind_address))?;
)
.await?;
tracing::info!( tracing::info!(
address = %config.bind_address, address = %config.bind_address,
@ -100,16 +100,64 @@ async fn health() -> &'static str {
"ok" "ok"
} }
/// Development endpoint for processing one real Intervals.icu
/// activity using INTERVALS_API_KEY.
///
/// This intentionally bypasses OAuth and webhooks.
async fn dev_process_activity(
State(state): State<AppState>,
Path(activity_id): Path<String>,
) -> Result<String, (axum::http::StatusCode, String)> {
process_activity(state, configured_athlete_id(), activity_id)
.await
.map(|_| "activity processed\n".to_string())
.map_err(internal_error)
}
fn configured_athlete_id() -> String {
std::env::var("INTERVALS_ATHLETE_ID").unwrap_or_else(|_| "0".to_string())
}
fn internal_error(error: anyhow::Error) -> (axum::http::StatusCode, String) {
tracing::error!(
%error,
"request failed"
);
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
error.to_string(),
)
}
/// Process one Intervals.icu activity.
///
/// In development mode the Intervals.icu personal API key from
/// INTERVALS_API_KEY is used by IntervalsClient.
///
/// The activity is:
///
/// 1. fetched from Intervals.icu
/// 2. its GPS/time streams are fetched
/// 3. GPS points are converted to TrackPoint values
/// 4. county-border crossings are detected
/// 5. crossing times are interpolated to millisecond precision
/// 6. matching Intervals.icu intervals are attached where possible
/// 7. everything is stored transactionally
pub async fn process_activity( pub async fn process_activity(
state: AppState, state: AppState,
intervals_athlete_id: String, intervals_athlete_id: String,
activity_id: String, activity_id: String,
) -> Result<()> { ) -> Result<()> {
/*
* The development API key represents one athlete.
*
* We still keep the athlete in our database because activities
* and crossings belong to an athlete.
*/
let athlete = sqlx::query( let athlete = sqlx::query(
r#" r#"
SELECT SELECT id
id,
access_token
FROM athletes FROM athletes
WHERE intervals_athlete_id = $1 WHERE intervals_athlete_id = $1
"#, "#,
@ -118,113 +166,127 @@ pub async fn process_activity(
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await?; .await?;
use sqlx::Row; let athlete_db_id: i64 = match athlete {
Some(row) => row.try_get("id")?,
let Some(athlete) = athlete else { None => {
return Err(anyhow!( let client = IntervalsClient::new(state.config.clone());
"no OAuth connection for athlete {}",
intervals_athlete_id /*
)); * Fetch the activity first so we can get a useful
* athlete/display name if available.
*/
let activity = client.activity(&activity_id).await?;
let display_name = activity
.get("athlete")
.and_then(|athlete| athlete.get("name").or_else(|| athlete.get("display_name")))
.and_then(Value::as_str)
.unwrap_or("Intervals.icu athlete");
let row = sqlx::query(
r#"
INSERT INTO athletes (
intervals_athlete_id,
display_name,
access_token,
scopes
)
VALUES ($1, $2, '', 'DEV_API_KEY')
ON CONFLICT (intervals_athlete_id)
DO UPDATE SET
display_name = EXCLUDED.display_name,
updated_at = now()
RETURNING id
"#,
)
.bind(&intervals_athlete_id)
.bind(display_name)
.fetch_one(&state.db)
.await?;
row.try_get("id")?
}
}; };
let athlete_db_id: i64 = let client = IntervalsClient::new(state.config.clone());
athlete.try_get("id")?;
let access_token: String = /*
athlete.try_get("access_token")?; * First fetch the activity metadata.
*
* Among other things this may contain Intervals.icu's
* automatically detected intervals.
*/
let activity = client.activity(&activity_id).await?;
let client = let start_time = IntervalsClient::activity_start(&activity)?;
IntervalsClient::new(state.config.clone());
// /*
// First fetch the activity metadata including icu_intervals. * Then fetch only the activity streams we need.
// *
let activity = * For county crossings:
client.activity( *
&access_token, * time
&activity_id, * latlng
) *
.await?; * Optional streams such as watts/hr can be added later
* without changing the crossing algorithm.
*/
let streams = client.streams(&activity_id).await?;
let start_time = let points = parse_track(&streams, start_time)?;
IntervalsClient::activity_start(
&activity
)?;
//
// Then fetch only what we actually need from the
// activity stream: time + GPS.
//
let streams =
client.streams(
&access_token,
&activity_id,
)
.await?;
let points =
parse_track(
&streams,
start_time,
)?;
if points.len() < 2 { if points.len() < 2 {
tracing::info!( tracing::info!(
activity_id = %activity_id, activity_id = %activity_id,
points = points.len(),
"activity has insufficient GPS data" "activity has insufficient GPS data"
); );
return Ok(()); return Ok(());
} }
// tracing::info!(
// Use a transaction so re-processing an activity is idempotent: activity_id = %activity_id,
// old crossings disappear and are replaced atomically. points = points.len(),
// start_time = %start_time,
let mut tx = "processing activity"
state.db.begin().await?; );
/*
* Use a transaction so re-processing an activity is
* idempotent:
*
* old crossings are deleted
* new crossings are inserted
* activity is marked processed
*
* all atomically.
*/
let mut tx = state.db.begin().await?;
let activity_db_id = let activity_db_id =
db::ensure_activity( db::ensure_activity(&mut tx, athlete_db_id, &activity_id, start_time, &activity).await?;
&mut tx,
athlete_db_id,
&activity_id,
start_time,
&activity,
)
.await?;
db::delete_activity_crossings( db::delete_activity_crossings(&mut tx, activity_db_id).await?;
&mut tx,
activity_db_id,
)
.await?;
let mut crossing_count = 0usize; let mut crossing_count = 0usize;
for pair in points.windows(2) { for pair in points.windows(2) {
let crossings = let crossings = geo::crossings_between(&state.db, &pair[0], &pair[1]).await?;
geo::crossings_between(
&state.db,
&pair[0],
&pair[1],
)
.await?;
for crossing in crossings { for crossing in crossings {
let interval = /*
IntervalsClient::matching_interval( * Try to associate this county crossing with an
&activity, * automatically detected Intervals.icu interval.
crossing.track_index, *
); * This is deliberately optional: a crossing does
* not become invalid merely because no interval
* was detected.
*/
let interval = IntervalsClient::matching_interval(&activity, crossing.track_index);
db::save_crossing( db::save_crossing(&mut tx, activity_db_id, &crossing, interval.as_ref()).await?;
&mut tx,
activity_db_id,
&crossing,
interval.as_ref(),
)
.await?;
crossing_count += 1; crossing_count += 1;
@ -234,16 +296,13 @@ pub async fn process_activity(
from = crossing.from_county_id, from = crossing.from_county_id,
to = crossing.to_county_id, to = crossing.to_county_id,
track_index = crossing.track_index, track_index = crossing.track_index,
interval_found = interval.is_some(),
"county crossing detected" "county crossing detected"
); );
} }
} }
db::mark_activity_processed( db::mark_activity_processed(&mut tx, activity_db_id).await?;
&mut tx,
activity_db_id,
)
.await?;
tx.commit().await?; tx.commit().await?;
@ -256,44 +315,23 @@ pub async fn process_activity(
Ok(()) Ok(())
} }
fn parse_track( fn parse_track(streams: &Value, start_time: DateTime<Utc>) -> Result<Vec<TrackPoint>> {
streams: &Value, let times = IntervalsClient::stream_values(streams, "time")
start_time: DateTime<Utc>, .ok_or_else(|| anyhow!("Intervals.icu response has no time stream"))?;
) -> Result<Vec<TrackPoint>> {
let times =
IntervalsClient::stream_values(
streams,
"time",
)
.ok_or_else(|| anyhow!(
"Intervals.icu response has no time stream"
))?;
let latlng = let latlng = IntervalsClient::stream_values(streams, "latlng")
IntervalsClient::stream_values( .ok_or_else(|| anyhow!("Intervals.icu response has no latlng stream"))?;
streams,
"latlng",
)
.ok_or_else(|| anyhow!(
"Intervals.icu response has no latlng stream"
))?;
let length = let length = times.len().min(latlng.len());
times.len().min(latlng.len());
let mut result = let mut result = Vec::with_capacity(length);
Vec::with_capacity(length);
for index in 0..length { for index in 0..length {
let Some(seconds) = let Some(seconds) = times[index].as_f64() else {
times[index].as_f64()
else {
continue; continue;
}; };
let Some(coordinate) = let Some(coordinate) = latlng[index].as_array() else {
latlng[index].as_array()
else {
continue; continue;
}; };
@ -301,32 +339,32 @@ fn parse_track(
continue; continue;
} }
let Some(lat) = let Some(lat) = coordinate[0].as_f64() else {
coordinate[0].as_f64()
else {
continue; continue;
}; };
let Some(lon) = let Some(lon) = coordinate[1].as_f64() else {
coordinate[1].as_f64()
else {
continue; continue;
}; };
if !lat.is_finite() if !lat.is_finite() || !lon.is_finite() || !seconds.is_finite() {
|| !lon.is_finite()
|| !seconds.is_finite()
{
continue; continue;
} }
let millis = /*
(seconds * 1000.0).round() as i64; * Intervals.icu's time stream is relative to the
* activity start.
*
* We retain millisecond precision. Nanosecond
* precision would not provide meaningful additional
* accuracy because GPS samples themselves are much
* less precise.
*/
let millis = (seconds * 1000.0).round() as i64;
result.push(TrackPoint { result.push(TrackPoint {
index, index,
time: start_time time: start_time + Duration::milliseconds(millis),
+ Duration::milliseconds(millis),
lat, lat,
lon, lon,
}); });
@ -335,6 +373,30 @@ fn parse_track(
Ok(result) Ok(result)
} }
/// Avoid accidentally logging database passwords.
fn redact_database_url(database_url: &str) -> String {
/*
* For our current local Unix-socket URL there normally is
* no password. For safety, don't print anything after the
* first '@' if a password is present.
*/
if let Some(at) = database_url.rfind('@') {
if let Some(scheme) = database_url.find("://") {
let prefix_end = scheme + 3;
if at > prefix_end {
return format!(
"{}***@{}",
&database_url[..prefix_end],
&database_url[at + 1..]
);
}
}
}
database_url.to_string()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use chrono::{TimeZone, Utc}; use chrono::{TimeZone, Utc};
@ -343,39 +405,23 @@ mod tests {
#[test] #[test]
fn interpolation_is_millisecond_precise() { fn interpolation_is_millisecond_precise() {
let a = let a = Utc.timestamp_millis_opt(1000).unwrap();
Utc.timestamp_millis_opt(1000)
.unwrap();
let b = let b = Utc.timestamp_millis_opt(2000).unwrap();
Utc.timestamp_millis_opt(2000)
.unwrap();
let result = let result = interpolate_time(a, b, 0.376);
interpolate_time(a, b, 0.376);
assert_eq!( assert_eq!(result.timestamp_millis(), 1376);
result.timestamp_millis(),
1376
);
} }
#[test] #[test]
fn interpolation_rounds_to_nearest_ms() { fn interpolation_rounds_to_nearest_ms() {
let a = let a = Utc.timestamp_millis_opt(1000).unwrap();
Utc.timestamp_millis_opt(1000)
.unwrap();
let b = let b = Utc.timestamp_millis_opt(2000).unwrap();
Utc.timestamp_millis_opt(2000)
.unwrap();
let result = let result = interpolate_time(a, b, 0.3764);
interpolate_time(a, b, 0.3764);
assert_eq!( assert_eq!(result.timestamp_millis(), 1376);
result.timestamp_millis(),
1376
);
} }
} }

View file

@ -1,38 +1,20 @@
use axum::{ use axum::{extract::State, response::Html};
extract::State,
response::Html,
};
use axum_extra::extract::cookie::CookieJar; use axum_extra::extract::cookie::CookieJar;
use crate::{ use crate::{AppState, db, leaderboard};
AppState,
db,
leaderboard,
};
pub async fn index( pub async fn index(State(state): State<AppState>, jar: CookieJar) -> Html<String> {
State(state): State<AppState>, let groups = leaderboard::build(&state.db).await.unwrap_or_default();
jar: CookieJar,
) -> Html<String> { let current_user = match jar.get("county_session") {
let groups = Some(cookie) => db::athlete_for_session(&state.db, cookie.value())
leaderboard::build(&state.db)
.await .await
.unwrap_or_default(); .ok()
.flatten()
.map(|(_, name)| name),
let current_user = None => None,
match jar.get("county_session") { };
Some(cookie) =>
db::athlete_for_session(
&state.db,
cookie.value(),
)
.await
.ok()
.flatten()
.map(|(_, name)| name),
None => None,
};
let mut html = String::from( let mut html = String::from(
r#"<!doctype html> r#"<!doctype html>
@ -113,7 +95,7 @@ button, .button {
html.push_str( html.push_str(
r#"<a class="button" href="/oauth/start"> r#"<a class="button" href="/oauth/start">
Mit Intervals.icu verbinden Mit Intervals.icu verbinden
</a>"# </a>"#,
); );
} }
@ -123,7 +105,7 @@ button, .button {
html.push_str( html.push_str(
r#"<div class="card"> r#"<div class="card">
Noch keine Landkreisüberquerungen. Noch keine Landkreisüberquerungen.
</div>"# </div>"#,
); );
} }
@ -153,31 +135,28 @@ button, .button {
<th>HR</th> <th>HR</th>
</tr> </tr>
</thead> </thead>
<tbody>"# <tbody>"#,
); );
for row in group.rows { for row in group.rows {
let interval = let interval = match row.interval_url {
match row.interval_url { Some(url) => format!(
Some(url) => "<a href=\"{}\" target=\"_blank\">Intervall</a>",
format!( escape_html(&url)
"<a href=\"{}\" target=\"_blank\">Intervall</a>", ),
escape_html(&url)
),
None => None => "<span class=\"small\">—</span>".into(),
"<span class=\"small\">—</span>".into(), };
};
let power = let power = row
row.average_watts .average_watts
.map(|v| format!("{:.0} W", v)) .map(|v| format!("{:.0} W", v))
.unwrap_or_else(|| "".into()); .unwrap_or_else(|| "".into());
let hr = let hr = row
row.average_heartrate .average_heartrate
.map(|v| format!("{:.0} bpm", v)) .map(|v| format!("{:.0} bpm", v))
.unwrap_or_else(|| "".into()); .unwrap_or_else(|| "".into());
html.push_str(&format!( html.push_str(&format!(
r#"<tr> r#"<tr>
@ -207,7 +186,7 @@ button, .button {
Datenquelle: Intervals.icu · Landkreisgeometrien: VG250 Datenquelle: Intervals.icu · Landkreisgeometrien: VG250
</footer> </footer>
</body> </body>
</html>"# </html>"#,
); );
Html(html) Html(html)

View file

@ -1,81 +1,71 @@
use axum::{ use axum::{extract::State, http::StatusCode, response::IntoResponse};
extract::State, use serde::Deserialize;
http::StatusCode,
Json,
};
use crate::{ use crate::AppState;
AppState,
model::WebhookEnvelope, #[derive(Debug, Deserialize)]
}; pub struct WebhookPayload {
pub secret: String,
#[serde(default)]
pub athlete_id: Option<String>,
#[serde(default)]
pub activity_id: Option<String>,
}
pub async fn receive( pub async fn receive(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<WebhookEnvelope>, axum::Json(payload): axum::Json<WebhookPayload>,
) -> Result<&'static str, StatusCode> { ) -> impl IntoResponse {
if payload.secret != state.config.intervals_webhook_secret { let Some(expected_secret) = state.config.intervals_webhook_secret.as_deref() else {
tracing::warn!("invalid Intervals.icu webhook secret"); tracing::error!("Intervals.icu webhook secret is not configured");
return Err(StatusCode::UNAUTHORIZED);
return (
StatusCode::INTERNAL_SERVER_ERROR,
"webhook secret not configured",
)
.into_response();
};
if payload.secret != expected_secret {
tracing::warn!("received webhook with invalid secret");
return (StatusCode::UNAUTHORIZED, "invalid webhook secret").into_response();
} }
for event in payload.events { let Some(activity_id) = payload.activity_id else {
match event.event_type.as_str() { tracing::warn!("received webhook without activity_id");
"ACTIVITY_UPLOADED" |
"ACTIVITY_ANALYZED" => {
let activity_id = event
.activity
.as_ref()
.and_then(|activity| {
activity.get("id")
})
.and_then(|id| {
id.as_str()
});
let Some(activity_id) = activity_id else { return (StatusCode::BAD_REQUEST, "missing activity_id").into_response();
tracing::warn!( };
athlete_id = %event.athlete_id,
"activity webhook without activity id"
);
continue; let athlete_id = payload
}; .athlete_id
.or_else(|| std::env::var("INTERVALS_ATHLETE_ID").ok());
if let Err(error) = let Some(athlete_id) = athlete_id else {
crate::process_activity( tracing::error!(
state.clone(), "webhook has no athlete_id and \
event.athlete_id.clone(), INTERVALS_ATHLETE_ID is not configured"
activity_id.to_owned(), );
).await
{
tracing::error!(
%error,
athlete_id = %event.athlete_id,
activity_id = %activity_id,
"activity processing failed"
);
// Return 500 so Intervals.icu can retry the webhook. return (StatusCode::BAD_REQUEST, "missing athlete_id").into_response();
return Err(StatusCode::INTERNAL_SERVER_ERROR); };
}
}
"APP_SCOPE_CHANGED" => { let state_clone = state.clone();
tracing::info!(
athlete_id = %event.athlete_id,
"OAuth scopes changed"
);
}
_ => { tokio::spawn(async move {
tracing::debug!( if let Err(error) =
event_type = %event.event_type, crate::process_activity(state_clone, athlete_id, activity_id.clone()).await
"ignoring Intervals.icu webhook" {
); tracing::error!(
} %error,
activity_id = %activity_id,
"failed to process webhook activity"
);
} }
} });
// Intervals.icu has historically retried on 204; use an ordinary 200. (StatusCode::ACCEPTED, "activity queued").into_response()
Ok("ok")
} }