1use std::path::PathBuf;
15use std::process::Command;
16
17use anyhow::{Context, Result, bail};
18use serde::{Deserialize, Serialize};
19
20use crate::config::ConfigPaths;
21
22const DEFAULT_API_URL: &str = "https://app.ryra.dev";
26
27pub fn api_base_url() -> String {
29 match std::env::var("RYRA_API_URL") {
30 Ok(v) if !v.trim().is_empty() => v.trim().trim_end_matches('/').to_string(),
31 _ => DEFAULT_API_URL.to_string(),
32 }
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Credentials {
39 pub token: String,
41}
42
43fn credentials_path() -> Result<PathBuf> {
44 Ok(ConfigPaths::resolve()?.config_dir.join("credentials.toml"))
45}
46
47pub fn load_credentials() -> Result<Option<Credentials>> {
49 let path = credentials_path()?;
50 match std::fs::read_to_string(&path) {
51 Ok(s) => {
52 let creds =
53 toml::from_str(&s).with_context(|| format!("parsing {}", path.display()))?;
54 Ok(Some(creds))
55 }
56 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
57 Err(e) => Err(anyhow::Error::new(e).context(format!("reading {}", path.display()))),
58 }
59}
60
61pub enum TokenSource {
64 Env(String),
66 Stored(String),
68}
69
70impl TokenSource {
71 pub fn token(&self) -> &str {
72 match self {
73 TokenSource::Env(t) | TokenSource::Stored(t) => t,
74 }
75 }
76}
77
78pub fn effective_token() -> Result<Option<TokenSource>> {
82 if let Ok(t) = std::env::var("RYRA_TOKEN") {
83 let t = t.trim().to_string();
84 if !t.is_empty() {
85 return Ok(Some(TokenSource::Env(t)));
86 }
87 }
88 Ok(load_credentials()?.map(|c| TokenSource::Stored(c.token)))
89}
90
91pub fn save_credentials(creds: &Credentials) -> Result<()> {
93 let paths = ConfigPaths::resolve()?;
94 paths.ensure_dirs()?;
95 let path = paths.config_dir.join("credentials.toml");
96 let body = toml::to_string(creds).context("serializing credentials")?;
97 crate::system::atomic_write::atomic_write(&path, body.as_bytes(), 0o600)?;
98 Ok(())
99}
100
101pub fn delete_credentials() -> Result<bool> {
104 let path = credentials_path()?;
105 match std::fs::remove_file(&path) {
106 Ok(()) => Ok(true),
107 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
108 Err(e) => Err(anyhow::Error::new(e).context(format!("removing {}", path.display()))),
109 }
110}
111
112pub fn revoke_stored_key() -> Result<bool> {
121 let Some(creds) = load_credentials()? else {
122 return Ok(false);
123 };
124 let resp = curl("POST", "/api/v1/account/logout", &creds.token, None)?;
125 match resp.status {
126 200 | 204 | 401 | 404 => Ok(true),
129 other => bail!("the control plane returned HTTP {other} revoking the key"),
130 }
131}
132
133struct ApiResponse {
135 status: u16,
136 body: String,
137}
138
139fn curl(method: &str, path: &str, token: &str, body: Option<&str>) -> Result<ApiResponse> {
143 curl_inner(method, path, Some(token), body)
144}
145
146fn curl_unauthed(method: &str, path: &str, body: Option<&str>) -> Result<ApiResponse> {
149 curl_inner(method, path, None, body)
150}
151
152fn curl_inner(
153 method: &str,
154 path: &str,
155 token: Option<&str>,
156 body: Option<&str>,
157) -> Result<ApiResponse> {
158 let url = format!("{}{}", api_base_url(), path);
159 let mut cmd = Command::new("curl");
160 cmd.args(["-sS", "-X", method])
161 .arg("-H")
162 .arg("Accept: application/json")
163 .arg("-w")
164 .arg("\n%{http_code}");
165 if let Some(t) = token {
166 cmd.arg("-H").arg(format!("Authorization: Bearer {t}"));
167 }
168 if let Some(b) = body {
169 cmd.args(["-H", "Content-Type: application/json", "--data-binary", b]);
170 }
171 cmd.arg(&url);
172 let out = cmd
173 .output()
174 .with_context(|| format!("curl {method} {url}"))?;
175 if !out.status.success() {
176 let err = String::from_utf8_lossy(&out.stderr);
177 bail!("could not reach {url}: {}", err.trim());
178 }
179 let combined = String::from_utf8_lossy(&out.stdout).into_owned();
180 let (body, code) = combined
181 .rsplit_once('\n')
182 .ok_or_else(|| anyhow::anyhow!("malformed curl response from {url} (no status code)"))?;
183 let status: u16 = code
184 .trim()
185 .parse()
186 .with_context(|| format!("parsing HTTP status from {code:?}"))?;
187 Ok(ApiResponse {
188 status,
189 body: body.to_string(),
190 })
191}
192
193pub fn verify_token(token: &str) -> Result<()> {
201 let resp = curl("GET", "/api/v1/auth/whoami", token, None)?;
202 match resp.status {
203 200 => Ok(()),
204 401 | 403 => bail!(
205 "the control plane rejected this API key (HTTP {}). \
206 Generate a fresh key at {}/account.",
207 resp.status,
208 api_base_url()
209 ),
210 other => {
211 let detail = resp.body.trim();
212 if detail.is_empty() {
213 bail!("unexpected response from the control plane: HTTP {other}");
214 }
215 bail!("unexpected response from the control plane: HTTP {other}: {detail}");
216 }
217 }
218}
219
220#[derive(Deserialize)]
224pub struct DeviceStart {
225 pub device_code: String,
227 pub user_code: String,
229 pub verification_uri: String,
231 pub verification_uri_complete: String,
233 pub expires_in: u64,
235 pub interval: u64,
237}
238
239pub enum DevicePoll {
241 Pending,
243 Approved(String),
245 Denied,
247 Expired,
249}
250
251pub fn device_start(label: &str) -> Result<DeviceStart> {
255 #[derive(Serialize)]
256 struct Req<'a> {
257 label: &'a str,
258 }
259 let body = serde_json::to_string(&Req { label }).context("encoding device/start request")?;
260 let resp = curl_unauthed("POST", "/api/v1/device/start", Some(&body))?;
261 match resp.status {
262 200 => serde_json::from_str(&resp.body).context("parsing device/start response"),
263 other => {
264 let detail = resp.body.trim();
265 if detail.is_empty() {
266 bail!("could not start device login: HTTP {other}");
267 }
268 bail!("could not start device login: HTTP {other}: {detail}");
269 }
270 }
271}
272
273pub fn device_poll(device_code: &str) -> Result<DevicePoll> {
276 #[derive(Serialize)]
277 struct Req<'a> {
278 device_code: &'a str,
279 }
280 let body =
281 serde_json::to_string(&Req { device_code }).context("encoding device/poll request")?;
282 let resp = curl_unauthed("POST", "/api/v1/device/poll", Some(&body))?;
283 match resp.status {
284 200 => {
285 #[derive(Deserialize)]
286 struct Body {
287 status: String,
288 key: Option<String>,
289 }
290 let b: Body =
291 serde_json::from_str(&resp.body).context("parsing device/poll response")?;
292 match b.status.as_str() {
293 "pending" => Ok(DevicePoll::Pending),
294 "approved" => {
295 let key = b.key.filter(|k| !k.trim().is_empty()).ok_or_else(|| {
296 anyhow::anyhow!("control plane approved the login but returned no key")
297 })?;
298 Ok(DevicePoll::Approved(key))
299 }
300 "denied" => Ok(DevicePoll::Denied),
301 "expired" => Ok(DevicePoll::Expired),
302 other => bail!("unexpected device login status from the control plane: {other:?}"),
303 }
304 }
305 other => {
306 let detail = resp.body.trim();
307 if detail.is_empty() {
308 bail!("could not check device login: HTTP {other}");
309 }
310 bail!("could not check device login: HTTP {other}: {detail}");
311 }
312 }
313}
314
315pub enum BackupState {
317 None,
319 Active { used_bytes: i64, quota_bytes: i64 },
321 Inactive(String),
323}
324
325pub fn backup_status(token: &str) -> Result<BackupState> {
327 let resp = curl("GET", "/api/v1/backup", token, None)?;
328 match resp.status {
329 200 => {
330 #[derive(Deserialize)]
331 struct Body {
332 status: String,
333 used_bytes: i64,
334 quota_bytes: i64,
335 }
336 let b: Body = serde_json::from_str(&resp.body).context("parsing backup status")?;
337 if b.status == "active" {
338 Ok(BackupState::Active {
339 used_bytes: b.used_bytes,
340 quota_bytes: b.quota_bytes,
341 })
342 } else {
343 Ok(BackupState::Inactive(b.status))
344 }
345 }
346 404 => Ok(BackupState::None),
347 401 | 403 => bail!(
348 "the control plane rejected this key (HTTP {}). Re-run `ryra account login`.",
349 resp.status
350 ),
351 other => bail!("unexpected response from the control plane: HTTP {other}"),
352 }
353}
354
355#[derive(Deserialize)]
357struct VendedCredentials {
358 access_key_id: String,
359 secret_access_key: String,
360 session_token: String,
361 endpoint: String,
362 bucket: String,
363 prefix: String,
364}
365
366fn vend_credentials(token: &str) -> Result<VendedCredentials> {
369 let resp = curl("POST", "/api/v1/backup/credentials", token, None)?;
370 match resp.status {
371 200 => serde_json::from_str(&resp.body).context("parsing vended backup credentials"),
372 401 | 403 => {
373 bail!("this key can't vend backup credentials; it needs the backups.write scope")
374 }
375 404 => {
376 bail!("no managed backup plan for this account; run `ryra backup config` to set one up")
377 }
378 409 => bail!("managed backup is not available: {}", resp.body.trim()),
379 other => bail!(
380 "unexpected response vending backup credentials: HTTP {other}: {}",
381 resp.body.trim()
382 ),
383 }
384}
385
386pub fn resolve_managed_backend() -> Result<crate::config::schema::BackupBackend> {
390 let src = effective_token()?.ok_or_else(|| {
391 anyhow::anyhow!("managed backups need a ryra account; run `ryra account login`")
392 })?;
393 let c = vend_credentials(src.token())?;
394 Ok(crate::config::schema::BackupBackend::S3 {
395 endpoint: c.endpoint,
396 bucket: c.bucket,
397 access_key_id: c.access_key_id,
398 secret_access_key: c.secret_access_key,
399 session_token: (!c.session_token.is_empty()).then_some(c.session_token),
402 prefix: Some(c.prefix),
403 })
404}