cargo fmt
This commit is contained in:
parent
f818f3623b
commit
e37a2d9263
6 changed files with 55 additions and 127 deletions
|
|
@ -54,9 +54,7 @@ impl IntervalsRateLimiters {
|
||||||
.expect("Intervals IP limiter mutex poisoned");
|
.expect("Intervals IP limiter mutex poisoned");
|
||||||
|
|
||||||
let next = match *last {
|
let next = match *last {
|
||||||
Some(previous) => {
|
Some(previous) => std::cmp::max(now, previous + IP_REQUEST_INTERVAL),
|
||||||
std::cmp::max(now, previous + IP_REQUEST_INTERVAL)
|
|
||||||
}
|
|
||||||
None => now,
|
None => now,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -183,15 +181,13 @@ impl Config {
|
||||||
|
|
||||||
cookie_secure,
|
cookie_secure,
|
||||||
|
|
||||||
intervals_rate_limiters:
|
intervals_rate_limiters: IntervalsRateLimiters::default(),
|
||||||
IntervalsRateLimiters::default(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn required(name: &str) -> Result<String> {
|
fn required(name: &str) -> Result<String> {
|
||||||
let value = env::var(name)
|
let value = env::var(name).with_context(|| format!("{name} is not configured"))?;
|
||||||
.with_context(|| format!("{name} is not configured"))?;
|
|
||||||
|
|
||||||
if value.trim().is_empty() {
|
if value.trim().is_empty() {
|
||||||
anyhow::bail!("{name} is empty");
|
anyhow::bail!("{name} is empty");
|
||||||
|
|
@ -201,9 +197,7 @@ fn required(name: &str) -> Result<String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn optional(name: &str) -> Option<String> {
|
fn optional(name: &str) -> Option<String> {
|
||||||
env::var(name)
|
env::var(name).ok().filter(|value| !value.trim().is_empty())
|
||||||
.ok()
|
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -221,9 +215,7 @@ mod tests {
|
||||||
limiters.wait_for_ip_slot().await;
|
limiters.wait_for_ip_slot().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
assert!(
|
assert!(Instant::now().duration_since(start) >= Duration::from_millis(190));
|
||||||
Instant::now().duration_since(start) >= Duration::from_millis(190)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
@ -234,14 +226,10 @@ mod tests {
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
limiters.wait_for_caller("caller-a").await;
|
limiters.wait_for_caller("caller-a").await;
|
||||||
assert!(
|
assert!(Instant::now().duration_since(start) >= Duration::from_millis(70));
|
||||||
Instant::now().duration_since(start) >= Duration::from_millis(70)
|
|
||||||
);
|
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
limiters.wait_for_caller("caller-b").await;
|
limiters.wait_for_caller("caller-b").await;
|
||||||
assert!(
|
assert!(Instant::now().duration_since(start) < Duration::from_millis(40));
|
||||||
Instant::now().duration_since(start) < Duration::from_millis(40)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -232,7 +232,6 @@ pub async fn upsert_oauth_athlete(
|
||||||
Ok(row.try_get("id")?)
|
Ok(row.try_get("id")?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Ensure that an athlete accessible through the current session exists
|
/// Ensure that an athlete accessible through the current session exists
|
||||||
/// locally so activities can reference it. The session credential itself is
|
/// locally so activities can reference it. The session credential itself is
|
||||||
/// intentionally not stored here; callers continue to use their authenticated
|
/// intentionally not stored here; callers continue to use their authenticated
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,7 @@ pub struct IntervalsClient {
|
||||||
|
|
||||||
impl IntervalsClient {
|
impl IntervalsClient {
|
||||||
pub fn new(config: Config) -> Self {
|
pub fn new(config: Config) -> Self {
|
||||||
let authentication = config
|
let authentication = config.intervals_api_key.clone().map(Authentication::ApiKey);
|
||||||
.intervals_api_key
|
|
||||||
.clone()
|
|
||||||
.map(Authentication::ApiKey);
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
client: Client::new(),
|
client: Client::new(),
|
||||||
|
|
@ -43,10 +40,7 @@ impl IntervalsClient {
|
||||||
|
|
||||||
/// Create a client using an OAuth access token.
|
/// Create a client using an OAuth access token.
|
||||||
/// Requests are authenticated with HTTP Bearer auth.
|
/// Requests are authenticated with HTTP Bearer auth.
|
||||||
pub fn with_access_token(
|
pub fn with_access_token(config: Config, access_token: impl Into<String>) -> Self {
|
||||||
config: Config,
|
|
||||||
access_token: impl Into<String>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
client: Client::new(),
|
client: Client::new(),
|
||||||
config,
|
config,
|
||||||
|
|
@ -70,9 +64,7 @@ impl IntervalsClient {
|
||||||
.intervals_client_id
|
.intervals_client_id
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| token.clone())),
|
.unwrap_or_else(|| token.clone())),
|
||||||
None => Err(anyhow!(
|
None => Err(anyhow!("Intervals.icu authentication is not configured")),
|
||||||
"Intervals.icu authentication is not configured"
|
|
||||||
)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,29 +76,17 @@ impl IntervalsClient {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn authenticated(
|
fn authenticated(&self, request: reqwest::RequestBuilder) -> Result<reqwest::RequestBuilder> {
|
||||||
&self,
|
|
||||||
request: reqwest::RequestBuilder,
|
|
||||||
) -> Result<reqwest::RequestBuilder> {
|
|
||||||
match self.authentication.as_ref() {
|
match self.authentication.as_ref() {
|
||||||
Some(Authentication::ApiKey(token)) => {
|
Some(Authentication::ApiKey(token)) => Ok(request.basic_auth("API_KEY", Some(token))),
|
||||||
Ok(request.basic_auth("API_KEY", Some(token)))
|
Some(Authentication::Bearer(token)) => Ok(request.bearer_auth(token)),
|
||||||
}
|
None => Err(anyhow!("Intervals.icu authentication is not configured")),
|
||||||
Some(Authentication::Bearer(token)) => {
|
|
||||||
Ok(request.bearer_auth(token))
|
|
||||||
}
|
|
||||||
None => Err(anyhow!(
|
|
||||||
"Intervals.icu authentication is not configured"
|
|
||||||
)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_json(&self, url: String) -> Result<Value> {
|
async fn get_json(&self, url: String) -> Result<Value> {
|
||||||
let caller = self.rate_limit_caller()?;
|
let caller = self.rate_limit_caller()?;
|
||||||
let limiters = self
|
let limiters = self.config.intervals_rate_limiters.clone();
|
||||||
.config
|
|
||||||
.intervals_rate_limiters
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
// Intervals.icu enforces an additional limit of ten calls/sec per
|
// Intervals.icu enforces an additional limit of ten calls/sec per
|
||||||
// source IP. Pace every request start globally for this process.
|
// source IP. Pace every request start globally for this process.
|
||||||
|
|
@ -126,10 +106,8 @@ impl IntervalsClient {
|
||||||
.context("Intervals.icu request failed")?;
|
.context("Intervals.icu request failed")?;
|
||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let retry_after =
|
let retry_after = parse_retry_after(response.headers().get("Retry-After"));
|
||||||
parse_retry_after(response.headers().get("Retry-After"));
|
let rate_limit = parse_rate_limit_headers(response.headers());
|
||||||
let rate_limit =
|
|
||||||
parse_rate_limit_headers(response.headers());
|
|
||||||
|
|
||||||
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||||
let body = response
|
let body = response
|
||||||
|
|
@ -173,11 +151,7 @@ impl IntervalsClient {
|
||||||
.context("cannot read Intervals.icu response")?;
|
.context("cannot read Intervals.icu response")?;
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!("Intervals.icu returned HTTP {}: {}", status, body));
|
||||||
"Intervals.icu returned HTTP {}: {}",
|
|
||||||
status,
|
|
||||||
body
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
|
|
@ -190,12 +164,7 @@ impl IntervalsClient {
|
||||||
);
|
);
|
||||||
|
|
||||||
return serde_json::from_str(&body)
|
return serde_json::from_str(&body)
|
||||||
.with_context(|| {
|
.with_context(|| format!("invalid JSON returned by Intervals.icu: {}", body));
|
||||||
format!(
|
|
||||||
"invalid JSON returned by Intervals.icu: {}",
|
|
||||||
body
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
unreachable!("rate-limit retry loop must return")
|
unreachable!("rate-limit retry loop must return")
|
||||||
|
|
@ -519,10 +488,7 @@ impl IntervalsClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_retry_after(value: Option<&reqwest::header::HeaderValue>) -> Option<std::time::Duration> {
|
||||||
fn parse_retry_after(
|
|
||||||
value: Option<&reqwest::header::HeaderValue>,
|
|
||||||
) -> Option<std::time::Duration> {
|
|
||||||
let value = value?.to_str().ok()?.trim();
|
let value = value?.to_str().ok()?.trim();
|
||||||
let seconds = value.parse::<u64>().ok()?;
|
let seconds = value.parse::<u64>().ok()?;
|
||||||
Some(std::time::Duration::from_secs(seconds))
|
Some(std::time::Duration::from_secs(seconds))
|
||||||
|
|
@ -537,9 +503,7 @@ fn parse_retry_after(
|
||||||
fn parse_rate_limit_headers(
|
fn parse_rate_limit_headers(
|
||||||
headers: &reqwest::header::HeaderMap,
|
headers: &reqwest::header::HeaderMap,
|
||||||
) -> (Option<u64>, Option<u64>, Option<u64>, Option<u64>) {
|
) -> (Option<u64>, Option<u64>, Option<u64>, Option<u64>) {
|
||||||
fn pair(
|
fn pair(value: Option<&reqwest::header::HeaderValue>) -> (Option<u64>, Option<u64>) {
|
||||||
value: Option<&reqwest::header::HeaderValue>,
|
|
||||||
) -> (Option<u64>, Option<u64>) {
|
|
||||||
let Some(value) = value else {
|
let Some(value) = value else {
|
||||||
return (None, None);
|
return (None, None);
|
||||||
};
|
};
|
||||||
|
|
@ -556,17 +520,10 @@ fn parse_rate_limit_headers(
|
||||||
(first, second)
|
(first, second)
|
||||||
}
|
}
|
||||||
|
|
||||||
let (limit_15m, limit_daily) =
|
let (limit_15m, limit_daily) = pair(headers.get("X-RateLimit-Limit"));
|
||||||
pair(headers.get("X-RateLimit-Limit"));
|
let (remaining_15m, remaining_daily) = pair(headers.get("X-RateLimit-Remaining"));
|
||||||
let (remaining_15m, remaining_daily) =
|
|
||||||
pair(headers.get("X-RateLimit-Remaining"));
|
|
||||||
|
|
||||||
(
|
(limit_15m, limit_daily, remaining_15m, remaining_daily)
|
||||||
limit_15m,
|
|
||||||
limit_daily,
|
|
||||||
remaining_15m,
|
|
||||||
remaining_daily,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_datetime(value: &str) -> Result<DateTime<Utc>> {
|
fn parse_datetime(value: &str) -> Result<DateTime<Utc>> {
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,10 @@ pub async fn api(
|
||||||
build(&state.db, query.group_id, offset, limit)
|
build(&state.db, query.group_id, offset, limit)
|
||||||
.await
|
.await
|
||||||
.map(|groups| {
|
.map(|groups| {
|
||||||
let returned = groups.iter().map(|group| group.rows.len() as i64).sum::<i64>();
|
let returned = groups
|
||||||
|
.iter()
|
||||||
|
.map(|group| group.rows.len() as i64)
|
||||||
|
.sum::<i64>();
|
||||||
Json(LeaderboardPage {
|
Json(LeaderboardPage {
|
||||||
groups,
|
groups,
|
||||||
next_offset: offset + returned,
|
next_offset: offset + returned,
|
||||||
|
|
@ -52,7 +55,12 @@ pub async fn api(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn build(db: &PgPool, group_id: Option<i64>, offset: i64, limit: i64) -> Result<Vec<LeaderboardGroup>> {
|
pub async fn build(
|
||||||
|
db: &PgPool,
|
||||||
|
group_id: Option<i64>,
|
||||||
|
offset: i64,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<LeaderboardGroup>> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
WITH bucketed AS (
|
WITH bucketed AS (
|
||||||
|
|
|
||||||
62
src/main.rs
62
src/main.rs
|
|
@ -162,15 +162,11 @@ async fn sync_index(
|
||||||
jar: axum_extra::extract::cookie::CookieJar,
|
jar: axum_extra::extract::cookie::CookieJar,
|
||||||
) -> Result<Html<String>, (axum::http::StatusCode, String)> {
|
) -> Result<Html<String>, (axum::http::StatusCode, String)> {
|
||||||
let session = current_session_athlete(&state, &jar)
|
let session = current_session_athlete(&state, &jar)
|
||||||
.await
|
|
||||||
.map_err(http_error)?;
|
|
||||||
|
|
||||||
let client =
|
|
||||||
IntervalsClient::with_api_key(state.config.clone(), session.access_token.clone());
|
|
||||||
let athletes = client
|
|
||||||
.athletes()
|
|
||||||
.await
|
.await
|
||||||
.map_err(http_error)?;
|
.map_err(http_error)?;
|
||||||
|
|
||||||
|
let client = IntervalsClient::with_api_key(state.config.clone(), session.access_token.clone());
|
||||||
|
let athletes = client.athletes().await.map_err(http_error)?;
|
||||||
let athletes = athletes
|
let athletes = athletes
|
||||||
.as_array()
|
.as_array()
|
||||||
.ok_or_else(|| http_error(anyhow!("athletes response is not an array")))?;
|
.ok_or_else(|| http_error(anyhow!("athletes response is not an array")))?;
|
||||||
|
|
@ -310,13 +306,9 @@ async fn sync_athlete(
|
||||||
*/
|
*/
|
||||||
let client = IntervalsClient::with_api_key(state.config.clone(), session.access_token.clone());
|
let client = IntervalsClient::with_api_key(state.config.clone(), session.access_token.clone());
|
||||||
|
|
||||||
let target_display_name = ensure_accessible_athlete(
|
let target_display_name = ensure_accessible_athlete(&state, &client, &athlete_id)
|
||||||
&state,
|
.await
|
||||||
&client,
|
.map_err(http_error)?;
|
||||||
&athlete_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(http_error)?;
|
|
||||||
|
|
||||||
let target_athlete_id = local_athlete(&state.db, &athlete_id)
|
let target_athlete_id = local_athlete(&state.db, &athlete_id)
|
||||||
.await
|
.await
|
||||||
|
|
@ -770,13 +762,9 @@ async fn sync_import_activity(
|
||||||
|
|
||||||
let client = IntervalsClient::with_api_key(state.config.clone(), session.access_token.clone());
|
let client = IntervalsClient::with_api_key(state.config.clone(), session.access_token.clone());
|
||||||
|
|
||||||
ensure_accessible_athlete(
|
ensure_accessible_athlete(&state, &client, &athlete_id)
|
||||||
&state,
|
.await
|
||||||
&client,
|
.map_err(http_error)?;
|
||||||
&athlete_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(http_error)?;
|
|
||||||
|
|
||||||
match process_activity_with_client(
|
match process_activity_with_client(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
|
|
@ -876,13 +864,9 @@ async fn sync_import_visible(
|
||||||
|
|
||||||
let client = IntervalsClient::with_api_key(state.config.clone(), session.access_token.clone());
|
let client = IntervalsClient::with_api_key(state.config.clone(), session.access_token.clone());
|
||||||
|
|
||||||
ensure_accessible_athlete(
|
ensure_accessible_athlete(&state, &client, &athlete_id)
|
||||||
&state,
|
.await
|
||||||
&client,
|
.map_err(http_error)?;
|
||||||
&athlete_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(http_error)?;
|
|
||||||
|
|
||||||
let target_athlete_id = local_athlete(&state.db, &athlete_id)
|
let target_athlete_id = local_athlete(&state.db, &athlete_id)
|
||||||
.await
|
.await
|
||||||
|
|
@ -975,15 +959,12 @@ async fn ensure_accessible_athlete(
|
||||||
client: &IntervalsClient,
|
client: &IntervalsClient,
|
||||||
athlete_id: &str,
|
athlete_id: &str,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let athlete = client
|
let athlete = client.athlete(athlete_id).await.with_context(|| {
|
||||||
.athlete(athlete_id)
|
format!(
|
||||||
.await
|
"cannot access Intervals.icu athlete {} with current authentication token",
|
||||||
.with_context(|| {
|
athlete_id
|
||||||
format!(
|
)
|
||||||
"cannot access Intervals.icu athlete {} with current authentication token",
|
})?;
|
||||||
athlete_id
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let display_name = athlete
|
let display_name = athlete
|
||||||
.get("name")
|
.get("name")
|
||||||
|
|
@ -992,12 +973,7 @@ async fn ensure_accessible_athlete(
|
||||||
.unwrap_or(athlete_id)
|
.unwrap_or(athlete_id)
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
db::ensure_local_athlete(
|
db::ensure_local_athlete(&state.db, athlete_id, &display_name).await?;
|
||||||
&state.db,
|
|
||||||
athlete_id,
|
|
||||||
&display_name,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(display_name)
|
Ok(display_name)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -728,7 +728,7 @@ function initLeaderboardElements(root){
|
||||||
const leaderboardList=document.getElementById('leaderboard-list');
|
const leaderboardList=document.getElementById('leaderboard-list');
|
||||||
if(leaderboardList){
|
if(leaderboardList){
|
||||||
const status=document.getElementById('leaderboard-status'),sentinel=document.getElementById('leaderboard-sentinel'),groups=new Map();
|
const status=document.getElementById('leaderboard-status'),sentinel=document.getElementById('leaderboard-sentinel'),groups=new Map();
|
||||||
let offset=0,loading=false,hasMore=true; const pageSize=50;
|
let offset=0,loading=false,hasMore=true; const pageSize=25;
|
||||||
async function loadLeaderboard(){
|
async function loadLeaderboard(){
|
||||||
if(loading||!hasMore)return; loading=true;
|
if(loading||!hasMore)return; loading=true;
|
||||||
try{
|
try{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue