1use std::path::PathBuf;
2
3fn config_dir() -> PathBuf {
4 if let Ok(dir) = std::env::var("LEAN_CTX_DATA_DIR") {
5 return PathBuf::from(dir).join("cloud");
6 }
7 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
8 home.join(".lean-ctx").join("cloud")
9}
10
11fn credentials_path() -> PathBuf {
12 config_dir().join("credentials.json")
13}
14
15pub fn api_url() -> String {
16 std::env::var("LEAN_CTX_API_URL").unwrap_or_else(|_| "https://api.leanctx.com".to_string())
17}
18
19#[derive(serde::Serialize, serde::Deserialize)]
20struct Credentials {
21 api_key: String,
22 user_id: String,
23 email: String,
24 #[serde(default)]
25 oauth_client_id: Option<String>,
26 #[serde(default)]
27 oauth_client_secret: Option<String>,
28 #[serde(default)]
29 oauth_access_token: Option<String>,
30 #[serde(default)]
31 oauth_expires_at_unix: Option<i64>,
32}
33
34fn load_credentials() -> Option<Credentials> {
35 let data = std::fs::read_to_string(credentials_path()).ok()?;
36 serde_json::from_str(&data).ok()
37}
38
39fn write_credentials(creds: &Credentials) -> std::io::Result<()> {
40 let dir = config_dir();
41 std::fs::create_dir_all(&dir)?;
42 let json = serde_json::to_string_pretty(creds).map_err(std::io::Error::other)?;
43 std::fs::write(credentials_path(), json)
44}
45
46pub fn save_credentials(api_key: &str, user_id: &str, email: &str) -> std::io::Result<()> {
47 let mut creds = load_credentials().unwrap_or(Credentials {
48 api_key: api_key.to_string(),
49 user_id: user_id.to_string(),
50 email: email.to_string(),
51 oauth_client_id: None,
52 oauth_client_secret: None,
53 oauth_access_token: None,
54 oauth_expires_at_unix: None,
55 });
56 creds.api_key = api_key.to_string();
57 creds.user_id = user_id.to_string();
58 creds.email = email.to_string();
59 creds.oauth_access_token = None;
61 creds.oauth_expires_at_unix = None;
62 write_credentials(&creds)
63}
64
65pub fn load_api_key() -> Option<String> {
66 load_credentials().map(|c| c.api_key)
67}
68
69pub fn is_logged_in() -> bool {
70 load_credentials().is_some()
71}
72
73fn now_unix() -> i64 {
74 use std::time::{SystemTime, UNIX_EPOCH};
75 SystemTime::now()
76 .duration_since(UNIX_EPOCH)
77 .unwrap_or_default()
78 .as_secs() as i64
79}
80
81fn auth_bearer_token() -> Result<String, String> {
82 let mut creds = load_credentials().ok_or("Not logged in. Run: lean-ctx login")?;
83
84 if let (Some(client_id), Some(client_secret)) = (
85 creds.oauth_client_id.clone(),
86 creds.oauth_client_secret.clone(),
87 ) {
88 let now = now_unix();
89 if let (Some(token), Some(exp)) = (
90 creds.oauth_access_token.clone(),
91 creds.oauth_expires_at_unix,
92 ) {
93 if exp > now + 10 {
94 return Ok(token);
95 }
96 }
97
98 let url = format!("{}/oauth/token", api_url());
99 let resp = ureq::post(&url)
100 .header("Content-Type", "application/x-www-form-urlencoded")
101 .send_form([
102 ("grant_type", "client_credentials"),
103 ("client_id", client_id.as_str()),
104 ("client_secret", client_secret.as_str()),
105 ])
106 .map_err(|e| format!("OAuth token request failed: {e}"))?;
107
108 let resp_body = resp
109 .into_body()
110 .read_to_string()
111 .map_err(|e| format!("Failed to read OAuth response: {e}"))?;
112
113 let json: serde_json::Value =
114 serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
115
116 let token = json["access_token"]
117 .as_str()
118 .ok_or("Missing access_token in response")?
119 .to_string();
120 let expires_in = json["expires_in"].as_i64().unwrap_or(3600);
121 let exp = now + expires_in.saturating_sub(30);
122
123 creds.oauth_access_token = Some(token.clone());
124 creds.oauth_expires_at_unix = Some(exp);
125 let _ = write_credentials(&creds);
126
127 return Ok(token);
128 }
129
130 Ok(creds.api_key)
131}
132
133pub fn oauth_register_client(client_name: Option<&str>) -> Result<String, String> {
134 let mut creds = load_credentials().ok_or("Not logged in. Run: lean-ctx login")?;
135 if creds.oauth_client_id.is_some() && creds.oauth_client_secret.is_some() {
136 return Ok("OAuth client already registered.".to_string());
137 }
138
139 let url = format!("{}/oauth/register", api_url());
140 let body = if let Some(name) = client_name {
141 serde_json::json!({ "client_name": name })
142 } else {
143 serde_json::json!({})
144 };
145
146 let resp = ureq::post(&url)
147 .header("Authorization", &format!("Bearer {}", creds.api_key))
148 .header("Content-Type", "application/json")
149 .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
150 .map_err(|e| format!("OAuth register failed: {e}"))?;
151
152 let resp_body = resp
153 .into_body()
154 .read_to_string()
155 .map_err(|e| format!("Failed to read response: {e}"))?;
156
157 let json: serde_json::Value =
158 serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
159
160 creds.oauth_client_id = Some(
161 json["client_id"]
162 .as_str()
163 .ok_or("Missing client_id in response")?
164 .to_string(),
165 );
166 creds.oauth_client_secret = Some(
167 json["client_secret"]
168 .as_str()
169 .ok_or("Missing client_secret in response")?
170 .to_string(),
171 );
172 creds.oauth_access_token = None;
173 creds.oauth_expires_at_unix = None;
174 write_credentials(&creds).map_err(|e| format!("Failed to persist OAuth credentials: {e}"))?;
175
176 Ok("OAuth client registered. Cloud requests will use short-lived access tokens.".to_string())
177}
178
179pub struct RegisterResult {
180 pub api_key: String,
181 pub user_id: String,
182 pub email_verified: bool,
183 pub verification_sent: bool,
184}
185
186pub fn register(email: &str, password: Option<&str>) -> Result<RegisterResult, String> {
187 let url = format!("{}/api/auth/register", api_url());
188 let mut body = serde_json::json!({ "email": email });
189 if let Some(pw) = password {
190 body["password"] = serde_json::Value::String(pw.to_string());
191 }
192
193 let resp = ureq::post(&url)
194 .header("Content-Type", "application/json")
195 .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
196 .map_err(|e| format!("Request failed: {e}"))?;
197
198 let resp_body = resp
199 .into_body()
200 .read_to_string()
201 .map_err(|e| format!("Failed to read response: {e}"))?;
202
203 let json: serde_json::Value =
204 serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
205
206 Ok(RegisterResult {
207 api_key: json["api_key"]
208 .as_str()
209 .ok_or("Missing api_key in response")?
210 .to_string(),
211 user_id: json["user_id"]
212 .as_str()
213 .ok_or("Missing user_id in response")?
214 .to_string(),
215 email_verified: json["email_verified"].as_bool().unwrap_or(false),
216 verification_sent: json["verification_sent"].as_bool().unwrap_or(false),
217 })
218}
219
220pub fn forgot_password(email: &str) -> Result<String, String> {
221 let url = format!("{}/api/auth/forgot-password", api_url());
222 let body = serde_json::json!({ "email": email });
223
224 let resp = ureq::post(&url)
225 .header("Content-Type", "application/json")
226 .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
227 .map_err(|e| format!("Request failed: {e}"))?;
228
229 let resp_body = resp
230 .into_body()
231 .read_to_string()
232 .map_err(|e| format!("Failed to read response: {e}"))?;
233
234 let json: serde_json::Value =
235 serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
236
237 Ok(json["message"]
238 .as_str()
239 .unwrap_or("If an account exists, a reset email has been sent.")
240 .to_string())
241}
242
243pub fn login(email: &str, password: &str) -> Result<RegisterResult, String> {
244 let url = format!("{}/api/auth/login", api_url());
245 let body = serde_json::json!({ "email": email, "password": password });
246
247 let resp = ureq::post(&url)
248 .header("Content-Type", "application/json")
249 .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
250 .map_err(|e| {
251 let msg = e.to_string();
252 if msg.contains("401") {
253 "Invalid email or password".to_string()
254 } else {
255 format!("Request failed: {e}")
256 }
257 })?;
258
259 let resp_body = resp
260 .into_body()
261 .read_to_string()
262 .map_err(|e| format!("Failed to read response: {e}"))?;
263
264 let json: serde_json::Value =
265 serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
266
267 Ok(RegisterResult {
268 api_key: json["api_key"]
269 .as_str()
270 .ok_or("Missing api_key in response")?
271 .to_string(),
272 user_id: json["user_id"]
273 .as_str()
274 .ok_or("Missing user_id in response")?
275 .to_string(),
276 email_verified: json["email_verified"].as_bool().unwrap_or(false),
277 verification_sent: false,
278 })
279}
280
281pub fn sync_stats(stats: &[serde_json::Value]) -> Result<String, String> {
282 let bearer = auth_bearer_token()?;
283 let url = format!("{}/api/stats", api_url());
284
285 let body = serde_json::json!({ "stats": stats });
286
287 let resp = ureq::post(&url)
288 .header("Authorization", &format!("Bearer {bearer}"))
289 .header("Content-Type", "application/json")
290 .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
291 .map_err(|e| format!("Sync failed: {e}"))?;
292
293 let resp_body = resp
294 .into_body()
295 .read_to_string()
296 .map_err(|e| format!("Failed to read response: {e}"))?;
297
298 let json: serde_json::Value =
299 serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
300
301 Ok(json["message"].as_str().unwrap_or("Synced").to_string())
302}
303
304pub fn contribute(entries: &[serde_json::Value]) -> Result<String, String> {
305 let url = format!("{}/api/contribute", api_url());
306
307 let body = serde_json::json!({ "entries": entries });
308
309 let resp = ureq::post(&url)
310 .header("Content-Type", "application/json")
311 .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
312 .map_err(|e| format!("Contribute failed: {e}"))?;
313
314 let resp_body = resp
315 .into_body()
316 .read_to_string()
317 .map_err(|e| format!("Failed to read response: {e}"))?;
318
319 let json: serde_json::Value =
320 serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
321
322 Ok(json["message"]
323 .as_str()
324 .unwrap_or("Contributed")
325 .to_string())
326}
327
328#[derive(serde::Deserialize)]
332pub struct PublishedCard {
333 pub id: String,
334 #[serde(default)]
335 pub edit_token: Option<String>,
336 pub url: String,
337}
338
339pub fn publish_wrapped(payload: &serde_json::Value) -> Result<PublishedCard, String> {
343 let url = format!("{}/api/wrapped", api_url());
344
345 let resp = ureq::post(&url)
346 .header("Content-Type", "application/json")
347 .send(&serde_json::to_vec(payload).map_err(|e| format!("JSON error: {e}"))?)
348 .map_err(|e| format!("Publish failed: {e}"))?;
349
350 let resp_body = resp
351 .into_body()
352 .read_to_string()
353 .map_err(|e| format!("Failed to read response: {e}"))?;
354
355 serde_json::from_str(&resp_body).map_err(|e| format!("Invalid response: {e}"))
356}
357
358pub fn unpublish_wrapped(id: &str, edit_token: &str) -> Result<(), String> {
360 let url = format!("{}/api/wrapped/{id}", api_url());
361
362 ureq::delete(&url)
363 .header("X-Edit-Token", edit_token)
364 .call()
365 .map_err(|e| format!("Unpublish failed: {e}"))?;
366 Ok(())
367}
368
369pub fn push_knowledge(entries: &[serde_json::Value]) -> Result<String, String> {
370 let bearer = auth_bearer_token()?;
371 let url = format!("{}/api/sync/knowledge", api_url());
372
373 let body = serde_json::json!({ "entries": entries });
374
375 let resp = ureq::post(&url)
376 .header("Authorization", &format!("Bearer {bearer}"))
377 .header("Content-Type", "application/json")
378 .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
379 .map_err(|e| format!("Push failed: {e}"))?;
380
381 let resp_body = resp
382 .into_body()
383 .read_to_string()
384 .map_err(|e| format!("Failed to read response: {e}"))?;
385
386 let json: serde_json::Value =
387 serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
388
389 Ok(format!(
390 "{} entries synced",
391 json["synced"].as_i64().unwrap_or(0)
392 ))
393}
394
395pub fn pull_cloud_models() -> Result<serde_json::Value, String> {
396 let bearer = auth_bearer_token()?;
397 let url = format!("{}/api/cloud/models", api_url());
398
399 let resp = ureq::get(&url)
400 .header("Authorization", &format!("Bearer {bearer}"))
401 .call()
402 .map_err(|e| {
403 let msg = e.to_string();
404 if msg.contains("403") {
405 "This feature is not available for your account.".to_string()
406 } else {
407 format!("Connection failed. Check your internet connection. ({e})")
408 }
409 })?;
410
411 let resp_body = resp
412 .into_body()
413 .read_to_string()
414 .map_err(|e| format!("Failed to read response: {e}"))?;
415
416 serde_json::from_str(&resp_body).map_err(|e| format!("Invalid response: {e}"))
417}
418
419pub fn save_cloud_models(data: &serde_json::Value) -> std::io::Result<()> {
420 let dir = config_dir();
421 std::fs::create_dir_all(&dir)?;
422 let json = serde_json::to_string_pretty(data).map_err(std::io::Error::other)?;
423 std::fs::write(dir.join("cloud_models.json"), json)
424}
425
426pub fn load_cloud_models() -> Option<serde_json::Value> {
427 let path = config_dir().join("cloud_models.json");
428 let data = std::fs::read_to_string(path).ok()?;
429 serde_json::from_str(&data).ok()
430}
431
432pub fn is_cloud_user() -> bool {
433 let path = config_dir().join("plan.txt");
434 std::fs::read_to_string(path).is_ok_and(|p| matches!(p.trim(), "cloud" | "pro"))
435}
436
437pub fn save_plan(plan: &str) -> std::io::Result<()> {
438 let dir = config_dir();
439 std::fs::create_dir_all(&dir)?;
440 std::fs::write(dir.join("plan.txt"), plan)
441}
442
443pub fn fetch_plan() -> Result<String, String> {
444 let bearer = auth_bearer_token()?;
445 let url = format!("{}/api/auth/me", api_url());
446
447 let resp = ureq::get(&url)
448 .header("Authorization", &format!("Bearer {bearer}"))
449 .call()
450 .map_err(|e| format!("Failed to check plan: {e}"))?;
451
452 let resp_body = resp
453 .into_body()
454 .read_to_string()
455 .map_err(|e| format!("Failed to read response: {e}"))?;
456
457 let json: serde_json::Value =
458 serde_json::from_str(&resp_body).map_err(|e| format!("Invalid response: {e}"))?;
459
460 Ok(json["plan"].as_str().unwrap_or("free").to_string())
461}
462
463pub fn push_commands(entries: &[serde_json::Value]) -> Result<String, String> {
464 let bearer = auth_bearer_token()?;
465 let url = format!("{}/api/sync/commands", api_url());
466 let body = serde_json::json!({ "commands": entries });
467 let resp = ureq::post(&url)
468 .header("Authorization", &format!("Bearer {bearer}"))
469 .header("Content-Type", "application/json")
470 .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
471 .map_err(|e| format!("Push failed: {e}"))?;
472 let resp_body = resp
473 .into_body()
474 .read_to_string()
475 .map_err(|e| format!("Failed to read response: {e}"))?;
476 let json: serde_json::Value =
477 serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
478 Ok(format!(
479 "{} commands synced",
480 json["synced"].as_i64().unwrap_or(0)
481 ))
482}
483
484pub fn push_cep(entries: &[serde_json::Value]) -> Result<String, String> {
485 let bearer = auth_bearer_token()?;
486 let url = format!("{}/api/sync/cep", api_url());
487 let body = serde_json::json!({ "scores": entries });
488 let resp = ureq::post(&url)
489 .header("Authorization", &format!("Bearer {bearer}"))
490 .header("Content-Type", "application/json")
491 .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
492 .map_err(|e| format!("Push failed: {e}"))?;
493 let resp_body = resp
494 .into_body()
495 .read_to_string()
496 .map_err(|e| format!("Failed to read response: {e}"))?;
497 let json: serde_json::Value =
498 serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
499 Ok(format!(
500 "{} sessions synced",
501 json["synced"].as_i64().unwrap_or(0)
502 ))
503}
504
505pub fn push_gain(entries: &[serde_json::Value]) -> Result<String, String> {
506 let bearer = auth_bearer_token()?;
507 let url = format!("{}/api/sync/gain", api_url());
508 let body = serde_json::json!({ "scores": entries });
509 let resp = ureq::post(&url)
510 .header("Authorization", &format!("Bearer {bearer}"))
511 .header("Content-Type", "application/json")
512 .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
513 .map_err(|e| format!("Push failed: {e}"))?;
514 let resp_body = resp
515 .into_body()
516 .read_to_string()
517 .map_err(|e| format!("Failed to read response: {e}"))?;
518 let json: serde_json::Value =
519 serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
520 Ok(format!(
521 "{} gain scores synced",
522 json["synced"].as_i64().unwrap_or(0)
523 ))
524}
525
526pub fn push_gotchas(entries: &[serde_json::Value]) -> Result<String, String> {
527 let bearer = auth_bearer_token()?;
528 let url = format!("{}/api/sync/gotchas", api_url());
529 let body = serde_json::json!({ "gotchas": entries });
530 let resp = ureq::post(&url)
531 .header("Authorization", &format!("Bearer {bearer}"))
532 .header("Content-Type", "application/json")
533 .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
534 .map_err(|e| format!("Push failed: {e}"))?;
535 let resp_body = resp
536 .into_body()
537 .read_to_string()
538 .map_err(|e| format!("Failed to read response: {e}"))?;
539 let json: serde_json::Value =
540 serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
541 Ok(format!(
542 "{} gotchas synced",
543 json["synced"].as_i64().unwrap_or(0)
544 ))
545}
546
547pub fn push_buddy(data: &serde_json::Value) -> Result<String, String> {
548 let bearer = auth_bearer_token()?;
549 let url = format!("{}/api/sync/buddy", api_url());
550 let resp = ureq::post(&url)
551 .header("Authorization", &format!("Bearer {bearer}"))
552 .header("Content-Type", "application/json")
553 .send(&serde_json::to_vec(data).map_err(|e| format!("JSON error: {e}"))?)
554 .map_err(|e| format!("Push failed: {e}"))?;
555 let resp_body = resp
556 .into_body()
557 .read_to_string()
558 .map_err(|e| format!("Failed to read response: {e}"))?;
559 let _json: serde_json::Value =
560 serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
561 Ok("Buddy synced".to_string())
562}
563
564pub fn push_feedback(entries: &[serde_json::Value]) -> Result<String, String> {
565 let bearer = auth_bearer_token()?;
566 let url = format!("{}/api/sync/feedback", api_url());
567 let resp = ureq::post(&url)
568 .header("Authorization", &format!("Bearer {bearer}"))
569 .header("Content-Type", "application/json")
570 .send(&serde_json::to_vec(entries).map_err(|e| format!("JSON error: {e}"))?)
571 .map_err(|e| format!("Push failed: {e}"))?;
572 let resp_body = resp
573 .into_body()
574 .read_to_string()
575 .map_err(|e| format!("Failed to read response: {e}"))?;
576 let json: serde_json::Value =
577 serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
578 Ok(format!(
579 "{} thresholds synced",
580 json["synced"].as_i64().unwrap_or(0)
581 ))
582}
583
584pub fn pull_knowledge() -> Result<Vec<serde_json::Value>, String> {
585 let bearer = auth_bearer_token()?;
586 let url = format!("{}/api/sync/knowledge", api_url());
587
588 let resp = ureq::get(&url)
589 .header("Authorization", &format!("Bearer {bearer}"))
590 .call()
591 .map_err(|e| format!("Pull failed: {e}"))?;
592
593 let resp_body = resp
594 .into_body()
595 .read_to_string()
596 .map_err(|e| format!("Failed to read response: {e}"))?;
597
598 let entries: Vec<serde_json::Value> =
599 serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
600
601 Ok(entries)
602}