feat: Monitor (LDOH proxy) routes migrated to Rust
- GET/PUT /api/monitor/config — LDOH cookie management via settings table
- POST /api/monitor/session — cookie auth token
- GET /monitor-proxy/ldoh/{*path} — reverse proxy with content rewriting
- Cookie-based auth, HTML/JS/CSS path rewriting
- 18/18 tests pass
This commit is contained in:
@@ -7,6 +7,7 @@ mod checkin;
|
|||||||
mod comfyui;
|
mod comfyui;
|
||||||
mod events;
|
mod events;
|
||||||
mod health;
|
mod health;
|
||||||
|
mod monitor;
|
||||||
mod organizations;
|
mod organizations;
|
||||||
mod proxy;
|
mod proxy;
|
||||||
mod site_announcements;
|
mod site_announcements;
|
||||||
@@ -23,6 +24,7 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.merge(auth::routes())
|
.merge(auth::routes())
|
||||||
.merge(events::routes())
|
.merge(events::routes())
|
||||||
.merge(checkin::routes())
|
.merge(checkin::routes())
|
||||||
|
.merge(monitor::routes())
|
||||||
.merge(organizations::routes())
|
.merge(organizations::routes())
|
||||||
.merge(site_announcements::routes())
|
.merge(site_announcements::routes())
|
||||||
.merge(stats::routes())
|
.merge(stats::routes())
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
// Monitor routes — LDOH incident dashboard proxy with cookie auth
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::{header, HeaderMap, StatusCode},
|
||||||
|
response::Response,
|
||||||
|
routing::{get, post, put},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
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<AppState> {
|
||||||
|
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<AppState>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
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::<serde_json::Value>(&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<AppState>,
|
||||||
|
Json(body): Json<serde_json::Value>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
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<AppState>,
|
||||||
|
) -> Json<Value> {
|
||||||
|
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<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(path): Path<String>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
// 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::<serde_json::Value>(&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 Ok(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()))?;
|
||||||
|
Ok(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<String, String> {
|
||||||
|
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/")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user