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:
parent
5a49624435
commit
1c3fd3d20f
1 changed files with 5 additions and 195 deletions
200
src/main.rs
200
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<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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue