tuitbot_server/routes/
scraper_session.rs1use 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#[derive(Deserialize)]
20pub struct ImportSessionRequest {
21 pub auth_token: String,
23 pub ct0: String,
25 #[serde(default)]
27 pub username: Option<String>,
28}
29
30pub 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
51pub 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 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 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
102async 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
149pub 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}