use std::path::Path;
use serde_json::Value;
use tuitbot_core::config::Config;
use crate::error::ApiError;
pub fn merge_patch_and_parse(
config_path: &Path,
patch: &Value,
) -> Result<(String, 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()
))
})?;
let mut toml_value: toml::Value = contents.parse().map_err(|e: toml::de::Error| {
ApiError::BadRequest(format!("failed to parse existing config: {e}"))
})?;
let patch_toml = json_to_toml(patch)
.map_err(|e| ApiError::BadRequest(format!("patch contains invalid values: {e}")))?;
merge_toml(&mut toml_value, &patch_toml);
let merged_str = toml::to_string_pretty(&toml_value)
.map_err(|e| ApiError::BadRequest(format!("failed to serialize merged config: {e}")))?;
let config: Config = toml::from_str(&merged_str)
.map_err(|e| ApiError::BadRequest(format!("merged config is invalid: {e}")))?;
Ok((merged_str, config))
}
pub(crate) fn load_base_config(config_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(super) fn redact_service_account_keys(json: &mut Value) {
if let Some(sources) = json
.get_mut("content_sources")
.and_then(|cs| cs.get_mut("sources"))
.and_then(|s| s.as_array_mut())
{
for source in sources {
if let Some(key) = source.get_mut("service_account_key") {
if !key.is_null() {
*key = serde_json::Value::String("[redacted]".to_string());
}
}
}
}
}
pub(super) fn delete_all_credentials(data_dir: &std::path::Path) -> bool {
let mut deleted = false;
for name in &["scraper_session.json", "tokens.json"] {
let path = data_dir.join(name);
match std::fs::remove_file(&path) {
Ok(()) => deleted = true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "failed to delete credential file")
}
}
}
let accounts_dir = data_dir.join("accounts");
match std::fs::remove_dir_all(&accounts_dir) {
Ok(()) => deleted = true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::warn!(error = %e, "failed to delete accounts directory"),
}
deleted
}
pub(super) fn merge_toml(base: &mut toml::Value, patch: &toml::Value) {
match (base, patch) {
(toml::Value::Table(base_table), toml::Value::Table(patch_table)) => {
for (key, patch_val) in patch_table {
if let Some(base_val) = base_table.get_mut(key) {
merge_toml(base_val, patch_val);
} else {
base_table.insert(key.clone(), patch_val.clone());
}
}
}
(base, _) => {
*base = patch.clone();
}
}
}
pub(super) fn json_to_toml(json: &serde_json::Value) -> Result<toml::Value, String> {
match json {
serde_json::Value::Object(map) => {
let mut table = toml::map::Map::new();
for (key, val) in map {
if val.is_null() {
continue;
}
table.insert(key.clone(), json_to_toml(val)?);
}
Ok(toml::Value::Table(table))
}
serde_json::Value::Array(arr) => {
let values: Result<Vec<_>, _> = arr.iter().map(json_to_toml).collect();
Ok(toml::Value::Array(values?))
}
serde_json::Value::String(s) => Ok(toml::Value::String(s.clone())),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(toml::Value::Integer(i))
} else if let Some(f) = n.as_f64() {
Ok(toml::Value::Float(f))
} else {
Err(format!("unsupported number: {n}"))
}
}
serde_json::Value::Bool(b) => Ok(toml::Value::Boolean(*b)),
serde_json::Value::Null => Err("null values are not supported in TOML arrays".to_string()),
}
}
use std::sync::Arc;
use std::time::Instant;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json;
use tuitbot_core::config::LlmConfig;
use tuitbot_core::llm::factory::create_provider;
use crate::state::AppState;
use super::{TestLlmRequest, TestResult};
pub async fn get_defaults() -> Result<Json<serde_json::Value>, crate::error::ApiError> {
let defaults = tuitbot_core::config::Config::default();
let json = serde_json::to_value(defaults).map_err(|e| {
crate::error::ApiError::BadRequest(format!("failed to serialize defaults: {e}"))
})?;
Ok(Json(json))
}
pub async fn test_llm(
Json(body): Json<TestLlmRequest>,
) -> Result<Json<serde_json::Value>, crate::error::ApiError> {
let llm_config = LlmConfig {
provider: body.provider,
api_key: body.api_key,
model: body.model,
base_url: body.base_url,
};
let provider = match create_provider(&llm_config) {
Ok(p) => p,
Err(e) => {
return Ok(Json(
serde_json::to_value(TestResult {
success: false,
error: Some(e.to_string()),
latency_ms: None,
})
.unwrap(),
));
}
};
let start = Instant::now();
let latency_ms = |s: &Instant| s.elapsed().as_millis() as u64;
match provider.health_check().await {
Ok(()) => Ok(Json(
serde_json::to_value(TestResult {
success: true,
error: None,
latency_ms: Some(latency_ms(&start)),
})
.unwrap(),
)),
Err(e) => Ok(Json(
serde_json::to_value(TestResult {
success: false,
error: Some(e.to_string()),
latency_ms: Some(latency_ms(&start)),
})
.unwrap(),
)),
}
}
use super::{FactoryResetCleared, FactoryResetRequest, FactoryResetResponse, FACTORY_RESET_PHRASE};
pub async fn factory_reset(
State(state): State<Arc<AppState>>,
Json(body): Json<FactoryResetRequest>,
) -> Result<impl IntoResponse, crate::error::ApiError> {
if body.confirmation != FACTORY_RESET_PHRASE {
return Err(crate::error::ApiError::BadRequest(
"incorrect confirmation phrase".to_string(),
));
}
let runtimes_stopped = {
let mut runtimes = state.runtimes.lock().await;
let count = runtimes.len() as u32;
for (_, mut rt) in runtimes.drain() {
rt.shutdown().await;
}
count
};
if let Some(cancel) = state.watchtower_cancel.write().await.take() {
cancel.cancel();
}
let reset_stats = tuitbot_core::storage::reset::factory_reset(&state.db).await?;
tuitbot_core::storage::accounts::ensure_default_account(&state.db).await?;
let config_deleted = match std::fs::remove_file(&state.config_path) {
Ok(()) => true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(e) => {
tracing::warn!(error = %e, "failed to delete config file");
false
}
};
let passphrase_path = state.data_dir.join("passphrase_hash");
let passphrase_deleted = match std::fs::remove_file(&passphrase_path) {
Ok(()) => true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(e) => {
tracing::warn!(error = %e, "failed to delete passphrase hash");
false
}
};
let media_dir = state.data_dir.join("media");
let media_deleted = match std::fs::remove_dir_all(&media_dir) {
Ok(()) => true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(e) => {
tracing::warn!(error = %e, "failed to delete media directory");
false
}
};
let credentials_deleted = delete_all_credentials(&state.data_dir);
*state.passphrase_hash.write().await = None;
*state.passphrase_hash_mtime.write().await = None;
state.content_generators.lock().await.clear();
state.login_attempts.lock().await.clear();
state.pending_oauth.lock().await.clear();
state.token_managers.lock().await.clear();
tracing::info!(
tables = reset_stats.tables_cleared,
rows = reset_stats.rows_deleted,
config = config_deleted,
passphrase = passphrase_deleted,
media = media_deleted,
credentials = credentials_deleted,
runtimes = runtimes_stopped,
"Factory reset completed"
);
let response = FactoryResetResponse {
status: "reset_complete".to_string(),
cleared: FactoryResetCleared {
tables_cleared: reset_stats.tables_cleared,
rows_deleted: reset_stats.rows_deleted,
config_deleted,
passphrase_deleted,
media_deleted,
credentials_deleted,
runtimes_stopped,
},
};
let cookie = "tuitbot_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0";
Ok((
StatusCode::OK,
[(axum::http::header::SET_COOKIE, cookie)],
Json(serde_json::to_value(response).unwrap()),
))
}