From ec9de0e0393fbf8fa081268cb4a701abe41a6a39 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Fri, 5 Jun 2026 00:32:31 +0300 Subject: [PATCH] feat(portal): make DPD portal a full mirror --- adk-rust/crates/detmir-dpd-portal/src/main.rs | 242 ++++++++++++++---- docs/DPD_PORTAL_RU.md | 41 ++- 2 files changed, 216 insertions(+), 67 deletions(-) diff --git a/adk-rust/crates/detmir-dpd-portal/src/main.rs b/adk-rust/crates/detmir-dpd-portal/src/main.rs index f20eef2..72db12c 100644 --- a/adk-rust/crates/detmir-dpd-portal/src/main.rs +++ b/adk-rust/crates/detmir-dpd-portal/src/main.rs @@ -3,9 +3,9 @@ use std::time::Duration; use anyhow::{Context, Result, anyhow}; use clap::Parser; use reqwest::blocking::Client; -use reqwest::header::{CONNECTION, HeaderValue}; +use reqwest::header::{ACCEPT, CONNECTION, CONTENT_DISPOSITION, CONTENT_TYPE, HeaderValue}; use serde_json::json; -use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; +use tiny_http::{Header, Request, Response, Server, StatusCode}; const INDEX_HTML: &str = include_str!("static/index.html"); const APP_CSS: &str = include_str!("static/app.css"); @@ -19,10 +19,10 @@ struct Cli { #[arg( long, - default_value = "http://127.0.0.1:8720/portal/api", - env = "DETMIR_DPD_UPSTREAM_API" + default_value = "http://127.0.0.1:8720", + env = "DETMIR_DPD_UPSTREAM_BASE" )] - upstream_api: String, + upstream_base: String, #[arg(long, default_value_t = 60, env = "DETMIR_DPD_TIMEOUT_SECONDS")] timeout_seconds: u64, @@ -55,85 +55,124 @@ fn run() -> Result<()> { } fn handle_request(request: Request, args: &Cli) -> Result<()> { - let method = request.method().clone(); - let path = normalize_path(request.url()); - if method != Method::Get { - return respond_text(request, StatusCode(405), "Method Not Allowed", "text/plain"); - } - match path.as_str() { - "/" | "/dpd" | "/dpd/" => respond_text( - request, - StatusCode(200), - INDEX_HTML, - "text/html; charset=utf-8", - ), - "/app.css" | "/dpd/app.css" => { - respond_text(request, StatusCode(200), APP_CSS, "text/css; charset=utf-8") - } - "/app.js" | "/dpd/app.js" => respond_text( - request, - StatusCode(200), - APP_JS, - "application/javascript; charset=utf-8", - ), - "/api/reports" | "/dpd/api/reports" => { - respond_json_text(request, StatusCode(200), &upstream_get(args, "reports")?) - } - "/api/health" | "/dpd/api/health" => respond_json_text( + let path = normalize_path_with_query(request.url()); + match path_no_query(&path) { + "/_dpd/health" => respond_json_text( request, StatusCode(200), &json!({ "ok": true, "portal": "detmir-dpd-portal", - "mode": "parallel-preview", - "upstream_api": sanitize_upstream(&args.upstream_api), + "mode": "parallel-full-mirror", + "upstream": sanitize_upstream(&args.upstream_base), }) .to_string(), ), - _ => respond_text(request, StatusCode(404), "Not Found", "text/plain"), + "/preview" => respond_redirect(request, "preview/"), + "/preview/" => respond_text( + request, + StatusCode(200), + INDEX_HTML, + "text/html; charset=utf-8", + ), + "/preview/app.css" => { + respond_text(request, StatusCode(200), APP_CSS, "text/css; charset=utf-8") + } + "/preview/app.js" => respond_text( + request, + StatusCode(200), + APP_JS, + "application/javascript; charset=utf-8", + ), + _ => proxy_to_upstream(request, args, &path), } } -fn upstream_get(args: &Cli, endpoint: &str) -> Result { +fn proxy_to_upstream(mut request: Request, args: &Cli, path: &str) -> Result<()> { + let method = reqwest::Method::from_bytes(request.method().as_str().as_bytes()) + .map_err(|err| anyhow!("unsupported method {}: {err}", request.method()))?; + let content_type = request_header(&request, "Content-Type"); + let accept = request_header(&request, "Accept"); + let mut body = Vec::new(); + request + .as_reader() + .read_to_end(&mut body) + .context("read request body")?; + let client = Client::builder() .timeout(Duration::from_secs(args.timeout_seconds)) .no_proxy() .build() .context("upstream HTTP client")?; - let url = format!( - "{}/{}", - args.upstream_api.trim_end_matches('/'), - endpoint.trim_start_matches('/') - ); - client - .get(&url) + + let url = format!("{}{}", args.upstream_base.trim_end_matches('/'), path); + let mut upstream = client + .request(method, &url) .header(CONNECTION, HeaderValue::from_static("close")) + .body(body); + if let Some(value) = content_type { + upstream = upstream.header(CONTENT_TYPE, value); + } + if let Some(value) = accept { + upstream = upstream.header(ACCEPT, value); + } + + let upstream_response = upstream .send() - .with_context(|| format!("GET {url}"))? - .error_for_status() - .with_context(|| format!("upstream status for {url}"))? - .text() - .context("upstream response body") + .with_context(|| format!("proxy upstream {url}"))?; + let status = StatusCode(upstream_response.status().as_u16()); + let content_type = upstream_response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(ToString::to_string); + let content_disposition = upstream_response + .headers() + .get(CONTENT_DISPOSITION) + .and_then(|value| value.to_str().ok()) + .map(ToString::to_string); + let mut bytes = upstream_response + .bytes() + .context("upstream response body")? + .to_vec(); + rewrite_mirrored_body(&mut bytes, content_type.as_deref()); + respond_bytes(request, status, bytes, content_type, content_disposition) } -fn normalize_path(url: &str) -> String { - let raw = url.split('?').next().unwrap_or(url); - let path = raw.trim(); - if let Some(stripped) = path.strip_prefix("/portal-dpd") { - if stripped.is_empty() { - "/".to_string() - } else { - stripped.to_string() +fn request_header(request: &Request, name: &'static str) -> Option { + request + .headers() + .iter() + .find(|header| header.field.equiv(name)) + .map(|header| header.value.as_str().to_string()) +} + +fn normalize_path_with_query(url: &str) -> String { + let path = url.trim(); + for prefix in ["/portal-dpd", "/dpd"] { + if let Some(stripped) = path.strip_prefix(prefix) { + return if stripped.is_empty() { + "/".to_string() + } else { + stripped.to_string() + }; } + } + if path.is_empty() { + "/".to_string() } else { path.to_string() } } +fn path_no_query(path: &str) -> &str { + path.split('?').next().unwrap_or(path) +} + fn sanitize_upstream(value: &str) -> String { let trimmed = value.trim(); if trimmed.starts_with("http://127.0.0.1") || trimmed.starts_with("http://localhost") { - "local detmir-portal api".to_string() + "local detmir-portal".to_string() } else { "configured upstream".to_string() } @@ -162,3 +201,96 @@ fn respond_text( fn respond_json_text(request: Request, status: StatusCode, text: &str) -> Result<()> { respond_text(request, status, text, "application/json; charset=utf-8") } + +fn respond_redirect(request: Request, location: &str) -> Result<()> { + let response = Response::from_string("") + .with_status_code(StatusCode(302)) + .with_header( + Header::from_bytes("Location", location) + .map_err(|_| anyhow!("invalid Location header"))?, + ) + .with_header( + Header::from_bytes("Cache-Control", "no-store") + .map_err(|_| anyhow!("invalid Cache-Control header"))?, + ); + request.respond(response)?; + Ok(()) +} + +fn respond_bytes( + request: Request, + status: StatusCode, + body: Vec, + content_type: Option, + content_disposition: Option, +) -> Result<()> { + let mut response = Response::from_data(body) + .with_status_code(status) + .with_header( + Header::from_bytes("Cache-Control", "no-store") + .map_err(|_| anyhow!("invalid Cache-Control header"))?, + ); + if let Some(value) = content_type { + response = response.with_header( + Header::from_bytes("Content-Type", value) + .map_err(|_| anyhow!("invalid upstream Content-Type header"))?, + ); + } + if let Some(value) = content_disposition { + response = response.with_header( + Header::from_bytes("Content-Disposition", value) + .map_err(|_| anyhow!("invalid upstream Content-Disposition header"))?, + ); + } + request.respond(response)?; + Ok(()) +} + +fn rewrite_mirrored_body(body: &mut Vec, content_type: Option<&str>) { + let Some(content_type) = content_type else { + return; + }; + if !(content_type.contains("text/html") || content_type.contains("javascript")) { + return; + } + let Ok(text) = std::str::from_utf8(body) else { + return; + }; + if !text.contains("/portal") { + return; + } + *body = text.replace("/portal", "/dpd").into_bytes(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_dpd_prefix_and_preserves_query() { + assert_eq!(normalize_path_with_query("/dpd/"), "/"); + assert_eq!( + normalize_path_with_query("/dpd/api/reports?anonymize=1"), + "/api/reports?anonymize=1" + ); + assert_eq!( + normalize_path_with_query("/portal-dpd/api/cases/c1?format=markdown"), + "/api/cases/c1?format=markdown" + ); + } + + #[test] + fn preview_paths_are_not_upstream_mirrored() { + assert_eq!(path_no_query("/preview/app.js?v=1"), "/preview/app.js"); + } + + #[test] + fn rewrites_portal_absolute_paths_for_dpd_mirror() { + let mut body = br#"href="/portal/api/reports"; const x = `/portal/api/cases/c1`;"#.to_vec(); + rewrite_mirrored_body(&mut body, Some("application/javascript; charset=utf-8")); + let text = String::from_utf8(body).unwrap(); + assert!(text.contains("/dpd/api/reports")); + assert!(text.contains("/dpd/api/cases/c1")); + assert!(!text.contains("/portal/api")); + } +} diff --git a/docs/DPD_PORTAL_RU.md b/docs/DPD_PORTAL_RU.md index 3d1c18d..9c7165f 100644 --- a/docs/DPD_PORTAL_RU.md +++ b/docs/DPD_PORTAL_RU.md @@ -1,12 +1,23 @@ # DPD/Dioxus Pilot Portal -`detmir-dpd-portal` - первый параллельный read-only портал для проверки нового -подхода к интерфейсу DetMir. +`detmir-dpd-portal` - параллельный DPD-портал DetMir. Основной режим работает +как полный mirror текущего `detmir-portal`, а экспериментальный компактный экран +оставлен отдельно как preview. ## Назначение -Портал не заменяет текущий `detmir-portal`. Он работает рядом с ним, читает уже -существующий `/api/reports` и показывает компактный управленческий срез: +Портал не заменяет текущий `detmir-portal`. Он работает рядом с ним и +проксирует все функции текущего портала через отдельный маршрут `/dpd/`: + +- вкладки; +- API; +- действия проверки; +- дела; +- расследования; +- markdown/download endpoints; +- материалы проверки. + +Компактный experimental preview доступен отдельно и показывает: - главный вывод; - события безопасности; @@ -19,28 +30,34 @@ Dioxus 0.7 fullstack использует разделение client/server и отдельные web/server feature flags. Для безопасного первого этапа создан параллельный Rust shell, -который не меняет боевой портал и оставляет место для постепенного переноса UI на -Dioxus-компоненты. +который не меняет боевой портал, но уже повторяет его функциональность через +mirror-proxy. Это оставляет место для постепенного переноса UI на +Dioxus-компоненты без риска разъезда данных и API. ## Запуск ```bash detmir-dpd-portal \ --bind 127.0.0.1:8722 \ - --upstream-api http://127.0.0.1:8720/portal/api + --upstream-base http://127.0.0.1:8720 ``` Переменные окружения: - `DETMIR_DPD_BIND`; -- `DETMIR_DPD_UPSTREAM_API`; +- `DETMIR_DPD_UPSTREAM_BASE`; - `DETMIR_DPD_TIMEOUT_SECONDS` - по умолчанию 60 секунд, чтобы первый холодный `/api/reports` после рестарта не выглядел как отказ DPD-портала. +## Маршруты + +- `/dpd/` - полный параллельный mirror текущего портала. +- `/dpd/_dpd/health` - health самого DPD gateway. +- `/dpd/preview/` - компактный preview-экран для будущего Dioxus-подхода. + ## Ограничения v0.1 -- только read-only; -- без новых бизнес-сущностей; +- DPD gateway не добавляет новые бизнес-сущности; - без замены текущего портала; -- данные берутся из существующего API; -- внешний reverse proxy настраивается отдельно. +- данные берутся из существующего портала/API; +- preview-экран остается read-only.