/
mcmare
/
RustAPI
Обзор
Документация
Войти
/
mcmare
/
RustAPI
Код
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
engine/src/webui/mod.rs
204 строки
7 KB
mcmare
Add WebUI: zero-config bootstrap, admin login, browser-based settings/endpoints/scripts management
18 июл 2026, 18:34
18 июл 2026, 18:34
e401c22
Код
Авторство
О чём код?
mod auth; mod endpoints; mod scripts; mod settings; use axum::{ extract::State, http::{header, HeaderValue, StatusCode}, middleware, response::{IntoResponse, Response}, routing::{delete, get, post, put}, Json, Router, }; use rust_embed::RustEmbed; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use auth::{AdminAuthState, AdminCredentials}; #[derive(RustEmbed)] #[folder = "webui-assets/"] struct Assets; #[derive(Clone)] pub struct WebuiState { pub config_dir: PathBuf, pub data_dir: PathBuf, pub jwt_secret: String, } /// Re-runs the full config loader against `config_dir` and reduces the result to a /// plain message — used after every write so a change that would break the running /// config (bad connection reference, duplicate route, ...) is rejected with a clear /// error instead of silently corrupting the server's config. pub(crate) fn revalidate(config_dir: &Path) -> Result<(), String> { crate::config::load_config(config_dir).map(|_| ()).map_err(|e| e.to_string()) } /// Writes `new_content` to `target` (or deletes it, if `None`), then revalidates the /// whole config; on failure the previous content is restored and a `400` is returned. pub(crate) fn write_validated( config_dir: &Path, target: &Path, new_content: Option<String>, ) -> Result<(), rustapi_core::ApiError> { use rustapi_core::ApiError; let previous = if target.exists() { Some(std::fs::read_to_string(target).map_err(|e| ApiError::Internal(e.into()))?) } else { None }; if let Some(parent) = target.parent() { std::fs::create_dir_all(parent).map_err(|e| ApiError::Internal(e.into()))?; } match &new_content { Some(text) => std::fs::write(target, text).map_err(|e| ApiError::Internal(e.into()))?, None => { let _ = std::fs::remove_file(target); } } if let Err(msg) = revalidate(config_dir) { match previous { Some(text) => { let _ = std::fs::write(target, text); } None => { let _ = std::fs::remove_file(target); } } return Err(ApiError::Validation(format!("change rejected, config would become invalid: {msg}"))); } Ok(()) } pub fn webui_router(config_dir: PathBuf, data_dir: PathBuf, jwt_secret: String) -> Router { let state = WebuiState { config_dir, data_dir, jwt_secret: jwt_secret.clone(), }; let protected = Router::new() .route("/ui/api/connections", get(settings::list_connections).post(settings::upsert_connection)) .route("/ui/api/connections/:name", delete(settings::delete_connection)) .route("/ui/api/tokens", get(settings::list_tokens).post(settings::add_token)) .route("/ui/api/tokens/:index", delete(settings::delete_token)) .route("/ui/api/endpoints", get(endpoints::list).post(endpoints::create)) .route("/ui/api/endpoints/:slug", put(endpoints::update).delete(endpoints::delete)) .route("/ui/api/scripts", get(scripts::list)) .route("/ui/api/scripts/:filename", get(scripts::read).put(scripts::write).delete(scripts::remove)) .with_state(state.clone()) .layer(middleware::from_fn_with_state( AdminAuthState { jwt_secret: jwt_secret.clone() }, auth::require_admin_session, )); let public = Router::new() .route("/ui", get(index)) .route("/ui/assets/*file", get(asset)) .route("/ui/api/status", get(status)) .route("/ui/api/setup", post(setup)) .route("/ui/api/login", post(login)) .route("/ui/api/logout", post(logout)) .with_state(state); public.merge(protected) } async fn index() -> impl IntoResponse { match Assets::get("index.html") { Some(f) => ([(header::CONTENT_TYPE, "text/html; charset=utf-8")], f.data).into_response(), None => StatusCode::NOT_FOUND.into_response(), } } fn content_type_for(file: &str) -> &'static str { match file.rsplit('.').next() { Some("css") => "text/css; charset=utf-8", Some("js") => "text/javascript; charset=utf-8", Some("html") => "text/html; charset=utf-8", Some("svg") => "image/svg+xml", _ => "application/octet-stream", } } async fn asset(axum::extract::Path(file): axum::extract::Path<String>) -> impl IntoResponse { match Assets::get(&file) { Some(f) => ([(header::CONTENT_TYPE, content_type_for(&file))], f.data).into_response(), None => StatusCode::NOT_FOUND.into_response(), } } #[derive(Serialize)] struct StatusOut { needs_setup: bool, authenticated: bool, } async fn status(State(state): State<WebuiState>, headers: axum::http::HeaderMap) -> Json<StatusOut> { let needs_setup = auth::load_admin(&state.data_dir).ok().flatten().is_none(); let authenticated = auth::has_valid_session(&headers, &state.jwt_secret); Json(StatusOut { needs_setup, authenticated }) } #[derive(Deserialize)] struct Credentials { username: String, password: String, } async fn setup(State(state): State<WebuiState>, Json(body): Json<Credentials>) -> Result<Response, rustapi_core::ApiError> { use rustapi_core::ApiError; if auth::load_admin(&state.data_dir).map_err(ApiError::Internal)?.is_some() { return Err(ApiError::Conflict("admin account already set up".to_string())); } if body.username.trim().is_empty() || body.password.len() < 8 { return Err(ApiError::Validation("username must be non-empty and password at least 8 characters".to_string())); } let password_hash = auth::hash_password(&body.password).map_err(ApiError::Internal)?; auth::save_admin( &state.data_dir, &AdminCredentials { username: body.username.clone(), password_hash }, ) .map_err(ApiError::Internal)?; let token = auth::sign_session(&body.username, &state.jwt_secret).map_err(ApiError::Internal)?; let mut resp = StatusCode::NO_CONTENT.into_response(); resp.headers_mut().insert( header::SET_COOKIE, HeaderValue::from_str(&auth::session_cookie_header(&token)).expect("cookie header is valid ASCII"), ); Ok(resp) } async fn login(State(state): State<WebuiState>, Json(body): Json<Credentials>) -> Result<Response, rustapi_core::ApiError> { use rustapi_core::ApiError; let Some(admin) = auth::load_admin(&state.data_dir).map_err(ApiError::Internal)? else { return Err(ApiError::Validation("admin account is not set up yet".to_string())); }; if admin.username != body.username || !auth::verify_password(&body.password, &admin.password_hash) { return Err(ApiError::Unauthorized); } let token = auth::sign_session(&admin.username, &state.jwt_secret).map_err(ApiError::Internal)?; let mut resp = StatusCode::NO_CONTENT.into_response(); resp.headers_mut().insert( header::SET_COOKIE, HeaderValue::from_str(&auth::session_cookie_header(&token)).expect("cookie header is valid ASCII"), ); Ok(resp) } async fn logout() -> Response { let mut resp = StatusCode::NO_CONTENT.into_response(); resp.headers_mut().insert( header::SET_COOKIE, HeaderValue::from_str(&auth::clear_session_cookie_header()).expect("cookie header is valid ASCII"), ); resp }