This commit is contained in:
Jonas Rabenstein 2026-08-13 03:23:00 +02:00
commit 2ab16a68b7
4 changed files with 556 additions and 151 deletions

View file

@ -1,6 +1,6 @@
use axum::{
extract::{Query, State},
response::{IntoResponse, Redirect},
extract::{Form, Query, State},
response::{Html, IntoResponse, Redirect},
};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use rand::{
@ -8,6 +8,7 @@ use rand::{
rng,
};
use serde::Deserialize;
use serde_json::Value;
use crate::{AppState, db, intervals::IntervalsClient};
@ -18,19 +19,19 @@ pub struct OAuthCallback {
pub error: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ApiKeyLoginForm {
pub api_key: String,
}
pub async fn start(State(state): State<AppState>) -> impl IntoResponse {
// Important: ThreadRng must not live across an await.
let oauth_state = {
let mut rng = rng();
Alphanumeric.sample_string(&mut rng, 48)
};
if let Err(error) = db::delete_old_oauth_states(&state.db).await {
tracing::error!(
%error,
"cannot clean OAuth states"
);
tracing::error!(%error, "cannot clean OAuth states");
return "internal error".into_response();
}
@ -39,11 +40,7 @@ pub async fn start(State(state): State<AppState>) -> impl IntoResponse {
.execute(&state.db)
.await
{
tracing::error!(
%error,
"cannot create OAuth state"
);
tracing::error!(%error, "cannot create OAuth state");
return "internal error".into_response();
}
@ -56,7 +53,6 @@ pub async fn start(State(state): State<AppState>) -> impl IntoResponse {
%error,
"cannot construct OAuth authorize URL"
);
return "cannot construct OAuth URL".into_response();
}
};
@ -64,6 +60,155 @@ pub async fn start(State(state): State<AppState>) -> impl IntoResponse {
Redirect::to(&authorize_url).into_response()
}
/// Development/workaround login using a personal Intervals.icu API key.
///
/// GET /api-key/start shows the form.
/// POST /api-key/start validates the key against athlete/0, creates/updates
/// the local athlete and creates the normal county-sprints session.
pub async fn api_key_start() -> Html<String> {
Html(
r#"<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Mit Intervals.icu API-Key anmelden</title>
<style>
body{font-family:system-ui,sans-serif;max-width:680px;margin:0 auto;padding:2rem;background:#111827;color:#f9fafb}
a{color:#93c5fd}.card{background:#1f2937;border-radius:12px;padding:1.5rem;margin-top:1rem}
input{box-sizing:border-box;width:100%;background:#111827;color:#f9fafb;border:1px solid #4b5563;border-radius:8px;padding:.75rem;margin:.5rem 0 1rem}
button{background:#2563eb;color:white;border:0;border-radius:8px;padding:.7rem 1rem;cursor:pointer}
.small{color:#9ca3af;font-size:.9rem}
</style>
</head>
<body>
<a href="/"> Landkreis-Sprints</a>
<div class="card">
<h1>Mit Intervals.icu API-Key anmelden</h1>
<p>Dein persönlicher Intervals.icu API-Key wird direkt gegen Intervals.icu geprüft. Der Besitzer des API-Keys wird als Benutzer verwendet.</p>
<form method="post" action="/api-key/start">
<label for="api_key">API-Key</label>
<input id="api_key" name="api_key" type="password" autocomplete="off" required>
<button type="submit">Anmelden</button>
</form>
<p class="small">Für diese lokale Entwicklungs-/Workaround-Variante wird der API-Key serverseitig für die spätere Aktivitätsverarbeitung gespeichert. Für Produktion sollten wir ihn verschlüsselt speichern.</p>
</div>
</body>
</html>"#.to_string(),
)
}
pub async fn api_key_login(
State(state): State<AppState>,
Form(form): Form<ApiKeyLoginForm>,
) -> impl IntoResponse {
let api_key = form.api_key.trim().to_string();
if api_key.is_empty() {
return Html(api_key_error("API-Key darf nicht leer sein")).into_response();
}
let client = IntervalsClient::with_api_key(state.config.clone(), api_key.clone());
let owner = match client.owner().await {
Ok(owner) => owner,
Err(error) => {
tracing::warn!(error = %error, "Intervals.icu API-key login failed");
return Html(api_key_error(
"API-Key konnte bei Intervals.icu nicht verifiziert werden",
))
.into_response();
}
};
let athlete_id = match athlete_id_from_value(&owner) {
Some(id) => id,
None => {
tracing::error!("Intervals.icu owner response has no athlete id");
return Html(api_key_error(
"Intervals.icu hat für den API-Key keinen Athleten geliefert",
))
.into_response();
}
};
let display_name = owner
.get("name")
.or_else(|| owner.get("display_name"))
.and_then(Value::as_str)
.unwrap_or("Intervals.icu athlete");
let athlete_db_id =
match db::upsert_api_key_athlete(&state.db, &athlete_id, display_name, &api_key).await {
Ok(id) => id,
Err(error) => {
tracing::error!(%error, "cannot store API-key athlete");
return Html(api_key_error("Benutzer konnte nicht gespeichert werden"))
.into_response();
}
};
let session_token = {
let mut rng = rng();
Alphanumeric.sample_string(&mut rng, 64)
};
if let Err(error) = db::create_session(&state.db, athlete_db_id, &session_token).await {
tracing::error!(%error, "cannot create API-key login session");
return Html(api_key_error("Session konnte nicht erstellt werden")).into_response();
}
let cookie = Cookie::build(("county_session", session_token))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.secure(state.config.cookie_secure);
let jar = CookieJar::new().add(cookie);
(jar, Redirect::to("/")).into_response()
}
fn athlete_id_from_value(value: &Value) -> Option<String> {
value
.get("id")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| {
value
.get("id")
.and_then(Value::as_i64)
.map(|id| id.to_string())
})
}
fn api_key_error(message: &str) -> String {
format!(
r#"<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>API-Key Login</title>
<style>
body{{font-family:system-ui,sans-serif;max-width:680px;margin:0 auto;padding:2rem;background:#111827;color:#f9fafb}}
a{{color:#93c5fd}}.card{{background:#1f2937;border-radius:12px;padding:1.5rem;margin-top:1rem}}
button{{background:#2563eb;color:white;border:0;border-radius:8px;padding:.7rem 1rem;text-decoration:none}}
</style>
</head>
<body>
<a href="/api-key/start"> zurück</a>
<div class="card">
<h1>Login fehlgeschlagen</h1>
<p>{}</p>
<p><a href="/api-key/start">Erneut versuchen</a></p>
</div>
</body>
</html>"#,
escape_html(message)
)
}
pub async fn callback(
State(state): State<AppState>,
Query(query): Query<OAuthCallback>,
@ -83,9 +228,8 @@ pub async fn callback(
let state_row = sqlx::query(
r#"
DELETE FROM oauth_states
WHERE
state = $1
AND created_at > now() - interval '10 minutes'
WHERE state = $1
AND created_at > now() - interval '10 minutes'
RETURNING state
"#,
)
@ -95,17 +239,9 @@ pub async fn callback(
match state_row {
Ok(Some(_)) => {}
Ok(None) => {
return "invalid or expired OAuth state".into_response();
}
Ok(None) => return "invalid or expired OAuth state".into_response(),
Err(error) => {
tracing::error!(
%error,
"OAuth state validation failed"
);
tracing::error!(%error, "OAuth state validation failed");
return "internal error".into_response();
}
}
@ -114,13 +250,8 @@ pub async fn callback(
let response = match client.exchange_code(&code).await {
Ok(response) => response,
Err(error) => {
tracing::error!(
%error,
"OAuth token exchange failed"
);
tracing::error!(%error, "OAuth token exchange failed");
return "OAuth token exchange failed".into_response();
}
};
@ -128,64 +259,24 @@ pub async fn callback(
let (access_token, scope, athlete_id, display_name) =
match IntervalsClient::token_data(&response) {
Ok(value) => value,
Err(error) => {
tracing::error!(
%error,
"invalid OAuth token response"
);
tracing::error!(%error, "invalid OAuth token response");
return "invalid OAuth response".into_response();
}
};
let row = sqlx::query(
r#"
INSERT INTO athletes (
intervals_athlete_id,
display_name,
access_token,
scopes
)
VALUES ($1, $2, $3, $4)
ON CONFLICT (intervals_athlete_id)
DO UPDATE SET
display_name = EXCLUDED.display_name,
access_token = EXCLUDED.access_token,
scopes = EXCLUDED.scopes,
updated_at = now()
RETURNING id
"#,
let athlete_db_id = match db::upsert_oauth_athlete(
&state.db,
&athlete_id,
&display_name,
&access_token,
&scope,
)
.bind(&athlete_id)
.bind(&display_name)
.bind(&access_token)
.bind(&scope)
.fetch_one(&state.db)
.await;
use sqlx::Row;
let athlete_db_id: i64 = match row {
Ok(row) => match row.try_get("id") {
Ok(id) => id,
Err(error) => {
tracing::error!(
%error,
"invalid athlete row"
);
return "internal error".into_response();
}
},
.await
{
Ok(id) => id,
Err(error) => {
tracing::error!(
%error,
"cannot store athlete"
);
tracing::error!(%error, "cannot store OAuth athlete");
return "cannot store athlete".into_response();
}
};
@ -196,11 +287,7 @@ pub async fn callback(
};
if let Err(error) = db::create_session(&state.db, athlete_db_id, &session_token).await {
tracing::error!(
%error,
"cannot create session"
);
tracing::error!(%error, "cannot create session");
return "cannot create session".into_response();
}
@ -217,15 +304,19 @@ pub async fn callback(
pub async fn logout(State(state): State<AppState>, jar: CookieJar) -> impl IntoResponse {
if let Some(cookie) = jar.get("county_session") {
let hash = db::hash_token(cookie.value());
let _ = sqlx::query("DELETE FROM sessions WHERE token_hash = $1")
.bind(&hash)
.execute(&state.db)
.await;
let _ = db::delete_session(&state.db, cookie.value()).await;
}
let jar = jar.remove(Cookie::build("county_session").path("/"));
(jar, Redirect::to("/")).into_response()
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}

117
src/db.rs
View file

@ -8,9 +8,7 @@ use crate::model::DetectedCrossing;
pub fn hash_token(token: &str) -> Vec<u8> {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hasher.finalize().to_vec()
}
@ -19,10 +17,7 @@ pub async fn create_session(db: &PgPool, athlete_id: i64, token: &str) -> Result
sqlx::query(
r#"
INSERT INTO sessions (
token_hash,
athlete_id
)
INSERT INTO sessions (token_hash, athlete_id)
VALUES ($1, $2)
"#,
)
@ -39,12 +34,9 @@ pub async fn athlete_for_session(db: &PgPool, token: &str) -> Result<Option<(i64
let row = sqlx::query(
r#"
SELECT
a.id,
a.display_name
SELECT a.id, a.display_name
FROM sessions s
JOIN athletes a
ON a.id = s.athlete_id
JOIN athletes a ON a.id = s.athlete_id
WHERE s.token_hash = $1
"#,
)
@ -56,7 +48,6 @@ pub async fn athlete_for_session(db: &PgPool, token: &str) -> Result<Option<(i64
if let Some(row) = row {
let id: i64 = row.try_get("id")?;
let name: String = row.try_get("display_name")?;
sqlx::query(
@ -201,6 +192,78 @@ pub async fn ensure_dev_athlete(
Ok(row.try_get("id")?)
}
/// Create or update an athlete authenticated with a personal API key.
pub async fn upsert_api_key_athlete(
db: &PgPool,
intervals_athlete_id: &str,
display_name: &str,
api_key: &str,
) -> Result<i64> {
let row = sqlx::query(
r#"
INSERT INTO athletes (
intervals_athlete_id,
display_name,
access_token,
scopes
)
VALUES ($1, $2, $3, 'API_KEY')
ON CONFLICT (intervals_athlete_id)
DO UPDATE SET
display_name = EXCLUDED.display_name,
access_token = EXCLUDED.access_token,
scopes = EXCLUDED.scopes,
updated_at = now()
RETURNING id
"#,
)
.bind(intervals_athlete_id)
.bind(display_name)
.bind(api_key)
.fetch_one(db)
.await?;
use sqlx::Row;
Ok(row.try_get("id")?)
}
/// Create or update an athlete authenticated through OAuth.
pub async fn upsert_oauth_athlete(
db: &PgPool,
intervals_athlete_id: &str,
display_name: &str,
access_token: &str,
scopes: &str,
) -> Result<i64> {
let row = sqlx::query(
r#"
INSERT INTO athletes (
intervals_athlete_id,
display_name,
access_token,
scopes
)
VALUES ($1, $2, $3, $4)
ON CONFLICT (intervals_athlete_id)
DO UPDATE SET
display_name = EXCLUDED.display_name,
access_token = EXCLUDED.access_token,
scopes = EXCLUDED.scopes,
updated_at = now()
RETURNING id
"#,
)
.bind(intervals_athlete_id)
.bind(display_name)
.bind(access_token)
.bind(scopes)
.fetch_one(db)
.await?;
use sqlx::Row;
Ok(row.try_get("id")?)
}
pub async fn ensure_activity(
tx: &mut Transaction<'_, Postgres>,
athlete_id: i64,
@ -217,17 +280,8 @@ pub async fn ensure_activity(
activity_json,
processed_at
)
VALUES (
$1,
$2,
$3,
$4,
NULL
)
ON CONFLICT (
athlete_id,
intervals_activity_id
)
VALUES ($1, $2, $3, $4, NULL)
ON CONFLICT (athlete_id, intervals_activity_id)
DO UPDATE SET
start_time = EXCLUDED.start_time,
activity_json = EXCLUDED.activity_json,
@ -243,7 +297,6 @@ pub async fn ensure_activity(
.await?;
use sqlx::Row;
Ok(row.try_get("id")?)
}
@ -285,10 +338,7 @@ pub async fn save_crossing(
$1,
$2,
$3,
ST_SetSRID(
ST_MakePoint($4, $5),
4326
),
ST_SetSRID(ST_MakePoint($4, $5), 4326),
$6,
$7,
$8
@ -326,3 +376,14 @@ pub async fn mark_activity_processed(
Ok(())
}
pub async fn delete_session(db: &PgPool, token: &str) -> Result<()> {
let hash = hash_token(token);
sqlx::query("DELETE FROM sessions WHERE token_hash = $1")
.bind(&hash)
.execute(db)
.await?;
Ok(())
}

View file

@ -12,12 +12,12 @@ use anyhow::{Context, Result, anyhow};
use axum::{
Router,
extract::{Path, Query, State},
response::Html,
response::{Html, Json},
routing::{get, post},
};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_json::{Value, json};
use sqlx::PgPool;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@ -56,9 +56,7 @@ async fn main() -> Result<()> {
let config = Config::from_env()?;
tracing::info!(
database_url = %redact_database_url(
&config.database_url
),
database_url = %redact_database_url(&config.database_url),
"connecting to PostgreSQL"
);
@ -81,6 +79,10 @@ async fn main() -> Result<()> {
.route("/health", get(health))
.route("/oauth/start", get(auth::start))
.route("/oauth/callback", get(auth::callback))
.route(
"/api-key/start",
get(auth::api_key_start).post(auth::api_key_login),
)
.route("/logout", get(auth::logout))
.route("/webhooks/intervals", post(webhook::receive))
.route("/api/leaderboard", get(leaderboard::api))
@ -94,6 +96,11 @@ async fn main() -> Result<()> {
"/sync/{athlete_id}/import-visible",
post(sync_import_visible),
)
.route("/activity/{activity_id}", get(web::activity))
.route("/groups", get(web::groups).post(web::save_group))
.route("/groups/delete", post(web::delete_group))
.route("/dev/sync/{api_key}", get(dev_sync_one))
.route("/dev/sync/{api_key}/{athlete_id}", get(dev_sync))
.layer(TraceLayer::new_for_http())
.with_state(state);
@ -105,7 +112,6 @@ async fn main() -> Result<()> {
);
axum::serve(listener, app).await?;
Ok(())
}
@ -119,7 +125,6 @@ fn redact_database_url(url: &str) -> String {
return format!("{prefix}@***");
}
}
url.to_string()
}
@ -310,7 +315,6 @@ async fn sync_athlete(
imported: false,
});
}
let mut html = String::from(
r#"<!doctype html>
<html lang="de">
@ -512,7 +516,6 @@ input[type="datetime-local"] {
html.push_str("</div>");
}
html.push_str(
r#"<p>
<a href="/"> Leaderboard</a>
@ -744,19 +747,245 @@ fn url_query(value: &str) -> String {
urlencoding::encode(value).into_owned()
}
async fn dev_sync(
State(state): State<AppState>,
Path((api_key, athlete_id)): Path<(String, String)>,
) -> Result<Json<Value>, axum::http::StatusCode> {
match dev_sync_inner(state, api_key, athlete_id).await {
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_one(
State(state): State<AppState>,
Path(api_key): Path<String>,
) -> Result<Json<Value>, axum::http::StatusCode> {
match dev_sync_inner(state, api_key, "0".to_string()).await {
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
};
let client = IntervalsClient::with_api_key(state.config.clone(), api_key);
let resolved_athlete_id = if athlete_id == "0" {
let owner = client.owner().await?;
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 - chrono::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"
);
let activities = client
.activities(&athlete_id, &oldest_string, &newest_string)
.await?;
let activities_array = activities
.as_array()
.context("Intervals.icu activities response is not an array")?;
ensure_dev_athlete(&state, &resolved_athlete_id, &client).await?;
let mut processed = 0usize;
let mut skipped = 0usize;
let mut failed = 0usize;
let mut results = Vec::new();
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_with_client(
state.clone(),
resolved_athlete_id.clone(),
activity_id.to_string(),
&client,
)
.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
}))
}
async fn ensure_dev_athlete(
state: &AppState,
intervals_athlete_id: &str,
client: &IntervalsClient,
) -> Result<()> {
let athlete = client.athlete(intervals_athlete_id).await?;
let display_name = athlete
.get("name")
.or_else(|| athlete.get("display_name"))
.and_then(Value::as_str)
.unwrap_or("Intervals.icu athlete");
let exists = sqlx::query(
r#"
SELECT id FROM athletes WHERE intervals_athlete_id = $1
"#,
)
.bind(intervals_athlete_id)
.fetch_optional(&state.db)
.await?;
if exists.is_some() {
return Ok(());
}
sqlx::query(
r#"
INSERT INTO athletes (
intervals_athlete_id,
access_token,
display_name,
scopes
)
VALUES ($1, $2, $3, $4)
"#,
)
.bind(intervals_athlete_id)
.bind("")
.bind(display_name)
.bind("")
.execute(&state.db)
.await
.context("cannot create local dev athlete")?;
Ok(())
}
/// Normal processing entry point used by the webhook path.
/// The OAuth access token is loaded from the local athlete row.
pub async fn process_activity(
state: AppState,
intervals_athlete_id: String,
activity_id: String,
) -> Result<()> {
let access_token: String = sqlx::query_scalar(
r#"
SELECT access_token
FROM athletes
WHERE intervals_athlete_id = $1
"#,
)
.bind(&intervals_athlete_id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| {
anyhow!(
"no local athlete for Intervals.icu athlete {}",
intervals_athlete_id
)
})?;
if access_token.trim().is_empty() {
return Err(anyhow!(
"local athlete {} has no access token",
intervals_athlete_id
));
}
let client = IntervalsClient::with_api_key(state.config.clone(), access_token);
process_activity_with_client(state, intervals_athlete_id, activity_id, &client).await
}
/// Same processing pipeline, but with an explicitly supplied Intervals client.
/// The development endpoint uses this so its personal API key is never
/// written into the database.
pub async fn process_activity_with_client(
state: AppState,
intervals_athlete_id: String,
activity_id: String,
client: &IntervalsClient,
) -> Result<()> {
let athlete = sqlx::query(
r#"
SELECT
id,
access_token
FROM athletes
WHERE intervals_athlete_id = $1
"#,
SELECT id
FROM athletes
WHERE intervals_athlete_id = $1
"#,
)
.bind(&intervals_athlete_id)
.fetch_optional(&state.db)
@ -773,25 +1002,23 @@ pub async fn process_activity(
let athlete_db_id: i64 = athlete.try_get("id")?;
let access_token: String = athlete.try_get("access_token")?;
let client = IntervalsClient::with_api_key(state.config.clone(), access_token);
let activity = client.activity(&activity_id).await?;
let start_time = IntervalsClient::activity_start(&activity)?;
let streams = client.streams(&activity_id).await?;
let points = parse_track(&streams, start_time)?;
tracing::debug!(
activity_id = %activity_id,
points = points.len(),
"parsed activity GPS points"
);
if points.len() < 2 {
tracing::info!(
activity_id = %activity_id,
points = points.len(),
"activity has insufficient GPS data"
);
return Ok(());
}
@ -820,13 +1047,14 @@ pub async fn process_activity(
from = crossing.from_county_id,
to = crossing.to_county_id,
track_index = crossing.track_index,
lat = crossing.lat,
lon = crossing.lon,
"county crossing detected"
);
}
}
db::mark_activity_processed(&mut tx, activity_db_id).await?;
tx.commit().await?;
tracing::info!(
@ -846,7 +1074,6 @@ fn parse_track(streams: &Value, start_time: DateTime<Utc>) -> Result<Vec<TrackPo
.ok_or_else(|| anyhow!("Intervals.icu response has no usable latlng stream"))?;
let length = times.len().min(latitudes.len()).min(longitudes.len());
let mut result = Vec::with_capacity(length);
for index in 0..length {
@ -857,7 +1084,6 @@ fn parse_track(streams: &Value, start_time: DateTime<Utc>) -> Result<Vec<TrackPo
let Some(lat) = latitudes[index].as_f64() else {
continue;
};
let Some(lon) = longitudes[index].as_f64() else {
continue;
};
@ -886,3 +1112,25 @@ fn parse_track(streams: &Value, start_time: DateTime<Utc>) -> Result<Vec<TrackPo
Ok(result)
}
#[cfg(test)]
mod tests {
use crate::geo::interpolate_time;
use chrono::{TimeZone, Utc};
#[test]
fn interpolation_is_millisecond_precise() {
let a = Utc.timestamp_millis_opt(1000).unwrap();
let b = Utc.timestamp_millis_opt(2000).unwrap();
let result = interpolate_time(a, b, 0.376);
assert_eq!(result.timestamp_millis(), 1376);
}
#[test]
fn interpolation_rounds_to_nearest_ms() {
let a = Utc.timestamp_millis_opt(1000).unwrap();
let b = Utc.timestamp_millis_opt(2000).unwrap();
let result = interpolate_time(a, b, 0.3764);
assert_eq!(result.timestamp_millis(), 1376);
}
}

View file

@ -1,4 +1,4 @@
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use chrono::{DateTime, Utc};
@ -382,7 +382,12 @@ fn render_index(
escape_html(name)
));
} else {
html.push_str(r#"<a class="button" href="/oauth/start">Mit Intervals.icu verbinden</a>"#);
html.push_str(
r#"<div>
<a class="button" href="/oauth/start">Mit Intervals.icu verbinden</a>
<a class="button secondary" href="/api-key/start">Mit API-Key anmelden</a>
</div>"#,
);
}
html.push_str("</header>");