171 lines
5.4 KiB
Rust
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)
|
|
}
|