// Monitor routes — LDOH incident dashboard proxy with cookie auth use crate::{ error::{AppError, AppResult}, AppState, }; use axum::{ extract::{Path, State}, http::{header, HeaderMap}, response::Response, routing::{get, post}, Json, Router, }; use serde_json::{json, Value}; const MONITOR_AUTH_COOKIE: &str = "meta_monitor_auth"; const LDOH_BASE_URL: &str = "https://ldoh.105117.xyz"; const LDOH_COOKIE_SETTING_KEY: &str = "monitor_ldoh_cookie"; pub fn routes() -> Router { Router::new() .route( "/api/monitor/config", get(get_monitor_config).put(update_monitor_config), ) .route("/api/monitor/session", post(create_session)) .route("/monitor-proxy/ldoh", get(ldoh_proxy_handler)) .route("/monitor-proxy/ldoh/", get(ldoh_proxy_handler)) .route("/monitor-proxy/ldoh/{*path}", get(ldoh_proxy_handler)) } /// GET /api/monitor/config — returns whether LDOH cookie is configured async fn get_monitor_config(State(state): State) -> AppResult> { let cookie: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?") .bind(LDOH_COOKIE_SETTING_KEY) .fetch_optional(&*state.db) .await?; let raw = cookie .and_then(|(v,)| { serde_json::from_str::(&v) .ok() .and_then(|parsed| parsed.as_str().map(String::from)) }) .unwrap_or_default(); let configured = !raw.is_empty(); let masked = if configured { mask_cookie_value(&raw) } else { String::new() }; Ok(Json(json!({ "ldohCookieConfigured": configured, "ldohCookieMasked": masked, }))) } /// PUT /api/monitor/config — save LDOH cookie async fn update_monitor_config( State(state): State, Json(body): Json, ) -> AppResult> { let cookie = body .get("ldohCookie") .and_then(|v| v.as_str()) .unwrap_or(""); if cookie.is_empty() { sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, '')") .bind(LDOH_COOKIE_SETTING_KEY) .execute(&*state.db) .await?; return Ok(Json(json!({ "success": true, "message": "LDOH Cookie 已清空", "ldohCookieConfigured": false, }))); } let normalized = normalize_ldoh_cookie(cookie); if !normalized.starts_with("ld_auth_session=") || normalized.len() < 24 { return Err(AppError::BadRequest("Cookie 格式无效".into())); } let escaped = serde_json::to_string(&normalized).unwrap_or_default(); sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") .bind(LDOH_COOKIE_SETTING_KEY) .bind(&escaped) .execute(&*state.db) .await?; Ok(Json(json!({ "success": true, "message": "LDOH Cookie 已保存", "ldohCookieConfigured": true, "ldohCookieMasked": mask_cookie_value(&normalized), }))) } /// POST /api/monitor/session — returns cookie value for client to set async fn create_session(State(state): State) -> Json { let token = &state.config.auth.auth_token; Json(json!({ "success": true, "_cookie": format!("{}={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=7200", MONITOR_AUTH_COOKIE, token), })) } /// GET /monitor-proxy/ldoh/{*path} — reverse proxy to ldoh.105117.xyz async fn ldoh_proxy_handler( State(state): State, headers: HeaderMap, Path(path): Path, ) -> Result { // Auth check via cookie let cookies = parse_cookies( headers .get(header::COOKIE) .and_then(|v| v.to_str().ok()) .unwrap_or(""), ); if cookies.get(MONITOR_AUTH_COOKIE).map(|s| s.as_str()) != Some(&state.config.auth.auth_token) { return Err(AppError::Unauthorized( "Missing or invalid monitor session".into(), )); } // Get stored LDOH cookie let stored: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?") .bind(LDOH_COOKIE_SETTING_KEY) .fetch_optional(&*state.db) .await?; let ldoh_cookie = stored .and_then(|(v,)| { serde_json::from_str::(&v) .ok() .and_then(|parsed| parsed.as_str().map(String::from)) }) .unwrap_or_default(); if ldoh_cookie.is_empty() { return Err(AppError::BadRequest("LDOH cookie not configured".into())); } // Build upstream URL let clean_path = if path.is_empty() || path == "/" { String::new() } else { format!("/{}", path) }; let target_url = format!("{}{}", LDOH_BASE_URL, clean_path); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to build client: {e}")))?; let mut req = client .get(&target_url) .header("cookie", &ldoh_cookie) .header( "accept", headers .get("accept") .and_then(|v| v.to_str().ok()) .unwrap_or("*/*"), ) .header( "accept-language", headers .get("accept-language") .and_then(|v| v.to_str().ok()) .unwrap_or("zh-CN,zh;q=0.9,en;q=0.8"), ) .header( "user-agent", headers .get("user-agent") .and_then(|v| v.to_str().ok()) .unwrap_or("boosapiMonitorProxy/1.0"), ); if let Some(referer) = headers.get("referer").and_then(|v| v.to_str().ok()) { req = req.header("referer", referer.replace("/monitor-proxy/ldoh", "")); } let resp = req .send() .await .map_err(|e| AppError::UpstreamError(format!("LDOH unreachable: {e}")))?; let status = resp.status(); let content_type = resp .headers() .get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or("application/octet-stream") .to_string(); let mut builder = axum::response::Response::builder().status(status); if let Some(ctl) = resp .headers() .get("cache-control") .and_then(|v| v.to_str().ok()) { builder = builder.header("cache-control", ctl); } // Rewrite text content if content_type.contains("text/html") || content_type.contains("javascript") || content_type.contains("text/css") || content_type.contains("application/json") { let text = resp .text() .await .map_err(|e| AppError::UpstreamError(e.to_string()))?; return builder .header(header::CONTENT_TYPE, &content_type) .body(axum::body::Body::from(rewrite_proxy_text(&text))) .map_err(|e| AppError::Internal(anyhow::anyhow!("{e}"))); } let bytes = resp .bytes() .await .map_err(|e| AppError::UpstreamError(e.to_string()))?; builder .header(header::CONTENT_TYPE, &content_type) .body(axum::body::Body::from(bytes)) .map_err(|e| AppError::Internal(anyhow::anyhow!("{e}"))) } // ── Helpers ──────────────────────────────────────────────────────────── fn parse_cookies(raw: &str) -> std::collections::HashMap { let mut map = std::collections::HashMap::new(); for part in raw.split(';') { let part = part.trim(); if let Some(idx) = part.find('=') { let key = part[..idx].trim().to_string(); let val = part[idx + 1..].trim().to_string(); if !key.is_empty() { map.insert(key, val); } } } map } fn mask_cookie_value(cookie_text: &str) -> String { let raw = cookie_text.trim(); if raw.is_empty() { return String::new(); } let value = raw.split('=').nth(1).unwrap_or(raw); if value.len() <= 10 { format!("{}****", &value[..2.min(value.len())]) } else { format!("{}****{}", &value[..6], &value[value.len() - 4..]) } } fn normalize_ldoh_cookie(raw: &str) -> String { let trimmed = raw.trim().to_string(); if trimmed.contains("ld_auth_session=") { if let Some(first) = trimmed.split(';').next() { if first.trim().starts_with("ld_auth_session=") { return first.trim().to_string(); } } } format!("ld_auth_session={}", trimmed) } fn rewrite_proxy_text(text: &str) -> String { text.replace("https://ldoh.105117.xyz/", "/monitor-proxy/ldoh/") .replace( "https:\\/\\/ldoh.105117.xyz\\/", "\\/monitor-proxy\\/ldoh\\/", ) .replace("src=\"/", "src=\"/monitor-proxy/ldoh/") .replace("src='/", "src='/monitor-proxy/ldoh/") .replace("href=\"/", "href=\"/monitor-proxy/ldoh/") .replace("href='/", "href='/monitor-proxy/ldoh/") .replace("action=\"/", "action=\"/monitor-proxy/ldoh/") .replace("action='/", "action='/monitor-proxy/ldoh/") .replace("\"/_next/", "\"/monitor-proxy/ldoh/_next/") .replace("'/_next/", "'/monitor-proxy/ldoh/_next/") .replace("\"\\\\/api/", "\"\\\\/monitor-proxy\\\\/ldoh\\\\/api/") .replace("'/api/", "'/monitor-proxy/ldoh/api/") .replace("\"/api/", "\"/monitor-proxy/ldoh/api/") }