diff --git a/crates/eidos-gui/src/dialogs.rs b/crates/eidos-gui/src/dialogs.rs index 21d6252..b64ba6f 100644 --- a/crates/eidos-gui/src/dialogs.rs +++ b/crates/eidos-gui/src/dialogs.rs @@ -38,6 +38,20 @@ impl std::fmt::Display for ThemeChoice { } } +/// The cached "show adult content" answer for the signed-in account: `Some(true)` +/// shown, `Some(false)` turned off by the user, `None` not known. +/// +/// Read straight from the credential store rather than plumbed through app state, +/// because that store IS what the client consults - a second copy in the UI could +/// disagree with what is actually being withheld. +fn adult_content_state() -> Option { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + eidos_instance::settings::load_nexus_creds().adult_pref(now) +} + pub(crate) fn settings_dialog<'a>(app: &App) -> Element<'a, Message> { let header = Row::new() .spacing(6) @@ -178,29 +192,58 @@ pub(crate) fn settings_dialog<'a>(app: &App) -> Element<'a, Message> { .into() } SettingsTab::Nexus => { - let connect_label = if app.api_key_validating { "Checking..." } else { "Validate & Save" }; - let mut connect = button(text(connect_label).size(12.0)).padding([5, 12]).style(button::primary); - if !app.api_key_validating { - connect = connect.on_press(Message::ApiKeyValidateStart); + // Sign-in, and only sign-in. There is no personal-API-key field, on + // purpose: Nexus requires personal keys absent from a distributed + // client, not merely unused, so there is nothing here to enter. + let signed_in = app.nexus_account.is_some(); + let label = if app.nexus_signing_in { + "Waiting for your browser..." + } else if signed_in { + "Sign in again" + } else { + "Sign in to Nexus Mods" + }; + let mut action = button(text(label).size(12.0)).padding([5, 12]).style(button::primary); + if !app.nexus_signing_in { + action = action.on_press(Message::NexusSignInStart); + } + let mut controls = Row::new().spacing(8).push(action); + if signed_in { + controls = controls.push( + button(text("Sign out").size(12.0)) + .padding([5, 12]) + .on_press(Message::NexusSignOut) + .style(button::secondary), + ); } - // Masked. It is a credential, and this field sits in a window users - // screenshot to ask for help - which is one of the ways a key leaks. - let field = text_input("Personal API key", &app.settings_api_key) - .secure(true) - .on_input(Message::ApiKeyChanged) - .on_submit(Message::ApiKeyValidateStart) - .padding(6) - .size(12.0) - .width(Length::Fill); - - let mut account = Column::new() - .spacing(6) - .push(Row::new().spacing(8).push(field).push(connect)); - if let Some(a) = &app.nexus_account { - let tier = if a.is_premium { "Premium" } else { "free" }; - account = account.push(text(format!("Connected as {} ({tier}).", a.name)).size(11.0)); + + let mut account = Column::new().spacing(6).push(controls); + match &app.nexus_account { + Some(a) => { + let tier = if a.is_premium { "Premium" } else { "free" }; + account = + account.push(text(format!("Signed in as {} ({tier}).", a.name)).size(11.0)); + // Say what is being withheld and why. Adult mods coming back + // blank with no explanation reads as Eidos being broken, and + // "could not check" is the case the user can actually act on. + account = account.push( + text(match adult_content_state() { + Some(true) => "Adult content: shown (enabled on your Nexus account).", + Some(false) => { + "Adult content: hidden. It is turned off on your Nexus account; \ + change it on nexusmods.com, then sign in again here." + } + None => { + "Adult content: hidden. Eidos could not read your Nexus content \ + settings, so it withholds adult mods until it can." + } + }) + .size(10.0), + ); + } + None => account = account.push(text("Not signed in.").size(11.0)), } - if let Some(err) = &app.api_key_error { + if let Some(err) = &app.nexus_error { account = account .push(text(format!("Error: {err}")).size(11.0).color(Color::from_rgb8(0x8A, 0x2A, 0x2A))); } @@ -214,7 +257,7 @@ pub(crate) fn settings_dialog<'a>(app: &App) -> Element<'a, Message> { Column::new() .spacing(6) .push( - text("From nexusmods.com -> Account -> API Keys. Stored in nexus.ini and shared with the CLI.") + text("Signing in opens your browser. The session is stored in nexus.ini and shared with the CLI.") .size(10.0), ) .push(account) diff --git a/crates/eidos-gui/src/main.rs b/crates/eidos-gui/src/main.rs index 7b3a5cd..71861f5 100644 --- a/crates/eidos-gui/src/main.rs +++ b/crates/eidos-gui/src/main.rs @@ -301,13 +301,13 @@ enum Message { /// Switch the Preferences tab (General / Nexus). SettingsTabSelected(SettingsTab), /// Edit the Nexus API key field. - ApiKeyChanged(String), - /// Validate + persist the entered Nexus API key. - ApiKeyValidateStart, - /// The key validation finished: the account on success, else an error. - /// Carries the key that was actually VALIDATED, so an edit made to the field - /// during the round-trip is never saved as if it had been checked. - ApiKeyValidateResult(String, Result), + /// Start the Nexus OAuth sign-in: open the browser, wait on the loopback + /// listener, exchange the code, store the session. + NexusSignInStart, + /// The sign-in finished: the account on success, else an error. + NexusSignInResult(Result), + /// Forget the stored Nexus session. + NexusSignOut, /// Set the preferred colour theme. ThemeChanged(PrefTheme), /// Set the default game id to open (`None` = none). @@ -966,14 +966,12 @@ struct App { /// Which collapsible sections of the Settings screen are open. Keyed by the /// same `&'static str` the section is built with, so a rename cannot drift. settings_expanded: HashSet<&'static str>, - /// The editable Nexus API key field. - settings_api_key: String, - /// The validated Nexus account, if the stored key checked out (or was cached). + /// The validated Nexus account, if a stored session checked out. nexus_account: Option, - /// A key validation is in flight (guards the button + concurrent validations). - api_key_validating: bool, - /// The last key-validation error, shown inline in the dialog. - api_key_error: Option, + /// A sign-in is in flight (guards the button + concurrent attempts). + nexus_signing_in: bool, + /// The last sign-in error, shown inline in the dialog. + nexus_error: Option, /// The persisted app-global preferences (theme, default game). prefs: Settings, // ---- Executables dialog ---- diff --git a/crates/eidos-gui/src/state.rs b/crates/eidos-gui/src/state.rs index 1efed2a..214c001 100644 --- a/crates/eidos-gui/src/state.rs +++ b/crates/eidos-gui/src/state.rs @@ -115,10 +115,9 @@ pub(crate) fn new(launch_command: Vec) -> (App, Task) { settings_expanded: SettingsTab::DEFAULT_OPEN.iter().copied().collect(), // Prefill the key field from the shared store (the same key `eidos nexus // key` writes), so it survives across sessions without a network round trip. - settings_api_key: eidos_instance::settings::load_nexus_key().unwrap_or_default(), nexus_account: None, - api_key_validating: false, - api_key_error: None, + nexus_signing_in: false, + nexus_error: None, prefs: Settings::load(), executables: None, endorsing: None, @@ -219,17 +218,15 @@ pub(crate) fn new(launch_command: Vec) -> (App, Task) { refresh_meta_cache(&mut app); app.collapsed = load_collapsed(&app); recompute_counts(&mut app); - // A stored key means the user IS connected: validate it in the background so - // the status bar shows the account instead of "not logged in" every session. - let startup = match load_nexus_api_key() { - Some(key) => Task::perform( - async move { - let result = eidos_nexus::Nexus::new(&key).validate(); - (key, result) - }, - |(key, result)| Message::ApiKeyValidateResult(key, result), - ), - None => Task::none(), + // A stored session means the user IS signed in: validate it in the background + // so the status bar shows the account instead of "not logged in" every session. + let startup = if eidos_nexus::Nexus::have_credentials() { + Task::perform( + async move { eidos_nexus::Nexus::connect().and_then(|n| n.validate()) }, + Message::NexusSignInResult, + ) + } else { + Task::none() }; (app, startup) } @@ -267,10 +264,38 @@ pub(crate) fn load_tools(app: &mut App) { app.tools = merged; } -/// The stored Nexus API key (the same key the CLI's `eidos nexus key` writes), -/// shared via `eidos-instance`'s settings store so the key never diverges. -pub(crate) fn load_nexus_api_key() -> Option { - eidos_instance::settings::load_nexus_key() +/// Run the whole Nexus OAuth sign-in, blocking: build the PKCE challenge, hand +/// the browser the authorize URL, wait on the loopback listener for the code, +/// exchange it, and store the session. +/// +/// Personal API keys are deliberately absent - Nexus requires them gone from a +/// distributed client - so this is the ONLY way Eidos authenticates, and it +/// needs a `client_id` registered with Nexus (`EIDOS_NEXUS_CLIENT_ID`). +pub(crate) fn nexus_sign_in() -> Result { + use eidos_nexus::oauth; + let cfg = oauth::Config::from_env().ok_or_else(|| { + "no Nexus client_id configured: set EIDOS_NEXUS_CLIENT_ID (Eidos ships no \ + default, so it cannot identify itself as another application)" + .to_string() + })?; + let pkce = oauth::Pkce::new().map_err(|e| e.to_string())?; + let state = oauth::random_token(32).map_err(|e| e.to_string())?; + let url = oauth::authorize_url(&cfg, &pkce, &state); + // Hand off to the browser BEFORE listening, so a failure to open is reported + // as itself rather than as a listener timeout two minutes later. + std::process::Command::new("xdg-open") + .arg(&url) + .spawn() + .map_err(|e| format!("could not open your browser: {e}"))?; + let code = oauth::wait_for_code(cfg.redirect_port, &state, std::time::Duration::from_secs(300))?; + let tokens = oauth::exchange_code(&cfg, &code, &pkce)?; + let mut creds = eidos_instance::settings::load_nexus_creds(); + creds.access_token = Some(tokens.access_token.clone()); + creds.refresh_token = Some(tokens.refresh_token); + creds.expires_at = tokens.expires_at; + eidos_instance::settings::save_nexus_creds(&creds) + .map_err(|e| format!("signed in, but could not store the session: {e}"))?; + eidos_nexus::Nexus::with_bearer(&tokens.access_token).validate() } /// Build the Executables editor state for the open instance: the user's tools.ini diff --git a/crates/eidos-gui/src/update.rs b/crates/eidos-gui/src/update.rs index 2a45b38..7976eb6 100644 --- a/crates/eidos-gui/src/update.rs +++ b/crates/eidos-gui/src/update.rs @@ -1879,60 +1879,47 @@ pub(crate) fn update_inner(app: &mut App, message: Message) -> Task { // ---- Settings / Preferences ------------------------------------------ Message::OpenSettings => { app.menu_mod = None; - app.api_key_error = None; - // Re-read the stored key so the field reflects what's on disk. - app.settings_api_key = eidos_instance::settings::load_nexus_key().unwrap_or_default(); + app.nexus_error = None; app.settings_open = true; } Message::CloseSettings => { app.settings_open = false; - app.api_key_error = None; + app.nexus_error = None; } Message::SettingsTabSelected(t) => app.settings_tab = t, - Message::ApiKeyChanged(s) => { - app.settings_api_key = s; - app.api_key_error = None; - } - Message::ApiKeyValidateStart => { - let key = app.settings_api_key.trim().to_string(); - if key.is_empty() { - app.api_key_error = Some("Enter your personal Nexus API key.".to_string()); + Message::NexusSignInStart => { + if app.nexus_signing_in { return Task::none(); } - if app.api_key_validating { - return Task::none(); - } - app.api_key_validating = true; - app.api_key_error = None; - // Blocking ureq inside the async closure, like SortPlugins. - return Task::perform( - async move { - let result = eidos_nexus::Nexus::new(&key).validate(); - (key, result) - }, - |(key, result)| Message::ApiKeyValidateResult(key, result), - ); + app.nexus_signing_in = true; + app.nexus_error = None; + app.status = Some("Opening your browser to sign in to Nexus...".to_string()); + // The whole dance on a worker: browser hand-off, loopback listener, + // code exchange. Blocking calls inside the async closure, like the + // other network work here. + return Task::perform(async move { nexus_sign_in() }, Message::NexusSignInResult); } - Message::ApiKeyValidateResult(key, result) => { - app.api_key_validating = false; + Message::NexusSignInResult(result) => { + app.nexus_signing_in = false; match result { Ok(account) => { - // Persist the key that was validated (the field may have been - // edited during the round-trip) so the CLI and a relaunch see it. - let saved = eidos_instance::settings::save_nexus_key(&key); - app.status = Some(match &saved { - Ok(()) => format!( - "Connected to Nexus as {} ({}).", - account.name, - if account.is_premium { "Premium" } else { "free" } - ), - Err(e) => format!("Validated, but could not save the key: {e}"), - }); + app.status = Some(format!( + "Signed in to Nexus as {} ({}).", + account.name, + if account.is_premium { "Premium" } else { "free" } + )); app.nexus_account = Some(account); } - Err(e) => { - app.api_key_error = Some(e); + Err(e) => app.nexus_error = Some(e), + } + } + Message::NexusSignOut => { + match eidos_instance::settings::clear_nexus_tokens() { + Ok(()) => { + app.nexus_account = None; + app.status = Some("Signed out of Nexus.".to_string()); } + Err(e) => app.nexus_error = Some(format!("could not sign out: {e}")), } } Message::DragScrollSpeedChanged(v) => { diff --git a/crates/eidos-instance/src/settings.rs b/crates/eidos-instance/src/settings.rs index dd961b6..c1eb259 100644 --- a/crates/eidos-instance/src/settings.rs +++ b/crates/eidos-instance/src/settings.rs @@ -29,50 +29,54 @@ pub fn config_home() -> PathBuf { }) } -/// `$XDG_CONFIG_HOME/eidos/nexus.ini`, holding the personal Nexus API key. Same -/// path the CLI uses, so the key is shared between the CLI and the GUI. +/// `$XDG_CONFIG_HOME/eidos/nexus.ini`, holding the Nexus OAuth session. Same +/// path the CLI uses, so a sign-in is shared between the CLI and the GUI. pub fn nexus_key_path() -> PathBuf { config_home().join("eidos").join("nexus.ini") } -/// The stored Nexus API key, if any. Reads the `[Nexus]` `api_key=` line; an -/// empty value reads as `None`. -pub fn load_nexus_key() -> Option { - let text = fs::read_to_string(nexus_key_path()).ok()?; - parse_nexus_key(&text) -} - -/// Persist the Nexus API key so it survives across sessions. +/// Everything `nexus.ini` can hold: the OAuth session, and nothing else. /// -/// Merges rather than overwrites: `nexus.ini` also holds the OAuth tokens, and -/// a plain `fs::write` of the key would sign the user out every time they -/// re-pasted it. -pub fn save_nexus_key(key: &str) -> io::Result<()> { - let mut creds = load_nexus_creds(); - let key = key.trim(); - creds.api_key = (!key.is_empty()).then(|| key.to_string()); - save_nexus_creds(&creds) -} - -/// Everything `nexus.ini` can hold. The personal API key and the OAuth tokens -/// live in the same file because they answer the same question - how this -/// machine talks to Nexus - and because a user who has both should not lose one -/// by touching the other. +/// It used to carry a personal API key beside the tokens. Nexus's API team +/// requires personal keys removed from a distributed client entirely - not +/// merely unused - so the field is gone rather than ignored, and an `api_key=` +/// line left over from an older version is passed through untouched but never +/// read. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct NexusCreds { - pub api_key: Option, pub access_token: Option, pub refresh_token: Option, /// Unix seconds. Absolute, so it survives being written and read back. pub expires_at: u64, + /// The account's "show adult content" preference, as last read from Nexus. + /// `None` means "not known", which is NOT the same as `Some(false)`: unknown + /// hides adult metadata AND tells the user we could not check, where a known + /// `false` is the user's own setting. See `eidos_nexus::AdultPolicy`. + pub adult_ok: Option, + /// Unix seconds when [`Self::adult_ok`] was read. A stale answer decays back + /// to "unknown" rather than being trusted forever - the user can change the + /// setting on the site at any time, and we would never hear about it. + pub adult_checked_at: u64, } +/// How long a cached adult-content preference is trusted before it decays to +/// "unknown". A judgement call, not a documented requirement: long enough that a +/// day's use costs one extra request, short enough that turning the setting off +/// on the website takes effect the next day without signing in again. +pub const ADULT_PREF_TTL: u64 = 24 * 3600; + impl NexusCreds { /// Whether a stored sign-in is present at all. Says nothing about whether /// the access token is still fresh - that is `expires_at`'s job. pub fn has_oauth(&self) -> bool { self.access_token.as_ref().is_some_and(|t| !t.is_empty()) } + + /// The cached adult-content preference, or `None` once it has aged out. + pub fn adult_pref(&self, now: u64) -> Option { + let fresh = now.saturating_sub(self.adult_checked_at) < ADULT_PREF_TTL; + self.adult_ok.filter(|_| fresh && self.adult_checked_at != 0) + } } /// Read every credential in `nexus.ini`. A missing or unreadable file reads as @@ -108,12 +112,18 @@ pub fn clear_nexus_tokens() -> io::Result<()> { creds.access_token = None; creds.refresh_token = None; creds.expires_at = 0; + // The adult-content preference belongs to the account that just signed out. + // Keeping it would apply one person's setting to whoever signs in next. + creds.adult_ok = None; + creds.adult_checked_at = 0; save_nexus_creds(&creds) } -/// The four fields this module owns; anything else in the file is passed through -/// untouched by [`render_nexus_creds`]. -const OWNED_KEYS: [&str; 4] = ["api_key", "access_token", "refresh_token", "expires_at"]; +/// The fields this module owns; anything else in the file is passed through +/// untouched by [`render_nexus_creds`] - including an `api_key=` line from a +/// version that still had one, which is neither read nor deleted. +const OWNED_KEYS: [&str; 5] = + ["access_token", "refresh_token", "expires_at", "adult_ok", "adult_checked_at"]; fn parse_nexus_creds(text: &str) -> NexusCreds { let val = |want: &str| { @@ -124,10 +134,17 @@ fn parse_nexus_creds(text: &str) -> NexusCreds { .filter(|v| !v.is_empty()) }; NexusCreds { - api_key: val("api_key"), access_token: val("access_token"), refresh_token: val("refresh_token"), expires_at: val("expires_at").and_then(|v| v.parse().ok()).unwrap_or(0), + // Anything that is not a clean `1`/`0` reads as "unknown", which hides + // adult content: a corrupted line must not be able to grant permission. + adult_ok: val("adult_ok").and_then(|v| match v.as_str() { + "1" | "true" => Some(true), + "0" | "false" => Some(false), + _ => None, + }), + adult_checked_at: val("adult_checked_at").and_then(|v| v.parse().ok()).unwrap_or(0), } } @@ -144,12 +161,15 @@ fn render_nexus_creds(existing: &str, creds: &NexusCreds) -> String { out.push('\n'); } }; - push("api_key", creds.api_key.as_deref().unwrap_or_default().trim()); push("access_token", creds.access_token.as_deref().unwrap_or_default().trim()); push("refresh_token", creds.refresh_token.as_deref().unwrap_or_default().trim()); if creds.expires_at != 0 { push("expires_at", &creds.expires_at.to_string()); } + if let Some(ok) = creds.adult_ok { + push("adult_ok", if ok { "1" } else { "0" }); + push("adult_checked_at", &creds.adult_checked_at.to_string()); + } for line in existing.lines() { let t = line.trim(); if t.is_empty() || t.starts_with('[') || t.starts_with('#') { @@ -165,16 +185,6 @@ fn render_nexus_creds(existing: &str, creds: &NexusCreds) -> String { out } -/// The `[Nexus]` `api_key=` value from a `nexus.ini` body, if non-empty. Split -/// out so it can be unit-tested without touching the filesystem. -fn parse_nexus_key(text: &str) -> Option { - text.lines() - .filter_map(|l| l.trim().split_once('=')) - .find(|(k, _)| k.trim() == "api_key") - .map(|(_, v)| v.trim().to_string()) - .filter(|v| !v.is_empty()) -} - /// `$XDG_CONFIG_HOME/eidos/settings.ini`, holding the non-secret app-global /// preferences (theme, default game, last window size). pub fn settings_path() -> PathBuf { @@ -361,39 +371,85 @@ mod tests { } #[test] - fn saving_a_key_does_not_sign_the_user_out() { - // The regression this guards: `nexus.ini` holds the API key AND the - // OAuth tokens, so a write of one that overwrites the file destroys the - // other. Re-pasting a key must not log you out. + fn an_old_api_key_line_is_neither_read_nor_destroyed() { + // `nexus.ini` used to hold a personal API key beside the tokens. Nexus + // requires personal keys gone from the client, so the field is no longer + // parsed - but a user upgrading has that line on disk, and silently + // rewriting their file to drop it is not this module's call. let existing = "[Nexus]\napi_key=old\naccess_token=at\nrefresh_token=rt\nexpires_at=999\n"; - let mut creds = parse_nexus_creds(existing); - creds.api_key = Some("new".into()); + let creds = parse_nexus_creds(existing); let out = render_nexus_creds(existing, &creds); + assert!(out.contains("api_key=old"), "the line was destroyed: {out}"); let back = parse_nexus_creds(&out); - assert_eq!(back.api_key.as_deref(), Some("new")); - assert_eq!(back.access_token.as_deref(), Some("at"), "the sign-in was destroyed"); + assert_eq!(back.access_token.as_deref(), Some("at")); assert_eq!(back.refresh_token.as_deref(), Some("rt")); assert_eq!(back.expires_at, 999); } #[test] - fn signing_out_keeps_the_api_key() { - let existing = "[Nexus]\napi_key=abc\naccess_token=at\nrefresh_token=rt\nexpires_at=5\n"; + fn the_adult_preference_survives_a_write_and_a_read() { + let mut creds = parse_nexus_creds("[Nexus]\naccess_token=at\n"); + creds.adult_ok = Some(true); + creds.adult_checked_at = 1_000; + let back = parse_nexus_creds(&render_nexus_creds("", &creds)); + assert_eq!(back.adult_ok, Some(true)); + assert_eq!(back.adult_checked_at, 1_000); + assert_eq!(back.adult_pref(1_000), Some(true)); + } + + #[test] + fn a_cached_adult_preference_decays_to_unknown_once_it_is_stale() { + // The user can turn the setting off on the website at any time and we + // would never hear about it, so a cached "yes" has to expire. + let creds = NexusCreds { adult_ok: Some(true), adult_checked_at: 1_000, ..Default::default() }; + assert_eq!(creds.adult_pref(1_000 + ADULT_PREF_TTL - 1), Some(true)); + assert_eq!(creds.adult_pref(1_000 + ADULT_PREF_TTL), None, "stale must read as unknown"); + } + + #[test] + fn a_corrupted_adult_line_reads_as_unknown_rather_than_as_permission() { + // Anything that is not a clean 1/0 must not be able to grant permission. + for line in ["adult_ok=yes", "adult_ok=", "adult_ok=maybe", "adult_ok=2"] { + let creds = parse_nexus_creds(&format!("[Nexus]\n{line}\nadult_checked_at=1000\n")); + assert_eq!(creds.adult_ok, None, "{line}"); + assert_eq!(creds.adult_pref(1_000), None, "{line}"); + } + } + + #[test] + fn signing_out_forgets_the_adult_preference_with_the_session() { + // It belongs to the account that just signed out; keeping it would apply + // one person's setting to whoever signs in next. + let existing = "[Nexus]\naccess_token=at\nadult_ok=1\nadult_checked_at=1000\n"; + let mut creds = parse_nexus_creds(existing); + assert_eq!(creds.adult_ok, Some(true)); + creds.access_token = None; + creds.adult_ok = None; + creds.adult_checked_at = 0; + let back = parse_nexus_creds(&render_nexus_creds(existing, &creds)); + assert_eq!(back.adult_ok, None); + assert_eq!(back.adult_pref(1_000), None); + } + + #[test] + fn signing_out_clears_the_session_and_nothing_else() { + let existing = "[Nexus]\naccess_token=at\nrefresh_token=rt\nexpires_at=5\nkeep=me\n"; let mut creds = parse_nexus_creds(existing); creds.access_token = None; creds.refresh_token = None; creds.expires_at = 0; - let back = parse_nexus_creds(&render_nexus_creds(existing, &creds)); - assert_eq!(back.api_key.as_deref(), Some("abc")); + let out = render_nexus_creds(existing, &creds); + let back = parse_nexus_creds(&out); assert!(!back.has_oauth()); assert_eq!(back.expires_at, 0); + assert!(out.contains("keep=me"), "an unrelated line was dropped: {out}"); } #[test] fn unknown_keys_survive_a_rewrite() { // A newer Eidos may store a field this build knows nothing about; // dropping it on every save would corrupt their config by downgrade. - let existing = "[Nexus]\napi_key=abc\nsomething_new=keep-me\n"; + let existing = "[Nexus]\naccess_token=at\nsomething_new=keep-me\n"; let out = render_nexus_creds(existing, &parse_nexus_creds(existing)); assert!(out.contains("something_new=keep-me"), "{out}"); assert_eq!(out.matches("[Nexus]").count(), 1, "the header was duplicated: {out}"); @@ -407,34 +463,16 @@ mod tests { } #[test] - fn nexus_key_round_trips_via_parser() { - let text = "[Nexus]\napi_key=abc123\n"; - assert_eq!(parse_nexus_key(text).as_deref(), Some("abc123")); - } - - #[test] - fn nexus_key_trims_whitespace() { - let text = "[Nexus]\napi_key = spaced-key \n"; - assert_eq!(parse_nexus_key(text).as_deref(), Some("spaced-key")); - } - - #[test] - fn empty_nexus_key_is_none() { - assert_eq!(parse_nexus_key("[Nexus]\napi_key=\n"), None); - assert_eq!(parse_nexus_key("[Nexus]\napi_key= \n"), None); - assert_eq!(parse_nexus_key("nothing here"), None); - } - - #[test] - fn nexus_key_survives_a_disk_round_trip() { - // Save then load from a real file, proving the key persists. + fn the_session_survives_a_disk_round_trip() { let path = tmp("nexus"); if let Some(parent) = path.parent() { let _ = fs::create_dir_all(parent); } - fs::write(&path, format!("[Nexus]\napi_key={}\n", "persisted-key")).unwrap(); + fs::write(&path, "[Nexus]\naccess_token=persisted\nexpires_at=42\n").unwrap(); let text = fs::read_to_string(&path).unwrap(); - assert_eq!(parse_nexus_key(&text).as_deref(), Some("persisted-key")); + let c = parse_nexus_creds(&text); + assert_eq!(c.access_token.as_deref(), Some("persisted")); + assert_eq!(c.expires_at, 42); let _ = fs::remove_file(&path); } diff --git a/crates/eidos-nexus/src/lib.rs b/crates/eidos-nexus/src/lib.rs index eba503d..da10626 100644 --- a/crates/eidos-nexus/src/lib.rs +++ b/crates/eidos-nexus/src/lib.rs @@ -1,12 +1,17 @@ //! Nexus Mods integration, mirroring Mod Organizer 2's client behaviour //! (`nxmaccessmanager.cpp` + `nexusinterface.cpp` + `downloadmanager.cpp`): //! -//! - **Auth**: either credential Nexus accepts, chosen by [`Nexus::connect`] - -//! a personal API key as a raw `APIKEY` header (MO2's legacy-but-supported -//! path), or an OAuth access token as `Authorization: Bearer`. Both are -//! validated via `users/validate`; like MO2, no `.json` suffix on requests. -//! Signing in additionally needs a `client_id` registered with Nexus (see -//! [`oauth`]); until one exists, `connect` uses the API key. +//! - **Auth**: an OAuth access token as `Authorization: Bearer`, and nothing +//! else. Validated via `users/validate`; like MO2, no `.json` suffix on +//! requests. Signing in needs a `client_id` registered with Nexus (see +//! [`oauth`]). +//! +//! Personal API keys are NOT supported, deliberately and not as a fallback. +//! Nexus's API team requires their complete removal before issuing a +//! `client_id`: the personal key is documented on their side as being for +//! testing and personal use, not for a distributed application. Eidos +//! therefore has no Nexus access at all until a `client_id` is issued, which +//! is the intended state rather than an oversight. //! - **`nxm://` links** (the site's "Mod Manager Download" button): //! `nxm:///mods//files/?key=..&expires=..&user_id=..` - //! non-premium downloads REQUIRE that key/expires pair forwarded to the @@ -14,6 +19,19 @@ //! - **`.meta` sidecar**: each download gets `.meta` in MO2's exact //! key set (gameName/modID/fileID/url/version/...), which `eidos-install` //! already reads to seed the installed mod's `meta.ini`. +//! - **Adult content** is gated INSIDE this crate, in +//! [`RemoteMod::from_payload`], and nowhere else. A mod the account may not be +//! shown has its descriptive fields blanked before the struct is built, so the +//! text never crosses the crate boundary and no display site can leak it by +//! forgetting to check. **Any field added to [`RemoteMod`] that comes from the +//! `games/{game}/mods/{id}` payload must be redacted there too** - there is no +//! second gate downstream to catch it. Fetching a file or a download link +//! requires the [`ModGate`] that lookup mints, so the ordering is enforced by +//! the compiler rather than by convention. +//! - **The request budget** is enforced BEFORE a request is sent, not after +//! Nexus refuses one: [`Nexus::get`] and `send_with_version` both start with a +//! pre-flight check, so an exhausted account stops the whole client rather +//! than each loop that remembered to look. See [`RATE_LIMITED`]. use std::fs; use std::io::{self, Read, Write}; @@ -86,7 +104,105 @@ pub struct Account { pub is_premium: bool, } +/// Whether this account may be shown adult mod metadata. +/// +/// Nexus keeps the answer on the account, not in the v1 API: it is +/// `preferences { adult }` on their GraphQL v2 endpoint, scoped to the bearer +/// token (see [`oauth::adult_preference`]). Vortex, Nexus's own manager, gates on +/// that preference alone and leaves age verification to the website, so Eidos +/// does the same rather than second-guessing who has verified what. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AdultPolicy { + /// The account has adult content switched on. + Allowed, + /// The account has it switched off. + Denied, + /// Not signed in, the preference could not be read, or the cached answer has + /// aged out. Hides, exactly like [`AdultPolicy::Denied`], but says something + /// different to the user: "we could not check" is not "you said no". + #[default] + Unknown, +} + +impl AdultPolicy { + /// The single place permission is decided. Only `Allowed` shows. + pub fn shows_adult(self) -> bool { + matches!(self, AdultPolicy::Allowed) + } +} + +/// Why a mod's metadata was withheld. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HiddenReason { + /// Adult mod; the account has adult content switched off. + AdultDenied, + /// Adult mod; the account's preference could not be read. + AdultUnknown, + /// The payload carried no content rating, so it is treated as adult. Kept + /// distinct because "we could not confirm this mod's rating" and "your + /// account hides adult content" have different fixes - and because if the + /// field ever moves, EVERY mod lands here, which is a signal worth reading. + RatingUnknown, + /// Nexus reports the mod as unavailable (hidden or deleted upstream). + Unavailable, +} + +/// The title shown in place of a withheld mod. A constant, never anything +/// derived from the payload. +pub const HIDDEN_TITLE: &str = "Adult content (hidden)"; + +impl HiddenReason { + /// What to tell the user, in words that leak nothing about the mod. + pub fn message(self) -> &'static str { + match self { + HiddenReason::AdultDenied => { + "Hidden because adult content is turned off on your Nexus account. Change it at \ + https://next.nexusmods.com/settings/content-blocking, then sign in again in Eidos." + } + HiddenReason::AdultUnknown => { + "Hidden: Eidos could not confirm your Nexus content settings. Sign in again to retry." + } + HiddenReason::RatingUnknown => "Hidden: Eidos could not confirm this mod's content rating.", + HiddenReason::Unavailable => "This mod is no longer available on Nexus Mods.", + } + } +} + +/// Proof that a mod's rating has been resolved and found showable. +/// +/// The fields are private and there is no public constructor, so the only way to +/// hold one is to have called [`Nexus::mod_info`] - which is what makes it +/// impossible to reach [`Nexus::file_info`] or [`Nexus::download_link`] without +/// having passed the gate first. The compiler enforces the ordering that a +/// convention would only describe. +#[derive(Debug, Clone, Copy)] +pub struct ModGate { + adult: bool, + hidden: Option, +} + +impl ModGate { + /// Whether this mod's metadata may be shown. + pub fn visible(&self) -> bool { + self.hidden.is_none() + } + /// Why it may not be, if it may not. + pub fn reason(&self) -> Option { + self.hidden + } + /// Whether Nexus flags this mod as adult (independent of the account's setting). + pub fn is_adult(&self) -> bool { + self.adult + } +} + /// Remote mod metadata (subset of `games/{game}/mods/{id}`). +/// +/// When [`Self::hidden`] is set, the descriptive fields have already been blanked +/// inside [`Nexus::mod_info`]: a withheld mod's name and summary never leave this +/// crate, so no display site can leak one by forgetting to check. Only `version` +/// survives, because it is what the update check compares and a version string +/// describes nothing. #[derive(Debug, Clone)] pub struct RemoteMod { pub name: String, @@ -94,6 +210,69 @@ pub struct RemoteMod { pub summary: String, pub category_id: Option, pub available: bool, + /// Nexus's own rating: `Some(true)` adult, `Some(false)` not, `None` when the + /// payload said nothing - which is treated as adult. + pub adult: Option, + /// The capability token [`Nexus::file_info`] and [`Nexus::download_link`] + /// require, and the single answer to "was this withheld, and why" + /// ([`ModGate::visible`] / [`ModGate::reason`]). + pub gate: ModGate, +} + +impl RemoteMod { + /// Apply the gate to a `games/{game}/mods/{id}` payload. + /// + /// Split out from [`Nexus::mod_info`], which is that one request followed by + /// this one decision, so the decision can be tested against real payloads + /// without a network - and so there is exactly one copy of it to audit. + /// + /// Fail closed at every step: a payload with no rating is treated as adult, + /// and an unreadable account preference hides rather than shows. Every way + /// this can be wrong ends with too little on screen, never too much. + pub(crate) fn from_payload(v: &serde_json::Value, policy: AdultPolicy) -> RemoteMod { + let adult = adult_flag(v); + let available = v.get("available").and_then(|x| x.as_bool()).unwrap_or(true); + let hidden = match (adult, policy, available) { + // Unavailable outranks the rest: Nexus has taken the page down, so + // there is nothing to show whatever the account allows. + (_, _, false) => Some(HiddenReason::Unavailable), + (Some(false), _, _) => None, + (Some(true), AdultPolicy::Allowed, _) => None, + (Some(true), AdultPolicy::Denied, _) => Some(HiddenReason::AdultDenied), + (Some(true), AdultPolicy::Unknown, _) => Some(HiddenReason::AdultUnknown), + (None, _, _) => Some(HiddenReason::RatingUnknown), + }; + + let redact = hidden.is_some(); + RemoteMod { + // Blanked rather than replaced with the placeholder: a caller that + // prints this without checking shows nothing, not a wrong name. + name: if redact { String::new() } else { s(v, "name") }, + // Kept either way. A version string describes nothing, and comparing + // it is the whole point of the update check - withholding it would + // break update detection for a mod the user already has installed. + version: s(v, "version"), + summary: if redact { String::new() } else { s(v, "summary") }, + category_id: if redact { None } else { v.get("category_id").and_then(|x| x.as_u64()) }, + available, + adult, + gate: ModGate { adult: adult.unwrap_or(true), hidden }, + } + } + + /// Whether this mod's metadata was withheld. + pub fn hidden(&self) -> Option { + self.gate.reason() + } + /// What to show in a list in place of the real name: the mod's name when it + /// may be shown, the neutral placeholder when it may not. + pub fn display_name(&self) -> &str { + if self.gate.visible() { + &self.name + } else { + HIDDEN_TITLE + } + } } /// Remote file metadata (subset of `.../files/{file_id}`). @@ -112,12 +291,55 @@ pub struct RemoteFile { pub size_in_bytes: u64, } -/// Nexus rate-limit budget from the `X-RL-*` response headers (MO2 surfaces these -/// and stops dispatching when exhausted). Fields are `None` until a reply is seen. +/// Nexus rate-limit budget from the `X-RL-*` response headers. Fields are `None` +/// until a reply carrying them is seen, and `None` always means "go ahead": a +/// fresh client has no headers yet, and the request that would teach it the +/// budget must be allowed out or the client deadlocks before it ever starts. #[derive(Debug, Clone, Copy, Default)] pub struct RateLimits { pub hourly_remaining: Option, pub daily_remaining: Option, + /// Unix seconds at which a spent hourly budget refills. COMPUTED from the + /// UTC hour boundary, not parsed from `X-RL-Hourly-Reset`: the two reset + /// headers are documented in different formats (`2019-02-01T12:00:00+00:00` + /// against `2019-02-02 00:00:00 +0000`), and Nexus documents the rule itself + /// ("the hourly quota resets each hour", "daily at 00:00 GMT"), so deriving + /// the boundary needs no date parser and no new dependency. + pub hourly_reset: Option, + /// Unix seconds at which a spent daily budget refills (next 00:00 UTC). + pub daily_reset: Option, + /// Set by a 429 that arrived while the budget still showed requests left - + /// i.e. the separate burst guard in front of the API, not the quota. Short + /// back-off rather than idling until the next hour. + pub blocked_until: Option, +} + +/// The next UTC hour boundary at or after `now`. +fn next_hour_utc(now: u64) -> u64 { + (now / 3600 + 1) * 3600 +} + +/// The next UTC midnight at or after `now`. +fn next_midnight_utc(now: u64) -> u64 { + (now / 86_400 + 1) * 86_400 +} + +/// How long to stand down after a 429 that the quota does not explain. +const BURST_BACKOFF: u64 = 60; + +/// The one phrase every rate-limit refusal carries, whether it came from a 429 +/// or from the pre-flight check that stopped a request being sent at all. +/// +/// It exists because the callers used to test for the condition themselves, and +/// disagreed: `check_updates` matched `"rate limited"` while the CLI matched +/// `"429"`, so a pre-flight refusal - which contains no status code - would have +/// stopped one loop and left the other hammering an exhausted account. Both now +/// route through [`is_rate_limited`], which cannot drift from the messages. +pub const RATE_LIMITED: &str = "rate limited by Nexus"; + +/// Whether an error string is one of ours meaning "the budget is spent". +pub fn is_rate_limited(err: &str) -> bool { + err.contains(RATE_LIMITED) } /// What [`Nexus::connect`] decided to do, separated from doing it so the rule @@ -128,9 +350,8 @@ enum CredentialChoice { Bearer, /// A stored session whose access token needs renewing first. Refresh, - /// No usable session; use the personal API key. - ApiKey, - /// Nothing stored at all. + /// No usable session. There is no fallback: without an OAuth session there + /// is no request Eidos is allowed to make. None, } @@ -143,7 +364,6 @@ fn choose_credential( now: u64, can_refresh: bool, ) -> CredentialChoice { - let has_key = creds.api_key.as_deref().is_some_and(|k| !k.is_empty()); if creds.has_oauth() { let stale = creds.expires_at <= now.saturating_add(TOKEN_SKEW.as_secs()); if !stale { @@ -156,20 +376,17 @@ fn choose_credential( return CredentialChoice::Refresh; } } - if has_key { - CredentialChoice::ApiKey - } else { - CredentialChoice::None - } + CredentialChoice::None } -/// How a request proves who it is. Nexus accepts either on the v1 API, and the -/// OAuth guide says the same token also works on v2 - so this is the ONLY thing -/// that changes between the personal-key path and the signed-in path. +/// How a request proves who it is. +/// +/// One variant, on purpose. It was an enum over `{ApiKey, Bearer}` until Nexus +/// required personal API keys gone from the client entirely; the shape is kept +/// so the header logic stays in one named place rather than inlined at every +/// call site. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Credential { - /// A personal API key, in the `APIKEY` header (what MO2 sends). - ApiKey(String), /// An OAuth access token, as `Authorization: Bearer `. Bearer(String), } @@ -179,12 +396,29 @@ pub struct Nexus { agent: ureq::Agent, credential: Credential, limits: std::cell::Cell, + /// Whether this account may be shown adult metadata. Defaults to + /// [`AdultPolicy::Unknown`], so a client built without stating a policy + /// hides adult content rather than leaking it - forgetting is safe. + adult: AdultPolicy, } fn s(v: &serde_json::Value, k: &str) -> String { v.get(k).and_then(|x| x.as_str()).unwrap_or_default().to_string() } +/// Nexus's adult rating for a mod payload, or `None` when the payload says +/// nothing - which the caller must read as "assume adult", not as "safe". +/// +/// `contains_adult_content` is the v1 spelling (node-nexus-api's `IModInfo`, and +/// what a captured v1 payload carries). The other two are how the newer APIs name +/// the same thing; accepting them costs a comparison and means a payload in a +/// slightly different shape still rates correctly instead of hiding every mod. +fn adult_flag(v: &serde_json::Value) -> Option { + ["contains_adult_content", "adult_content", "adultContent"] + .iter() + .find_map(|k| v.get(*k).and_then(|x| x.as_bool())) +} + /// The `version` an endorsement request must carry: the mod's installed version, /// trimmed, or `"1.0"` when it is blank (Nexus rejects an empty version, but /// tolerates a placeholder for mods with no recorded version). @@ -198,11 +432,6 @@ fn endorse_version(version: &str) -> &str { } impl Nexus { - /// A client authenticating with a personal API key. - pub fn new(api_key: &str) -> Nexus { - Nexus::with_credential(Credential::ApiKey(api_key.trim().to_string())) - } - /// A client authenticating with an OAuth access token. /// /// The caller is responsible for handing over a token that is still valid - @@ -213,6 +442,18 @@ impl Nexus { Nexus::with_credential(Credential::Bearer(access_token.trim().to_string())) } + /// The same client, told what the account allows. Adult metadata is withheld + /// unless this says [`AdultPolicy::Allowed`]. + pub fn with_adult_policy(mut self, adult: AdultPolicy) -> Nexus { + self.adult = adult; + self + } + + /// What this client believes the account allows. + pub fn adult_policy(&self) -> AdultPolicy { + self.adult + } + pub fn with_credential(credential: Credential) -> Nexus { // Read/write timeouts detect a stalled connection (which would otherwise // hang a task forever and leave GUI buttons greyed) without capping the @@ -227,47 +468,79 @@ impl Nexus { .timeout_recv_body(Some(std::time::Duration::from_secs(30))) .timeout_send_body(Some(std::time::Duration::from_secs(30))) // A non-2xx reply comes back as a RESPONSE, not an error. Nexus puts - // the rate-limit budget in headers on EVERY reply including a 429, - // and that is the one we most need to read: ureq 2 handed us the - // response inside Error::Status, and 3 does not, so asking for the - // response directly is how the budget stays visible. + // the rate-limit budget in headers on a rejection too - a 429 above + // all, the one we most need to read - and ureq 2 handed us the + // response inside Error::Status where ureq 3 does not, so asking for + // the response directly is how the budget stays visible. (Not on + // EVERY reply, despite what this comment used to claim: a 401 from + // the v1 API carries no X-RL-* headers at all, which is why + // `capture_limits` merges instead of overwriting.) .http_status_as_error(false) .build() .into(); - Nexus { agent, credential, limits: std::cell::Cell::new(RateLimits::default()) } + Nexus { + agent, + credential, + limits: std::cell::Cell::new(RateLimits::default()), + adult: AdultPolicy::Unknown, + } } - /// Which credential this client carries. Exposed so a caller can report - /// "signed in" versus "using an API key" without holding the secret itself. + /// Which credential this client carries. Exposed so a caller can report the + /// state without holding the secret itself. pub fn credential_kind(&self) -> &'static str { match self.credential { - Credential::ApiKey(_) => "api_key", Credential::Bearer(_) => "oauth", } } - /// Whether ANY credential is stored, without touching the network. + /// Whether a signed-in session is stored, without touching the network. /// /// Reads a file, so it is safe to call from a UI update handler - unlike /// [`Nexus::connect`], which may spend a round trip renewing a token and /// belongs in a background task. pub fn have_credentials() -> bool { - let creds = eidos_instance::settings::load_nexus_creds(); - creds.has_oauth() || creds.api_key.as_deref().is_some_and(|k| !k.is_empty()) + eidos_instance::settings::load_nexus_creds().has_oauth() + } + + /// A signed-in client that knows what the account allows. + /// + /// The adult-content preference is cached in `nexus.ini` with a TTL rather + /// than fetched per request: it is one extra call a day, and the setting is + /// the user's own, changed rarely and on the website. A read that fails for + /// ANY reason leaves the policy `Unknown`, which hides - so being offline + /// costs the user adult metadata for that session, never the reverse. + fn signed_in(creds: &mut eidos_instance::settings::NexusCreds, token: &str) -> Nexus { + let now = oauth::now_unix(); + let known = creds.adult_pref(now).or_else(|| { + let fetched = oauth::adult_preference(token)?; + creds.adult_ok = Some(fetched); + creds.adult_checked_at = now; + // A failed write only costs the next session another lookup. + let _ = eidos_instance::settings::save_nexus_creds(creds); + Some(fetched) + }); + let policy = match known { + Some(true) => AdultPolicy::Allowed, + Some(false) => AdultPolicy::Denied, + None => AdultPolicy::Unknown, + }; + Nexus::with_bearer(token).with_adult_policy(policy) } /// The client to use right now, from whatever is stored on this machine. /// - /// Prefers a signed-in OAuth session over a personal API key, refreshing the - /// access token first when it is stale. Falls back to the API key whenever - /// OAuth cannot be made to work - an expired session with no way to renew - /// it should degrade to the key the user already pasted, not to an error. + /// Uses the signed-in OAuth session, renewing the access token first when it + /// is stale. There is no personal-API-key fallback: a session that cannot be + /// renewed is an error saying so, because a distributed client is not allowed + /// to carry a personal key at all. pub fn connect() -> Result { let mut creds = eidos_instance::settings::load_nexus_creds(); let cfg = oauth::Config::from_env(); match choose_credential(&creds, oauth::now_unix(), cfg.is_some()) { CredentialChoice::Bearer => { - Ok(Nexus::with_bearer(creds.access_token.as_deref().unwrap_or_default())) + let token = creds.access_token.clone().unwrap_or_default(); + Ok(Nexus::signed_in(&mut creds, &token)) } CredentialChoice::Refresh => { let cfg = cfg.expect("choose_credential only picks Refresh when a config exists"); @@ -280,23 +553,17 @@ impl Nexus { // A failed write is not fatal: the token in hand still // works, the user just signs in again next session. let _ = eidos_instance::settings::save_nexus_creds(&creds); - Ok(Nexus::with_bearer(&t.access_token)) + Ok(Nexus::signed_in(&mut creds, &t.access_token)) } - // The refresh token is spent or revoked. Their API key, if - // any, is still good - so try that before giving up. - Err(e) => match creds.api_key.as_deref() { - Some(key) if !key.is_empty() => Ok(Nexus::new(key)), - _ => Err(format!("Nexus sign-in expired and could not be renewed: {e}")), - }, + // The refresh token is spent or revoked, and there is + // nothing to fall back to: signing in again is the only + // route, which is what the message has to say. + Err(e) => Err(format!("Nexus sign-in expired and could not be renewed: {e}")), } } - CredentialChoice::ApiKey => { - Ok(Nexus::new(creds.api_key.as_deref().unwrap_or_default())) + CredentialChoice::None => { + Err("not connected to Nexus: sign in from Settings".to_string()) } - CredentialChoice::None => Err( - "not connected to Nexus: sign in, or set a personal API key in Settings" - .to_string(), - ), } } @@ -305,55 +572,148 @@ impl Nexus { self.limits.get() } + /// Record the budget a reply carried. + /// + /// A field is only overwritten when its header is actually PRESENT. Not every + /// reply carries them - a 401 from the v1 API carries none at all - and the + /// earlier version rebuilt the whole struct from each reply, so one such + /// answer wiped a known-exhausted budget back to "unknown" and the pre-flight + /// check below would wave the next request straight through. fn capture_limits(&self, resp: &ureq::http::Response) { let parse = |h: &str| { resp.headers().get(h).and_then(|v| v.to_str().ok()).and_then(|x| x.trim().parse::().ok()) }; - self.limits.set(RateLimits { - hourly_remaining: parse("X-RL-Hourly-Remaining"), - daily_remaining: parse("X-RL-Daily-Remaining"), - }); + let now = oauth::now_unix(); + let mut lim = self.limits.get(); + if let Some(h) = parse("X-RL-Hourly-Remaining") { + lim.hourly_remaining = Some(h); + lim.hourly_reset = (h <= 0).then(|| next_hour_utc(now)); + } + if let Some(d) = parse("X-RL-Daily-Remaining") { + lim.daily_remaining = Some(d); + lim.daily_reset = (d <= 0).then(|| next_midnight_utc(now)); + } + self.limits.set(lim); + } + + /// A 429 the quota does not explain is the burst guard sitting in front of + /// the API (it rejects sustained bursts regardless of budget). Stand down for + /// a minute rather than idling until the next hour, and never synthesise a + /// zero into the counters - they are what the server told us. + fn note_rejection(&self, code: u16) { + if code != 429 { + return; + } + let mut lim = self.limits.get(); + let spent = |v: Option| v.is_some_and(|n| n <= 0); + if !spent(lim.hourly_remaining) && !spent(lim.daily_remaining) { + lim.blocked_until = Some(oauth::now_unix() + BURST_BACKOFF); + self.limits.set(lim); + } + } + + /// Whether a request may be sent right now, or the reason it may not. + /// + /// Nexus's own rule is that an account is refused only once BOTH buckets are + /// spent, which is why MO2, node-nexus-api and Wabbajack all track the larger + /// of the two. Their reviewer asked for the stricter reading - stop as soon as + /// EITHER counter reaches zero - so that is what this implements, at the cost + /// of idling while the other bucket still has room. + /// + /// An exhausted bucket clears itself on the clock: past the boundary the + /// counter goes back to "unknown" and the next request re-learns the real + /// budget from its own headers. No probe request is needed, which is why + /// `users/validate` is not exempted from this check even though it is said + /// not to count against the hourly quota - issuing any request while we + /// believe the account is exhausted is the behaviour being corrected. + fn preflight(&self, now: u64) -> Option { + let mut lim = self.limits.get(); + let mut expired = false; + + if lim.blocked_until.is_some_and(|t| now >= t) { + lim.blocked_until = None; + expired = true; + } + if lim.hourly_reset.is_some_and(|t| now >= t) { + (lim.hourly_remaining, lim.hourly_reset) = (None, None); + expired = true; + } + if lim.daily_reset.is_some_and(|t| now >= t) { + (lim.daily_remaining, lim.daily_reset) = (None, None); + expired = true; + } + if expired { + self.limits.set(lim); + } + + if lim.blocked_until.is_some() { + return Some(format!("{RATE_LIMITED} - too many requests too quickly; retrying shortly")); + } + if lim.hourly_remaining.is_some_and(|n| n <= 0) { + return Some(format!( + "{RATE_LIMITED} - the hourly request budget is spent; it refills at the next hour (UTC)" + )); + } + if lim.daily_remaining.is_some_and(|n| n <= 0) { + return Some(format!( + "{RATE_LIMITED} - the daily request budget is spent; it refills at 00:00 UTC" + )); + } + None } - /// The MO2 status-code mapping shared by every request (401 = bad key, - /// 429 = rate limited, else a generic message with the code). + /// Whether the next request would be refused by [`Nexus::preflight`]. For + /// loops that check before building a request, so they stop visibly instead + /// of relying on the choke point to reject each attempt in turn. + pub fn would_block(&self) -> bool { + self.preflight(oauth::now_unix()).is_some() + } + + /// The MO2 status-code mapping shared by every request (401 = the session is + /// not accepted, 429 = rate limited, else a generic message with the code). fn status_err(code: u16) -> String { match code { - 401 => "invalid API key (401)".to_string(), - 429 => "rate limited by Nexus (429) - try again later".to_string(), + 401 => "Nexus rejected the sign-in (401) - sign in again".to_string(), + 429 => format!("{RATE_LIMITED} (429) - try again later"), other => format!("Nexus API error (HTTP {other})"), } } /// Attach the identifying headers MO2 sends on every v1 call - /// (nxmaccessmanager.cpp addAPIHeaders) plus whichever credential we hold. + /// (nxmaccessmanager.cpp addAPIHeaders) plus the bearer token. /// /// `Application-Name`/`Application-Version` are not decoration: the Nexus - /// API acceptable-use policy requires them so usage can be attributed, and - /// they ride on BOTH credential paths. + /// API acceptable-use policy requires them so usage can be attributed. fn with_headers(&self, req: ureq::RequestBuilder) -> ureq::RequestBuilder { let req = req .header("Protocol-Version", "1.0.0") .header("Application-Name", "Eidos") .header("Application-Version", env!("CARGO_PKG_VERSION")); match &self.credential { - Credential::ApiKey(key) => req.header("APIKEY", key), Credential::Bearer(token) => req.header("Authorization", format!("Bearer {token}")), } } fn get(&self, path: &str) -> Result { + // Before anything is sent. Every read endpoint funnels through here, so + // this one line is what makes "stop as soon as the budget is spent" true + // of the whole client rather than of the loops that remembered to ask. + if let Some(stop) = self.preflight(oauth::now_unix()) { + return Err(stop); + } let url = format!("{API_BASE}/{path}"); - // MO2 identifies the client on every v1 request (nxmaccessmanager.cpp - // addAPIHeaders): Protocol-Version + Application-Name/-Version with APIKEY. - // It also reads the X-RL-* budget from every reply (incl. a 429). + // Every v1 request identifies the client, as MO2 does + // (nxmaccessmanager.cpp addAPIHeaders): Protocol-Version plus + // Application-Name/-Version, alongside the bearer token. match self.with_headers(self.agent.get(&url)).call() { Ok(mut resp) => { - // Read the budget FIRST: it is present on a rejection too, and a - // 429 is exactly when the caller needs to know how long to wait. + // Read the budget FIRST: a rejection carries it too, and a 429 is + // exactly when the caller needs to know how long to wait. Not + // every reply has it, which is why capture_limits merges. self.capture_limits(&resp); let code = resp.status().as_u16(); if !resp.status().is_success() { + self.note_rejection(code); return Err(Nexus::status_err(code)); } resp.body_mut().read_json().map_err(|e| e.to_string()) @@ -367,13 +727,18 @@ impl Nexus { /// reply body is ignored - only success/failure matters. Captures the X-RL-* /// budget on success and on a Status error, exactly like [`get`]. fn send_with_version(&self, req: ureq::RequestBuilder, version: &str) -> Result<(), String> { + if let Some(stop) = self.preflight(oauth::now_unix()) { + return Err(stop); + } match self.with_headers(req).send_form([("version", version)]) { Ok(resp) => { self.capture_limits(&resp); if resp.status().is_success() { Ok(()) } else { - Err(Nexus::status_err(resp.status().as_u16())) + let code = resp.status().as_u16(); + self.note_rejection(code); + Err(Nexus::status_err(code)) } } Err(other) => Err(other.to_string()), @@ -419,19 +784,28 @@ impl Nexus { } /// Mod metadata: `games/{game}/mods/{id}` (the update check reads `version`). + /// + /// THE GATE. This is the only place a mod's rating is known, so it is the + /// only place it can be enforced: the descriptive fields are blanked here, + /// before the struct exists, and the [`ModGate`] minted alongside them is what + /// every later call demands. Anything added to [`RemoteMod`] that comes from + /// this payload must be redacted in [`redact`] too - there is no second gate + /// downstream to catch it. pub fn mod_info(&self, game: &str, mod_id: u64) -> Result { let v = self.get(&format!("games/{game}/mods/{mod_id}"))?; - Ok(RemoteMod { - name: s(&v, "name"), - version: s(&v, "version"), - summary: s(&v, "summary"), - category_id: v.get("category_id").and_then(|x| x.as_u64()), - available: v.get("available").and_then(|x| x.as_bool()).unwrap_or(true), - }) + Ok(RemoteMod::from_payload(&v, self.adult)) } /// File metadata: `games/{game}/mods/{id}/files/{file_id}`. - pub fn file_info(&self, game: &str, mod_id: u64, file_id: u64) -> Result { + /// + /// Takes the [`ModGate`] minted by [`Nexus::mod_info`], so a file's name and + /// description cannot be fetched for a mod whose metadata is withheld: the + /// caller has to have looked the mod up first, and this refuses if that lookup + /// closed the gate. + pub fn file_info(&self, gate: &ModGate, game: &str, mod_id: u64, file_id: u64) -> Result { + if let Some(why) = gate.reason() { + return Err(why.message().to_string()); + } let v = self.get(&format!("games/{game}/mods/{mod_id}/files/{file_id}"))?; Ok(RemoteFile { name: s(&v, "name"), @@ -463,7 +837,14 @@ impl Nexus { /// Resolve the CDN download URI for an `nxm://` link: `download_link` /// (+`?key=..&expires=..` for non-premium, exactly like MO2). Returns the /// first mirror's URI. - pub fn download_link(&self, nxm: &NxmUrl) -> Result { + pub fn download_link(&self, gate: &ModGate, nxm: &NxmUrl) -> Result { + // Same token as `file_info`: a mod whose page Eidos may not describe is + // one whose files Eidos does not fetch either. The link itself carries no + // description, but resolving it is the first step of a flow that goes on + // to print the file name and write it into a `.meta` sidecar. + if let Some(why) = gate.reason() { + return Err(why.message().to_string()); + } let mut path = format!( "games/{}/mods/{}/files/{}/download_link", nxm.game, nxm.mod_id, nxm.file_id @@ -579,7 +960,16 @@ pub struct UpdateCheckResult { pub fn check_updates(nexus: &Nexus, inst: &eidos_instance::Instance, nexus_game: &str) -> Result { // One "updated this month" query, then only fetch the intersection - stays // inside the API rate limits (MO2's approach). - let updated = nexus.updated_mod_ids(nexus_game, "1m")?; + let updated = match nexus.updated_mod_ids(nexus_game, "1m") { + Ok(v) => v, + // An exhausted budget is a state to report, not a failure: the caller + // shows "the budget is spent" rather than an error toast, and nothing + // about the mod list is wrong - it just could not be refreshed. + Err(e) if is_rate_limited(&e) => { + return Ok(UpdateCheckResult { rate_limited: true, ..Default::default() }) + } + Err(e) => return Err(e), + }; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -601,6 +991,13 @@ pub fn check_updates(nexus: &Nexus, inst: &eidos_instance::Instance, nexus_game: if !stale && !updated.contains(&mod_id) { continue; } + // Stop before building the request, not after it is refused. The choke + // point in `get` would reject it anyway; checking here is what makes the + // loop visibly stop issuing requests once the budget is spent. + if nexus.would_block() { + result.rate_limited = true; + break; + } result.queried += 1; match nexus.mod_info(nexus_game, mod_id) { @@ -619,10 +1016,11 @@ pub fn check_updates(nexus: &Nexus, inst: &eidos_instance::Instance, nexus_game: } } Err(e) => { - // MO2 stops dispatching the moment the account is exhausted. Match - // our own status_err wording, not a bare "429" - a mod id or file - // size containing 429 in some other error text must not trip this. - if e.contains("rate limited") { + // Stop dispatching the moment the account is exhausted. The test + // is the shared predicate, not a bare "429": a pre-flight refusal + // carries no status code, and a mod id or file size that happens + // to contain "429" must not trip it. + if is_rate_limited(&e) { result.rate_limited = true; break; } @@ -770,6 +1168,15 @@ pub fn write_download_meta( file: &RemoteFile, remote_mod: &RemoteMod, ) -> io::Result { + // Defence in depth, and the reason the gate had to sit upstream of this + // function rather than at the display sites: what goes in here is written to + // disk, and `modName` goes on to name the directory under `mods/`. A gate + // that only guarded the screen could not un-write either. Callers refuse a + // hidden mod long before reaching this point; if one ever does not, refuse + // rather than persisting a description that may not be shown. + if let Some(why) = remote_mod.hidden() { + return Err(io::Error::other(why.message())); + } let meta_path = PathBuf::from(format!("{}.meta", archive.display())); let mut out = String::from("[General]\n"); out.push_str(&format!("gameName={game_short}\n")); @@ -800,6 +1207,314 @@ pub fn write_download_meta( mod tests { use super::*; + /// A gate for a mod that may be shown, for fixtures whose subject is not the + /// gate itself. + fn shown_gate() -> ModGate { + ModGate { adult: false, hidden: None } + } + + /// A mod payload as the v1 API returns it, with the adult flag under our + /// control. `adult: None` omits the field entirely, which is the case that + /// must be read as "assume adult". + fn mod_payload(adult: Option) -> serde_json::Value { + let mut v = serde_json::json!({ + "name": "Ivy the Companion", + "version": "3.2", + "summary": "A summary that must not leak.", + "category_id": 42, + "available": true, + }); + if let Some(a) = adult { + v["contains_adult_content"] = serde_json::Value::Bool(a); + } + v + } + + /// The gate as the client applies it: the real function, on a real payload. + fn gated(payload: &serde_json::Value, policy: AdultPolicy) -> RemoteMod { + RemoteMod::from_payload(payload, policy) + } + + // ---- The age gate ------------------------------------------------------ + + #[test] + fn an_adult_mod_is_redacted_when_the_account_has_not_opted_in() { + let m = gated(&mod_payload(Some(true)), AdultPolicy::Denied); + assert_eq!(m.hidden(), Some(HiddenReason::AdultDenied)); + assert!(m.name.is_empty() && m.summary.is_empty() && m.category_id.is_none()); + assert!(!m.gate.visible()); + } + + #[test] + fn an_adult_mod_is_returned_in_full_when_the_account_has_opted_in() { + let m = gated(&mod_payload(Some(true)), AdultPolicy::Allowed); + assert_eq!(m.hidden(), None); + assert_eq!(m.name, "Ivy the Companion"); + assert_eq!(m.category_id, Some(42)); + assert!(m.gate.visible() && m.gate.is_adult()); + } + + #[test] + fn a_non_adult_mod_is_untouched_by_the_gate() { + for policy in [AdultPolicy::Allowed, AdultPolicy::Denied, AdultPolicy::Unknown] { + let m = gated(&mod_payload(Some(false)), policy); + assert_eq!(m.hidden(), None, "{policy:?}"); + assert_eq!(m.name, "Ivy the Companion"); + assert!(!m.gate.is_adult()); + } + } + + #[test] + fn a_payload_with_no_rating_is_treated_as_adult() { + // The field absent must not read as "safe". Its own reason, because if + // the API ever moves the field EVERY mod lands here, and that has to be + // distinguishable from the user's own setting. + let m = gated(&mod_payload(None), AdultPolicy::Allowed); + assert_eq!(m.hidden(), Some(HiddenReason::RatingUnknown)); + assert!(m.name.is_empty()); + } + + #[test] + fn an_unknown_account_preference_hides_rather_than_shows() { + let m = gated(&mod_payload(Some(true)), AdultPolicy::Unknown); + assert_eq!(m.hidden(), Some(HiddenReason::AdultUnknown)); + assert!(m.name.is_empty()); + } + + #[test] + fn a_client_built_without_a_policy_hides_adult_content() { + // Forgetting to state a policy must fail closed, so the default is the + // one that hides - not the one that shows. + assert_eq!(Nexus::with_bearer("t").adult_policy(), AdultPolicy::Unknown); + assert!(!AdultPolicy::default().shows_adult()); + } + + #[test] + fn a_redacted_mod_keeps_its_version_so_the_update_check_still_works() { + // The one field that survives redaction. A user with an adult mod already + // installed must keep seeing that an update exists. + let m = gated(&mod_payload(Some(true)), AdultPolicy::Denied); + assert_eq!(m.version, "3.2"); + } + + #[test] + fn the_placeholder_and_the_explanations_contain_nothing_from_the_payload() { + let payload = mod_payload(Some(true)); + let m = gated(&payload, AdultPolicy::Denied); + let shown = format!("{} {}", m.display_name(), m.hidden().unwrap().message()); + for leak in ["Ivy", "Companion", "summary that must not leak", "42"] { + assert!(!shown.contains(leak), "{shown} leaked {leak}"); + } + assert_eq!(m.display_name(), HIDDEN_TITLE); + } + + #[test] + fn the_denied_explanation_points_at_the_setting_that_fixes_it() { + assert!(HiddenReason::AdultDenied + .message() + .contains("next.nexusmods.com/settings/content-blocking")); + } + + #[test] + fn an_unavailable_mod_is_withheld_whatever_the_account_allows() { + let mut payload = mod_payload(Some(false)); + payload["available"] = serde_json::Value::Bool(false); + let m = gated(&payload, AdultPolicy::Allowed); + assert_eq!(m.hidden(), Some(HiddenReason::Unavailable)); + assert!(m.name.is_empty()); + } + + #[test] + fn a_hidden_mod_yields_no_download_sidecar() { + // The gate has to sit upstream of persistence: `modName` from this file + // becomes a directory name under mods/, which no display-side check + // could ever take back. + let dir = std::env::temp_dir().join(format!("eidos-gate-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + let archive = dir.join("x.7z"); + let nxm = NxmUrl { + game: "skyrimspecialedition".into(), + mod_id: 1, + file_id: 2, + key: None, + expires: None, + user_id: None, + }; + let file = RemoteFile { + name: "Main file".into(), + file_name: "x.7z".into(), + version: "1.0".into(), + mod_version: "1.0".into(), + category_id: None, + description: String::new(), + size_in_bytes: 1, + }; + let hidden = gated(&mod_payload(Some(true)), AdultPolicy::Denied); + assert!(write_download_meta(&archive, "SkyrimSE", &nxm, "https://x/y", &file, &hidden).is_err()); + assert!(!archive.with_extension("7z.meta").exists()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn the_adult_preference_is_read_from_whichever_spelling_the_payload_uses() { + // v1 spells it contains_adult_content; the newer APIs use other names for + // the same thing. Accepting all three means a payload in a slightly + // different shape rates correctly instead of hiding every mod. + for key in ["contains_adult_content", "adult_content", "adultContent"] { + let v = serde_json::json!({ key: true }); + assert_eq!(adult_flag(&v), Some(true), "{key}"); + } + assert_eq!(adult_flag(&serde_json::json!({ "name": "x" })), None); + } + + // ---- The request budget ------------------------------------------------ + + /// A client whose last-seen budget is whatever the test says it is. + fn client_with(limits: RateLimits) -> Nexus { + let n = Nexus::with_bearer("t"); + n.limits.set(limits); + n + } + + const NOON: u64 = 1_700_000_000; // a fixed instant; the maths is absolute + + #[test] + fn a_spent_hourly_budget_blocks_the_next_request_before_it_is_sent() { + let n = client_with(RateLimits { + hourly_remaining: Some(0), + hourly_reset: Some(next_hour_utc(NOON)), + ..Default::default() + }); + let stop = n.preflight(NOON).expect("must refuse"); + assert!(is_rate_limited(&stop), "{stop}"); + assert!(stop.contains("hourly"), "{stop}"); + } + + #[test] + fn a_spent_daily_budget_blocks_the_next_request_before_it_is_sent() { + let n = client_with(RateLimits { + daily_remaining: Some(0), + daily_reset: Some(next_midnight_utc(NOON)), + ..Default::default() + }); + let stop = n.preflight(NOON).expect("must refuse"); + assert!(is_rate_limited(&stop) && stop.contains("daily"), "{stop}"); + } + + #[test] + fn either_counter_reaching_zero_is_enough_to_stop() { + // Nexus's own rule refuses only once BOTH buckets are spent, which is why + // the reference clients track the larger of the two. Their reviewer asked + // for the stricter reading, so one empty bucket stops us even while the + // other still has room. + let n = client_with(RateLimits { + hourly_remaining: Some(0), + hourly_reset: Some(next_hour_utc(NOON)), + daily_remaining: Some(4_000), + ..Default::default() + }); + assert!(n.preflight(NOON).is_some()); + } + + #[test] + fn an_unknown_budget_never_blocks_so_a_fresh_client_can_learn_it() { + // The deadlock this avoids: no headers seen yet, so if "unknown" blocked, + // the very request that would teach us the budget could never go out. + assert!(Nexus::with_bearer("t").preflight(NOON).is_none()); + } + + #[test] + fn an_account_with_no_budget_headers_at_all_is_never_blocked() { + // Whatever the account tier, absent headers mean "no budget stated". + let n = client_with(RateLimits { hourly_remaining: None, daily_remaining: None, ..Default::default() }); + assert!(n.preflight(NOON).is_none()); + assert!(!n.would_block()); + } + + #[test] + fn the_hourly_block_lifts_at_the_next_utc_hour_and_the_budget_goes_unknown() { + let reset = next_hour_utc(NOON); + let n = client_with(RateLimits { + hourly_remaining: Some(0), + hourly_reset: Some(reset), + ..Default::default() + }); + assert!(n.preflight(reset - 1).is_some(), "still inside the hour"); + assert!(n.preflight(reset).is_none(), "the boundary releases it"); + // And it forgets the spent count, so the next reply re-teaches the truth + // instead of the client carrying a stale zero forever. + assert_eq!(n.rate_limits().hourly_remaining, None); + } + + #[test] + fn the_daily_block_lifts_at_the_next_utc_midnight() { + let reset = next_midnight_utc(NOON); + let n = client_with(RateLimits { + daily_remaining: Some(0), + daily_reset: Some(reset), + ..Default::default() + }); + assert!(n.preflight(reset - 1).is_some()); + assert!(n.preflight(reset).is_none()); + } + + #[test] + fn the_reset_boundaries_are_the_next_ones_and_roll_over_cleanly() { + // 23:30 rolls to the next midnight, not to hour 24 of the same day - the + // off-by-one that a `(hour + 1) % 24` formulation invites. + let almost_midnight = next_midnight_utc(NOON) - 1_800; + assert_eq!(next_hour_utc(almost_midnight), next_midnight_utc(NOON)); + assert_eq!(next_midnight_utc(almost_midnight), next_midnight_utc(NOON)); + // Exactly on a boundary means the NEXT one, never "now". + let midnight = next_midnight_utc(NOON); + assert_eq!(next_midnight_utc(midnight), midnight + 86_400); + assert_eq!(next_hour_utc(midnight), midnight + 3_600); + } + + #[test] + fn a_burst_guard_429_backs_off_briefly_rather_than_for_an_hour() { + // A 429 while the counters still show budget is the burst guard in front + // of the API, not the quota. Standing down until the next hour for that + // would idle the client for up to an hour over a one-second burst. + let n = client_with(RateLimits { hourly_remaining: Some(90), ..Default::default() }); + n.note_rejection(429); + let until = n.rate_limits().blocked_until.expect("burst back-off recorded"); + assert!(until <= oauth::now_unix() + BURST_BACKOFF); + assert!(n.would_block()); + // And it is a back-off, not an invented zero: the counters still say what + // the server said. + assert_eq!(n.rate_limits().hourly_remaining, Some(90)); + } + + #[test] + fn a_429_that_the_spent_budget_explains_adds_no_extra_back_off() { + let n = client_with(RateLimits { + hourly_remaining: Some(0), + hourly_reset: Some(next_hour_utc(NOON)), + ..Default::default() + }); + n.note_rejection(429); + assert_eq!(n.rate_limits().blocked_until, None); + } + + #[test] + fn every_rate_limit_refusal_is_recognised_by_the_one_predicate() { + // The bug this locks out: the library tested for "rate limited" while the + // CLI tested for "429", so a pre-flight refusal - which carries no status + // code - stopped one loop and left the other hammering a spent account. + let n = client_with(RateLimits { + hourly_remaining: Some(0), + hourly_reset: Some(next_hour_utc(NOON)), + ..Default::default() + }); + assert!(is_rate_limited(&n.preflight(NOON).unwrap())); + assert!(is_rate_limited(&Nexus::status_err(429))); + assert!(!is_rate_limited(&Nexus::status_err(401))); + // A mod id or byte count that happens to contain "429" is not a budget + // refusal, which is exactly what the old substring test got wrong. + assert!(!is_rate_limited("Nexus API error (HTTP 500) for mod 42942")); + } + #[test] fn parses_a_full_mod_manager_link() { // The shape the site's "Mod Manager Download" button produces. @@ -864,6 +1579,8 @@ mod tests { summary: "".into(), category_id: Some(42), available: true, + adult: Some(false), + gate: shown_gate(), }; let meta = write_download_meta(&archive, "SkyrimSE", &nxm, "https://cdn/x.7z", &file, &rmod).unwrap(); let text = fs::read_to_string(&meta).unwrap(); @@ -934,7 +1651,7 @@ mod tests { #[test] fn status_err_maps_the_mo2_codes() { - assert_eq!(Nexus::status_err(401), "invalid API key (401)"); + assert_eq!(Nexus::status_err(401), "Nexus rejected the sign-in (401) - sign in again"); assert!(Nexus::status_err(429).contains("429")); assert!(Nexus::status_err(429).contains("rate limited")); assert_eq!(Nexus::status_err(503), "Nexus API error (HTTP 503)"); @@ -1011,6 +1728,8 @@ mod tests { summary: String::new(), category_id: None, available: true, + adult: Some(false), + gate: shown_gate(), }; let path = write_download_meta(&archive, "SkyrimSE", &nxm, "https://x/y", &file, &m).unwrap(); let body = fs::read_to_string(&path).unwrap(); @@ -1028,18 +1747,20 @@ mod tests { // ---- which credential a session actually uses ------------------------- // // `Nexus::connect` reads the disk, the clock and the environment; the RULE - // it applies does not, so it is tested here directly. These are the cases - // that decide whether a user with both an API key and a lapsed sign-in can - // still download. + // it applies does not, so it is tested here directly. Since personal API keys + // were removed at Nexus's request, these cases are about one question: when + // there is no usable session, does the client say so rather than reach for + // something it is not allowed to use. use eidos_instance::settings::NexusCreds; fn signed_in(expires_at: u64) -> NexusCreds { NexusCreds { - api_key: None, access_token: Some("at".into()), refresh_token: Some("rt".into()), expires_at, + adult_ok: None, + adult_checked_at: 0, } } @@ -1058,35 +1779,16 @@ mod tests { } #[test] - fn oauth_wins_over_a_key_that_is_also_present() { - let mut c = signed_in(10_000); - c.api_key = Some("abc".into()); - assert_eq!(choose_credential(&c, 1_000, true), CredentialChoice::Bearer); - } - - #[test] - fn a_lapsed_session_falls_back_to_the_key_instead_of_failing() { - // No client_id registered yet, so no refresh is possible - exactly the - // state Eidos ships in today. A user with a key must still work. - let mut c = signed_in(500); - c.api_key = Some("abc".into()); - assert_eq!(choose_credential(&c, 1_000, false), CredentialChoice::ApiKey); + fn a_lapsed_session_that_cannot_be_renewed_is_reported_not_worked_around() { + // No client_id registered, so no refresh is possible. There is nothing + // else to try: this used to fall back to a personal API key, which is + // exactly what Nexus requires a distributed client not to do. + assert_eq!(choose_credential(&signed_in(500), 1_000, false), CredentialChoice::None); // Same when the refresh token itself is gone. + let mut c = signed_in(500); c.refresh_token = None; - assert_eq!(choose_credential(&c, 1_000, true), CredentialChoice::ApiKey); - } - - #[test] - fn a_lapsed_session_with_no_key_is_not_silently_ok() { - assert_eq!(choose_credential(&signed_in(500), 1_000, false), CredentialChoice::None); - } - - #[test] - fn a_key_alone_is_the_ordinary_case_today() { - let c = NexusCreds { api_key: Some("abc".into()), ..Default::default() }; - assert_eq!(choose_credential(&c, 1_000, true), CredentialChoice::ApiKey); - assert!(!c.has_oauth()); + assert_eq!(choose_credential(&c, 1_000, true), CredentialChoice::None); } #[test] @@ -1095,10 +1797,10 @@ mod tests { } #[test] - fn each_credential_rides_in_its_own_header() { - // The whole point of the Credential split: a Bearer request must NOT - // carry APIKEY, and vice versa. - assert_eq!(Nexus::new("k").credential_kind(), "api_key"); + fn the_only_credential_is_a_bearer_token() { + // There is one way to authenticate and one header it rides in. If a + // second variant ever reappears here, it has to be a deliberate decision + // rather than a fallback that crept back in. assert_eq!(Nexus::with_bearer("t").credential_kind(), "oauth"); assert_eq!( Nexus::with_credential(Credential::Bearer("t".into())).credential_kind(), diff --git a/crates/eidos-nexus/src/oauth.rs b/crates/eidos-nexus/src/oauth.rs index dbcad55..7d5a20d 100644 --- a/crates/eidos-nexus/src/oauth.rs +++ b/crates/eidos-nexus/src/oauth.rs @@ -24,7 +24,7 @@ //! `MO2_NEXUS_CLIENT_ID`) and the flow works unchanged. //! //! The personal API key path stays: it needs no registration, it is what the -//! `[Nexus] api_key=` setting already holds, and MO2 keeps its equivalent too. +//! `[Nexus]` section already holds, and MO2 keeps its equivalent too. use std::io::{BufRead, BufReader, Write}; use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; @@ -286,9 +286,6 @@ pub struct Tokens { /// Unix seconds. Absolute rather than a duration, because it has to survive /// being written to disk and read back in a later session. pub expires_at: u64, - /// Some deployments hand back a v1 API key alongside the tokens; the rest of - /// Eidos speaks v1, so keep it when it is offered. - pub api_key: Option, } impl Tokens { @@ -436,11 +433,6 @@ pub fn tokens_from_json(v: &serde_json::Value, now: u64) -> Result Result Option { + let agent: ureq::Agent = + ureq::Agent::config_builder().http_status_as_error(false).build().into(); + let body = serde_json::json!({ + "query": "query preferences { preferences { adult adultBlurImages } }" + }); + let mut resp = agent + .post(GRAPHQL_URL) + .header("Application-Name", "Eidos") + .header("Application-Version", env!("CARGO_PKG_VERSION")) + .header("Authorization", format!("Bearer {access_token}")) + .send_json(&body) + .ok()?; + if !resp.status().is_success() { + return None; + } + let v: serde_json::Value = resp.body_mut().read_json().ok()?; + // A GraphQL error is reported inside a 200 body, so the status above is not + // enough: an errors array means the answer is not to be trusted. + if v.get("errors").is_some_and(|e| !e.is_null()) { + return None; + } + v.get("data")?.get("preferences")?.get("adult")?.as_bool() +} + /// Exchange the authorization code for tokens (the PKCE verifier proves we are /// the client that started the flow). pub fn exchange_code(cfg: &Config, code: &str, pkce: &Pkce) -> Result { @@ -614,7 +646,10 @@ mod tests { let t = tokens_from_json(&v, 1_000).unwrap(); assert_eq!(t.expires_at, 4_600); assert!(t.is_valid()); - assert!(t.api_key.is_none()); + // The token endpoint may hand back a v1 API key. Eidos does not parse it: + // Nexus requires personal-key usage gone from the client entirely, and a + // key that is read "just in case" is exactly what that rules out. + assert!(!format!("{t:?}").contains("api_key")); } #[test] diff --git a/crates/eidos/src/main.rs b/crates/eidos/src/main.rs index 3d20b37..4abc7d4 100644 --- a/crates/eidos/src/main.rs +++ b/crates/eidos/src/main.rs @@ -38,21 +38,14 @@ use tools::*; /// `~/.config/eidos/nexus.ini`, holding the personal Nexus API key. Delegates to /// the shared `eidos-instance` settings store so the CLI and the GUI can never /// disagree on the path or the file format. -fn nexus_key_path() -> std::path::PathBuf { - eidos_instance::settings::nexus_key_path() -} - -/// A connected Nexus client, or exit with a pointer to `eidos nexus key`. +/// A connected Nexus client, or exit with a pointer to signing in. fn nexus_client() -> eidos_nexus::Nexus { - // `connect` prefers a signed-in OAuth session and falls back to the personal - // key, so this one call covers both paths; the message below is what "no - // credential at all" looks like from the CLI. match eidos_nexus::Nexus::connect() { Ok(nexus) => nexus, Err(_) => { eprintln!( - "No Nexus API key configured. Get yours at nexusmods.com -> Site settings -> \ - API keys (Personal API Key), then run: eidos nexus key " + "Not signed in to Nexus. Sign in from the GUI (Settings -> Nexus); \ + personal API keys are not supported." ); exit(1); } @@ -62,70 +55,35 @@ fn nexus_client() -> eidos_nexus::Nexus { /// `eidos nexus key|status|update` - account + update checks. fn cmd_nexus(args: &[String]) { match args.first().map(String::as_str) { - Some("key") => { - // Read it from stdin by default. Passed as an argument it lands in the - // shell history and, for as long as the process lives, in /proc where - // any local process can read it - a credential should not be a word in - // a command line. The argument form still works so existing scripts do - // not break, but it says what it just cost. - let key = match args.get(1) { - Some(k) => { - eprintln!( - "eidos: warning - a key passed as an argument is now in your shell \ - history and was visible in /proc. Prefer: eidos nexus key (then paste)" - ); - k.clone() - } - None => { - eprint!("Paste your personal Nexus API key (it will not be echoed): "); - let _ = std::io::Write::flush(&mut std::io::stderr()); - let mut line = String::new(); - if std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut line).is_err() - { - eprintln!("\nusage: eidos nexus key (paste the key on stdin)"); - exit(2); - } - eprintln!(); - line.trim().to_string() - } - }; - if key.is_empty() { - eprintln!("No key given. Get one at https://www.nexusmods.com/users/myaccount?tab=api"); - exit(2); - } - let key = &key; - match eidos_nexus::Nexus::new(key).validate() { + Some("status") => { + let nexus = nexus_client(); + match nexus.validate() { Ok(acct) => { - let path = nexus_key_path(); - if let Err(e) = eidos_instance::settings::save_nexus_key(key) { - eprintln!("could not store the key at {}: {e}", path.display()); - exit(1); - } println!( - "Connected as {} ({}). Key stored in {}.", + "Connected as {} ({}).", acct.name, - if acct.is_premium { "premium" } else { "free" }, - path.display() + if acct.is_premium { "premium" } else { "free" } + ); + // Say it out loud. When adult metadata is being withheld the + // user needs to know it is a setting and where to change it, + // not wonder why a mod page came back blank. + println!( + "Adult content: {}", + match nexus.adult_policy() { + eidos_nexus::AdultPolicy::Allowed => "shown (enabled on your account)", + eidos_nexus::AdultPolicy::Denied => + "hidden (turned off on your account, at nexusmods.com)", + eidos_nexus::AdultPolicy::Unknown => + "hidden (Eidos could not read your account setting)", + } ); - println!("Next: register the browser handler: eidos nxm --register"); } Err(e) => { - eprintln!("key validation failed: {e}"); + eprintln!("not connected: {e}"); exit(1); } } } - Some("status") => match nexus_client().validate() { - Ok(acct) => println!( - "Connected as {} ({}).", - acct.name, - if acct.is_premium { "premium" } else { "free" } - ), - Err(e) => { - eprintln!("not connected: {e}"); - exit(1); - } - }, Some("update") => { let Some(id) = args.get(1) else { eprintln!("usage: eidos nexus update "); @@ -176,6 +134,12 @@ fn cmd_nexus(args: &[String]) { if stale { individual += 1; } + // Stop before the request is built, not after Nexus refuses it. + if nexus.would_block() { + rate_limited = true; + eprintln!(" Nexus request budget spent - stopping; remaining mods unchecked."); + break; + } match nexus.mod_info(game.def.nexus_game, mod_id) { Ok(remote) => { meta.set_newest_version(&remote.version); @@ -192,10 +156,13 @@ fn cmd_nexus(args: &[String]) { } } Err(e) => { - // MO2 stops dispatching the moment the account is exhausted. - if e.contains("429") { + // The shared predicate, not a bare "429". This loop used + // to test for the status code while the library tested + // for the wording, so a pre-flight refusal - which has no + // status code - stopped one and left this one hammering. + if eidos_nexus::is_rate_limited(&e) { rate_limited = true; - eprintln!(" rate limited by Nexus - stopping; remaining mods unchecked."); + eprintln!(" {e} - stopping; remaining mods unchecked."); break; } eprintln!(" {}: {e}", m.name); @@ -213,14 +180,13 @@ fn cmd_nexus(args: &[String]) { println!("Nexus budget: {h} request(s) left this hour{daily}."); } if rate_limited { - eprintln!("Some mods were not checked (hourly limit reached). Re-run after the hour."); + eprintln!("Some mods were not checked (request budget spent). Re-run once it refills."); } } _ => { eprintln!( "usage:\n\ - \x20 eidos nexus key connect (personal API key)\n\ - \x20 eidos nexus status check the stored key\n\ + \x20 eidos nexus status check the stored sign-in\n\ \x20 eidos nexus update check installed mods for updates" ); exit(2); @@ -239,7 +205,7 @@ fn usage() -> ! { \x20 eidos play -- run with mods mounted over the game\n\ \x20 eidos install install a downloaded mod archive (.7z/.zip/.rar)\n\ \x20 eidos tool [...] manage + run tools (xEdit/FNIS/...) through the view\n\ - \x20 eidos nexus key|status|update connect a Nexus account / check for mod updates\n\ + \x20 eidos nexus status|update check the Nexus sign-in / check for mod updates\n\ \x20 eidos nxm | --register download a Nexus Mod Manager link / register the handler\n\ \x20 eidos export [-o file] export the mod list to CSV (MO2 format; --active = enabled only)\n\ \x20 eidos sort [--dry-run] LOOT-sort the plugin load order (--update-masterlist to refresh)\n\ diff --git a/crates/eidos/src/nxm.rs b/crates/eidos/src/nxm.rs index 6e8a6db..d3b893f 100644 --- a/crates/eidos/src/nxm.rs +++ b/crates/eidos/src/nxm.rs @@ -58,21 +58,29 @@ pub(crate) fn cmd_nxm(args: &[String]) { inst.create().ok(); let nexus = nexus_client(); - let file = match nexus.file_info(&nxm.game, nxm.mod_id, nxm.file_id) { - Ok(f) => f, + // The MOD is looked up first, before its file. That ordering is not + // stylistic: the lookup is what resolves the mod's content rating, + // and the token it returns is what the two calls below require. A + // mod Eidos may not describe is one it does not fetch files for. + let remote_mod = match nexus.mod_info(&nxm.game, nxm.mod_id) { + Ok(m) => m, Err(e) => { - eprintln!("file lookup failed: {e}"); + eprintln!("mod lookup failed: {e}"); exit(1); } }; - let remote_mod = match nexus.mod_info(&nxm.game, nxm.mod_id) { - Ok(m) => m, + if let Some(why) = remote_mod.hidden() { + eprintln!("{}", why.message()); + exit(1); + } + let file = match nexus.file_info(&remote_mod.gate, &nxm.game, nxm.mod_id, nxm.file_id) { + Ok(f) => f, Err(e) => { - eprintln!("mod lookup failed: {e}"); + eprintln!("file lookup failed: {e}"); exit(1); } }; - let link = match nexus.download_link(&nxm) { + let link = match nexus.download_link(&remote_mod.gate, &nxm) { Ok(l) => l, Err(e) => { eprintln!("could not resolve the download: {e}"); diff --git a/docs/project/status.md b/docs/project/status.md index 375bc06..c1acb2f 100644 --- a/docs/project/status.md +++ b/docs/project/status.md @@ -67,8 +67,11 @@ README carries only the short version; this is the receipts. `WINEDLLOVERRIDES` forcing, so the Wine builtin cannot silently win. Nothing is ever copied into the real game folder - unlike Root Builder's copy mode, which has to restore backups afterwards and leaves debris if it dies -- [x] Nexus Mods integration (`eidos-nexus`) - connect with a personal API key - (`eidos nexus key`), register the `nxm://` handler (`eidos nxm --register`) +- [x] Nexus Mods integration (`eidos-nexus`) - sign in with OAuth (authorization + code + PKCE S256, loopback listener, no personal API key anywhere: Nexus + requires them absent from a distributed client, so Eidos needs a registered + `client_id` and has no Nexus access without one), register the `nxm://` + handler (`eidos nxm --register`) so the site's "Mod Manager Download" button downloads straight into the instance's `downloads/` (with MO2-format `.meta` sidecars), check installed mods for updates (`eidos nexus update`, MO2's rate-limit-friendly