//! Pages of the `{{ app }}` app. Handlers gather data and render a view from
//! `templates/` (see `views.rs`); the HTML lives in the templates.
//!
//! Signing in (`/login`, `/logout`, passkeys) lives in `sign_in.rs`.
use axum::Router;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::routing::get;
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
use crate::sign_in::token;
use crate::views::Views;
use crate::{AppState, TITLE};
/// The app's pages, under its base path (see [`AppState::url`]).
pub fn router(state: AppState) -> Router {
let mut router = Router::new()
.route(&state.url("/"), get(home))
.route("/health", get(health));
if !state.base.is_empty() {
// `/admin/` as well as `/admin`.
router = router.route(&format!("{}/", state.base), get(home));
}
router.with_state(state)
}
/// Result of the backend's `auth_me` tool.
#[derive(Deserialize)]
struct Me {
name: String,
email: Option<String>,
roles: Vec<String>,
}
/// What every view receives: the app's name and the page title, plus the
/// page's own data (flattened).
#[derive(Serialize)]
struct Page<'a, T: Serialize> {
app: &'static str,
title: &'a str,
#[serde(flatten)]
data: T,
}
#[derive(Serialize)]
struct Param {
name: String,
value: String,
}
#[derive(Serialize)]
struct UserView {
name: String,
email: Option<String>,
roles: String,
}
/// Data of the `home` view.
#[derive(Serialize)]
struct Home {
/// Domain parameters from the runtime, e.g. `{toko}` in `{toko}.localhost`.
params: Vec<Param>,
user: Option<UserView>,
error: Option<String>,
login_url: String,
logout_url: String,
/// For the "Add a passkey" button (see `sign_in.rs`).
passkey_url: String,
script_url: String,
}
/// Renders view `name`. Debug builds reload the templates on every request,
/// so edits show without restarting, and show template errors in the page.
pub(crate) fn render<T: Serialize>(
app: &AppState,
status: StatusCode,
name: &str,
title: &str,
data: T,
) -> Response {
let page = Page {
app: TITLE,
title,
data,
};
let rendered = if cfg!(debug_assertions) {
Views::load(&app.templates_dir).and_then(|views| views.render(name, &page))
} else {
app.views.render(name, &page)
};
match rendered {
Ok(html) => (status, Html(html)).into_response(),
Err(err) => {
tracing::error!("view `{name}`: {err}");
let detail = if cfg!(debug_assertions) {
err
} else {
String::new()
};
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("template error {detail}"),
)
.into_response()
}
}
}
async fn home(State(app): State<AppState>, headers: HeaderMap) -> Response {
let params = route_params(&headers)
.into_iter()
.map(|(name, value)| Param { name, value })
.collect();
let me = match token(&headers) {
Some(token) => Some(app.mcp.call::<Me>("auth_me", json!({}), Some(&token)).await),
None => None,
};
let (user, error) = match me {
Some(Ok(me)) => (
Some(UserView {
name: me.name,
email: me.email,
roles: me.roles.join(", "),
}),
None,
),
Some(Err(err)) if err.code != "unauthenticated" => (None, Some(err.message)),
_ => (None, None),
};
let data = Home {
params,
user,
error,
login_url: app.url("/login"),
logout_url: app.url("/logout"),
passkey_url: app.url("/passkey"),
script_url: app.url("/sign-in.js"),
};
render(&app, StatusCode::OK, "home", "Home", data)
}
/// Liveness probe for orchestrators.
async fn health() -> &'static str {
"ok"
}
/// `(name, value)` for every `X-Slave-Param-<Name>` header set by the runtime.
fn route_params(headers: &HeaderMap) -> Vec<(String, String)> {
let mut params: Vec<(String, String)> = headers
.iter()
.filter_map(|(name, value)| {
let name = name.as_str().strip_prefix("x-slave-param-")?;
Some((name.to_owned(), value.to_str().ok()?.to_owned()))
})
.collect();
params.sort();
params
}