Files
rustdesk-server/src/api/admin/mod.rs
T
mike 1e961cdd92
build / build-linux-amd64 (push) Successful in 2m2s
Implementing multi-language Admin UI
2026-05-09 16:58:20 +02:00

358 lines
13 KiB
Rust

//! Admin dashboard router. Mounted at `/admin/*` by `api::router` when
//! the operator hasn't disabled it via `--admin-ui-dir=` (empty).
//!
//! Static HTML/CSS lives in `admin_ui/` next to the source tree and is
//! embedded into the binary at build time via `include_str!` — no separate
//! deploy artifact, no ServeDir wildcard route conflicting with the
//! literal /admin/login etc. The ASSETS table at the bottom is the
//! authoritative list of files we ship.
//!
//! Layout served at runtime:
//! /admin/ ← index.html (the SPA shell)
//! /admin/login.html ← login form
//! /admin/login POST handler (form-encoded, sets session cookie)
//! /admin/logout POST handler (clears session cookie)
//! /admin/me GET fragment (current user, sidebar widget)
//! /admin/pages/* GET fragments (one per page)
pub mod auth;
pub mod i18n;
pub mod me;
pub mod oidc_login;
pub mod pages;
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::routing::{get, post};
use axum::Router;
use std::sync::Arc;
use crate::api::admin::i18n::{lang_from_headers, t, Lang};
/// Files embedded into the binary. Paths are relative to this source file
/// per `include_str!`. Adding a new HTML asset = one new entry here.
const INDEX_HTML: &str = include_str!("../../../admin_ui/index.html");
const LOGIN_HTML: &str = include_str!("../../../admin_ui/login.html");
/// Third-party JS dependencies vendored under `admin_ui/assets/` so the
/// dashboard doesn't fetch from cdn.tailwindcss.com / unpkg.com at runtime.
/// See docs/CONFIGURATION.md "Web client" for the upgrade procedure.
const TAILWIND_JS: &[u8] = include_bytes!("../../../admin_ui/assets/tailwindcss.js");
const HTMX_JS: &[u8] = include_bytes!("../../../admin_ui/assets/htmx.min.js");
pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
if state.cfg.admin_ui_dir.is_empty() {
// Operator opted out by setting the flag to empty.
return None;
}
let r = Router::new()
// Static HTML pages — explicit routes per file, no wildcard.
.route("/admin", get(serve_index))
.route("/admin/", get(serve_index))
.route("/admin/index.html", get(serve_index))
.route("/admin/login.html", get(serve_login))
// Vendored third-party JS — versions pinned in source, so we can
// cache aggressively (immutable + 1-year max-age).
.route("/admin/assets/tailwindcss.js", get(serve_tailwind))
.route("/admin/assets/htmx.min.js", get(serve_htmx))
// Dynamic dashboard endpoints.
.route("/admin/login", post(auth::login))
.route("/admin/logout", post(auth::logout))
.route("/admin/me", get(me::me))
// OIDC entry points consumed by login.html (unauthenticated — they
// *initiate* a sign-in). The matching /oidc/callback is mounted by
// the public api router and finishes both desktop and admin flows.
.route("/admin/oidc/providers", get(oidc_login::list_providers))
.route("/admin/login/oidc/:provider", get(oidc_login::start_login))
// Page fragments — one per sidebar entry.
.route("/admin/pages/users", get(pages::users::index))
.route("/admin/pages/users/create", post(pages::users::create))
.route(
"/admin/pages/users/:id/update-info",
post(pages::users::update_info),
)
.route(
"/admin/pages/users/:id/password-reset",
post(pages::users::reset_password),
)
.route(
"/admin/pages/users/:id/toggle-admin",
post(pages::users::toggle_admin),
)
.route(
"/admin/pages/users/:id/toggle-status",
post(pages::users::toggle_status),
)
.route(
"/admin/pages/users/:id/totp-enroll",
post(pages::users::totp_enroll),
)
.route(
"/admin/pages/users/:id/totp-unenroll",
post(pages::users::totp_unenroll),
)
.route("/admin/pages/users/:id/delete", post(pages::users::delete))
// Devices
.route(
"/admin/pages/devices/list-fragment",
get(pages::devices::list_fragment),
)
.route(
"/admin/pages/devices/:peer_id/detail",
get(pages::devices::detail),
)
.route(
"/admin/pages/devices/:peer_id/disconnect",
post(pages::devices::force_disconnect),
)
.route(
"/admin/pages/devices/:peer_id/sysinfo-refresh",
post(pages::devices::force_sysinfo),
)
.route(
"/admin/pages/devices/:peer_id/delete",
post(pages::devices::delete),
)
// Groups
.route("/admin/pages/groups/create", post(pages::groups::create))
.route("/admin/pages/groups/:id/delete", post(pages::groups::delete))
.route(
"/admin/pages/groups/:id/members/add",
post(pages::groups::add_member),
)
.route(
"/admin/pages/groups/:id/members/:user_id/remove",
post(pages::groups::remove_member),
)
.route(
"/admin/pages/groups/:id/peers/add",
post(pages::groups::add_peer),
)
.route(
"/admin/pages/groups/:id/peers/:peer_id/remove",
post(pages::groups::remove_peer),
)
// Strategies
.route(
"/admin/pages/strategies/create",
post(pages::strategies::create),
)
.route(
"/admin/pages/strategies/:id/update",
post(pages::strategies::update),
)
.route(
"/admin/pages/strategies/:id/delete",
post(pages::strategies::delete),
)
.route("/admin/pages/deploy", get(pages::deploy::index))
.route(
"/admin/pages/deploy/generate",
post(pages::deploy::generate),
)
// Web client (M6) — full-page SPA, NOT an HTMX fragment. Mounted
// outside /admin/pages/ because it's a standalone document the
// operator opens in a new tab from the Devices action menu.
.route(
"/admin/connect/:peer_id",
get(pages::connect::index),
)
.route(
"/admin/connect/assets/bundle.js",
get(pages::connect::bundle_js),
)
.route(
"/admin/connect/assets/bundle.css",
get(pages::connect::bundle_css),
)
.route("/admin/pages/devices", get(pages::devices::index))
.route("/admin/pages/groups", get(pages::groups::index))
.route("/admin/pages/strategies", get(pages::strategies::index))
.route(
"/admin/pages/address-books",
get(pages::address_books::index),
)
.route(
"/admin/pages/address-books/create",
post(pages::address_books::create),
)
.route(
"/admin/pages/address-books/:guid/delete",
post(pages::address_books::delete),
)
.route(
"/admin/pages/address-books/:guid/manage",
get(pages::address_books::manage),
)
.route(
"/admin/pages/address-books/:guid/shares/add",
post(pages::address_books::share_add),
)
.route(
"/admin/pages/address-books/:guid/shares/:user_id/remove",
post(pages::address_books::share_remove),
)
// Self-service profile — cookie-only, no admin gate.
.route("/admin/pages/profile", get(pages::profile::index))
.route(
"/admin/pages/profile/update-info",
post(pages::profile::update_info),
)
.route(
"/admin/pages/profile/change-password",
post(pages::profile::change_password),
)
.route(
"/admin/pages/profile/totp/start",
post(pages::profile::totp_start),
)
.route(
"/admin/pages/profile/totp/confirm",
post(pages::profile::totp_confirm),
)
.route(
"/admin/pages/profile/totp/remove",
post(pages::profile::totp_remove),
)
.route("/admin/pages/audit", get(pages::audit::index));
hbb_common::log::info!(
"admin dashboard mounted at /admin (HTML embedded; --admin-ui-dir is informational)"
);
Some(r)
}
async fn serve_index(headers: HeaderMap) -> Response {
let lang = lang_from_headers(&headers);
html_response_owned(render_index(lang))
}
async fn serve_login(headers: HeaderMap) -> Response {
let lang = lang_from_headers(&headers);
html_response_owned(render_login(lang))
}
/// Apply i18n placeholders to the embedded `index.html` template.
fn render_index(lang: Lang) -> String {
let body = INDEX_HTML
.replace("{{LANG_CODE}}", lang.code())
.replace("{{T_APP_TITLE}}", t(lang, "shell.app_title"))
.replace("{{T_NAV_USERS}}", t(lang, "nav.users"))
.replace("{{T_NAV_DEVICES}}", t(lang, "nav.devices"))
.replace("{{T_NAV_GROUPS}}", t(lang, "nav.groups"))
.replace("{{T_NAV_STRATEGIES}}", t(lang, "nav.strategies"))
.replace("{{T_NAV_AB}}", t(lang, "nav.address_books"))
.replace("{{T_NAV_AUDIT}}", t(lang, "nav.audit"))
.replace("{{T_NAV_DEPLOY}}", t(lang, "nav.deploy"))
.replace("{{T_NAV_PROFILE}}", t(lang, "nav.profile"))
.replace("{{T_NAV_SIGNOUT}}", t(lang, "nav.signout"))
.replace("{{T_LANGUAGE}}", t(lang, "common.language"))
.replace("{{T_LOADING}}", t(lang, "common.loading"));
apply_lang_selected(body, lang)
}
/// Apply i18n placeholders to the embedded `login.html` template.
fn render_login(lang: Lang) -> String {
let body = LOGIN_HTML
.replace("{{LANG_CODE}}", lang.code())
.replace("{{T_TITLE}}", t(lang, "login.title"))
.replace("{{T_SUBTITLE}}", t(lang, "login.subtitle"))
.replace("{{T_USERNAME}}", t(lang, "login.username"))
.replace("{{T_PASSWORD}}", t(lang, "login.password"))
.replace("{{T_TOTP_LABEL}}", t(lang, "login.totp_label"))
.replace("{{T_SIGNIN}}", t(lang, "login.signin"))
.replace("{{T_OR}}", t(lang, "login.or"))
.replace("{{T_LANGUAGE}}", t(lang, "common.language"))
.replace(
"{{T_SIGNIN_WITH_JSON}}",
&json_string(t(lang, "login.signin_with")),
);
apply_lang_selected(body, lang)
}
/// Inject `selected` into the matching `<option>` for the active language and
/// blank out the others. Both templates use the same `{{LANG_SEL_XX}}` markers.
fn apply_lang_selected(body: String, lang: Lang) -> String {
let mut sel_en = "";
let mut sel_de = "";
let mut sel_fr = "";
let mut sel_ro = "";
let mut sel_es = "";
match lang {
Lang::En => sel_en = " selected",
Lang::De => sel_de = " selected",
Lang::Fr => sel_fr = " selected",
Lang::Ro => sel_ro = " selected",
Lang::Es => sel_es = " selected",
}
body.replace("{{LANG_SEL_EN}}", sel_en)
.replace("{{LANG_SEL_DE}}", sel_de)
.replace("{{LANG_SEL_FR}}", sel_fr)
.replace("{{LANG_SEL_RO}}", sel_ro)
.replace("{{LANG_SEL_ES}}", sel_es)
}
/// JSON-encode a string so it can be embedded inside a `<script>` block as a
/// JS string literal. We only need to escape `"`, `\`, and the control chars
/// that show up in our translations — none of them realistically contain
/// newlines or `</script>`, but escape them defensively anyway.
fn json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'<' => out.push_str("\\u003c"),
'>' => out.push_str("\\u003e"),
'&' => out.push_str("\\u0026"),
c if (c as u32) < 0x20 => {
use std::fmt::Write as _;
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
out
}
fn html_response_owned(body: String) -> Response {
// Cache-Control: no-cache so the operator sees fresh HTML after a
// server upgrade without having to bump asset URLs.
let mut resp = Html(body).into_response();
resp.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
);
resp
}
async fn serve_tailwind() -> Response {
js_response(TAILWIND_JS)
}
async fn serve_htmx() -> Response {
js_response(HTMX_JS)
}
fn js_response(body: &'static [u8]) -> Response {
let mut resp = (StatusCode::OK, body).into_response();
let h = resp.headers_mut();
h.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/javascript; charset=utf-8"),
);
// Vendored at a pinned version — safe to cache for a year. If we
// ever bump the version we should also bump the asset path so
// browsers don't keep stale copies; for now the path-pinned version
// is implicit in the binary build.
h.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
resp
}