feat(portal): make DPD portal a full mirror
This commit is contained in:
@@ -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<String> {
|
||||
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<String> {
|
||||
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<u8>,
|
||||
content_type: Option<String>,
|
||||
content_disposition: Option<String>,
|
||||
) -> 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<u8>, 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"));
|
||||
}
|
||||
}
|
||||
|
||||
+29
-12
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user