Stage 3: Admin-UI (maud + htmx) -- Config maskiert anzeigen/aendern, Live-Reload
CI / test (push) Successful in 11m47s
CI / test (push) Successful in 11m47s
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::{
|
||||
header::{AUTHORIZATION, WWW_AUTHENTICATE},
|
||||
HeaderValue, StatusCode,
|
||||
},
|
||||
middleware::Next,
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Form, Router,
|
||||
};
|
||||
use base64::Engine;
|
||||
use dtrack_core::{DtrackClient, DtrackConfig};
|
||||
use dtrack_tools::SharedClient;
|
||||
use maud::{html, Markup, PreEscaped, DOCTYPE};
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Geteilter Zustand der Admin-Routen.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: Arc<RwLock<DtrackConfig>>,
|
||||
pub client: SharedClient,
|
||||
pub config_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Basic-Auth-Credentials fuer /admin. `None` = Admin-UI deaktiviert.
|
||||
#[derive(Clone)]
|
||||
pub struct AdminAuth(pub Option<Arc<(String, String)>>);
|
||||
|
||||
impl AdminAuth {
|
||||
pub fn from_env() -> Self {
|
||||
match (
|
||||
std::env::var("DTRACK_ADMIN_USER"),
|
||||
std::env::var("DTRACK_ADMIN_PASSWORD"),
|
||||
) {
|
||||
(Ok(u), Ok(p)) if !u.is_empty() && !p.is_empty() => Self(Some(Arc::new((u, p)))),
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"DTRACK_ADMIN_USER/PASSWORD nicht gesetzt -- Admin-UI ist deaktiviert."
|
||||
);
|
||||
Self(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Baut den Admin-Router (eigene Basic-Auth-Schicht + AppState).
|
||||
pub fn router(state: AppState, auth: AdminAuth) -> Router {
|
||||
Router::new()
|
||||
.route("/admin", get(page))
|
||||
.route("/admin/config", post(update))
|
||||
.route("/admin/reveal", get(reveal))
|
||||
.layer(axum::middleware::from_fn_with_state(auth, basic_auth))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn basic_auth(State(auth): State<AdminAuth>, req: Request, next: Next) -> Response {
|
||||
let Some(creds) = &auth.0 else {
|
||||
// Admin nicht konfiguriert -> Routen existieren faktisch nicht.
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
|
||||
let decoded = req
|
||||
.headers()
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|h| h.strip_prefix("Basic "))
|
||||
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
|
||||
.and_then(|bytes| String::from_utf8(bytes).ok());
|
||||
|
||||
let ok = decoded
|
||||
.as_deref()
|
||||
.and_then(|s| s.split_once(':'))
|
||||
.map(|(u, p)| u == creds.0.as_str() && p == creds.1.as_str())
|
||||
.unwrap_or(false);
|
||||
|
||||
if ok {
|
||||
next.run(req).await
|
||||
} else {
|
||||
let mut resp = StatusCode::UNAUTHORIZED.into_response();
|
||||
resp.headers_mut().insert(
|
||||
WWW_AUTHENTICATE,
|
||||
HeaderValue::from_static("Basic realm=\"dtrack-admin\""),
|
||||
);
|
||||
resp
|
||||
}
|
||||
}
|
||||
|
||||
const CSS: &str = "body{font-family:system-ui,sans-serif;max-width:680px;margin:2rem auto;padding:0 1rem;color:#222}\
|
||||
h1{font-size:1.3rem}label{display:block;margin:1rem 0}\
|
||||
input[type=url],input[type=password]{width:100%;padding:.5rem;margin-top:.25rem;box-sizing:border-box}\
|
||||
button{padding:.5rem 1rem;cursor:pointer}.ok{color:#176d2c;font-weight:600}\
|
||||
#keyfield{font-family:monospace;background:#f3f3f3;padding:.2rem .4rem;border-radius:3px}";
|
||||
|
||||
fn render(cfg: &DtrackConfig, saved: bool) -> Markup {
|
||||
html! {
|
||||
(DOCTYPE)
|
||||
html lang="de" {
|
||||
head {
|
||||
meta charset="utf-8";
|
||||
meta name="viewport" content="width=device-width, initial-scale=1";
|
||||
title { "dtrack-mcp · Admin" }
|
||||
script src="https://unpkg.com/htmx.org@2.0.3" {}
|
||||
style { (PreEscaped(CSS)) }
|
||||
}
|
||||
body {
|
||||
h1 { "Dependency-Track MCP — Konfiguration" }
|
||||
@if saved { p.ok { "Gespeichert. Client wurde live neu gebaut." } }
|
||||
form method="post" action="/admin/config" {
|
||||
label {
|
||||
"DT-URL"
|
||||
input type="url" name="url" value=(cfg.url) required;
|
||||
}
|
||||
label {
|
||||
"API-Key (aktuell)"
|
||||
div {
|
||||
span #keyfield { (cfg.masked_api_key()) }
|
||||
" "
|
||||
button type="button"
|
||||
hx-get="/admin/reveal"
|
||||
hx-target="#keyfield"
|
||||
hx-swap="innerHTML" { "anzeigen" }
|
||||
}
|
||||
}
|
||||
label {
|
||||
"Neuen API-Key setzen (leer = unveraendert)"
|
||||
input type="password" name="api_key" autocomplete="off";
|
||||
}
|
||||
label {
|
||||
input type="checkbox" name="insecure" checked[cfg.insecure];
|
||||
" TLS-Pruefung deaktivieren (insecure)"
|
||||
}
|
||||
button type="submit" { "Speichern" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn page(State(st): State<AppState>) -> Html<String> {
|
||||
let cfg = st.config.read().await.clone();
|
||||
Html(render(&cfg, false).into_string())
|
||||
}
|
||||
|
||||
async fn reveal(State(st): State<AppState>) -> Html<String> {
|
||||
let key = st.config.read().await.api_key.clone();
|
||||
Html(html! { (key) }.into_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ConfigForm {
|
||||
url: String,
|
||||
api_key: String,
|
||||
insecure: Option<String>,
|
||||
}
|
||||
|
||||
async fn update(State(st): State<AppState>, Form(form): Form<ConfigForm>) -> Response {
|
||||
let mut cfg = st.config.read().await.clone();
|
||||
cfg.url = form.url.trim().to_string();
|
||||
let new_key = form.api_key.trim();
|
||||
if !new_key.is_empty() {
|
||||
cfg.api_key = new_key.to_string();
|
||||
}
|
||||
cfg.insecure = form.insecure.is_some();
|
||||
|
||||
let new_client = match DtrackClient::from_config(&cfg) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return (StatusCode::BAD_REQUEST, format!("Config ungueltig: {e}")).into_response(),
|
||||
};
|
||||
|
||||
*st.client.write().await = new_client;
|
||||
*st.config.write().await = cfg.clone();
|
||||
|
||||
if let Some(path) = &st.config_path {
|
||||
if let Err(e) = cfg.save(path) {
|
||||
tracing::error!("Config konnte nicht gespeichert werden: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Html(render(&cfg, true).into_string()).into_response()
|
||||
}
|
||||
Reference in New Issue
Block a user