use std::sync::Arc; use axum::extract::State; use axum::http::HeaderMap; use axum::response::IntoResponse; use axum::routing::{get, post}; use axum::{Json, Router}; use serde::Deserialize; use crate::auth; use crate::config; use crate::database; use crate::error::http_error; use crate::state::{self, AppState}; #[derive(Deserialize)] pub struct PasswordRequest { password: String, } #[derive(Deserialize)] pub struct AppConfigRequest { #[serde(rename = "BOT_TOKEN")] bot_token: Option, #[serde(rename = "CHANNEL_NAME")] channel_name: Option, #[serde(rename = "PASS_WORD")] pass_word: Option, #[serde(rename = "BASE_URL")] base_url: Option, #[serde(rename = "PICGO_API_KEY")] picgo_api_key: Option, } #[derive(Deserialize)] pub struct VerifyRequest { #[serde(rename = "BOT_TOKEN")] bot_token: Option, #[serde(rename = "CHANNEL_NAME")] channel_name: Option, } fn validate_config(cfg: &std::collections::HashMap>) -> Result<(), (axum::http::StatusCode, &'static str, &'static str)> { if let Some(Some(token)) = cfg.get("BOT_TOKEN") { let t = token.trim(); if !t.is_empty() && (!t.contains(':') || t.len() < 20) { return Err((axum::http::StatusCode::BAD_REQUEST, "BOT_TOKEN 格式不正确", "invalid_bot_token")); } } if let Some(Some(channel)) = cfg.get("CHANNEL_NAME") { let c = channel.trim(); if !c.is_empty() && !c.starts_with('@') && !c.starts_with("-100") { return Err((axum::http::StatusCode::BAD_REQUEST, "CHANNEL_NAME 格式不正确(@username 或 -100...)", "invalid_channel")); } } if let Some(Some(url)) = cfg.get("BASE_URL") { let u = url.trim(); if !u.is_empty() && !u.starts_with("http://") && !u.starts_with("https://") { return Err((axum::http::StatusCode::BAD_REQUEST, "BASE_URL 必须以 http:// 或 https:// 开头", "invalid_base_url")); } } Ok(()) } fn merge_config( existing: &std::collections::HashMap>, incoming: &AppConfigRequest, ) -> Result< std::collections::HashMap>, (axum::http::StatusCode, &'static str, &'static str), > { let mut result = existing.clone(); if let Some(ref v) = incoming.bot_token { let v = v.trim().to_string(); result.insert("BOT_TOKEN".into(), if v.is_empty() { None } else { Some(v) }); } if let Some(ref v) = incoming.channel_name { let v = v.trim().to_string(); result.insert("CHANNEL_NAME".into(), if v.is_empty() { None } else { Some(v) }); } if let Some(ref v) = incoming.pass_word { let v = v.trim().to_string(); if v.is_empty() { result.insert("PASS_WORD".into(), None); result.insert("SESSION_TOKEN".into(), None); } else { // Hash password and compute a cryptographically random session token. // The token is independent of the password, so sessions cannot be // forged from knowledge of the plaintext or hash. If hashing fails we // REJECT the update rather than falling back to plaintext storage. match auth::hash_password(&v) { Ok(hashed) => { let session_token = auth::generate_session_token(); result.insert("PASS_WORD".into(), Some(hashed)); result.insert("SESSION_TOKEN".into(), Some(session_token)); } Err(e) => { tracing::error!("密码哈希失败: {}", e); return Err(( axum::http::StatusCode::INTERNAL_SERVER_ERROR, "密码哈希失败", "hash_error", )); } } } } if let Some(ref v) = incoming.base_url { let v = v.trim().to_string(); result.insert("BASE_URL".into(), if v.is_empty() { None } else { Some(v) }); } if let Some(ref v) = incoming.picgo_api_key { let v = v.trim().to_string(); result.insert("PICGO_API_KEY".into(), if v.is_empty() { None } else { Some(v) }); } Ok(result) } fn is_https(headers: &HeaderMap) -> bool { headers .get("x-forwarded-proto") .and_then(|v| v.to_str().ok()) .map_or(false, |v| v == "https") } async fn get_app_config(State(state): State>) -> impl IntoResponse { let settings = config::get_app_settings(&state.settings, &state.db_pool); let bot = state.bot_state.lock().await; Json(serde_json::json!({ "status": "ok", "cfg": { "BOT_TOKEN_SET": settings.get("BOT_TOKEN").and_then(|v| v.as_deref()).map_or(false, |v| !v.is_empty()), "CHANNEL_NAME": settings.get("CHANNEL_NAME").and_then(|v| v.as_deref()).unwrap_or(""), "PASS_WORD_SET": settings.get("PASS_WORD").and_then(|v| v.as_deref()).map_or(false, |v| !v.is_empty()), "BASE_URL": settings.get("BASE_URL").and_then(|v| v.as_deref()).unwrap_or(""), "PICGO_API_KEY_SET": settings.get("PICGO_API_KEY").and_then(|v| v.as_deref()).map_or(false, |v| !v.is_empty()), }, "bot": { "ready": bot.bot_ready, "running": bot.bot_running, "error": bot.bot_error, } })) } async fn save_config_only( State(state): State>, Json(payload): Json, ) -> Result { let existing = database::get_app_settings_from_db(&state.db_pool).unwrap_or_default(); let merged = merge_config(&existing, &payload) .map_err(|(status, msg, code)| http_error(status, msg, code))?; if let Err((status, msg, code)) = validate_config(&merged) { return Err(http_error(status, msg, code)); } database::save_app_settings_to_db(&state.db_pool, &merged).map_err(|e| { tracing::error!("保存配置失败: {}", e); http_error( axum::http::StatusCode::INTERNAL_SERVER_ERROR, "保存配置失败", "save_error", ) })?; tracing::info!("配置已保存(未应用)"); Ok(Json(serde_json::json!({ "status": "ok", "message": "已保存(未应用)" }))) } async fn save_and_apply( State(state): State>, headers: HeaderMap, Json(payload): Json, ) -> Result { let existing = database::get_app_settings_from_db(&state.db_pool).unwrap_or_default(); let merged = merge_config(&existing, &payload) .map_err(|(status, msg, code)| http_error(status, msg, code))?; if let Err((status, msg, code)) = validate_config(&merged) { return Err(http_error(status, msg, code)); } database::save_app_settings_to_db(&state.db_pool, &merged).map_err(|e| { tracing::error!("保存配置失败: {}", e); http_error( axum::http::StatusCode::INTERNAL_SERVER_ERROR, "保存配置失败", "save_error", ) })?; let _ = state::apply_runtime_settings(state.clone(), true).await; let bot = state.bot_state.lock().await; // Handle password cookie using the server-side random session token. // We honor x-forwarded-proto so cookies get the Secure flag when a // trusted reverse proxy terminates TLS; the COOKIE_SECURE env var // (handled inside build_cookie) can force Secure regardless. let session_token = merged .get("SESSION_TOKEN") .and_then(|v| v.as_deref()) .unwrap_or(""); let pwd = merged .get("PASS_WORD") .and_then(|v| v.as_deref()) .unwrap_or(""); let secure = is_https(&headers); let cookie = if !pwd.is_empty() && !session_token.is_empty() { auth::build_cookie(session_token, secure) } else { // No password set OR no session token — clear any stale cookie. auth::build_clear_cookie() }; Ok(( [(axum::http::header::SET_COOKIE, cookie)], Json(serde_json::json!({ "status": "ok", "message": "已保存并应用", "bot": { "ready": bot.bot_ready, "running": bot.bot_running, } })), )) } async fn reset_config(State(state): State>) -> impl IntoResponse { database::reset_app_settings_in_db(&state.db_pool).ok(); let _ = state::apply_runtime_settings(state.clone(), true).await; tracing::warn!("配置已重置"); let cookie = crate::auth::build_clear_cookie(); ( [(axum::http::header::SET_COOKIE, cookie)], Json(serde_json::json!({ "status": "ok", "message": "配置已重置" })), ) } async fn set_password( State(state): State>, Json(payload): Json, ) -> Result, crate::error::AppError> { let db_pool = &state.db_pool; let password = payload.password.trim().to_string(); // Hash the password with argon2 and compute session token let hashed = auth::hash_password(&password).map_err(|e| { tracing::error!("密码哈希失败: {}", e); crate::error::AppError::new( axum::http::StatusCode::INTERNAL_SERVER_ERROR, "密码哈希失败", "hash_error", ) })?; // Random session token, independent of the password. let session_token = auth::generate_session_token(); let mut current = database::get_app_settings_from_db(db_pool).unwrap_or_default(); current.insert("PASS_WORD".into(), Some(hashed)); current.insert("SESSION_TOKEN".into(), Some(session_token)); database::save_app_settings_to_db(db_pool, ¤t).map_err(|_| { crate::error::AppError::new( axum::http::StatusCode::INTERNAL_SERVER_ERROR, "无法写入密码。", "write_password_failed", ) })?; let _ = state::apply_runtime_settings(state.clone(), false).await; tracing::info!("密码已成功设置"); Ok(Json(serde_json::json!({ "status": "ok", "message": "密码已成功设置。" }))) } async fn verify_bot( State(state): State>, Json(payload): Json, ) -> impl IntoResponse { let app_settings = config::get_app_settings(&state.settings, &state.db_pool); let token = payload .bot_token .or_else(|| app_settings.get("BOT_TOKEN").and_then(|v| v.clone())) .unwrap_or_default(); if token.is_empty() { return Json(serde_json::json!({ "status": "ok", "ok": false, "available": false, "message": "未提供 BOT_TOKEN" })); } let url = format!("https://api.telegram.org/bot{}/getMe", token); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() .unwrap(); match client.get(&url).send().await { Ok(resp) => match resp.json::().await { Ok(data) => { if data["ok"].as_bool() == Some(true) { let username = data["result"]["username"] .as_str() .unwrap_or("unknown"); Json(serde_json::json!({ "status": "ok", "ok": true, "available": true, "result": { "username": username } })) } else { Json(serde_json::json!({ "status": "ok", "ok": false, "available": false, "message": data["description"].as_str().unwrap_or("Unknown error") })) } } Err(e) => { tracing::warn!("verify_bot parse error: {}", e); Json(serde_json::json!({ "status": "ok", "ok": false, "available": false, "message": "解析响应失败" })) } }, Err(e) => { tracing::warn!("verify_bot connect error: {}", e); Json(serde_json::json!({ "status": "ok", "ok": false, "available": false, "message": "连接失败" })) }, } } async fn verify_channel( State(state): State>, Json(payload): Json, ) -> impl IntoResponse { let app_settings = config::get_app_settings(&state.settings, &state.db_pool); let token = payload .bot_token .or_else(|| app_settings.get("BOT_TOKEN").and_then(|v| v.clone())) .unwrap_or_default(); let channel = payload .channel_name .or_else(|| app_settings.get("CHANNEL_NAME").and_then(|v| v.clone())) .unwrap_or_default(); if token.is_empty() || channel.is_empty() { return Json(serde_json::json!({ "status": "ok", "available": false, "message": "缺少 BOT_TOKEN 或 CHANNEL_NAME" })); } let url = format!("https://api.telegram.org/bot{}/sendMessage", token); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() .unwrap(); match client .post(&url) .json(&serde_json::json!({ "chat_id": channel, "text": "tgState channel check" })) .send() .await { Ok(resp) => match resp.json::().await { Ok(data) => { if data["ok"].as_bool() == Some(true) { // Try to delete test message if let Some(msg_id) = data["result"]["message_id"].as_i64() { let del_url = format!("https://api.telegram.org/bot{}/deleteMessage", token); let _ = client .post(&del_url) .json(&serde_json::json!({ "chat_id": channel, "message_id": msg_id })) .send() .await; } Json(serde_json::json!({ "status": "ok", "available": true })) } else { Json(serde_json::json!({ "status": "ok", "available": false, "message": data["description"].as_str().unwrap_or("Unknown error") })) } } Err(e) => { tracing::warn!("verify_channel parse error: {}", e); Json(serde_json::json!({ "status": "ok", "available": false, "message": "解析响应失败" })) } }, Err(e) => { tracing::warn!("verify_channel connect error: {}", e); Json(serde_json::json!({ "status": "ok", "available": false, "message": "连接失败" })) }, } } pub fn router() -> Router> { Router::new() .route("/api/app-config", get(get_app_config)) .route("/api/app-config/save", post(save_config_only)) .route("/api/app-config/apply", post(save_and_apply)) .route("/api/reset-config", post(reset_config)) .route("/api/set-password", post(set_password)) .route("/api/verify/bot", post(verify_bot)) .route("/api/verify/channel", post(verify_channel)) }