use std::path::PathBuf;
use std::process::Command;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use crate::config::ConfigPaths;
const DEFAULT_API_URL: &str = "https://app.ryra.dev";
pub fn api_base_url() -> String {
match std::env::var("RYRA_API_URL") {
Ok(v) if !v.trim().is_empty() => v.trim().trim_end_matches('/').to_string(),
_ => DEFAULT_API_URL.to_string(),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Credentials {
pub token: String,
}
fn credentials_path() -> Result<PathBuf> {
Ok(ConfigPaths::resolve()?.config_dir.join("credentials.toml"))
}
pub fn load_credentials() -> Result<Option<Credentials>> {
let path = credentials_path()?;
match std::fs::read_to_string(&path) {
Ok(s) => {
let creds =
toml::from_str(&s).with_context(|| format!("parsing {}", path.display()))?;
Ok(Some(creds))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(anyhow::Error::new(e).context(format!("reading {}", path.display()))),
}
}
pub enum TokenSource {
Env(String),
Stored(String),
}
impl TokenSource {
pub fn token(&self) -> &str {
match self {
TokenSource::Env(t) | TokenSource::Stored(t) => t,
}
}
}
pub fn effective_token() -> Result<Option<TokenSource>> {
if let Ok(t) = std::env::var("RYRA_TOKEN") {
let t = t.trim().to_string();
if !t.is_empty() {
return Ok(Some(TokenSource::Env(t)));
}
}
Ok(load_credentials()?.map(|c| TokenSource::Stored(c.token)))
}
pub fn save_credentials(creds: &Credentials) -> Result<()> {
let paths = ConfigPaths::resolve()?;
paths.ensure_dirs()?;
let path = paths.config_dir.join("credentials.toml");
let body = toml::to_string(creds).context("serializing credentials")?;
crate::system::atomic_write::atomic_write(&path, body.as_bytes(), 0o600)?;
Ok(())
}
pub fn delete_credentials() -> Result<bool> {
let path = credentials_path()?;
match std::fs::remove_file(&path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(anyhow::Error::new(e).context(format!("removing {}", path.display()))),
}
}
struct ApiResponse {
status: u16,
body: String,
}
fn curl(method: &str, path: &str, token: &str, body: Option<&str>) -> Result<ApiResponse> {
let url = format!("{}{}", api_base_url(), path);
let mut cmd = Command::new("curl");
cmd.args(["-sS", "-X", method])
.arg("-H")
.arg(format!("Authorization: Bearer {token}"))
.arg("-H")
.arg("Accept: application/json")
.arg("-w")
.arg("\n%{http_code}");
if let Some(b) = body {
cmd.args(["-H", "Content-Type: application/json", "--data-binary", b]);
}
cmd.arg(&url);
let out = cmd
.output()
.with_context(|| format!("curl {method} {url}"))?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
bail!("could not reach {url}: {}", err.trim());
}
let combined = String::from_utf8_lossy(&out.stdout).into_owned();
let (body, code) = combined
.rsplit_once('\n')
.ok_or_else(|| anyhow::anyhow!("malformed curl response from {url} (no status code)"))?;
let status: u16 = code
.trim()
.parse()
.with_context(|| format!("parsing HTTP status from {code:?}"))?;
Ok(ApiResponse {
status,
body: body.to_string(),
})
}
pub fn verify_token(token: &str) -> Result<()> {
let resp = curl("GET", "/api/v1/plans", token, None)?;
match resp.status {
200 => Ok(()),
401 | 403 => bail!(
"the control plane rejected this API key (HTTP {}). \
Generate a fresh key at {}/account.",
resp.status,
api_base_url()
),
other => {
let detail = resp.body.trim();
if detail.is_empty() {
bail!("unexpected response from the control plane: HTTP {other}");
}
bail!("unexpected response from the control plane: HTTP {other}: {detail}");
}
}
}
pub enum BackupState {
None,
Active { used_bytes: i64, quota_bytes: i64 },
Inactive(String),
}
pub fn backup_status(token: &str) -> Result<BackupState> {
let resp = curl("GET", "/api/v1/backup", token, None)?;
match resp.status {
200 => {
#[derive(Deserialize)]
struct Body {
status: String,
used_bytes: i64,
quota_bytes: i64,
}
let b: Body = serde_json::from_str(&resp.body).context("parsing backup status")?;
if b.status == "active" {
Ok(BackupState::Active {
used_bytes: b.used_bytes,
quota_bytes: b.quota_bytes,
})
} else {
Ok(BackupState::Inactive(b.status))
}
}
404 => Ok(BackupState::None),
401 | 403 => bail!(
"the control plane rejected this key (HTTP {}). Re-run `ryra account login`.",
resp.status
),
other => bail!("unexpected response from the control plane: HTTP {other}"),
}
}
pub fn backup_checkout(token: &str) -> Result<String> {
let resp = curl("POST", "/api/v1/billing/backup-checkout", token, None)?;
match resp.status {
200 => {
#[derive(Deserialize)]
struct Body {
url: String,
}
let b: Body = serde_json::from_str(&resp.body).context("parsing checkout response")?;
Ok(b.url)
}
401 | 403 => bail!(
"this key can't start a backup checkout: it needs an account-scoped key with \
the billing.write scope. Generate one in the dashboard."
),
409 => bail!("backups can't be purchased right now: {}", resp.body.trim()),
other => bail!(
"unexpected response from the control plane: HTTP {other}: {}",
resp.body.trim()
),
}
}
#[derive(Deserialize)]
struct VendedCredentials {
access_key_id: String,
secret_access_key: String,
session_token: String,
endpoint: String,
bucket: String,
prefix: String,
}
fn vend_credentials(token: &str) -> Result<VendedCredentials> {
let resp = curl("POST", "/api/v1/backup/credentials", token, None)?;
match resp.status {
200 => serde_json::from_str(&resp.body).context("parsing vended backup credentials"),
401 | 403 => {
bail!("this key can't vend backup credentials; it needs the backups.write scope")
}
404 => bail!(
"no managed backup plan for this account; run `ryra backup configure` to set one up"
),
409 => bail!("managed backup is not available: {}", resp.body.trim()),
other => bail!(
"unexpected response vending backup credentials: HTTP {other}: {}",
resp.body.trim()
),
}
}
pub fn resolve_managed_backend() -> Result<crate::config::schema::BackupBackend> {
let src = effective_token()?.ok_or_else(|| {
anyhow::anyhow!("managed backups need a ryra account; run `ryra account login`")
})?;
let c = vend_credentials(src.token())?;
Ok(crate::config::schema::BackupBackend::S3 {
endpoint: c.endpoint,
bucket: c.bucket,
access_key_id: c.access_key_id,
secret_access_key: c.secret_access_key,
session_token: Some(c.session_token),
prefix: Some(c.prefix),
})
}