This commit is contained in:
Jonas Rabenstein 2026-08-12 14:24:50 +02:00
commit eda8e76a20
4 changed files with 481 additions and 218 deletions

View file

@ -16,6 +16,8 @@ pub struct Config {
pub intervals_webhook_secret: Option<String>, pub intervals_webhook_secret: Option<String>,
pub cookie_secure: bool, pub cookie_secure: bool,
pub dev_sync_days: i64,
} }
impl Config { impl Config {
@ -23,6 +25,7 @@ impl Config {
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
let database_url = required("DATABASE_URL")?; let database_url = required("DATABASE_URL")?;
let bind_address = let bind_address =
env::var("BIND_ADDRESS").unwrap_or_else(|_| "127.0.0.1:8080".to_string()); env::var("BIND_ADDRESS").unwrap_or_else(|_| "127.0.0.1:8080".to_string());
@ -47,6 +50,15 @@ impl Config {
.parse::<bool>() .parse::<bool>()
.context("COOKIE_SECURE must be true or false")?; .context("COOKIE_SECURE must be true or false")?;
let dev_sync_days = env::var("DEV_SYNC_DAYS")
.unwrap_or_else(|_| "30".to_string())
.parse::<i64>()
.context("DEV_SYNC_DAYS must be an integer")?;
if dev_sync_days <= 0 {
anyhow::bail!("DEV_SYNC_DAYS must be greater than zero");
}
Ok(Self { Ok(Self {
database_url, database_url,
bind_address, bind_address,
@ -61,6 +73,8 @@ impl Config {
intervals_webhook_secret, intervals_webhook_secret,
cookie_secure, cookie_secure,
dev_sync_days,
}) })
} }

View file

@ -8,7 +8,9 @@ use crate::model::DetectedCrossing;
pub fn hash_token(token: &str) -> Vec<u8> { pub fn hash_token(token: &str) -> Vec<u8> {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(token.as_bytes()); hasher.update(token.as_bytes());
hasher.finalize().to_vec() hasher.finalize().to_vec()
} }
@ -54,6 +56,7 @@ pub async fn athlete_for_session(db: &PgPool, token: &str) -> Result<Option<(i64
if let Some(row) = row { if let Some(row) = row {
let id: i64 = row.try_get("id")?; let id: i64 = row.try_get("id")?;
let name: String = row.try_get("display_name")?; let name: String = row.try_get("display_name")?;
sqlx::query( sqlx::query(
@ -102,7 +105,13 @@ pub async fn ensure_activity(
activity_json, activity_json,
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

View file

@ -1,7 +1,7 @@
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use chrono::{DateTime, NaiveDateTime, Utc}; use chrono::{DateTime, NaiveDateTime, Utc};
use reqwest::{Client, StatusCode}; use reqwest::Client;
use serde_json::{Value, json}; use serde_json::Value;
use crate::config::Config; use crate::config::Config;
@ -9,13 +9,28 @@ use crate::config::Config;
pub struct IntervalsClient { pub struct IntervalsClient {
client: Client, client: Client,
config: Config, config: Config,
api_key: Option<String>,
} }
impl IntervalsClient { impl IntervalsClient {
pub fn new(config: Config) -> Self { pub fn new(config: Config) -> Self {
let api_key = config.intervals_api_key.clone();
Self { Self {
client: Client::new(), client: Client::new(),
config, config,
api_key,
}
}
/// Create a client using an explicitly supplied personal API key.
///
/// Primarily used by /dev/sync/{api_key}/{athlete_id}.
pub fn with_api_key(config: Config, api_key: impl Into<String>) -> Self {
Self {
client: Client::new(),
config,
api_key: Some(api_key.into()),
} }
} }
@ -24,7 +39,9 @@ impl IntervalsClient {
} }
fn api_key(&self) -> Result<&str> { fn api_key(&self) -> Result<&str> {
self.config.require_api_key() self.api_key
.as_deref()
.context("Intervals.icu API key is not configured")
} }
fn api_url(&self, path: &str) -> String { fn api_url(&self, path: &str) -> String {
@ -41,12 +58,13 @@ impl IntervalsClient {
async fn get_json(&self, url: String) -> Result<Value> { async fn get_json(&self, url: String) -> Result<Value> {
let response = self let response = self
.authenticated(self.client.get(url))? .authenticated(self.client.get(&url))?
.send() .send()
.await .await
.context("Intervals.icu request failed")?; .context("Intervals.icu request failed")?;
let status = response.status(); let status = response.status();
let body = response let body = response
.text() .text()
.await .await
@ -60,40 +78,103 @@ impl IntervalsClient {
.with_context(|| format!("invalid JSON returned by Intervals.icu: {}", body)) .with_context(|| format!("invalid JSON returned by Intervals.icu: {}", body))
} }
/// Get the athlete belonging to the configured API key.
///
/// GET /api/v1/athlete/0
pub async fn owner(&self) -> Result<Value> {
let url = self.api_url("athlete/0");
self.get_json(url).await
}
/// Get a specific athlete.
pub async fn athlete(&self, athlete_id: &str) -> Result<Value> {
let url = self.api_url(&format!("athlete/{athlete_id}"));
self.get_json(url).await
}
/// Get a single activity. /// Get a single activity.
/// ///
/// Uses:
/// GET /api/v1/activity/{id} /// GET /api/v1/activity/{id}
pub async fn activity(&self, activity_id: &str) -> Result<Value> { pub async fn activity(&self, activity_id: &str) -> Result<Value> {
let url = self.api_url(&format!("activity/{activity_id}")); let url = self.api_url(&format!("activity/{activity_id}?intervals=true"));
self.get_json(url).await self.get_json(url).await
} }
/// Get activity streams. /// Get activity streams.
/// ///
/// Uses:
/// GET /api/v1/activity/{id}/streams.json /// GET /api/v1/activity/{id}/streams.json
/// ///
/// The response is normally an array of stream objects: /// We explicitly request the streams needed for GPS processing.
///
/// [
/// {"type":"time","data":[...]},
/// {"type":"latlng","data":[...]},
/// ...
/// ]
pub async fn streams(&self, activity_id: &str) -> Result<Value> { pub async fn streams(&self, activity_id: &str) -> Result<Value> {
let url = self.api_url(&format!("activity/{activity_id}/streams.json")); let url = self.api_url(&format!(
"activity/{activity_id}/streams.json?types=time,latlng"
));
self.get_json(url).await self.get_json(url).await
} }
/// Fetch the athlete's activities. /// Find a stream object by type.
/// pub fn find_stream<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Value> {
/// `0` means the athlete belonging to the API key. // Normal API response:
pub async fn activities(&self, oldest: &str, newest: &str) -> Result<Value> { //
let athlete_id = &self.config.intervals_athlete_id; // [
// {
// "type": "time",
// "data": [...]
// },
// {
// "type": "latlng",
// "data": [...],
// "data2": [...]
// }
// ]
if let Some(array) = streams.as_array() {
return array
.iter()
.find(|stream| stream.get("type").and_then(Value::as_str) == Some(stream_type));
}
// Also tolerate:
//
// {
// "time": {...},
// "latlng": {...}
// }
streams.get(stream_type)
}
/// Get the `data` array of a normal stream.
pub fn stream_values<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Vec<Value>> {
let stream = Self::find_stream(streams, stream_type)?;
stream.get("data").and_then(Value::as_array)
}
/// Get the latitude and longitude arrays from the
/// Intervals.icu `latlng` stream.
///
/// Intervals.icu represents latlng as:
///
/// data = latitude
/// data2 = longitude
///
pub fn latlng_values<'a>(streams: &'a Value) -> Option<(&'a Vec<Value>, &'a Vec<Value>)> {
let stream = Self::find_stream(streams, "latlng")?;
let lat = stream.get("data")?.as_array()?;
let lon = stream.get("data2")?.as_array()?;
Some((lat, lon))
}
/// Fetch an athlete's activities.
///
/// `athlete_id = "0"` means the athlete belonging to
/// the API key.
pub async fn activities(&self, athlete_id: &str, oldest: &str, newest: &str) -> Result<Value> {
let url = format!( let url = format!(
"{}?oldest={}&newest={}", "{}?oldest={}&newest={}",
self.api_url(&format!("athlete/{athlete_id}/activities")), self.api_url(&format!("athlete/{athlete_id}/activities")),
@ -104,27 +185,6 @@ impl IntervalsClient {
self.get_json(url).await self.get_json(url).await
} }
/// Return a stream's values independent of whether the API
/// returned an object or an array of stream objects.
pub fn stream_values<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Vec<Value>> {
// Current API format: array of streams.
if let Some(array) = streams.as_array() {
for stream in array {
if stream.get("type").and_then(Value::as_str) == Some(stream_type) {
return stream.get("data").and_then(Value::as_array);
}
}
}
// Also support a dictionary-like response:
//
// {
// "time": [...],
// "latlng": [...]
// }
streams.get(stream_type).and_then(Value::as_array)
}
/// Extract the activity start timestamp. /// Extract the activity start timestamp.
pub fn activity_start(activity: &Value) -> Result<DateTime<Utc>> { pub fn activity_start(activity: &Value) -> Result<DateTime<Utc>> {
let value = activity let value = activity
@ -142,9 +202,6 @@ impl IntervalsClient {
/// Try to find an Intervals.icu interval corresponding /// Try to find an Intervals.icu interval corresponding
/// to a GPS stream index. /// 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> { pub fn matching_interval(activity: &Value, track_index: usize) -> Option<Value> {
let intervals = activity.get("icu_intervals")?; let intervals = activity.get("icu_intervals")?;
@ -158,6 +215,7 @@ impl IntervalsClient {
let end = interval.get("end_index").or_else(|| interval.get("end")); let end = interval.get("end_index").or_else(|| interval.get("end"));
let start = start.and_then(Value::as_u64)?; let start = start.and_then(Value::as_u64)?;
let end = end.and_then(Value::as_u64)?; let end = end.and_then(Value::as_u64)?;
if (start as usize) <= track_index && track_index <= end as usize { if (start as usize) <= track_index && track_index <= end as usize {
@ -168,8 +226,7 @@ impl IntervalsClient {
None None
} }
/// OAuth methods are intentionally retained so we can /// OAuth methods retained for the normal multi-user flow.
/// re-enable multi-user OAuth later.
pub fn oauth_authorize_url(&self, state: &str) -> Result<String> { pub fn oauth_authorize_url(&self, state: &str) -> Result<String> {
let client_id = self let client_id = self
.config .config
@ -219,6 +276,7 @@ impl IntervalsClient {
.context("OAuth token request failed")?; .context("OAuth token request failed")?;
let status = response.status(); let status = response.status();
let body = response.text().await?; let body = response.text().await?;
if !status.is_success() { if !status.is_success() {
@ -252,9 +310,14 @@ impl IntervalsClient {
let athlete_id = athlete let athlete_id = athlete
.get("id") .get("id")
.and_then(Value::as_str) .and_then(Value::as_str)
.or_else(|| athlete.get("id").and_then(Value::as_i64).map(|_| "")) .map(str::to_string)
.unwrap_or("") .or_else(|| {
.to_string(); athlete
.get("id")
.and_then(Value::as_i64)
.map(|id| id.to_string())
})
.unwrap_or_default();
let display_name = athlete let display_name = athlete
.get("name") .get("name")

View file

@ -12,11 +12,12 @@ use anyhow::{Context, Result, anyhow};
use axum::{ use axum::{
Router, Router,
extract::{Path, State}, extract::{Path, State},
response::Json,
routing::{get, post}, routing::{get, post},
}; };
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use serde_json::Value; use serde_json::{Value, json};
use sqlx::{PgPool, Row}; use sqlx::PgPool;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@ -64,27 +65,16 @@ async fn main() -> Result<()> {
.route("/logout", get(auth::logout)) .route("/logout", get(auth::logout))
.route("/webhooks/intervals", post(webhook::receive)) .route("/webhooks/intervals", post(webhook::receive))
.route("/api/leaderboard", get(leaderboard::api)) .route("/api/leaderboard", get(leaderboard::api))
/* // Development-only activity synchronisation.
* Development-only endpoint. //
* // /dev/sync/{api_key}
* This lets us test the complete activity-processing // /dev/sync/{api_key}/{athlete_id}
* pipeline without having to trigger an Intervals.icu .route("/dev/sync/{api_key}", get(dev_sync_one))
* webhook. .route("/dev/sync/{api_key}/{athlete_id}", get(dev_sync))
*
* 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 = tokio::net::TcpListener::bind(&config.bind_address) let listener = tokio::net::TcpListener::bind(&config.bind_address).await?;
.await
.with_context(|| format!("cannot bind to {}", config.bind_address))?;
tracing::info!( tracing::info!(
address = %config.bind_address, address = %config.bind_address,
@ -100,64 +90,327 @@ async fn health() -> &'static str {
"ok" "ok"
} }
/// Development endpoint for processing one real Intervals.icu fn redact_database_url(url: &str) -> String {
/// activity using INTERVALS_API_KEY. if let Some((prefix, _)) = url.split_once('@') {
if prefix.contains("://") {
return format!("{prefix}@***");
}
}
url.to_string()
}
/// Development endpoint for importing/analyzing activities.
/// ///
/// This intentionally bypasses OAuth and webhooks. /// Examples:
async fn dev_process_activity( ///
/// GET /dev/sync/MY_API_KEY
/// GET /dev/sync/MY_API_KEY/0
/// GET /dev/sync/MY_API_KEY/i123456
///
/// `0` means the owner of the supplied API key.
async fn dev_sync(
State(state): State<AppState>, State(state): State<AppState>,
Path(activity_id): Path<String>, Path((api_key, athlete_id)): Path<(String, String)>,
) -> Result<String, (axum::http::StatusCode, String)> { ) -> Result<Json<Value>, axum::http::StatusCode> {
process_activity(state, configured_athlete_id(), activity_id) match dev_sync_inner(state, api_key, athlete_id).await {
.await Ok(result) => Ok(Json(result)),
.map(|_| "activity processed\n".to_string())
.map_err(internal_error)
}
fn configured_athlete_id() -> String { Err(error) => {
std::env::var("INTERVALS_ATHLETE_ID").unwrap_or_else(|_| "0".to_string())
}
fn internal_error(error: anyhow::Error) -> (axum::http::StatusCode, String) {
tracing::error!( tracing::error!(
%error, error = %error,
"request failed" "development sync failed"
); );
( Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
axum::http::StatusCode::INTERNAL_SERVER_ERROR, }
error.to_string(), }
)
} }
/// Process one Intervals.icu activity. /// One-segment variant:
/// ///
/// In development mode the Intervals.icu personal API key from /// /dev/sync/{api_key}
/// INTERVALS_API_KEY is used by IntervalsClient.
/// ///
/// The activity is: /// Equivalent to:
/// ///
/// 1. fetched from Intervals.icu /// /dev/sync/{api_key}/0
/// 2. its GPS/time streams are fetched async fn dev_sync_one(
/// 3. GPS points are converted to TrackPoint values State(state): State<AppState>,
/// 4. county-border crossings are detected Path(api_key): Path<String>,
/// 5. crossing times are interpolated to millisecond precision ) -> Result<Json<Value>, axum::http::StatusCode> {
/// 6. matching Intervals.icu intervals are attached where possible match dev_sync_inner(state, api_key, "0".to_string()).await {
/// 7. everything is stored transactionally Ok(result) => Ok(Json(result)),
Err(error) => {
tracing::error!(
error = %error,
"development sync failed"
);
Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
async fn dev_sync_inner(state: AppState, api_key: String, athlete_id: String) -> Result<Value> {
if api_key.trim().is_empty() {
return Err(anyhow!("API key is empty"));
}
let athlete_id = if athlete_id.trim().is_empty() {
"0".to_string()
} else {
athlete_id
};
/*
* This client is explicitly authenticated using the API key
* supplied to the development endpoint.
*/
let client = IntervalsClient::with_api_key(state.config.clone(), api_key.clone());
/*
* Resolve athlete 0 through Intervals.icu.
*
* Intervals.icu supports athlete/0 for personal API keys;
* it refers to the athlete belonging to that key.
*/
let resolved_athlete_id = if athlete_id == "0" {
let owner = client
.owner()
.await
.context("cannot resolve API-key owner")?;
owner
.get("id")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| {
owner
.get("id")
.and_then(Value::as_i64)
.map(|id| id.to_string())
})
.context("Intervals.icu owner response has no athlete id")?
} else {
athlete_id.clone()
};
let newest = Utc::now().date_naive();
let oldest = newest - Duration::days(state.config.dev_sync_days);
let oldest_string = oldest.format("%Y-%m-%d").to_string();
let newest_string = newest.format("%Y-%m-%d").to_string();
tracing::info!(
athlete_id = %resolved_athlete_id,
oldest = %oldest_string,
newest = %newest_string,
"starting development activity sync"
);
/*
* IMPORTANT:
*
* Use the resolved athlete ID here, not the original
* "0" value.
*/
let activities = client
.activities(&resolved_athlete_id, &oldest_string, &newest_string)
.await
.context("cannot fetch development activities")?;
let activities_array = activities
.as_array()
.context("Intervals.icu activities response is not an array")?;
let mut processed = 0usize;
let mut skipped = 0usize;
let mut failed = 0usize;
let mut results = Vec::new();
/*
* Make sure there is a corresponding local athlete row.
*
* We store the development API key as access_token because
* process_activity() uses the local athlete record and the
* existing activity-processing pipeline expects that field.
*/
ensure_dev_athlete(&state, &resolved_athlete_id, &client, &api_key).await?;
for activity in activities_array {
let Some(activity_id) = activity.get("id").and_then(Value::as_str) else {
skipped += 1;
results.push(json!({
"status": "skipped",
"reason": "activity has no id"
}));
continue;
};
match process_activity(
state.clone(),
resolved_athlete_id.clone(),
activity_id.to_string(),
)
.await
{
Ok(()) => {
processed += 1;
results.push(json!({
"id": activity_id,
"status": "processed"
}));
}
Err(error) => {
failed += 1;
tracing::error!(
activity_id = %activity_id,
error = %error,
"development activity processing failed"
);
results.push(json!({
"id": activity_id,
"status": "failed",
"error": error.to_string()
}));
}
}
}
tracing::info!(
athlete_id = %resolved_athlete_id,
found = activities_array.len(),
processed,
skipped,
failed,
"development activity sync finished"
);
Ok(json!({
"athlete_id": resolved_athlete_id,
"oldest": oldest_string,
"newest": newest_string,
"found": activities_array.len(),
"processed": processed,
"skipped": skipped,
"failed": failed,
"activities": results
}))
}
/// Make sure the athlete used by /dev/sync exists locally.
///
/// If the athlete already exists, update its development API
/// credential so process_activity() can use it.
///
/// We deliberately do not print the API key.
async fn ensure_dev_athlete(
state: &AppState,
intervals_athlete_id: &str,
client: &IntervalsClient,
api_key: &str,
) -> Result<()> {
let athlete = client
.athlete(intervals_athlete_id)
.await
.with_context(|| {
format!(
"cannot fetch Intervals.icu athlete {}",
intervals_athlete_id
)
})?;
let display_name = athlete
.get("name")
.or_else(|| athlete.get("display_name"))
.and_then(Value::as_str)
.unwrap_or("Intervals.icu athlete");
/*
* If this athlete already exists, update the development
* credential.
*
* This is particularly useful if the row was originally
* created by an earlier development sync with an empty token.
*/
let existing = sqlx::query(
r#"
SELECT id
FROM athletes
WHERE intervals_athlete_id = $1
"#,
)
.bind(intervals_athlete_id)
.fetch_optional(&state.db)
.await?;
if existing.is_some() {
sqlx::query(
r#"
UPDATE athletes
SET
access_token = $2,
display_name = $3,
updated_at = now()
WHERE intervals_athlete_id = $1
"#,
)
.bind(intervals_athlete_id)
.bind(api_key)
.bind(display_name)
.execute(&state.db)
.await
.context("cannot update local dev athlete")?;
return Ok(());
}
/*
* `scopes` is NOT NULL in the initial migration, so it must
* be supplied here.
*/
sqlx::query(
r#"
INSERT INTO athletes (
intervals_athlete_id,
display_name,
access_token,
scopes
)
VALUES ($1, $2, $3, $4)
"#,
)
.bind(intervals_athlete_id)
.bind(display_name)
.bind(api_key)
.bind("dev")
.execute(&state.db)
.await
.context("cannot create local dev athlete")?;
Ok(())
}
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 id SELECT
id,
access_token
FROM athletes FROM athletes
WHERE intervals_athlete_id = $1 WHERE intervals_athlete_id = $1
"#, "#,
@ -166,73 +419,45 @@ pub async fn process_activity(
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await?; .await?;
let athlete_db_id: i64 = match athlete { use sqlx::Row;
Some(row) => row.try_get("id")?,
None => { let Some(athlete) = athlete else {
let client = IntervalsClient::new(state.config.clone()); return Err(anyhow!(
"no local athlete for Intervals.icu 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 = athlete.try_get("id")?;
/*
* The normal activity-processing path uses the configured
* Intervals client.
*
* The development endpoint has already ensured that the
* athlete exists locally and that its credential is stored.
*/
let client = IntervalsClient::new(state.config.clone()); let client = IntervalsClient::new(state.config.clone());
/* /*
* First fetch the activity metadata. * Fetch activity metadata.
*
* Among other things this may contain Intervals.icu's
* automatically detected intervals.
*/ */
let activity = client.activity(&activity_id).await?; let activity = client
.activity(&activity_id)
.await
.with_context(|| format!("cannot fetch Intervals.icu activity {}", activity_id))?;
let start_time = IntervalsClient::activity_start(&activity)?; let start_time = IntervalsClient::activity_start(&activity)?;
/* /*
* Then fetch only the activity streams we need. * Fetch GPS/time streams.
*
* For county crossings:
*
* time
* latlng
*
* Optional streams such as watts/hr can be added later
* without changing the crossing algorithm.
*/ */
let streams = client.streams(&activity_id).await?; let streams = client.streams(&activity_id).await.with_context(|| {
format!(
"cannot fetch streams for Intervals.icu activity {}",
activity_id
)
})?;
let points = parse_track(&streams, start_time)?; let points = parse_track(&streams, start_time)?;
@ -246,22 +471,16 @@ pub async fn process_activity(
return Ok(()); return Ok(());
} }
tracing::info!(
activity_id = %activity_id,
points = points.len(),
start_time = %start_time,
"processing activity"
);
/* /*
* Use a transaction so re-processing an activity is * Re-processing is idempotent:
* idempotent:
* *
* old crossings are deleted * - update activity
* new crossings are inserted * - remove existing crossings
* activity is marked processed * - calculate crossings
* - insert crossings
* - mark processed
* *
* all atomically. * All changes happen inside one transaction.
*/ */
let mut tx = state.db.begin().await?; let mut tx = state.db.begin().await?;
@ -276,14 +495,6 @@ pub async fn process_activity(
let crossings = geo::crossings_between(&state.db, &pair[0], &pair[1]).await?; let crossings = geo::crossings_between(&state.db, &pair[0], &pair[1]).await?;
for crossing in crossings { for crossing in crossings {
/*
* Try to associate this county crossing with an
* automatically detected Intervals.icu interval.
*
* 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); let interval = IntervalsClient::matching_interval(&activity, crossing.track_index);
db::save_crossing(&mut tx, activity_db_id, &crossing, interval.as_ref()).await?; db::save_crossing(&mut tx, activity_db_id, &crossing, interval.as_ref()).await?;
@ -296,7 +507,6 @@ 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"
); );
} }
@ -351,15 +561,6 @@ fn parse_track(streams: &Value, start_time: DateTime<Utc>) -> Result<Vec<TrackPo
continue; continue;
} }
/*
* 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; let millis = (seconds * 1000.0).round() as i64;
result.push(TrackPoint { result.push(TrackPoint {
@ -373,30 +574,6 @@ fn parse_track(streams: &Value, start_time: DateTime<Utc>) -> Result<Vec<TrackPo
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};