Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ INTERVAL_OPENF1_LIVE_ENABLED=true
# instead; a token saved in the settings panel takes precedence over this value.
# INTERVAL_OPENF1_LIVE_TOKEN=YOUR_OPENF1_SPONSOR_TOKEN

# Historical downloads need a Formula 1 account with an F1 TV subscription (an Apple TV
# subscription must be linked to a Formula 1 account first). The desktop app's settings
# panel stores this login too; set these only for headless or `cargo run` backends.
# A login saved in the settings panel takes precedence over these values.
# F1_USERNAME=you@example.com
# F1_PASSWORD=your-formula1-account-password

# Optional:
# INTERVAL_OPENF1_LIVE_BASE_URL=https://api.openf1.org/v1/
# INTERVAL_OPENF1_LIVE_AUTH_HEADER=Authorization
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,21 @@ Select a session to download and replay it. Python and FastF1 are set up automat

## Runtime configuration

### Historical data (F1 TV)

Downloading a historical session needs a Formula 1 account with an active F1 TV subscription: the F1 archive now requires it, and the bundled FastF1 is pinned to the `3.9.0a0` prerelease because that is the first release that can sign in. Open the gear menu and fill in the **F1 TV account** section with the email and password of your Formula 1 account. If you subscribe through Apple TV, sign in with the Formula 1 account the subscription is linked to, not your Apple ID; link it first at [account.formula1.com](https://account.formula1.com/#/en/partner-account-sync?country=us&lead_source=web_syndication_apple_us&partnerId=syn_apple_us) if you have not already.

SIGN IN saves the login and runs a real sign-in; the section shows when the login was last verified, and only says "Signed in" after that check has passed. FastF1 performs the sign-in at the start of every download and keeps the resulting token in memory for that run only, so nothing is written to disk, a changed password or account is always used on the next download, and your own FastF1 installs' token file is never touched. Sessions downloaded earlier keep working without a login.

Headless or `cargo run` backends can set `F1_USERNAME` and `F1_PASSWORD` instead; a login saved in the app takes precedence. The exporter never prompts for credentials, so a missing or rejected login fails the download with a clear error instead of hanging.

### Live data

Open the gear menu and sign in with an [OpenF1 account with live access](https://openf1.org/auth.html). Historical replays do not require a login. Access tokens refresh automatically.
Open the gear menu and sign in with an [OpenF1 account with live access](https://openf1.org/auth.html) in the **OpenF1 account** section. Access tokens refresh automatically.

### Credential storage

Credentials are stored in plain text in `interval/settings.json` under your platform's configuration directory: `%APPDATA%` on Windows, `~/Library/Application Support` on macOS, or `${XDG_CONFIG_HOME:-~/.config}` on Linux.
Both logins are stored in plain text in `interval/settings.json` under your platform's configuration directory: `%APPDATA%` on Windows, `~/Library/Application Support` on macOS, or `${XDG_CONFIG_HOME:-~/.config}` on Linux. Each login is saved and cleared independently.

### Data and settings

Expand All @@ -48,6 +58,7 @@ Set `INTERVAL_DESKTOP_DATA_DIR` in your environment to use another directory. Fo
| --- | --- |
| `INTERVAL_OPENF1_LIVE_ENABLED` | Set to `false` to disable live discovery and polling. Default: `true`. |
| `INTERVAL_OPENF1_LIVE_TOKEN` | Optional bearer token when no login is saved. Saved credentials take precedence. |
| `F1_USERNAME` / `F1_PASSWORD` | Formula 1 account for historical downloads when no login is saved in the app. Saved credentials take precedence. |
| `INTERVAL_OPENF1_LIVE_BASE_URL` | Custom live API endpoint. Default: `https://api.openf1.org/v1/`. |
| `INTERVAL_OPENF1_LIVE_AUTH_HEADER` | Custom token header. Default: `Authorization`. |
| `RUST_LOG` | Diagnostic logging filter, such as `interval_backend=debug`. |
8 changes: 7 additions & 1 deletion backend/src/api/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,15 @@ pub async fn ingest_session(
)
.await?;

let (f1_login, _) = crate::settings::resolve_f1_auth(
&state.settings.load(),
crate::settings::f1_login_from_env(),
);
let bundle = match tokio::time::timeout(
Duration::from_secs(1_000),
state.fastf1.fetch_race_bundle(&session, meeting.as_ref()),
state
.fastf1
.fetch_race_bundle(&session, meeting.as_ref(), f1_login),
)
.await
{
Expand Down
7 changes: 7 additions & 0 deletions backend/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ pub fn settings_router(state: AppState) -> Router {
"/api/settings/openf1-login/test",
post(settings::test_login),
)
.route(
"/api/settings/f1-login",
get(settings::get_f1_login)
.put(settings::put_f1_login)
.delete(settings::delete_f1_login),
)
.route("/api/settings/f1-login/test", post(settings::test_f1_login))
.with_state(state)
}

Expand Down
114 changes: 96 additions & 18 deletions backend/src/api/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,99 @@
//! user typed in themselves and is echoed back so the panel can show who is signed in.

use super::{ApiError, AppState};
use crate::connectors::openf1_live::{OpenF1Auth, OpenF1Login, OpenF1LiveError};
use crate::settings::resolve_openf1_auth;
use crate::connectors::fastf1_historical::{F1AuthProbe, F1Login};
use crate::connectors::openf1_live::{OpenF1Auth, OpenF1LiveError, OpenF1Login};
use crate::settings::{f1_login_from_env, resolve_f1_auth, resolve_openf1_auth, AuthSource};
use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize)]
pub struct F1LoginSettings {
configured: bool,
username: Option<String>,
source: &'static str,
env_login_present: bool,
verified_at: Option<String>,
path: Option<String>,
}

pub async fn get_f1_login(State(state): State<AppState>) -> Json<F1LoginSettings> {
Json(current_f1_settings(&state))
}

pub async fn put_f1_login(
State(state): State<AppState>,
Json(body): Json<SetLoginRequest>,
) -> Result<Json<F1LoginSettings>, ApiError> {
let login = F1Login {
username: validated_username(&body.username)?,
password: validated_password(body.password)?,
};
state.settings.store_f1_login(Some(&login))?;
Ok(Json(current_f1_settings(&state)))
}

pub async fn delete_f1_login(
State(state): State<AppState>,
) -> Result<Json<F1LoginSettings>, ApiError> {
state.settings.store_f1_login(None)?;
Ok(Json(current_f1_settings(&state)))
}

pub async fn test_f1_login(State(state): State<AppState>) -> Json<F1AuthProbe> {
let (login, source) = resolve_f1_auth(&state.settings.load(), f1_login_from_env());
let probe = match state.fastf1.probe_login(login.clone()).await {
Ok(probe) => probe,
Err(error) => F1AuthProbe {
result: "invalid".to_string(),
message: error.to_string(),
},
};
if let (AuthSource::Settings, Some(tested)) = (source, login) {
let verified_at = probe.is_ok().then(|| chrono::Utc::now().to_rfc3339());
if let Err(error) = state.settings.store_f1_verified_at(verified_at, &tested) {
tracing::warn!(error = %error, "could not record the F1 login verification");
}
}
Json(probe)
}

fn current_f1_settings(state: &AppState) -> F1LoginSettings {
let stored = state.settings.load();
let env = f1_login_from_env();
let env_login_present = env.is_some();
let (login, source) = resolve_f1_auth(&stored, env);
F1LoginSettings {
configured: login.is_some(),
username: login.map(|login| login.username),
source: source.as_str(),
env_login_present,
verified_at: (source == AuthSource::Settings)
.then_some(stored.f1_verified_at)
.flatten(),
path: state.settings.path_display(),
}
}

fn validated_username(username: &str) -> Result<String, ApiError> {
let username = username.trim();
if username.is_empty() {
return Err(ApiError::BadRequest(
"username must not be blank".to_string(),
));
}
Ok(username.to_string())
}

fn validated_password(password: String) -> Result<String, ApiError> {
if password.trim().is_empty() {
return Err(ApiError::BadRequest(
"password must not be blank".to_string(),
));
}
Ok(password)
}

#[derive(Debug, Serialize)]
pub struct OpenF1LoginSettings {
configured: bool,
Expand Down Expand Up @@ -51,18 +139,11 @@ pub async fn put_login(
State(state): State<AppState>,
Json(body): Json<SetLoginRequest>,
) -> Result<Json<OpenF1LoginSettings>, ApiError> {
let username = body.username.trim();
if username.is_empty() {
return Err(ApiError::BadRequest("username must not be blank".to_string()));
}
// Not trimmed: OpenF1 generates the password, and a generated password may well
// start or end with whitespace. Only an all-blank one is rejected.
if body.password.trim().is_empty() {
return Err(ApiError::BadRequest("password must not be blank".to_string()));
}
let login = OpenF1Login {
username: username.to_string(),
password: body.password,
username: validated_username(&body.username)?,
password: validated_password(body.password)?,
};

// Persist first: a write failure must not leave a login applied in memory that
Expand Down Expand Up @@ -91,10 +172,9 @@ pub async fn test_login(State(state): State<AppState>) -> Json<OpenF1LoginProbe>
"unauthorized",
format!("OpenF1 rejected the login (HTTP {status})."),
),
Err(OpenF1LiveError::Request(error)) if error.is_timeout() => (
"unreachable",
"OpenF1 did not respond in time.".to_string(),
),
Err(OpenF1LiveError::Request(error)) if error.is_timeout() => {
("unreachable", "OpenF1 did not respond in time.".to_string())
}
Err(OpenF1LiveError::Request(error)) => {
("unreachable", format!("Could not reach OpenF1: {error}"))
}
Expand All @@ -113,9 +193,7 @@ pub async fn test_login(State(state): State<AppState>) -> Json<OpenF1LoginProbe>
fn current_settings(state: &AppState) -> OpenF1LoginSettings {
let stored = state.settings.load();
let env = env_token();
let env_token_present = env
.as_ref()
.is_some_and(|value| !value.trim().is_empty());
let env_token_present = env.as_ref().is_some_and(|value| !value.trim().is_empty());
let (auth, source) = resolve_openf1_auth(&stored, env);
let username = match &auth {
OpenF1Auth::Login(login) => Some(login.username.clone()),
Expand Down
Loading
Loading