use std::sync::Arc;
use axum::extract::{Path, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use tuitbot_core::config::{effective_config, validate_override_keys, Config};
use tuitbot_core::storage::accounts::{
self, account_scraper_session_path, account_token_path, UpdateAccountParams, DEFAULT_ACCOUNT_ID,
};
use tuitbot_core::x_api::{XApiClient, XApiHttpClient};
use crate::account::{require_mutate, AccountContext, Role};
use crate::error::ApiError;
use crate::state::AppState;
pub async fn list_accounts(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
) -> Result<Json<Value>, ApiError> {
require_mutate(&ctx)?;
let accs = accounts::list_accounts(&state.db).await?;
Ok(Json(json!(accs)))
}
pub async fn get_account(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> {
require_mutate(&ctx)?;
let account = accounts::get_account(&state.db, &id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("account not found: {id}")))?;
Ok(Json(json!(account)))
}
#[derive(Deserialize)]
pub struct CreateAccountRequest {
pub label: String,
}
pub async fn create_account(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Json(body): Json<CreateAccountRequest>,
) -> Result<Json<Value>, ApiError> {
require_mutate(&ctx)?;
let id = uuid::Uuid::new_v4().to_string();
accounts::create_account(&state.db, &id, &body.label).await?;
let token_path = format!("accounts/{}/tokens.json", id);
accounts::update_account(
&state.db,
&id,
UpdateAccountParams {
token_path: Some(&token_path),
..Default::default()
},
)
.await?;
migrate_default_credentials(&state, &id).await;
let account = accounts::get_account(&state.db, &id)
.await?
.ok_or_else(|| ApiError::Internal("account creation failed".to_string()))?;
Ok(Json(json!(account)))
}
#[derive(Deserialize)]
pub struct UpdateAccountRequest {
pub label: Option<String>,
pub config_overrides: Option<String>,
}
pub async fn update_account(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Path(id): Path<String>,
Json(body): Json<UpdateAccountRequest>,
) -> Result<Json<Value>, ApiError> {
require_mutate(&ctx)?;
accounts::get_account(&state.db, &id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("account not found: {id}")))?;
if let Some(ref overrides_str) = body.config_overrides {
let trimmed = overrides_str.trim();
if !trimmed.is_empty() && trimmed != "{}" {
let overrides: serde_json::Value = serde_json::from_str(trimmed)
.map_err(|e| ApiError::BadRequest(format!("invalid config_overrides JSON: {e}")))?;
validate_override_keys(&overrides).map_err(|e| ApiError::BadRequest(e.to_string()))?;
let base_config = load_base_config(&state.config_path)?;
effective_config(&base_config, trimmed)
.map_err(|e| ApiError::BadRequest(format!("invalid effective config: {e}")))?;
}
}
accounts::update_account(
&state.db,
&id,
UpdateAccountParams {
label: body.label.as_deref(),
config_overrides: body.config_overrides.as_deref(),
..Default::default()
},
)
.await?;
let updated = accounts::get_account(&state.db, &id)
.await?
.ok_or_else(|| ApiError::Internal("account disappeared".to_string()))?;
Ok(Json(json!(updated)))
}
async fn migrate_default_credentials(state: &AppState, new_account_id: &str) {
let active = match accounts::list_accounts(&state.db).await {
Ok(list) => list,
Err(_) => return,
};
let non_default_count = active.iter().filter(|a| a.id != DEFAULT_ACCOUNT_ID).count();
if non_default_count != 1 {
return;
}
let default_session = account_scraper_session_path(&state.data_dir, DEFAULT_ACCOUNT_ID);
let default_tokens = account_token_path(&state.data_dir, DEFAULT_ACCOUNT_ID);
let has_session = default_session.exists();
let has_tokens = default_tokens.exists();
if !has_session && !has_tokens {
return;
}
let new_dir = state.data_dir.join("accounts").join(new_account_id);
if let Err(e) = std::fs::create_dir_all(&new_dir) {
tracing::warn!("failed to create account dir for migration: {e}");
return;
}
if has_session {
let dest = account_scraper_session_path(&state.data_dir, new_account_id);
if let Err(e) = std::fs::rename(&default_session, &dest) {
tracing::warn!("failed to migrate scraper session: {e}");
} else {
tracing::info!(
account_id = %new_account_id,
"migrated scraper session from default account"
);
}
}
if has_tokens {
let dest = account_token_path(&state.data_dir, new_account_id);
if let Err(e) = std::fs::rename(&default_tokens, &dest) {
tracing::warn!("failed to migrate OAuth tokens: {e}");
} else {
tracing::info!(
account_id = %new_account_id,
"migrated OAuth tokens from default account"
);
}
}
}
fn load_base_config(config_path: &std::path::Path) -> Result<Config, ApiError> {
let contents = std::fs::read_to_string(config_path).map_err(|e| {
ApiError::BadRequest(format!(
"could not read config file {}: {e}",
config_path.display()
))
})?;
toml::from_str(&contents)
.map_err(|e| ApiError::BadRequest(format!("failed to parse config: {e}")))
}
pub async fn delete_account(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> {
require_mutate(&ctx)?;
accounts::delete_account(&state.db, &id)
.await
.map_err(|_| ApiError::BadRequest("cannot delete this account".to_string()))?;
Ok(Json(json!({"status": "archived"})))
}
pub async fn list_roles(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> {
require_mutate(&ctx)?;
let roles = accounts::list_roles(&state.db, &id).await?;
Ok(Json(json!(roles)))
}
#[derive(Deserialize)]
pub struct SetRoleRequest {
pub actor: String,
pub role: String,
}
pub async fn set_role(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Path(id): Path<String>,
Json(body): Json<SetRoleRequest>,
) -> Result<Json<Value>, ApiError> {
require_mutate(&ctx)?;
let _role: Role = body
.role
.parse()
.map_err(|e: String| ApiError::BadRequest(e))?;
accounts::set_role(&state.db, &id, &body.actor, &body.role).await?;
Ok(Json(json!({"status": "ok"})))
}
#[derive(Deserialize)]
pub struct RemoveRoleRequest {
pub actor: String,
}
pub async fn remove_role(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Path(id): Path<String>,
Json(body): Json<RemoveRoleRequest>,
) -> Result<Json<Value>, ApiError> {
require_mutate(&ctx)?;
accounts::remove_role(&state.db, &id, &body.actor).await?;
Ok(Json(json!({"status": "ok"})))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_account_request_deser() {
let json = r#"{"label": "My Account"}"#;
let req: CreateAccountRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.label, "My Account");
}
#[test]
fn update_account_request_deser() {
let json = r#"{"label": "New Label", "config_overrides": "{}"}"#;
let req: UpdateAccountRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.label.as_deref(), Some("New Label"));
assert_eq!(req.config_overrides.as_deref(), Some("{}"));
}
#[test]
fn update_account_request_optional_fields() {
let json = r#"{}"#;
let req: UpdateAccountRequest = serde_json::from_str(json).unwrap();
assert!(req.label.is_none());
assert!(req.config_overrides.is_none());
}
#[test]
fn set_role_request_deser() {
let json = r#"{"actor": "user@example.com", "role": "admin"}"#;
let req: SetRoleRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.actor, "user@example.com");
assert_eq!(req.role, "admin");
}
#[test]
fn remove_role_request_deser() {
let json = r#"{"actor": "user@example.com"}"#;
let req: RemoveRoleRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.actor, "user@example.com");
}
#[test]
fn load_base_config_nonexistent() {
let result = load_base_config(std::path::Path::new("/nonexistent/config.toml"));
assert!(result.is_err());
}
#[test]
fn load_base_config_valid() {
let dir = tempfile::tempdir().expect("tempdir");
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "").expect("write");
let result = load_base_config(&config_path);
let _ = result;
}
#[test]
fn create_account_request_debug() {
let _req = CreateAccountRequest {
label: "Test".to_string(),
};
let json = serde_json::to_string(&serde_json::json!({"label": "Test"})).unwrap();
let _: CreateAccountRequest = serde_json::from_str(&json).unwrap();
}
#[test]
fn set_role_request_roundtrip() {
let json = r#"{"actor": "bot", "role": "viewer"}"#;
let req: SetRoleRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.actor, "bot");
assert_eq!(req.role, "viewer");
}
}
pub async fn sync_profile(
State(state): State<Arc<AppState>>,
ctx: AccountContext,
Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> {
tracing::info!(account_id = %id, "sync_profile called");
require_mutate(&ctx)?;
let _account = accounts::get_account(&state.db, &id)
.await?
.ok_or_else(|| ApiError::NotFound(format!("account not found: {id}")))?;
let token_path = account_token_path(&state.data_dir, &id);
let user = match state.get_x_access_token(&token_path, &id).await {
Ok(access_token) => {
tracing::info!(account_id = %id, "sync_profile: using OAuth tokens");
let client = XApiHttpClient::new(access_token);
client
.get_me()
.await
.map_err(|e| ApiError::Internal(format!("X API error: {e}")))?
}
Err(_) => {
tracing::info!(account_id = %id, "sync_profile: no OAuth, falling back to cookie transport");
let account_dir = accounts::account_data_dir(&state.data_dir, &id);
let client = if let Some(ref health) = state.scraper_health {
tuitbot_core::x_api::LocalModeXClient::with_session_and_health(
false,
&account_dir,
health.clone(),
)
.await
} else {
tuitbot_core::x_api::LocalModeXClient::with_session(false, &account_dir).await
};
client
.get_me()
.await
.map_err(|e| {
tracing::error!(account_id = %id, error = %e, "sync_profile: cookie transport failed");
ApiError::Internal(format!("profile sync failed: {e}"))
})?
}
};
accounts::update_account(
&state.db,
&id,
UpdateAccountParams {
x_user_id: Some(&user.id),
x_username: Some(&user.username),
x_display_name: Some(&user.name),
x_avatar_url: user.profile_image_url.as_deref(),
..Default::default()
},
)
.await?;
let updated = accounts::get_account(&state.db, &id)
.await?
.ok_or_else(|| ApiError::Internal("account disappeared".to_string()))?;
Ok(Json(json!(updated)))
}