dev api key
This commit is contained in:
parent
06c837b244
commit
89091a03d3
11 changed files with 791 additions and 762 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
27
flake.nix
27
flake.nix
|
|
@ -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
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
179
src/auth.rs
179
src/auth.rs
|
|
@ -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("/"),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
41
src/db.rs
41
src/db.rs
|
|
@ -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
|
||||||
|
|
|
||||||
31
src/geo.rs
31
src/geo.rs
|
|
@ -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,
|
||||||
|
|
|
||||||
448
src/intervals.rs
448
src/intervals.rs
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
|
||||||
428
src/main.rs
428
src/main.rs
|
|
@ -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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
83
src/web.rs
83
src/web.rs
|
|
@ -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)
|
||||||
|
|
|
||||||
122
src/webhook.rs
122
src/webhook.rs
|
|
@ -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")
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue