merge
This commit is contained in:
parent
961d6665b5
commit
2ab16a68b7
4 changed files with 556 additions and 151 deletions
281
src/auth.rs
281
src/auth.rs
|
|
@ -1,6 +1,6 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::{Form, Query, State},
|
||||||
response::{IntoResponse, Redirect},
|
response::{Html, IntoResponse, Redirect},
|
||||||
};
|
};
|
||||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||||
use rand::{
|
use rand::{
|
||||||
|
|
@ -8,6 +8,7 @@ use rand::{
|
||||||
rng,
|
rng,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{AppState, db, intervals::IntervalsClient};
|
use crate::{AppState, db, intervals::IntervalsClient};
|
||||||
|
|
||||||
|
|
@ -18,19 +19,19 @@ pub struct OAuthCallback {
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ApiKeyLoginForm {
|
||||||
|
pub api_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn start(State(state): State<AppState>) -> impl IntoResponse {
|
pub async fn start(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
// Important: ThreadRng must not live across an await.
|
|
||||||
let oauth_state = {
|
let oauth_state = {
|
||||||
let mut rng = rng();
|
let mut rng = rng();
|
||||||
Alphanumeric.sample_string(&mut rng, 48)
|
Alphanumeric.sample_string(&mut rng, 48)
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(error) = db::delete_old_oauth_states(&state.db).await {
|
if let Err(error) = db::delete_old_oauth_states(&state.db).await {
|
||||||
tracing::error!(
|
tracing::error!(%error, "cannot clean OAuth states");
|
||||||
%error,
|
|
||||||
"cannot clean OAuth states"
|
|
||||||
);
|
|
||||||
|
|
||||||
return "internal error".into_response();
|
return "internal error".into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,11 +40,7 @@ pub async fn start(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::error!(
|
tracing::error!(%error, "cannot create OAuth state");
|
||||||
%error,
|
|
||||||
"cannot create OAuth state"
|
|
||||||
);
|
|
||||||
|
|
||||||
return "internal error".into_response();
|
return "internal error".into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,7 +53,6 @@ pub async fn start(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
%error,
|
%error,
|
||||||
"cannot construct OAuth authorize URL"
|
"cannot construct OAuth authorize URL"
|
||||||
);
|
);
|
||||||
|
|
||||||
return "cannot construct OAuth URL".into_response();
|
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()
|
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(
|
pub async fn callback(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(query): Query<OAuthCallback>,
|
Query(query): Query<OAuthCallback>,
|
||||||
|
|
@ -83,9 +228,8 @@ pub async fn callback(
|
||||||
let state_row = sqlx::query(
|
let state_row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
DELETE FROM oauth_states
|
DELETE FROM oauth_states
|
||||||
WHERE
|
WHERE state = $1
|
||||||
state = $1
|
AND created_at > now() - interval '10 minutes'
|
||||||
AND created_at > now() - interval '10 minutes'
|
|
||||||
RETURNING state
|
RETURNING state
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -95,17 +239,9 @@ pub async fn callback(
|
||||||
|
|
||||||
match state_row {
|
match state_row {
|
||||||
Ok(Some(_)) => {}
|
Ok(Some(_)) => {}
|
||||||
|
Ok(None) => return "invalid or expired OAuth state".into_response(),
|
||||||
Ok(None) => {
|
|
||||||
return "invalid or expired OAuth state".into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::error!(
|
tracing::error!(%error, "OAuth state validation failed");
|
||||||
%error,
|
|
||||||
"OAuth state validation failed"
|
|
||||||
);
|
|
||||||
|
|
||||||
return "internal error".into_response();
|
return "internal error".into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -114,13 +250,8 @@ pub async fn callback(
|
||||||
|
|
||||||
let response = match client.exchange_code(&code).await {
|
let response = match client.exchange_code(&code).await {
|
||||||
Ok(response) => response,
|
Ok(response) => response,
|
||||||
|
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::error!(
|
tracing::error!(%error, "OAuth token exchange failed");
|
||||||
%error,
|
|
||||||
"OAuth token exchange failed"
|
|
||||||
);
|
|
||||||
|
|
||||||
return "OAuth token exchange failed".into_response();
|
return "OAuth token exchange failed".into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -128,64 +259,24 @@ pub async fn callback(
|
||||||
let (access_token, scope, athlete_id, display_name) =
|
let (access_token, scope, athlete_id, display_name) =
|
||||||
match IntervalsClient::token_data(&response) {
|
match IntervalsClient::token_data(&response) {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
|
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::error!(
|
tracing::error!(%error, "invalid OAuth token response");
|
||||||
%error,
|
|
||||||
"invalid OAuth token response"
|
|
||||||
);
|
|
||||||
|
|
||||||
return "invalid OAuth response".into_response();
|
return "invalid OAuth response".into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let row = sqlx::query(
|
let athlete_db_id = match db::upsert_oauth_athlete(
|
||||||
r#"
|
&state.db,
|
||||||
INSERT INTO athletes (
|
&athlete_id,
|
||||||
intervals_athlete_id,
|
&display_name,
|
||||||
display_name,
|
&access_token,
|
||||||
access_token,
|
&scope,
|
||||||
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(&athlete_id)
|
.await
|
||||||
.bind(&display_name)
|
{
|
||||||
.bind(&access_token)
|
Ok(id) => id,
|
||||||
.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();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::error!(
|
tracing::error!(%error, "cannot store OAuth athlete");
|
||||||
%error,
|
|
||||||
"cannot store athlete"
|
|
||||||
);
|
|
||||||
|
|
||||||
return "cannot store athlete".into_response();
|
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 {
|
if let Err(error) = db::create_session(&state.db, athlete_db_id, &session_token).await {
|
||||||
tracing::error!(
|
tracing::error!(%error, "cannot create session");
|
||||||
%error,
|
|
||||||
"cannot create session"
|
|
||||||
);
|
|
||||||
|
|
||||||
return "cannot create session".into_response();
|
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 {
|
pub async fn logout(State(state): State<AppState>, jar: CookieJar) -> impl IntoResponse {
|
||||||
if let Some(cookie) = jar.get("county_session") {
|
if let Some(cookie) = jar.get("county_session") {
|
||||||
let hash = db::hash_token(cookie.value());
|
let _ = db::delete_session(&state.db, cookie.value()).await;
|
||||||
|
|
||||||
let _ = sqlx::query("DELETE FROM sessions WHERE token_hash = $1")
|
|
||||||
.bind(&hash)
|
|
||||||
.execute(&state.db)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let jar = jar.remove(Cookie::build("county_session").path("/"));
|
let jar = jar.remove(Cookie::build("county_session").path("/"));
|
||||||
|
|
||||||
(jar, Redirect::to("/")).into_response()
|
(jar, Redirect::to("/")).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn escape_html(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
|
||||||
117
src/db.rs
117
src/db.rs
|
|
@ -8,9 +8,7 @@ use crate::model::DetectedCrossing;
|
||||||
|
|
||||||
pub fn hash_token(token: &str) -> Vec<u8> {
|
pub fn hash_token(token: &str) -> Vec<u8> {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
|
|
||||||
hasher.update(token.as_bytes());
|
hasher.update(token.as_bytes());
|
||||||
|
|
||||||
hasher.finalize().to_vec()
|
hasher.finalize().to_vec()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,10 +17,7 @@ pub async fn create_session(db: &PgPool, athlete_id: i64, token: &str) -> Result
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO sessions (
|
INSERT INTO sessions (token_hash, athlete_id)
|
||||||
token_hash,
|
|
||||||
athlete_id
|
|
||||||
)
|
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -39,12 +34,9 @@ pub async fn athlete_for_session(db: &PgPool, token: &str) -> Result<Option<(i64
|
||||||
|
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT a.id, a.display_name
|
||||||
a.id,
|
|
||||||
a.display_name
|
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN athletes a
|
JOIN athletes a ON a.id = s.athlete_id
|
||||||
ON a.id = s.athlete_id
|
|
||||||
WHERE s.token_hash = $1
|
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 {
|
if let Some(row) = row {
|
||||||
let id: i64 = row.try_get("id")?;
|
let id: i64 = row.try_get("id")?;
|
||||||
|
|
||||||
let name: String = row.try_get("display_name")?;
|
let name: String = row.try_get("display_name")?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
|
|
@ -201,6 +192,78 @@ pub async fn ensure_dev_athlete(
|
||||||
Ok(row.try_get("id")?)
|
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(
|
pub async fn ensure_activity(
|
||||||
tx: &mut Transaction<'_, Postgres>,
|
tx: &mut Transaction<'_, Postgres>,
|
||||||
athlete_id: i64,
|
athlete_id: i64,
|
||||||
|
|
@ -217,17 +280,8 @@ pub async fn ensure_activity(
|
||||||
activity_json,
|
activity_json,
|
||||||
processed_at
|
processed_at
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES ($1, $2, $3, $4, NULL)
|
||||||
$1,
|
ON CONFLICT (athlete_id, intervals_activity_id)
|
||||||
$2,
|
|
||||||
$3,
|
|
||||||
$4,
|
|
||||||
NULL
|
|
||||||
)
|
|
||||||
ON CONFLICT (
|
|
||||||
athlete_id,
|
|
||||||
intervals_activity_id
|
|
||||||
)
|
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
start_time = EXCLUDED.start_time,
|
start_time = EXCLUDED.start_time,
|
||||||
activity_json = EXCLUDED.activity_json,
|
activity_json = EXCLUDED.activity_json,
|
||||||
|
|
@ -243,7 +297,6 @@ pub async fn ensure_activity(
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
|
||||||
Ok(row.try_get("id")?)
|
Ok(row.try_get("id")?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -285,10 +338,7 @@ pub async fn save_crossing(
|
||||||
$1,
|
$1,
|
||||||
$2,
|
$2,
|
||||||
$3,
|
$3,
|
||||||
ST_SetSRID(
|
ST_SetSRID(ST_MakePoint($4, $5), 4326),
|
||||||
ST_MakePoint($4, $5),
|
|
||||||
4326
|
|
||||||
),
|
|
||||||
$6,
|
$6,
|
||||||
$7,
|
$7,
|
||||||
$8
|
$8
|
||||||
|
|
@ -326,3 +376,14 @@ pub async fn mark_activity_processed(
|
||||||
|
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|
|
||||||
300
src/main.rs
300
src/main.rs
|
|
@ -12,12 +12,12 @@ use anyhow::{Context, Result, anyhow};
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
response::Html,
|
response::{Html, Json},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Duration, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::{Value, json};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
@ -56,9 +56,7 @@ async fn main() -> Result<()> {
|
||||||
let config = Config::from_env()?;
|
let config = Config::from_env()?;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
database_url = %redact_database_url(
|
database_url = %redact_database_url(&config.database_url),
|
||||||
&config.database_url
|
|
||||||
),
|
|
||||||
"connecting to PostgreSQL"
|
"connecting to PostgreSQL"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -81,6 +79,10 @@ async fn main() -> Result<()> {
|
||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
.route("/oauth/start", get(auth::start))
|
.route("/oauth/start", get(auth::start))
|
||||||
.route("/oauth/callback", get(auth::callback))
|
.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("/logout", get(auth::logout))
|
||||||
.route("/webhooks/intervals", post(webhook::receive))
|
.route("/webhooks/intervals", post(webhook::receive))
|
||||||
.route("/api/leaderboard", get(leaderboard::api))
|
.route("/api/leaderboard", get(leaderboard::api))
|
||||||
|
|
@ -94,6 +96,11 @@ async fn main() -> Result<()> {
|
||||||
"/sync/{athlete_id}/import-visible",
|
"/sync/{athlete_id}/import-visible",
|
||||||
post(sync_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())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
|
|
@ -105,7 +112,6 @@ async fn main() -> Result<()> {
|
||||||
);
|
);
|
||||||
|
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,7 +125,6 @@ fn redact_database_url(url: &str) -> String {
|
||||||
return format!("{prefix}@***");
|
return format!("{prefix}@***");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
url.to_string()
|
url.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -310,7 +315,6 @@ async fn sync_athlete(
|
||||||
imported: false,
|
imported: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut html = String::from(
|
let mut html = String::from(
|
||||||
r#"<!doctype html>
|
r#"<!doctype html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
|
|
@ -512,7 +516,6 @@ input[type="datetime-local"] {
|
||||||
|
|
||||||
html.push_str("</div>");
|
html.push_str("</div>");
|
||||||
}
|
}
|
||||||
|
|
||||||
html.push_str(
|
html.push_str(
|
||||||
r#"<p>
|
r#"<p>
|
||||||
<a href="/">← Leaderboard</a>
|
<a href="/">← Leaderboard</a>
|
||||||
|
|
@ -744,19 +747,245 @@ fn url_query(value: &str) -> String {
|
||||||
urlencoding::encode(value).into_owned()
|
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(
|
pub async fn process_activity(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
intervals_athlete_id: String,
|
intervals_athlete_id: String,
|
||||||
activity_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<()> {
|
) -> Result<()> {
|
||||||
let athlete = sqlx::query(
|
let athlete = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT id
|
||||||
id,
|
FROM athletes
|
||||||
access_token
|
WHERE intervals_athlete_id = $1
|
||||||
FROM athletes
|
"#,
|
||||||
WHERE intervals_athlete_id = $1
|
|
||||||
"#,
|
|
||||||
)
|
)
|
||||||
.bind(&intervals_athlete_id)
|
.bind(&intervals_athlete_id)
|
||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
|
|
@ -773,25 +1002,23 @@ pub async fn process_activity(
|
||||||
|
|
||||||
let athlete_db_id: i64 = athlete.try_get("id")?;
|
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 activity = client.activity(&activity_id).await?;
|
||||||
|
|
||||||
let start_time = IntervalsClient::activity_start(&activity)?;
|
let start_time = IntervalsClient::activity_start(&activity)?;
|
||||||
|
|
||||||
let streams = client.streams(&activity_id).await?;
|
let streams = client.streams(&activity_id).await?;
|
||||||
|
|
||||||
let points = parse_track(&streams, start_time)?;
|
let points = parse_track(&streams, start_time)?;
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
activity_id = %activity_id,
|
||||||
|
points = points.len(),
|
||||||
|
"parsed activity GPS points"
|
||||||
|
);
|
||||||
|
|
||||||
if points.len() < 2 {
|
if points.len() < 2 {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
activity_id = %activity_id,
|
activity_id = %activity_id,
|
||||||
points = points.len(),
|
points = points.len(),
|
||||||
"activity has insufficient GPS data"
|
"activity has insufficient GPS data"
|
||||||
);
|
);
|
||||||
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -820,13 +1047,14 @@ pub async fn process_activity(
|
||||||
from = crossing.from_county_id,
|
from = crossing.from_county_id,
|
||||||
to = crossing.to_county_id,
|
to = crossing.to_county_id,
|
||||||
track_index = crossing.track_index,
|
track_index = crossing.track_index,
|
||||||
|
lat = crossing.lat,
|
||||||
|
lon = crossing.lon,
|
||||||
"county crossing detected"
|
"county crossing detected"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
db::mark_activity_processed(&mut tx, activity_db_id).await?;
|
db::mark_activity_processed(&mut tx, activity_db_id).await?;
|
||||||
|
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
|
|
||||||
tracing::info!(
|
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"))?;
|
.ok_or_else(|| anyhow!("Intervals.icu response has no usable latlng stream"))?;
|
||||||
|
|
||||||
let length = times.len().min(latitudes.len()).min(longitudes.len());
|
let length = times.len().min(latitudes.len()).min(longitudes.len());
|
||||||
|
|
||||||
let mut result = Vec::with_capacity(length);
|
let mut result = Vec::with_capacity(length);
|
||||||
|
|
||||||
for index in 0..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 {
|
let Some(lat) = latitudes[index].as_f64() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(lon) = longitudes[index].as_f64() else {
|
let Some(lon) = longitudes[index].as_f64() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
@ -886,3 +1112,25 @@ fn parse_track(streams: &Value, start_time: DateTime<Utc>) -> Result<Vec<TrackPo
|
||||||
|
|
||||||
Ok(result)
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
|
@ -382,7 +382,12 @@ fn render_index(
|
||||||
escape_html(name)
|
escape_html(name)
|
||||||
));
|
));
|
||||||
} else {
|
} 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>");
|
html.push_str("</header>");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue