mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
feat(admin): serve Operator Console SPA at /admin (rust-embed)
Preact + Vite standalone admin-ui bundled into the server binary via rust-embed. Login is NIP-07 (Smirk wallet signs the NIP-98 admin challenge); tabs cover instance status, admin keys, invite codes, and the effective-config editor (live vs restart-required per field). admin-ui/dist is committed because rust-embed reads it at compile time; node_modules is gitignored (npm ci reproduces it). A lib test asserts index.html and the /admin/ asset base are embedded so an unbuilt console fails the build instead of an operator. The console is served on the admin plane only (loopback bind), behind the existing NIP-98 admin_guard + MAC allowlist.
This commit is contained in:
+70
-1
@@ -1032,8 +1032,61 @@ pub async fn admin_invites_list(
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Operator console SPA (embedded, served at /admin) ─────────────────────────
|
||||
// The Preact console built from admin-ui/ and embedded via rust-embed (single-binary
|
||||
// self-host, no separate deploy). Served UNAUTHENTICATED (no admin_guard) — the shell
|
||||
// loads without a session and runs the NIP-98 login client-side against the guarded
|
||||
// JSON routes. Only exact /admin, /admin/, /admin/assets/*path are added, so the JSON
|
||||
// routes (/admin/keys, /admin/config, ...) are never shadowed.
|
||||
|
||||
#[derive(rust_embed::RustEmbed)]
|
||||
#[folder = "admin-ui/dist/"]
|
||||
struct AdminAssets;
|
||||
|
||||
pub async fn admin_index() -> Response {
|
||||
match AdminAssets::get("index.html") {
|
||||
Some(f) => (
|
||||
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||
f.data.into_owned(),
|
||||
)
|
||||
.into_response(),
|
||||
None => (
|
||||
StatusCode::NOT_FOUND,
|
||||
"operator console not built (run: npm --prefix admin-ui run build)",
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn admin_asset(Path(path): Path<String>) -> Response {
|
||||
match AdminAssets::get(&format!("assets/{path}")) {
|
||||
Some(f) => {
|
||||
let mime = if path.ends_with(".js") || path.ends_with(".mjs") {
|
||||
"application/javascript; charset=utf-8"
|
||||
} else if path.ends_with(".css") {
|
||||
"text/css; charset=utf-8"
|
||||
} else if path.ends_with(".svg") {
|
||||
"image/svg+xml"
|
||||
} else if path.ends_with(".woff2") {
|
||||
"font/woff2"
|
||||
} else if path.ends_with(".png") {
|
||||
"image/png"
|
||||
} else if path.ends_with(".json") {
|
||||
"application/json"
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
};
|
||||
([(axum::http::header::CONTENT_TYPE, mime)], f.data.into_owned()).into_response()
|
||||
}
|
||||
None => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/admin", get(admin_index))
|
||||
.route("/admin/", get(admin_index))
|
||||
.route("/admin/assets/*path", get(admin_asset))
|
||||
.route("/admin/auth/challenge", post(admin_challenge))
|
||||
.route("/admin/auth/verify", post(admin_verify))
|
||||
.route("/admin/auth/refresh", post(admin_refresh))
|
||||
@@ -1049,7 +1102,23 @@ pub fn routes() -> Router<Arc<AppState>> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::host_allowed;
|
||||
use super::{host_allowed, AdminAssets};
|
||||
|
||||
// The Operator Console SPA must be built into admin-ui/dist/ and embedded at
|
||||
// compile time (rust-embed). If admin-ui was never built, index.html is absent
|
||||
// and /admin serves nothing — this catches that before it reaches an operator.
|
||||
#[test]
|
||||
fn admin_console_is_embedded() {
|
||||
let index = AdminAssets::get("index.html").expect("admin-ui/dist/index.html embedded");
|
||||
let html = std::str::from_utf8(&index.data).expect("index.html is utf-8");
|
||||
assert!(html.contains("Smirk Operator Console"), "console title missing");
|
||||
// Vite rewrites asset URLs to the /admin/ base; the served route matches.
|
||||
assert!(html.contains("/admin/assets/"), "asset base not rewritten to /admin/");
|
||||
assert!(
|
||||
AdminAssets::iter().any(|p| p.starts_with("assets/")),
|
||||
"no built JS/CSS assets embedded",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_allowlist() {
|
||||
|
||||
Reference in New Issue
Block a user