1use std::path::Path;
4use std::sync::Arc;
5use std::time::Instant;
6
7use axum::extract::State;
8use axum::http::StatusCode;
9use axum::response::IntoResponse;
10use axum::Json;
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use tuitbot_core::auth::error::AuthError;
14use tuitbot_core::auth::{passphrase, session};
15use tuitbot_core::config::{
16 effective_config, merge_overrides, split_patch_by_scope, Config, LlmConfig,
17};
18use tuitbot_core::error::ConfigError;
19use tuitbot_core::llm::factory::create_provider;
20use tuitbot_core::storage::accounts::{self, DEFAULT_ACCOUNT_ID};
21
22use crate::account::AccountContext;
23use crate::error::ApiError;
24use crate::state::AppState;
25
26#[derive(Deserialize)]
28struct ClaimRequest {
29 passphrase: String,
30}
31
32#[derive(Serialize)]
37struct ValidationResponse {
38 valid: bool,
39 #[serde(skip_serializing_if = "Vec::is_empty")]
40 errors: Vec<ValidationErrorItem>,
41}
42
43#[derive(Serialize)]
44struct ValidationErrorItem {
45 field: String,
46 message: String,
47}
48
49#[derive(Deserialize)]
50pub struct TestLlmRequest {
51 pub provider: String,
52 #[serde(default)]
53 pub api_key: Option<String>,
54 #[serde(default)]
55 pub model: String,
56 #[serde(default)]
57 pub base_url: Option<String>,
58}
59
60#[derive(Serialize)]
61struct TestResult {
62 success: bool,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 error: Option<String>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 latency_ms: Option<u64>,
67}
68
69fn merge_patch_and_parse(config_path: &Path, patch: &Value) -> Result<(String, Config), ApiError> {
77 let contents = std::fs::read_to_string(config_path).map_err(|e| {
78 ApiError::BadRequest(format!(
79 "could not read config file {}: {e}",
80 config_path.display()
81 ))
82 })?;
83
84 let mut toml_value: toml::Value = contents.parse().map_err(|e: toml::de::Error| {
85 ApiError::BadRequest(format!("failed to parse existing config: {e}"))
86 })?;
87
88 let patch_toml = json_to_toml(patch)
89 .map_err(|e| ApiError::BadRequest(format!("patch contains invalid values: {e}")))?;
90
91 merge_toml(&mut toml_value, &patch_toml);
92
93 let merged_str = toml::to_string_pretty(&toml_value)
94 .map_err(|e| ApiError::BadRequest(format!("failed to serialize merged config: {e}")))?;
95
96 let config: Config = toml::from_str(&merged_str)
97 .map_err(|e| ApiError::BadRequest(format!("merged config is invalid: {e}")))?;
98
99 Ok((merged_str, config))
100}
101
102fn load_base_config(config_path: &Path) -> Result<Config, ApiError> {
104 let contents = std::fs::read_to_string(config_path).map_err(|e| {
105 ApiError::BadRequest(format!(
106 "could not read config file {}: {e}",
107 config_path.display()
108 ))
109 })?;
110
111 toml::from_str(&contents)
112 .map_err(|e| ApiError::BadRequest(format!("failed to parse config: {e}")))
113}
114
115fn config_errors_to_response(errors: Vec<ConfigError>) -> Vec<ValidationErrorItem> {
116 errors
117 .into_iter()
118 .map(|e| match e {
119 ConfigError::MissingField { field } => ValidationErrorItem {
120 field,
121 message: "this field is required".to_string(),
122 },
123 ConfigError::InvalidValue { field, message } => ValidationErrorItem { field, message },
124 other => ValidationErrorItem {
125 field: String::new(),
126 message: other.to_string(),
127 },
128 })
129 .collect()
130}
131
132pub async fn config_status(State(state): State<Arc<AppState>>) -> Json<Value> {
141 let configured = state.config_path.exists();
142 let claimed = passphrase::is_claimed(&state.data_dir);
143 let capabilities = state.deployment_mode.capabilities();
144 Json(serde_json::json!({
145 "configured": configured,
146 "claimed": claimed,
147 "deployment_mode": state.deployment_mode,
148 "capabilities": capabilities,
149 }))
150}
151
152pub async fn init_settings(
160 State(state): State<Arc<AppState>>,
161 Json(mut body): Json<Value>,
162) -> Result<impl IntoResponse, ApiError> {
163 if state.config_path.exists() {
164 return Err(ApiError::Conflict(
165 "configuration already exists; use PATCH /api/settings to update".to_string(),
166 ));
167 }
168
169 if !body.is_object() {
170 return Err(ApiError::BadRequest(
171 "request body must be a JSON object".to_string(),
172 ));
173 }
174
175 let claim: Option<ClaimRequest> = body
177 .as_object_mut()
178 .and_then(|obj| obj.remove("claim"))
179 .map(serde_json::from_value)
180 .transpose()
181 .map_err(|e| ApiError::BadRequest(format!("invalid claim object: {e}")))?;
182
183 if let Some(ref claim) = claim {
185 if claim.passphrase.len() < 8 {
186 return Err(ApiError::BadRequest(
187 "passphrase must be at least 8 characters".into(),
188 ));
189 }
190 if passphrase::is_claimed(&state.data_dir) {
191 return Err(ApiError::Conflict("instance already claimed".into()));
192 }
193 }
194
195 let toml_value = json_to_toml(&body)
197 .map_err(|e| ApiError::BadRequest(format!("invalid config values: {e}")))?;
198
199 let toml_str = toml::to_string_pretty(&toml_value)
200 .map_err(|e| ApiError::BadRequest(format!("failed to serialize config: {e}")))?;
201
202 let config: Config = toml::from_str(&toml_str)
204 .map_err(|e| ApiError::BadRequest(format!("invalid config: {e}")))?;
205
206 if let Err(errors) = config.validate() {
207 let items = config_errors_to_response(errors);
208 return Ok((
209 StatusCode::OK,
210 Json(serde_json::json!({
211 "status": "validation_failed",
212 "errors": items
213 })),
214 )
215 .into_response());
216 }
217
218 if let Some(parent) = state.config_path.parent() {
220 std::fs::create_dir_all(parent)
221 .map_err(|e| ApiError::BadRequest(format!("failed to create config directory: {e}")))?;
222 }
223
224 std::fs::write(&state.config_path, &toml_str).map_err(|e| {
225 ApiError::BadRequest(format!(
226 "could not write config file {}: {e}",
227 state.config_path.display()
228 ))
229 })?;
230
231 #[cfg(unix)]
233 {
234 use std::os::unix::fs::PermissionsExt;
235 let _ =
236 std::fs::set_permissions(&state.config_path, std::fs::Permissions::from_mode(0o600));
237 }
238
239 let json = serde_json::to_value(&config)
240 .map_err(|e| ApiError::BadRequest(format!("failed to serialize config: {e}")))?;
241
242 if let Some(claim) = claim {
244 passphrase::create_passphrase_hash(&state.data_dir, &claim.passphrase).map_err(
245 |e| match e {
246 AuthError::AlreadyClaimed => ApiError::Conflict("instance already claimed".into()),
247 other => ApiError::Internal(format!("failed to create passphrase: {other}")),
248 },
249 )?;
250
251 let new_hash = passphrase::load_passphrase_hash(&state.data_dir)
253 .map_err(|e| ApiError::Internal(format!("failed to load passphrase hash: {e}")))?;
254 {
255 let mut hash = state.passphrase_hash.write().await;
256 *hash = new_hash;
257 }
258 {
259 let mut mtime = state.passphrase_hash_mtime.write().await;
260 *mtime = passphrase::passphrase_hash_mtime(&state.data_dir);
261 }
262
263 let new_session = session::create_session(&state.db)
265 .await
266 .map_err(|e| ApiError::Internal(format!("failed to create session: {e}")))?;
267
268 let cookie = format!(
269 "tuitbot_session={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800",
270 new_session.raw_token,
271 );
272
273 tracing::info!("instance claimed via /settings/init");
274
275 return Ok((
276 StatusCode::OK,
277 [(axum::http::header::SET_COOKIE, cookie)],
278 Json(serde_json::json!({
279 "status": "created",
280 "config": json,
281 "csrf_token": new_session.csrf_token,
282 })),
283 )
284 .into_response());
285 }
286
287 Ok((
289 StatusCode::OK,
290 Json(serde_json::json!({
291 "status": "created",
292 "config": json
293 })),
294 )
295 .into_response())
296}
297
298pub async fn get_settings(
308 State(state): State<Arc<AppState>>,
309 ctx: AccountContext,
310) -> Result<Json<Value>, ApiError> {
311 let base_config = load_base_config(&state.config_path)?;
312
313 if ctx.account_id == DEFAULT_ACCOUNT_ID {
314 let mut json = serde_json::to_value(base_config)
315 .map_err(|e| ApiError::BadRequest(format!("failed to serialize config: {e}")))?;
316 redact_service_account_keys(&mut json);
317 return Ok(Json(json));
318 }
319
320 let account = accounts::get_account(&state.db, &ctx.account_id)
322 .await?
323 .ok_or_else(|| ApiError::NotFound(format!("account not found: {}", ctx.account_id)))?;
324
325 let result = effective_config(&base_config, &account.config_overrides)
326 .map_err(|e| ApiError::BadRequest(format!("config merge failed: {e}")))?;
327
328 let mut json = serde_json::to_value(&result.config)
329 .map_err(|e| ApiError::BadRequest(format!("failed to serialize config: {e}")))?;
330 redact_service_account_keys(&mut json);
331
332 let response = serde_json::json!({
334 "config": json,
335 "_overrides": result.overridden_keys,
336 });
337
338 Ok(Json(response))
339}
340
341fn redact_service_account_keys(json: &mut Value) {
344 if let Some(sources) = json
345 .get_mut("content_sources")
346 .and_then(|cs| cs.get_mut("sources"))
347 .and_then(|s| s.as_array_mut())
348 {
349 for source in sources {
350 if let Some(key) = source.get_mut("service_account_key") {
351 if !key.is_null() {
352 *key = serde_json::Value::String("[redacted]".to_string());
353 }
354 }
355 }
356 }
357}
358
359pub async fn patch_settings(
366 State(state): State<Arc<AppState>>,
367 ctx: AccountContext,
368 Json(patch): Json<Value>,
369) -> Result<Json<Value>, ApiError> {
370 if !patch.is_object() {
371 return Err(ApiError::BadRequest(
372 "request body must be a JSON object".to_string(),
373 ));
374 }
375
376 if ctx.account_id == DEFAULT_ACCOUNT_ID {
377 let (merged_str, config) = merge_patch_and_parse(&state.config_path, &patch)?;
379
380 std::fs::write(&state.config_path, &merged_str).map_err(|e| {
381 ApiError::BadRequest(format!(
382 "could not write config file {}: {e}",
383 state.config_path.display()
384 ))
385 })?;
386
387 let mut json = serde_json::to_value(config)
388 .map_err(|e| ApiError::BadRequest(format!("failed to serialize config: {e}")))?;
389 redact_service_account_keys(&mut json);
390 return Ok(Json(json));
391 }
392
393 let (_account_patch, rejected) = split_patch_by_scope(&patch);
395 if !rejected.is_empty() {
396 return Err(ApiError::Forbidden(format!(
397 "instance-scoped keys cannot be changed per-account: {}",
398 rejected.join(", ")
399 )));
400 }
401
402 let account = accounts::get_account(&state.db, &ctx.account_id)
404 .await?
405 .ok_or_else(|| ApiError::NotFound(format!("account not found: {}", ctx.account_id)))?;
406
407 let new_overrides = merge_overrides(&account.config_overrides, &patch)
409 .map_err(|e| ApiError::BadRequest(format!("override merge failed: {e}")))?;
410
411 let base_config = load_base_config(&state.config_path)?;
413 let result = effective_config(&base_config, &new_overrides)
414 .map_err(|e| ApiError::BadRequest(format!("effective config invalid: {e}")))?;
415
416 accounts::update_account(
418 &state.db,
419 &ctx.account_id,
420 accounts::UpdateAccountParams {
421 config_overrides: Some(&new_overrides),
422 ..Default::default()
423 },
424 )
425 .await?;
426
427 let mut json = serde_json::to_value(&result.config)
428 .map_err(|e| ApiError::BadRequest(format!("failed to serialize config: {e}")))?;
429 redact_service_account_keys(&mut json);
430
431 let response = serde_json::json!({
432 "config": json,
433 "_overrides": result.overridden_keys,
434 });
435
436 Ok(Json(response))
437}
438
439pub async fn validate_settings(
444 State(state): State<Arc<AppState>>,
445 ctx: AccountContext,
446 Json(patch): Json<Value>,
447) -> Result<Json<Value>, ApiError> {
448 if !patch.is_object() {
449 return Err(ApiError::BadRequest(
450 "request body must be a JSON object".to_string(),
451 ));
452 }
453
454 if ctx.account_id == DEFAULT_ACCOUNT_ID {
455 let (_merged_str, config) = merge_patch_and_parse(&state.config_path, &patch)?;
457
458 let response = match config.validate() {
459 Ok(()) => ValidationResponse {
460 valid: true,
461 errors: Vec::new(),
462 },
463 Err(errors) => ValidationResponse {
464 valid: false,
465 errors: config_errors_to_response(errors),
466 },
467 };
468
469 return Ok(Json(serde_json::to_value(response).unwrap()));
470 }
471
472 let (_account_patch, rejected) = split_patch_by_scope(&patch);
474 if !rejected.is_empty() {
475 return Ok(Json(
476 serde_json::to_value(ValidationResponse {
477 valid: false,
478 errors: vec![ValidationErrorItem {
479 field: "config_overrides".to_string(),
480 message: format!(
481 "instance-scoped keys cannot be changed per-account: {}",
482 rejected.join(", ")
483 ),
484 }],
485 })
486 .unwrap(),
487 ));
488 }
489
490 let account = accounts::get_account(&state.db, &ctx.account_id)
491 .await?
492 .ok_or_else(|| ApiError::NotFound(format!("account not found: {}", ctx.account_id)))?;
493
494 let new_overrides = merge_overrides(&account.config_overrides, &patch)
495 .map_err(|e| ApiError::BadRequest(format!("override merge failed: {e}")))?;
496
497 let base_config = load_base_config(&state.config_path)?;
498 let result = effective_config(&base_config, &new_overrides)
499 .map_err(|e| ApiError::BadRequest(format!("effective config invalid: {e}")))?;
500
501 let response = match result.config.validate() {
502 Ok(()) => ValidationResponse {
503 valid: true,
504 errors: Vec::new(),
505 },
506 Err(errors) => ValidationResponse {
507 valid: false,
508 errors: config_errors_to_response(errors),
509 },
510 };
511
512 Ok(Json(serde_json::to_value(response).unwrap()))
513}
514
515pub async fn get_defaults() -> Result<Json<Value>, ApiError> {
517 let defaults = Config::default();
518 let json = serde_json::to_value(defaults)
519 .map_err(|e| ApiError::BadRequest(format!("failed to serialize defaults: {e}")))?;
520 Ok(Json(json))
521}
522
523pub async fn test_llm(Json(body): Json<TestLlmRequest>) -> Result<Json<Value>, ApiError> {
525 let llm_config = LlmConfig {
526 provider: body.provider,
527 api_key: body.api_key,
528 model: body.model,
529 base_url: body.base_url,
530 };
531
532 let provider = match create_provider(&llm_config) {
533 Ok(p) => p,
534 Err(e) => {
535 return Ok(Json(
536 serde_json::to_value(TestResult {
537 success: false,
538 error: Some(e.to_string()),
539 latency_ms: None,
540 })
541 .unwrap(),
542 ));
543 }
544 };
545
546 let start = Instant::now();
547 let latency_ms = |s: &Instant| s.elapsed().as_millis() as u64;
548
549 match provider.health_check().await {
550 Ok(()) => Ok(Json(
551 serde_json::to_value(TestResult {
552 success: true,
553 error: None,
554 latency_ms: Some(latency_ms(&start)),
555 })
556 .unwrap(),
557 )),
558 Err(e) => Ok(Json(
559 serde_json::to_value(TestResult {
560 success: false,
561 error: Some(e.to_string()),
562 latency_ms: Some(latency_ms(&start)),
563 })
564 .unwrap(),
565 )),
566 }
567}
568
569const FACTORY_RESET_PHRASE: &str = "RESET TUITBOT";
575
576#[derive(Deserialize)]
577pub struct FactoryResetRequest {
578 confirmation: String,
579}
580
581#[derive(Serialize)]
582struct FactoryResetResponse {
583 status: String,
584 cleared: FactoryResetCleared,
585}
586
587#[derive(Serialize)]
588struct FactoryResetCleared {
589 tables_cleared: u32,
590 rows_deleted: u64,
591 config_deleted: bool,
592 passphrase_deleted: bool,
593 media_deleted: bool,
594 credentials_deleted: bool,
595 runtimes_stopped: u32,
596}
597
598pub async fn factory_reset(
606 State(state): State<Arc<AppState>>,
607 Json(body): Json<FactoryResetRequest>,
608) -> Result<impl IntoResponse, ApiError> {
609 if body.confirmation != FACTORY_RESET_PHRASE {
611 return Err(ApiError::BadRequest(
612 "incorrect confirmation phrase".to_string(),
613 ));
614 }
615
616 let runtimes_stopped = {
618 let mut runtimes = state.runtimes.lock().await;
619 let count = runtimes.len() as u32;
620 for (_, mut rt) in runtimes.drain() {
621 rt.shutdown().await;
622 }
623 count
624 };
625
626 if let Some(ref token) = state.watchtower_cancel {
628 token.cancel();
629 }
630
631 let reset_stats = tuitbot_core::storage::reset::factory_reset(&state.db).await?;
633
634 tuitbot_core::storage::accounts::ensure_default_account(&state.db).await?;
636
637 let config_deleted = match std::fs::remove_file(&state.config_path) {
639 Ok(()) => true,
640 Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
641 Err(e) => {
642 tracing::warn!(error = %e, "failed to delete config file");
643 false
644 }
645 };
646
647 let passphrase_path = state.data_dir.join("passphrase_hash");
649 let passphrase_deleted = match std::fs::remove_file(&passphrase_path) {
650 Ok(()) => true,
651 Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
652 Err(e) => {
653 tracing::warn!(error = %e, "failed to delete passphrase hash");
654 false
655 }
656 };
657
658 let media_dir = state.data_dir.join("media");
660 let media_deleted = match std::fs::remove_dir_all(&media_dir) {
661 Ok(()) => true,
662 Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
663 Err(e) => {
664 tracing::warn!(error = %e, "failed to delete media directory");
665 false
666 }
667 };
668
669 let credentials_deleted = delete_all_credentials(&state.data_dir);
671
672 *state.passphrase_hash.write().await = None;
674 *state.passphrase_hash_mtime.write().await = None;
675 state.content_generators.lock().await.clear();
676 state.login_attempts.lock().await.clear();
677 state.pending_oauth.lock().await.clear();
678 state.token_managers.lock().await.clear();
679
680 tracing::info!(
681 tables = reset_stats.tables_cleared,
682 rows = reset_stats.rows_deleted,
683 config = config_deleted,
684 passphrase = passphrase_deleted,
685 media = media_deleted,
686 credentials = credentials_deleted,
687 runtimes = runtimes_stopped,
688 "Factory reset completed"
689 );
690
691 let response = FactoryResetResponse {
693 status: "reset_complete".to_string(),
694 cleared: FactoryResetCleared {
695 tables_cleared: reset_stats.tables_cleared,
696 rows_deleted: reset_stats.rows_deleted,
697 config_deleted,
698 passphrase_deleted,
699 media_deleted,
700 credentials_deleted,
701 runtimes_stopped,
702 },
703 };
704
705 let cookie = "tuitbot_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0";
706 Ok((
707 StatusCode::OK,
708 [(axum::http::header::SET_COOKIE, cookie)],
709 Json(serde_json::to_value(response).unwrap()),
710 ))
711}
712
713fn delete_all_credentials(data_dir: &std::path::Path) -> bool {
719 let mut deleted = false;
720
721 for name in &["scraper_session.json", "tokens.json"] {
723 let path = data_dir.join(name);
724 match std::fs::remove_file(&path) {
725 Ok(()) => deleted = true,
726 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
727 Err(e) => {
728 tracing::warn!(path = %path.display(), error = %e, "failed to delete credential file")
729 }
730 }
731 }
732
733 let accounts_dir = data_dir.join("accounts");
735 match std::fs::remove_dir_all(&accounts_dir) {
736 Ok(()) => deleted = true,
737 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
738 Err(e) => tracing::warn!(error = %e, "failed to delete accounts directory"),
739 }
740
741 deleted
742}
743
744fn merge_toml(base: &mut toml::Value, patch: &toml::Value) {
751 match (base, patch) {
752 (toml::Value::Table(base_table), toml::Value::Table(patch_table)) => {
753 for (key, patch_val) in patch_table {
754 if let Some(base_val) = base_table.get_mut(key) {
755 merge_toml(base_val, patch_val);
756 } else {
757 base_table.insert(key.clone(), patch_val.clone());
758 }
759 }
760 }
761 (base, _) => {
762 *base = patch.clone();
763 }
764 }
765}
766
767fn json_to_toml(json: &serde_json::Value) -> Result<toml::Value, String> {
773 match json {
774 serde_json::Value::Object(map) => {
775 let mut table = toml::map::Map::new();
776 for (key, val) in map {
777 if val.is_null() {
778 continue;
779 }
780 table.insert(key.clone(), json_to_toml(val)?);
781 }
782 Ok(toml::Value::Table(table))
783 }
784 serde_json::Value::Array(arr) => {
785 let values: Result<Vec<_>, _> = arr.iter().map(json_to_toml).collect();
786 Ok(toml::Value::Array(values?))
787 }
788 serde_json::Value::String(s) => Ok(toml::Value::String(s.clone())),
789 serde_json::Value::Number(n) => {
790 if let Some(i) = n.as_i64() {
791 Ok(toml::Value::Integer(i))
792 } else if let Some(f) = n.as_f64() {
793 Ok(toml::Value::Float(f))
794 } else {
795 Err(format!("unsupported number: {n}"))
796 }
797 }
798 serde_json::Value::Bool(b) => Ok(toml::Value::Boolean(*b)),
799 serde_json::Value::Null => Err("null values are not supported in TOML arrays".to_string()),
800 }
801}