cookcli 0.38.1

A command-line interface for managing and working with Cooklang recipes
Documentation
use crate::server::{activity, AppState};
use crate::sync::{self, device_flow, PendingDeviceFlow, SyncSession};
use crate::web::viewer::Viewer;
use axum::{
    extract::{Extension, State},
    http::StatusCode,
    Json,
};
use serde::Serialize;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio_util::sync::CancellationToken;

#[derive(Serialize)]
pub struct PendingLogin {
    pub user_code: String,
    pub verification_uri: String,
    pub verification_uri_complete: String,
    pub expires_in_secs: u64,
}

#[derive(Serialize)]
pub struct SyncStatusResponse {
    pub logged_in: bool,
    pub email: Option<String>,
    pub syncing: bool,
    pub pending_login: Option<PendingLogin>,
    /// Why sync is currently off, e.g. `"payment_required"`. Absent while
    /// syncing or when the last stop wasn't a known, user-actionable
    /// condition. Additive field: older clients that don't read it are
    /// unaffected.
    pub reason: Option<String>,
}

pub async fn sync_status(State(state): State<Arc<AppState>>) -> Json<SyncStatusResponse> {
    let (logged_in, email, syncing, reason) = state.sync_status().await;

    let pending_login = {
        let guard = state.pending_device_flow.lock().await;
        guard.as_ref().map(|p| PendingLogin {
            user_code: p.user_code.clone(),
            verification_uri: p.verification_uri.clone(),
            verification_uri_complete: p.verification_uri_complete.clone(),
            expires_in_secs: p
                .expires_at
                .saturating_duration_since(Instant::now())
                .as_secs(),
        })
    };

    Json(SyncStatusResponse {
        logged_in,
        email,
        syncing,
        pending_login,
        reason,
    })
}

#[derive(Serialize)]
pub struct LoginResponse {
    pub user_code: String,
    pub verification_uri: String,
    pub verification_uri_complete: String,
    pub expires_in_secs: u64,
}

pub async fn sync_login(
    State(state): State<Arc<AppState>>,
    Extension(viewer): Extension<Viewer>,
) -> Result<Json<LoginResponse>, (StatusCode, Json<serde_json::Value>)> {
    if state.sync_session.lock().unwrap().is_some() {
        return Err((
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({ "error": "Already logged in" })),
        ));
    }

    let mut pending_guard = state.pending_device_flow.lock().await;
    if pending_guard.is_some() {
        return Err((
            StatusCode::CONFLICT,
            Json(serde_json::json!({ "error": "Login already in progress" })),
        ));
    }

    let client = reqwest::Client::new();
    let name = device_flow::client_name(device_flow::server_host_label());
    let dc = device_flow::request_device_code(&client, &name)
        .await
        .map_err(|e| {
            (
                StatusCode::BAD_GATEWAY,
                Json(serde_json::json!({ "error": format!("cook.md unreachable: {e}") })),
            )
        })?;

    let cancel = CancellationToken::new();
    let expires_at = Instant::now() + Duration::from_secs(dc.expires_in);
    let interval = Duration::from_secs(dc.interval);

    *pending_guard = Some(PendingDeviceFlow {
        user_code: dc.user_code.clone(),
        verification_uri: dc.verification_uri.clone(),
        verification_uri_complete: dc.verification_uri_complete.clone(),
        expires_at,
        cancel: cancel.clone(),
    });
    drop(pending_guard);

    let state_clone = state.clone();
    let device_code = dc.device_code.clone();
    tokio::spawn(async move {
        let result =
            device_flow::poll_for_token(&client, &device_code, interval, expires_at, cancel).await;

        *state_clone.pending_device_flow.lock().await = None;

        match result {
            Ok(jwt) => match SyncSession::from_jwt(jwt) {
                Ok(session) => {
                    if let Err(e) = session.save(&state_clone.session_path) {
                        tracing::error!("Failed to save session: {e}");
                        return;
                    }
                    *state_clone.sync_session.lock().unwrap() = Some(session.clone());

                    match sync::sync_db_path() {
                        Ok(db_path) => match sync::start_sync(
                            &session,
                            state_clone.base_path.to_string(),
                            db_path,
                        ) {
                            Ok(handle) => {
                                *state_clone.last_sync_reason.lock().unwrap() = None;
                                *state_clone.sync_handle.lock().await = Some(handle);
                                tracing::info!("Sync started after login");
                            }
                            Err(e) => tracing::warn!("Failed to start sync after login: {e}"),
                        },
                        Err(e) => tracing::error!("Failed to resolve sync db path: {e}"),
                    }
                }
                Err(e) => tracing::error!("Failed to build SyncSession from JWT: {e}"),
            },
            Err(device_flow::DeviceFlowError::Cancelled) => {
                tracing::info!("Login cancelled by user");
            }
            Err(e) => tracing::error!("Login failed: {e}"),
        }
    });

    activity::record(&viewer, "started linking this server to cook.md");

    Ok(Json(LoginResponse {
        user_code: dc.user_code,
        verification_uri: dc.verification_uri,
        verification_uri_complete: dc.verification_uri_complete,
        expires_in_secs: dc.expires_in,
    }))
}

pub async fn sync_cancel_login(
    State(state): State<Arc<AppState>>,
    Extension(viewer): Extension<Viewer>,
) -> Json<serde_json::Value> {
    let mut guard = state.pending_device_flow.lock().await;
    if let Some(p) = guard.take() {
        p.cancel.cancel();
        activity::record(&viewer, "cancelled linking this server to cook.md");
        Json(serde_json::json!({ "cancelled": true }))
    } else {
        Json(serde_json::json!({ "cancelled": false }))
    }
}

pub async fn sync_logout(
    State(state): State<Arc<AppState>>,
    Extension(viewer): Extension<Viewer>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
    if let Some(p) = state.pending_device_flow.lock().await.take() {
        p.cancel.cancel();
    }

    if let Some(handle) = state.sync_handle.lock().await.take() {
        handle.stop().await;
    }
    *state.last_sync_reason.lock().unwrap() = None;

    *state.sync_session.lock().unwrap() = None;
    if let Err(e) = SyncSession::delete(&state.session_path) {
        tracing::warn!("Failed to delete session file: {e}");
    }
    activity::record(&viewer, "unlinked this server from cook.md");

    Ok(Json(serde_json::json!({ "ok": true })))
}