1use std::sync::Arc;
7
8use axum::extract::{Path, State};
9use axum::Json;
10use serde::Deserialize;
11use serde_json::{json, Value};
12use tuitbot_core::config::{effective_config, validate_override_keys, Config};
13use tuitbot_core::storage::accounts::{
14 self, account_scraper_session_path, account_token_path, UpdateAccountParams, DEFAULT_ACCOUNT_ID,
15};
16use tuitbot_core::x_api::{XApiClient, XApiHttpClient};
17
18use crate::account::{require_mutate, AccountContext, Role};
19use crate::error::ApiError;
20use crate::state::AppState;
21
22pub async fn list_accounts(
24 State(state): State<Arc<AppState>>,
25 ctx: AccountContext,
26) -> Result<Json<Value>, ApiError> {
27 require_mutate(&ctx)?;
28 let accs = accounts::list_accounts(&state.db).await?;
29 Ok(Json(json!(accs)))
30}
31
32pub async fn get_account(
34 State(state): State<Arc<AppState>>,
35 ctx: AccountContext,
36 Path(id): Path<String>,
37) -> Result<Json<Value>, ApiError> {
38 require_mutate(&ctx)?;
39 let account = accounts::get_account(&state.db, &id)
40 .await?
41 .ok_or_else(|| ApiError::NotFound(format!("account not found: {id}")))?;
42 Ok(Json(json!(account)))
43}
44
45#[derive(Deserialize)]
46pub struct CreateAccountRequest {
47 pub label: String,
48}
49
50pub async fn create_account(
55 State(state): State<Arc<AppState>>,
56 ctx: AccountContext,
57 Json(body): Json<CreateAccountRequest>,
58) -> Result<Json<Value>, ApiError> {
59 require_mutate(&ctx)?;
60 let id = uuid::Uuid::new_v4().to_string();
61 accounts::create_account(&state.db, &id, &body.label).await?;
62
63 let token_path = format!("accounts/{}/tokens.json", id);
65 accounts::update_account(
66 &state.db,
67 &id,
68 UpdateAccountParams {
69 token_path: Some(&token_path),
70 ..Default::default()
71 },
72 )
73 .await?;
74
75 migrate_default_credentials(&state, &id).await;
80
81 let account = accounts::get_account(&state.db, &id)
82 .await?
83 .ok_or_else(|| ApiError::Internal("account creation failed".to_string()))?;
84
85 Ok(Json(json!(account)))
86}
87
88#[derive(Deserialize)]
89pub struct UpdateAccountRequest {
90 pub label: Option<String>,
91 pub config_overrides: Option<String>,
92}
93
94pub async fn update_account(
100 State(state): State<Arc<AppState>>,
101 ctx: AccountContext,
102 Path(id): Path<String>,
103 Json(body): Json<UpdateAccountRequest>,
104) -> Result<Json<Value>, ApiError> {
105 require_mutate(&ctx)?;
106
107 accounts::get_account(&state.db, &id)
109 .await?
110 .ok_or_else(|| ApiError::NotFound(format!("account not found: {id}")))?;
111
112 if let Some(ref overrides_str) = body.config_overrides {
114 let trimmed = overrides_str.trim();
115 if !trimmed.is_empty() && trimmed != "{}" {
116 let overrides: serde_json::Value = serde_json::from_str(trimmed)
117 .map_err(|e| ApiError::BadRequest(format!("invalid config_overrides JSON: {e}")))?;
118
119 validate_override_keys(&overrides).map_err(|e| ApiError::BadRequest(e.to_string()))?;
120
121 let base_config = load_base_config(&state.config_path)?;
123 effective_config(&base_config, trimmed)
124 .map_err(|e| ApiError::BadRequest(format!("invalid effective config: {e}")))?;
125 }
126 }
127
128 accounts::update_account(
129 &state.db,
130 &id,
131 UpdateAccountParams {
132 label: body.label.as_deref(),
133 config_overrides: body.config_overrides.as_deref(),
134 ..Default::default()
135 },
136 )
137 .await?;
138
139 let updated = accounts::get_account(&state.db, &id)
140 .await?
141 .ok_or_else(|| ApiError::Internal("account disappeared".to_string()))?;
142
143 Ok(Json(json!(updated)))
144}
145
146async fn migrate_default_credentials(state: &AppState, new_account_id: &str) {
153 let active = match accounts::list_accounts(&state.db).await {
155 Ok(list) => list,
156 Err(_) => return,
157 };
158 let non_default_count = active.iter().filter(|a| a.id != DEFAULT_ACCOUNT_ID).count();
159 if non_default_count != 1 {
160 return;
161 }
162
163 let default_session = account_scraper_session_path(&state.data_dir, DEFAULT_ACCOUNT_ID);
164 let default_tokens = account_token_path(&state.data_dir, DEFAULT_ACCOUNT_ID);
165
166 let has_session = default_session.exists();
167 let has_tokens = default_tokens.exists();
168
169 if !has_session && !has_tokens {
170 return;
171 }
172
173 let new_dir = state.data_dir.join("accounts").join(new_account_id);
174 if let Err(e) = std::fs::create_dir_all(&new_dir) {
175 tracing::warn!("failed to create account dir for migration: {e}");
176 return;
177 }
178
179 if has_session {
180 let dest = account_scraper_session_path(&state.data_dir, new_account_id);
181 if let Err(e) = std::fs::rename(&default_session, &dest) {
182 tracing::warn!("failed to migrate scraper session: {e}");
183 } else {
184 tracing::info!(
185 account_id = %new_account_id,
186 "migrated scraper session from default account"
187 );
188 }
189 }
190
191 if has_tokens {
192 let dest = account_token_path(&state.data_dir, new_account_id);
193 if let Err(e) = std::fs::rename(&default_tokens, &dest) {
194 tracing::warn!("failed to migrate OAuth tokens: {e}");
195 } else {
196 tracing::info!(
197 account_id = %new_account_id,
198 "migrated OAuth tokens from default account"
199 );
200 }
201 }
202}
203
204fn load_base_config(config_path: &std::path::Path) -> Result<Config, ApiError> {
206 let contents = std::fs::read_to_string(config_path).map_err(|e| {
207 ApiError::BadRequest(format!(
208 "could not read config file {}: {e}",
209 config_path.display()
210 ))
211 })?;
212
213 toml::from_str(&contents)
214 .map_err(|e| ApiError::BadRequest(format!("failed to parse config: {e}")))
215}
216
217pub async fn delete_account(
219 State(state): State<Arc<AppState>>,
220 ctx: AccountContext,
221 Path(id): Path<String>,
222) -> Result<Json<Value>, ApiError> {
223 require_mutate(&ctx)?;
224 accounts::delete_account(&state.db, &id)
225 .await
226 .map_err(|_| ApiError::BadRequest("cannot delete this account".to_string()))?;
227 Ok(Json(json!({"status": "archived"})))
228}
229
230pub async fn list_roles(
234 State(state): State<Arc<AppState>>,
235 ctx: AccountContext,
236 Path(id): Path<String>,
237) -> Result<Json<Value>, ApiError> {
238 require_mutate(&ctx)?;
239 let roles = accounts::list_roles(&state.db, &id).await?;
240 Ok(Json(json!(roles)))
241}
242
243#[derive(Deserialize)]
244pub struct SetRoleRequest {
245 pub actor: String,
246 pub role: String,
247}
248
249pub async fn set_role(
251 State(state): State<Arc<AppState>>,
252 ctx: AccountContext,
253 Path(id): Path<String>,
254 Json(body): Json<SetRoleRequest>,
255) -> Result<Json<Value>, ApiError> {
256 require_mutate(&ctx)?;
257
258 let _role: Role = body
260 .role
261 .parse()
262 .map_err(|e: String| ApiError::BadRequest(e))?;
263
264 accounts::set_role(&state.db, &id, &body.actor, &body.role).await?;
265 Ok(Json(json!({"status": "ok"})))
266}
267
268#[derive(Deserialize)]
269pub struct RemoveRoleRequest {
270 pub actor: String,
271}
272
273pub async fn remove_role(
275 State(state): State<Arc<AppState>>,
276 ctx: AccountContext,
277 Path(id): Path<String>,
278 Json(body): Json<RemoveRoleRequest>,
279) -> Result<Json<Value>, ApiError> {
280 require_mutate(&ctx)?;
281 accounts::remove_role(&state.db, &id, &body.actor).await?;
282 Ok(Json(json!({"status": "ok"})))
283}
284
285#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn create_account_request_deser() {
293 let json = r#"{"label": "My Account"}"#;
294 let req: CreateAccountRequest = serde_json::from_str(json).unwrap();
295 assert_eq!(req.label, "My Account");
296 }
297
298 #[test]
299 fn update_account_request_deser() {
300 let json = r#"{"label": "New Label", "config_overrides": "{}"}"#;
301 let req: UpdateAccountRequest = serde_json::from_str(json).unwrap();
302 assert_eq!(req.label.as_deref(), Some("New Label"));
303 assert_eq!(req.config_overrides.as_deref(), Some("{}"));
304 }
305
306 #[test]
307 fn update_account_request_optional_fields() {
308 let json = r#"{}"#;
309 let req: UpdateAccountRequest = serde_json::from_str(json).unwrap();
310 assert!(req.label.is_none());
311 assert!(req.config_overrides.is_none());
312 }
313
314 #[test]
315 fn set_role_request_deser() {
316 let json = r#"{"actor": "user@example.com", "role": "admin"}"#;
317 let req: SetRoleRequest = serde_json::from_str(json).unwrap();
318 assert_eq!(req.actor, "user@example.com");
319 assert_eq!(req.role, "admin");
320 }
321
322 #[test]
323 fn remove_role_request_deser() {
324 let json = r#"{"actor": "user@example.com"}"#;
325 let req: RemoveRoleRequest = serde_json::from_str(json).unwrap();
326 assert_eq!(req.actor, "user@example.com");
327 }
328
329 #[test]
330 fn load_base_config_nonexistent() {
331 let result = load_base_config(std::path::Path::new("/nonexistent/config.toml"));
332 assert!(result.is_err());
333 }
334
335 #[test]
336 fn load_base_config_valid() {
337 let dir = tempfile::tempdir().expect("tempdir");
338 let config_path = dir.path().join("config.toml");
339 std::fs::write(&config_path, "").expect("write");
341 let result = load_base_config(&config_path);
342 let _ = result;
344 }
345
346 #[test]
347 fn create_account_request_debug() {
348 let _req = CreateAccountRequest {
349 label: "Test".to_string(),
350 };
351 let json = serde_json::to_string(&serde_json::json!({"label": "Test"})).unwrap();
353 let _: CreateAccountRequest = serde_json::from_str(&json).unwrap();
354 }
355
356 #[test]
357 fn set_role_request_roundtrip() {
358 let json = r#"{"actor": "bot", "role": "viewer"}"#;
359 let req: SetRoleRequest = serde_json::from_str(json).unwrap();
360 assert_eq!(req.actor, "bot");
361 assert_eq!(req.role, "viewer");
362 }
363}
364
365pub async fn sync_profile(
371 State(state): State<Arc<AppState>>,
372 ctx: AccountContext,
373 Path(id): Path<String>,
374) -> Result<Json<Value>, ApiError> {
375 tracing::info!(account_id = %id, "sync_profile called");
376 require_mutate(&ctx)?;
377
378 let _account = accounts::get_account(&state.db, &id)
379 .await?
380 .ok_or_else(|| ApiError::NotFound(format!("account not found: {id}")))?;
381
382 let token_path = account_token_path(&state.data_dir, &id);
384 let user = match state.get_x_access_token(&token_path, &id).await {
385 Ok(access_token) => {
386 tracing::info!(account_id = %id, "sync_profile: using OAuth tokens");
387 let client = XApiHttpClient::new(access_token);
388 client
389 .get_me()
390 .await
391 .map_err(|e| ApiError::Internal(format!("X API error: {e}")))?
392 }
393 Err(_) => {
394 tracing::info!(account_id = %id, "sync_profile: no OAuth, falling back to cookie transport");
396 let account_dir = accounts::account_data_dir(&state.data_dir, &id);
397 let client = if let Some(ref health) = state.scraper_health {
399 tuitbot_core::x_api::LocalModeXClient::with_session_and_health(
400 false,
401 &account_dir,
402 health.clone(),
403 )
404 .await
405 } else {
406 tuitbot_core::x_api::LocalModeXClient::with_session(false, &account_dir).await
407 };
408 client
409 .get_me()
410 .await
411 .map_err(|e| {
412 tracing::error!(account_id = %id, error = %e, "sync_profile: cookie transport failed");
413 ApiError::Internal(format!("profile sync failed: {e}"))
414 })?
415 }
416 };
417
418 accounts::update_account(
419 &state.db,
420 &id,
421 UpdateAccountParams {
422 x_user_id: Some(&user.id),
423 x_username: Some(&user.username),
424 x_display_name: Some(&user.name),
425 x_avatar_url: user.profile_image_url.as_deref(),
426 ..Default::default()
427 },
428 )
429 .await?;
430
431 let updated = accounts::get_account(&state.db, &id)
432 .await?
433 .ok_or_else(|| ApiError::Internal("account disappeared".to_string()))?;
434
435 Ok(Json(json!(updated)))
436}