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
285pub async fn sync_profile(
293 State(state): State<Arc<AppState>>,
294 ctx: AccountContext,
295 Path(id): Path<String>,
296) -> Result<Json<Value>, ApiError> {
297 tracing::info!(account_id = %id, "sync_profile called");
298 require_mutate(&ctx)?;
299
300 let _account = accounts::get_account(&state.db, &id)
301 .await?
302 .ok_or_else(|| ApiError::NotFound(format!("account not found: {id}")))?;
303
304 let token_path = account_token_path(&state.data_dir, &id);
306 let user = match state.get_x_access_token(&token_path, &id).await {
307 Ok(access_token) => {
308 tracing::info!(account_id = %id, "sync_profile: using OAuth tokens");
309 let client = XApiHttpClient::new(access_token);
310 client
311 .get_me()
312 .await
313 .map_err(|e| ApiError::Internal(format!("X API error: {e}")))?
314 }
315 Err(_) => {
316 tracing::info!(account_id = %id, "sync_profile: no OAuth, falling back to cookie transport");
318 let account_dir = accounts::account_data_dir(&state.data_dir, &id);
319 let client =
320 tuitbot_core::x_api::LocalModeXClient::with_session(false, &account_dir).await;
321 client
322 .get_me()
323 .await
324 .map_err(|e| {
325 tracing::error!(account_id = %id, error = %e, "sync_profile: cookie transport failed");
326 ApiError::Internal(format!("profile sync failed: {e}"))
327 })?
328 }
329 };
330
331 accounts::update_account(
332 &state.db,
333 &id,
334 UpdateAccountParams {
335 x_user_id: Some(&user.id),
336 x_username: Some(&user.username),
337 x_display_name: Some(&user.name),
338 x_avatar_url: user.profile_image_url.as_deref(),
339 ..Default::default()
340 },
341 )
342 .await?;
343
344 let updated = accounts::get_account(&state.db, &id)
345 .await?
346 .ok_or_else(|| ApiError::Internal("account disappeared".to_string()))?;
347
348 Ok(Json(json!(updated)))
349}