Skip to main content

tuitbot_server/routes/
scraper_session.rs

1//! Scraper session endpoints for importing/managing browser cookie sessions.
2
3use std::sync::Arc;
4
5use axum::extract::State;
6use axum::Json;
7use serde::Deserialize;
8use serde_json::Value;
9use tuitbot_core::config::merge_overrides;
10use tuitbot_core::storage::accounts::{self, account_scraper_session_path, DEFAULT_ACCOUNT_ID};
11use tuitbot_core::x_api::ScraperSession;
12
13use crate::account::AccountContext;
14use crate::error::ApiError;
15use crate::routes::settings::merge_patch_and_parse;
16use crate::state::AppState;
17
18/// Request body for importing a browser session.
19#[derive(Deserialize)]
20pub struct ImportSessionRequest {
21    /// The `auth_token` cookie value from the browser.
22    pub auth_token: String,
23    /// The `ct0` cookie value (CSRF token) from the browser.
24    pub ct0: String,
25    /// Optional X username for display purposes.
26    #[serde(default)]
27    pub username: Option<String>,
28}
29
30/// `GET /api/settings/scraper-session` — check if a browser session exists.
31pub async fn get_scraper_session(
32    State(state): State<Arc<AppState>>,
33    ctx: AccountContext,
34) -> Result<Json<Value>, ApiError> {
35    let session_path = account_scraper_session_path(&state.data_dir, &ctx.account_id);
36    let session = ScraperSession::load(&session_path)
37        .map_err(|e| ApiError::Internal(format!("failed to read session: {e}")))?;
38
39    match session {
40        Some(s) => Ok(Json(serde_json::json!({
41            "exists": true,
42            "username": s.username,
43            "created_at": s.created_at,
44        }))),
45        None => Ok(Json(serde_json::json!({
46            "exists": false,
47        }))),
48    }
49}
50
51/// `POST /api/settings/scraper-session` — import browser cookies.
52pub async fn import_scraper_session(
53    State(state): State<Arc<AppState>>,
54    ctx: AccountContext,
55    Json(body): Json<ImportSessionRequest>,
56) -> Result<Json<Value>, ApiError> {
57    if body.auth_token.trim().is_empty() || body.ct0.trim().is_empty() {
58        return Err(ApiError::BadRequest(
59            "auth_token and ct0 are required".to_string(),
60        ));
61    }
62
63    let session = ScraperSession {
64        auth_token: body.auth_token.trim().to_string(),
65        ct0: body.ct0.trim().to_string(),
66        username: body.username,
67        created_at: Some(chrono::Utc::now().to_rfc3339()),
68    };
69
70    let session_path = account_scraper_session_path(&state.data_dir, &ctx.account_id);
71
72    // Ensure the parent directory exists for non-default accounts.
73    if let Some(parent) = session_path.parent() {
74        std::fs::create_dir_all(parent)
75            .map_err(|e| ApiError::Internal(format!("failed to create session directory: {e}")))?;
76    }
77
78    session
79        .save(&session_path)
80        .map_err(|e| ApiError::Internal(format!("failed to save session: {e}")))?;
81
82    // Ensure provider_backend is set to "scraper" so that `can_post_for()`
83    // checks the correct credential file. Without this, a user who previously
84    // had X API tokens configured would still have provider_backend="" and
85    // `can_post_for()` would return false even though a valid session exists.
86    let backend_updated = ensure_scraper_backend(&state, &ctx.account_id).await?;
87
88    tracing::info!(
89        account_id = %ctx.account_id,
90        backend_updated,
91        "Browser session imported successfully"
92    );
93
94    Ok(Json(serde_json::json!({
95        "status": "imported",
96        "username": session.username,
97        "created_at": session.created_at,
98        "backend_updated": backend_updated,
99    })))
100}
101
102/// Set `provider_backend = "scraper"` in the config if it isn't already.
103///
104/// Returns `true` if the config was updated, `false` if it was already correct.
105async fn ensure_scraper_backend(state: &AppState, account_id: &str) -> Result<bool, ApiError> {
106    let config = state
107        .load_effective_config(account_id)
108        .await
109        .map_err(|e| ApiError::Internal(format!("failed to load config: {e}")))?;
110
111    if config.x_api.provider_backend == "scraper" {
112        return Ok(false);
113    }
114
115    let patch = serde_json::json!({
116        "x_api": { "provider_backend": "scraper" }
117    });
118
119    if account_id == DEFAULT_ACCOUNT_ID {
120        let (merged_str, _config) = merge_patch_and_parse(&state.config_path, &patch)?;
121        std::fs::write(&state.config_path, &merged_str).map_err(|e| {
122            ApiError::Internal(format!(
123                "could not write config file {}: {e}",
124                state.config_path.display()
125            ))
126        })?;
127    } else {
128        let account = accounts::get_account(&state.db, account_id)
129            .await?
130            .ok_or_else(|| ApiError::NotFound(format!("account not found: {account_id}")))?;
131
132        let new_overrides = merge_overrides(&account.config_overrides, &patch)
133            .map_err(|e| ApiError::Internal(format!("override merge failed: {e}")))?;
134
135        accounts::update_account(
136            &state.db,
137            account_id,
138            accounts::UpdateAccountParams {
139                config_overrides: Some(&new_overrides),
140                ..Default::default()
141            },
142        )
143        .await?;
144    }
145
146    Ok(true)
147}
148
149/// `DELETE /api/settings/scraper-session` — remove the browser session.
150pub async fn delete_scraper_session(
151    State(state): State<Arc<AppState>>,
152    ctx: AccountContext,
153) -> Result<Json<Value>, ApiError> {
154    let session_path = account_scraper_session_path(&state.data_dir, &ctx.account_id);
155    let deleted = ScraperSession::delete(&session_path)
156        .map_err(|e| ApiError::Internal(format!("failed to delete session: {e}")))?;
157
158    Ok(Json(serde_json::json!({
159        "deleted": deleted,
160    })))
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn import_session_request_deserializes() {
169        let json = r#"{"auth_token":"tok123","ct0":"csrf456"}"#;
170        let req: ImportSessionRequest = serde_json::from_str(json).expect("deser");
171        assert_eq!(req.auth_token, "tok123");
172        assert_eq!(req.ct0, "csrf456");
173        assert!(req.username.is_none());
174    }
175
176    #[test]
177    fn import_session_request_with_username() {
178        let json = r#"{"auth_token":"tok","ct0":"ct","username":"alice"}"#;
179        let req: ImportSessionRequest = serde_json::from_str(json).expect("deser");
180        assert_eq!(req.username.as_deref(), Some("alice"));
181    }
182
183    #[test]
184    fn import_session_request_empty_username() {
185        let json = r#"{"auth_token":"tok","ct0":"ct","username":null}"#;
186        let req: ImportSessionRequest = serde_json::from_str(json).expect("deser");
187        assert!(req.username.is_none());
188    }
189}