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/// `POST /api/accounts/{id}/sync-profile` — fetch X profile and update account.
288///
289/// Tries OAuth tokens first (`/users/me`). If unavailable, falls back to
290/// the cookie transport (scraper session) so local no-key mode users can
291/// still sync their profile picture, username, and display name.
292pub 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    // Try OAuth first, fall back to cookie transport.
305    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            // No OAuth tokens — try the cookie transport.
317            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}