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).
This commit is contained in:
Claude 2026-08-13 02:09:37 +00:00 committed by Jonas Rabenstein
commit 1c3fd3d20f

View file

@ -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<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(
@ -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,