Files
cdxs/src/sync_client.rs
T

171 lines
5.4 KiB
Rust

//! Client side of cdxs state synchronization.
//!
//! Sync only exchanges portable cdxs state. Server credentials stay on the
//! server, and the client's sync token remains local.
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
use serde::{Deserialize, Serialize};
use crate::config_store::Store;
#[derive(Debug, Serialize)]
struct LoginRequest<'a> {
username: &'a str,
password: &'a str,
}
#[derive(Debug, Deserialize)]
struct LoginResponse {
token: String,
}
pub async fn login(server: &str, user: &str, password: &str) -> Result<()> {
// Normalize the server URL once so later pull/push can simply append API
// paths without double slashes.
let server = server.trim().trim_end_matches('/');
if server.is_empty() {
return Err(anyhow!("server URL 不能为空"));
}
let response = reqwest::Client::new()
.post(format!("{server}/v1/login"))
.json(&LoginRequest {
username: user,
password,
})
.send()
.await
.context("sync login 请求失败")?;
let status = response.status();
let body = response.text().await.context("读取 sync login 响应失败")?;
if !status.is_success() {
return Err(anyhow!("sync login 失败: status={}, body={}", status, body));
}
let login: LoginResponse = serde_json::from_str(&body).context("解析 sync login 响应失败")?;
let home = crate::paths::codex_home(None)?;
let mut store = Store::load(&home)?;
store.sync.server_url = Some(server.to_string());
store.sync.username = Some(user.to_string());
store.sync.token = Some(login.token);
store.save(&home)?;
println!("sync login 成功: {server} ({user})");
Ok(())
}
pub async fn pull() -> Result<()> {
let home = crate::paths::codex_home(None)?;
let mut local = Store::load(&home)?;
let (server, token) = sync_endpoint(&local)?;
let remote = reqwest::Client::new()
.get(format!("{server}/v1/state"))
.headers(auth_headers(&token)?)
.send()
.await
.context("sync pull 请求失败")?;
let status = remote.status();
let body = remote.text().await.context("读取 sync pull 响应失败")?;
if !status.is_success() {
return Err(anyhow!("sync pull 失败: status={}, body={}", status, body));
}
let remote_store: Store = serde_json::from_str(&body).context("解析 sync pull 响应失败")?;
// Pull replaces local portable state, but keeps local sync endpoint/token.
local.meta = remote_store.meta;
local.accounts = remote_store.accounts;
local.homes = remote_store.homes;
local.sync.last_pull_at = Some(Utc::now().timestamp());
local.save(&home)?;
println!("sync pull 完成");
Ok(())
}
pub async fn push() -> Result<()> {
let home = crate::paths::codex_home(None)?;
let mut local = Store::load(&home)?;
let (server, token) = sync_endpoint(&local)?;
let mut payload = local.clone();
// Do not upload local server users or sync token back into the shared state.
payload.server = Default::default();
payload.sync = Default::default();
let response = reqwest::Client::new()
.put(format!("{server}/v1/state"))
.headers(auth_headers(&token)?)
.json(&payload)
.send()
.await
.context("sync push 请求失败")?;
let status = response.status();
let body = response.text().await.context("读取 sync push 响应失败")?;
if !status.is_success() {
return Err(anyhow!("sync push 失败: status={}, body={}", status, body));
}
local.sync.last_push_at = Some(Utc::now().timestamp());
local.save(&home)?;
println!("sync push 完成");
Ok(())
}
pub fn status() -> Result<()> {
let home = crate::paths::codex_home(None)?;
let store = Store::load(&home)?;
println!(
"server: {}",
store.sync.server_url.as_deref().unwrap_or("-")
);
println!("user: {}", store.sync.username.as_deref().unwrap_or("-"));
println!(
"token: {}",
if store.sync.token.as_deref().is_some() {
"<stored>"
} else {
"-"
}
);
println!(
"last_pull_at: {}",
store
.sync
.last_pull_at
.map(|v| v.to_string())
.unwrap_or_else(|| "-".to_string())
);
println!(
"last_push_at: {}",
store
.sync
.last_push_at
.map(|v| v.to_string())
.unwrap_or_else(|| "-".to_string())
);
Ok(())
}
fn sync_endpoint(store: &Store) -> Result<(String, String)> {
// Centralize validation so pull and push produce the same user-facing errors.
let server = store
.sync
.server_url
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.ok_or_else(|| anyhow!("未登录 sync server,请先运行 sync login"))?
.trim_end_matches('/')
.to_string();
let token = store
.sync
.token
.clone()
.ok_or_else(|| anyhow!("缺少 sync token,请重新 sync login"))?;
Ok((server, token))
}
fn auth_headers(token: &str) -> Result<HeaderMap> {
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {token}")).context("构建 Authorization 头失败")?,
);
Ok(headers)
}