Skip to main content

tuitbot_server/routes/
accounts.rs

1//! Account management endpoints.
2//!
3//! CRUD for the account registry, role management, and per-account
4//! configuration overrides.
5
6use 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
22/// `GET /api/accounts` — list all active accounts (admin only).
23pub 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
32/// `GET /api/accounts/{id}` — get account details.
33pub 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
50/// `POST /api/accounts` — create a new account (admin only).
51///
52/// Sets `token_path` to `accounts/{id}/tokens.json` so each account
53/// has an isolated credential file.
54pub 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    // Set token_path for credential isolation.
64    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 credentials from the default account when this is the first
76    // non-default account.  This handles the common onboarding path where the
77    // user configures a browser session on the default account and then creates
78    // a named account — without this, the session would be orphaned.
79    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
94/// `PATCH /api/accounts/{id}` — update account config/label (admin only).
95///
96/// When `config_overrides` is provided, validates that:
97/// 1. The JSON only contains account-scoped keys.
98/// 2. Merging with the base config produces a valid effective config.
99pub 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    // Verify account exists.
108    accounts::get_account(&state.db, &id)
109        .await?
110        .ok_or_else(|| ApiError::NotFound(format!("account not found: {id}")))?;
111
112    // Validate config_overrides if provided.
113    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            // Validate the effective config by merging with base.
122            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
146/// Migrate credential files from the default account to a newly created account.
147///
148/// Only runs when the default account has credential files (scraper session
149/// and/or OAuth tokens) and there are no other non-default active accounts —
150/// i.e. this is the user's first named account.  Files are *moved* so the
151/// default account no longer shows stale "Linked" status.
152async fn migrate_default_credentials(state: &AppState, new_account_id: &str) {
153    // Only migrate when this is the first non-default account.
154    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
204/// Load and parse the base config from the TOML file.
205fn 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
217/// `DELETE /api/accounts/{id}` — archive an account (admin only).
218pub 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
230// ---- Role management ----
231
232/// `GET /api/accounts/{id}/roles` — list roles for an account.
233pub 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
249/// `POST /api/accounts/{id}/roles` — set a role for an actor on an account.
250pub 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    // Validate role string.
259    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
273/// `DELETE /api/accounts/{id}/roles` — remove a role assignment.
274pub 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// ---- Profile sync ----
286
287#[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        // Write minimal valid config
340        std::fs::write(&config_path, "").expect("write");
341        let result = load_base_config(&config_path);
342        // Parsing empty file may succeed with defaults or fail — either is valid
343        let _ = result;
344    }
345
346    #[test]
347    fn create_account_request_debug() {
348        let _req = CreateAccountRequest {
349            label: "Test".to_string(),
350        };
351        // This should not panic (exercises Deserialize derive)
352        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
365/// `POST /api/accounts/{id}/sync-profile` — fetch X profile and update account.
366///
367/// Tries OAuth tokens first (`/users/me`). If unavailable, falls back to
368/// the cookie transport (scraper session) so local no-key mode users can
369/// still sync their profile picture, username, and display name.
370pub 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    // Try OAuth first, fall back to cookie transport.
383    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            // No OAuth tokens — try the cookie transport.
395            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            // Pass the shared health handle so the sync outcome is reflected in /health.
398            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}