1use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, bail};
8use keyring_core::{Entry, Error as KeyringError};
9use modde_core::paths;
10use modde_core::settings::AppSettings;
11use reqwest::Client;
12use serde::Deserialize;
13use tracing::{debug, info, warn};
14
15use crate::error::{SourceResult, status_error};
16
17const KEYRING_SERVICE: &str = "modde";
18const KEYRING_KEY: &str = "nexus-api-key";
19
20#[derive(Debug, Deserialize)]
21struct ValidateResponse {
22 #[serde(default)]
23 is_premium: bool,
24 name: Option<String>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ApiKeySource {
30 OAuth,
31 ModdeConfigFile,
32 Environment,
33 Keyring,
34 EnvironmentFile,
35 LegacySettingsToml,
36}
37
38impl ApiKeySource {
39 #[must_use]
41 pub fn label(&self) -> &'static str {
42 match self {
43 Self::OAuth => "OAuth token",
44 Self::ModdeConfigFile => "modde config file",
45 Self::Environment => "NEXUS_API_KEY",
46 Self::Keyring => "system keyring",
47 Self::EnvironmentFile => "NEXUS_API_KEY_FILE",
48 Self::LegacySettingsToml => "legacy settings.toml",
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct LoadedApiKey {
56 pub key: String,
57 pub source: ApiKeySource,
58}
59
60pub fn store_api_key(api_key: &str) -> Result<()> {
62 let entry = keyring_entry(KEYRING_SERVICE, KEYRING_KEY)?;
63 entry
64 .set_password(api_key)
65 .context("failed to store API key in keyring")?;
66 info!("Nexus API key stored in system keyring");
67 Ok(())
68}
69
70pub fn delete_api_key() -> Result<()> {
72 let entry = keyring_entry(KEYRING_SERVICE, KEYRING_KEY)?;
73 entry
74 .delete_credential()
75 .context("failed to delete API key from keyring")?;
76 info!("Nexus API key removed from system keyring");
77 Ok(())
78}
79
80fn load_from_keyring() -> Option<String> {
82 let entry = match keyring_entry(KEYRING_SERVICE, KEYRING_KEY) {
83 Ok(e) => e,
84 Err(e) => {
85 debug!("keyring unavailable: {e}");
86 return None;
87 }
88 };
89 match entry.get_password() {
90 Ok(key) if !key.is_empty() => {
91 debug!("loaded API key from system keyring");
92 Some(key)
93 }
94 Ok(_) => None,
95 Err(KeyringError::NoEntry) => None,
96 Err(e) => {
97 warn!("failed to read from keyring: {e}");
98 None
99 }
100 }
101}
102
103fn keyring_entry(service: &str, key: &str) -> Result<Entry> {
104 keyring::use_native_store(false).context("failed to initialize system keyring store")?;
105 Entry::new(service, key).context("failed to create keyring entry")
106}
107
108pub fn load_api_key() -> Result<String> {
118 load_api_key_with_source().map(|loaded| loaded.key)
119}
120
121pub fn load_api_key_with_source() -> Result<LoadedApiKey> {
123 if let Some(token) = super::oauth::load_token() {
124 if !token.is_expired() {
125 debug!("using OAuth token for Nexus authentication");
126 return Ok(LoadedApiKey {
127 key: token.access_token,
128 source: ApiKeySource::OAuth,
129 });
130 }
131 debug!("OAuth token expired, falling back to API key");
132 }
133
134 resolve_api_key_from_sources(
135 &config_api_key_path(),
136 std::env::var("NEXUS_API_KEY").ok(),
137 load_from_keyring(),
138 std::env::var("NEXUS_API_KEY_FILE").ok().map(PathBuf::from),
139 AppSettings::load().nexus_api_key,
140 )
141}
142
143#[must_use]
145pub fn config_api_key_path() -> std::path::PathBuf {
146 paths::modde_config_dir().join("nexus_api_key")
147}
148
149#[must_use]
151pub fn config_api_key_exists() -> bool {
152 config_api_key_path().exists()
153}
154
155pub fn write_config_api_key(api_key: &str) -> Result<()> {
157 write_config_api_key_to(&config_api_key_path(), api_key)
158}
159
160fn write_config_api_key_to(path: &Path, api_key: &str) -> Result<()> {
161 let api_key = api_key.trim();
162 if api_key.is_empty() {
163 bail!("API key cannot be empty");
164 }
165
166 if let Some(parent) = path.parent() {
167 std::fs::create_dir_all(parent)
168 .with_context(|| format!("failed to create {}", parent.display()))?;
169 }
170 std::fs::write(path, api_key).with_context(|| {
171 format!(
172 "failed to write Nexus API key to modde config file {}",
173 path.display()
174 )
175 })?;
176
177 #[cfg(unix)]
178 {
179 use std::os::unix::fs::PermissionsExt;
180 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
181 .with_context(|| format!("failed to restrict permissions on {}", path.display()))?;
182 }
183
184 Ok(())
185}
186
187pub fn delete_config_api_key() -> Result<()> {
189 let path = config_api_key_path();
190 if let Err(e) = std::fs::remove_file(&path)
191 && e.kind() != std::io::ErrorKind::NotFound
192 {
193 return Err(e).with_context(|| format!("failed to delete {}", path.display()));
194 }
195 Ok(())
196}
197
198fn load_from_config_file(path: &std::path::Path) -> Result<Option<String>> {
199 if !path.exists() {
200 return Ok(None);
201 }
202
203 let key = std::fs::read_to_string(path)
204 .with_context(|| format!("failed to read API key from {}", path.display()))?
205 .trim()
206 .to_string();
207 if key.is_empty() {
208 Ok(None)
209 } else {
210 Ok(Some(key))
211 }
212}
213
214fn resolve_api_key_from_sources(
215 config_path: &Path,
216 env_key: Option<String>,
217 keyring_key: Option<String>,
218 env_file_path: Option<PathBuf>,
219 legacy_settings_key: String,
220) -> Result<LoadedApiKey> {
221 if let Some(key) = load_from_config_file(config_path)? {
222 return Ok(LoadedApiKey {
223 key,
224 source: ApiKeySource::ModdeConfigFile,
225 });
226 }
227
228 if let Some(key) = env_key.map(|key| key.trim().to_string())
229 && !key.is_empty()
230 {
231 return Ok(LoadedApiKey {
232 key,
233 source: ApiKeySource::Environment,
234 });
235 }
236
237 if let Some(key) = keyring_key.map(|key| key.trim().to_string())
238 && !key.is_empty()
239 {
240 return Ok(LoadedApiKey {
241 key,
242 source: ApiKeySource::Keyring,
243 });
244 }
245
246 if let Some(path) = env_file_path {
247 let key = std::fs::read_to_string(&path)
248 .with_context(|| format!("failed to read API key from {}", path.display()))?
249 .trim()
250 .to_string();
251 if !key.is_empty() {
252 return Ok(LoadedApiKey {
253 key,
254 source: ApiKeySource::EnvironmentFile,
255 });
256 }
257 }
258
259 let legacy_settings_key = legacy_settings_key.trim().to_string();
260 if !legacy_settings_key.is_empty() {
261 return Ok(LoadedApiKey {
262 key: legacy_settings_key,
263 source: ApiKeySource::LegacySettingsToml,
264 });
265 }
266
267 bail!("No Nexus API key found. Set NEXUS_API_KEY env var or run `modde nexus auth`.")
268}
269
270pub async fn check_premium(client: &Client, api_key: &str) -> Result<bool> {
272 Ok(check_premium_source(client, api_key).await?)
273}
274
275pub async fn check_premium_source(client: &Client, api_key: &str) -> SourceResult<bool> {
277 let validate_url = format!("{}/users/validate.json", super::base_url());
278 let resp: ValidateResponse = status_error(
279 client
280 .get(&validate_url)
281 .header("apikey", api_key)
282 .send()
283 .await?,
284 )?
285 .json()
286 .await?;
287
288 info!(
289 user = resp.name.as_deref().unwrap_or("unknown"),
290 premium = resp.is_premium,
291 "Nexus account validated"
292 );
293
294 Ok(resp.is_premium)
295}
296
297#[cfg(test)]
298mod tests {
299 use super::{
300 ApiKeySource, load_from_config_file, resolve_api_key_from_sources, write_config_api_key_to,
301 };
302 use std::path::PathBuf;
303
304 #[test]
305 fn load_from_config_file_trims_key() {
306 let dir = tempfile::tempdir().unwrap();
307 let path = dir.path().join("nexus_api_key");
308 std::fs::write(&path, " test-key\n").unwrap();
309
310 let key = load_from_config_file(&path).unwrap();
311 assert_eq!(key.as_deref(), Some("test-key"));
312 }
313
314 #[test]
315 fn load_from_config_file_ignores_missing_or_empty_file() {
316 let dir = tempfile::tempdir().unwrap();
317 let missing = dir.path().join("missing");
318 assert!(load_from_config_file(&missing).unwrap().is_none());
319
320 let empty = dir.path().join("nexus_api_key");
321 std::fs::write(&empty, "\n").unwrap();
322 assert!(load_from_config_file(&empty).unwrap().is_none());
323 }
324
325 #[test]
326 fn config_file_overrides_environment() {
327 let dir = tempfile::tempdir().unwrap();
328 let path = dir.path().join("nexus_api_key");
329 std::fs::write(&path, " config-key \n").unwrap();
330
331 let loaded = resolve_api_key_from_sources(
332 &path,
333 Some("env-key".to_string()),
334 None,
335 None,
336 String::new(),
337 )
338 .unwrap();
339
340 assert_eq!(loaded.key, "config-key");
341 assert_eq!(loaded.source, ApiKeySource::ModdeConfigFile);
342 }
343
344 #[test]
345 fn missing_config_file_falls_back_to_environment() {
346 let dir = tempfile::tempdir().unwrap();
347 let path = dir.path().join("nexus_api_key");
348
349 let loaded = resolve_api_key_from_sources(
350 &path,
351 Some("env-key".to_string()),
352 None,
353 None,
354 String::new(),
355 )
356 .unwrap();
357
358 assert_eq!(loaded.key, "env-key");
359 assert_eq!(loaded.source, ApiKeySource::Environment);
360 }
361
362 #[test]
363 fn replacement_writes_only_config_file() {
364 let dir = tempfile::tempdir().unwrap();
365 let path = dir.path().join("nexus_api_key");
366
367 write_config_api_key_to(&path, " replacement-key \n").unwrap();
368
369 assert_eq!(std::fs::read_to_string(&path).unwrap(), "replacement-key");
370 let loaded = resolve_api_key_from_sources(
371 &path,
372 Some("env-key".to_string()),
373 None,
374 None,
375 String::new(),
376 )
377 .unwrap();
378
379 assert_eq!(loaded.key, "replacement-key");
380 assert_eq!(loaded.source, ApiKeySource::ModdeConfigFile);
381 }
382
383 #[test]
384 fn deleting_config_file_falls_back_to_environment() {
385 let dir = tempfile::tempdir().unwrap();
386 let path = dir.path().join("nexus_api_key");
387 write_config_api_key_to(&path, "config-key").unwrap();
388 std::fs::remove_file(&path).unwrap();
389
390 let loaded = resolve_api_key_from_sources(
391 &path,
392 Some("env-key".to_string()),
393 None,
394 None,
395 String::new(),
396 )
397 .unwrap();
398
399 assert_eq!(loaded.key, "env-key");
400 assert_eq!(loaded.source, ApiKeySource::Environment);
401 }
402
403 #[test]
404 fn environment_file_precedes_legacy_settings() {
405 let dir = tempfile::tempdir().unwrap();
406 let config_path = dir.path().join("nexus_api_key");
407 let env_file = dir.path().join("env-key");
408 std::fs::write(&env_file, "file-key").unwrap();
409
410 let loaded = resolve_api_key_from_sources(
411 &config_path,
412 None,
413 None,
414 Some(PathBuf::from(&env_file)),
415 "legacy-key".to_string(),
416 )
417 .unwrap();
418
419 assert_eq!(loaded.key, "file-key");
420 assert_eq!(loaded.source, ApiKeySource::EnvironmentFile);
421 }
422}