From 1c3fd3d20f5742e6368805bd8c6c1e7eec303a81 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 02:09:37 +0000 Subject: [PATCH] remove /dev/sync debug endpoints /dev/sync/{api_key} and /dev/sync/{api_key}/{athlete_id} took a personal Intervals.icu API key as a URL path segment, which leaks into server/proxy access logs (and tower_http's TraceLayer spans), browser history, and Referer headers. There's no legitimate reason to keep a credential-in-URL debug backdoor around outside local development, so it's gone along with its handlers. This also removes main.rs's local ensure_dev_athlete, which duplicated db::ensure_dev_athlete but used a racy check-then-insert instead of an upsert (TOCTOU on concurrent dev syncs for the same athlete). --- src/main.rs | 200 ++-------------------------------------------------- 1 file changed, 5 insertions(+), 195 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2f5d2e8..a1b02fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,12 +12,12 @@ use anyhow::{Context, Result, anyhow}; use axum::{ Router, extract::{Path, Query, State}, - response::{Html, Json}, + response::Html, routing::{get, post}, }; use chrono::{DateTime, Duration, Utc}; use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; +use serde_json::Value; use sqlx::PgPool; use tower_http::trace::TraceLayer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -99,8 +99,6 @@ async fn main() -> Result<()> { .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); @@ -747,194 +745,6 @@ fn url_query(value: &str) -> String { urlencoding::encode(value).into_owned() } -async fn dev_sync( - State(state): State, - Path((api_key, athlete_id)): Path<(String, String)>, -) -> Result, 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, - Path(api_key): Path, -) -> Result, 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 { - 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( @@ -971,9 +781,9 @@ pub async fn process_activity( 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. +/// Same processing pipeline, but with an explicitly supplied Intervals client, +/// so callers that already have a client (or a token that should not be +/// looked up again) can reuse it. pub async fn process_activity_with_client( state: AppState, intervals_athlete_id: String,