codex-sync 0.3.0

Sync and merge Codex conversations across computers, LAN, SSH, and offline storage
use crate::{config::Config, discovery, sync};
use anyhow::Result;
use axum::{
    Router,
    body::Body,
    extract::{Path, State},
    http::{HeaderMap, StatusCode},
    response::{Html, IntoResponse},
    routing::{get, post},
};
use std::{net::SocketAddr, sync::Arc};
use subtle::ConstantTimeEq;

type AppState = Arc<Config>;

pub async fn serve(cfg: Config) -> Result<()> {
    let addr: SocketAddr = cfg.listen.parse()?;
    let discovery_cfg = cfg.clone();
    tokio::spawn(async move {
        if let Err(e) = discovery::responder(discovery_cfg).await {
            eprintln!("发现服务停止:{e}");
        }
    });
    let state = Arc::new(cfg);
    let app = Router::new()
        .route("/", get(index))
        .route("/api/status", get(status))
        .route("/api/peers", get(peers))
        .route("/api/manifest", get(manifest))
        .route("/api/file/{*path}", get(file))
        .route("/api/pull", post(pull))
        .with_state(state);
    println!("Codex Sync 已启动:http://{addr}");
    axum::serve(tokio::net::TcpListener::bind(addr).await?, app).await?;
    Ok(())
}

fn authorized(headers: &HeaderMap, cfg: &Config) -> bool {
    let expected = format!("Bearer {}", cfg.shared_key);
    headers
        .get("authorization")
        .map(|actual| actual.as_bytes().ct_eq(expected.as_bytes()).into())
        .unwrap_or(false)
}

async fn index() -> Html<&'static str> {
    Html(include_str!("web.html"))
}

async fn status(State(cfg): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
    if !authorized(&headers, &cfg) {
        return StatusCode::UNAUTHORIZED.into_response();
    }
    status_response(&cfg)
}

fn status_response(cfg: &Config) -> axum::response::Response {
    match sync::manifest(cfg) {
        Ok(m) => axum::Json(m).into_response(),
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
    }
}

async fn peers(State(cfg): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
    if !authorized(&headers, &cfg) {
        return StatusCode::UNAUTHORIZED.into_response();
    }
    match discovery::discover(&cfg, 2).await {
        Ok(p) => axum::Json(p).into_response(),
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
    }
}

async fn manifest(State(cfg): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
    if !authorized(&headers, &cfg) {
        return StatusCode::UNAUTHORIZED.into_response();
    }
    status_response(&cfg)
}

async fn file(
    State(cfg): State<AppState>,
    headers: HeaderMap,
    Path(path): Path<String>,
) -> impl IntoResponse {
    if !authorized(&headers, &cfg) {
        return StatusCode::UNAUTHORIZED.into_response();
    }
    match read_served_file(&cfg, &path) {
        Ok(bytes) => Body::from(bytes).into_response(),
        Err(_) => StatusCode::NOT_FOUND.into_response(),
    }
}

fn read_served_file(cfg: &Config, path: &str) -> Result<Vec<u8>> {
    let file = sync::safe_path(&cfg.sync_dir, path)?;
    let meta = std::fs::symlink_metadata(&file)?;
    if !meta.file_type().is_file() || meta.file_type().is_symlink() {
        anyhow::bail!("拒绝非普通文件");
    }
    if meta.len() > cfg.max_file_size_bytes {
        anyhow::bail!("文件超过大小上限");
    }
    Ok(std::fs::read(file)?)
}

#[derive(serde::Deserialize)]
struct PullRequest {
    peer: String,
    overwrite: Option<bool>,
}
async fn pull(
    State(cfg): State<AppState>,
    headers: HeaderMap,
    axum::Json(req): axum::Json<PullRequest>,
) -> impl IntoResponse {
    if !authorized(&headers, &cfg) {
        return StatusCode::UNAUTHORIZED.into_response();
    }
    match sync::pull(&cfg, &req.peer, req.overwrite.unwrap_or(false)).await {
        Ok(r) => axum::Json(r).into_response(),
        Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::http::{HeaderValue, header::AUTHORIZATION};

    #[test]
    fn every_request_needs_the_exact_shared_key() {
        let cfg = Config {
            shared_key: "a very long test key that exceeds thirty two chars".into(),
            ..Config::default()
        };
        let mut valid = HeaderMap::new();
        valid.insert(
            AUTHORIZATION,
            HeaderValue::from_static("Bearer a very long test key that exceeds thirty two chars"),
        );
        let mut invalid = HeaderMap::new();
        invalid.insert(AUTHORIZATION, HeaderValue::from_static("Bearer wrong"));
        assert!(authorized(&valid, &cfg));
        assert!(!authorized(&invalid, &cfg));
        assert!(!authorized(&HeaderMap::new(), &cfg));
    }
}