Files
komp_ac/web/src/lib.rs
2026-08-15 14:58:36 +02:00

495 lines
18 KiB
Rust

use std::{env, net::SocketAddr};
use axum::{
Router,
http::{HeaderMap, HeaderValue, header},
response::IntoResponse,
routing::get,
};
mod pages;
mod schema;
mod services;
mod ui;
mod authz;
mod i18n;
// The server's system column vocabulary, read out of `common` the same way the
// generated protos are: this crate compiles that source tree directly instead
// of depending on the crate. Only `is_system_column` is used here.
#[path = "../../common/src/system_column.rs"]
#[allow(dead_code)]
mod system_column;
mod analytics {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../common/src/proto/komp_ac.analytics.rs"
));
}
mod auth {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../common/src/proto/komp_ac.auth.rs"
));
}
mod definitions {
#[allow(dead_code)]
pub mod common {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../common/src/proto/komp_ac.common.rs"
));
}
pub mod table_definition {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../common/src/proto/komp_ac.table_definition.rs"
));
}
pub mod table_structure {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../common/src/proto/komp_ac.table_structure.rs"
));
}
pub mod table_script {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../common/src/proto/komp_ac.table_script.rs"
));
}
pub mod table_validation {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../common/src/proto/komp_ac.table_validation.rs"
));
}
pub mod tables_data {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../common/src/proto/komp_ac.tables_data.rs"
));
}
pub mod ecb {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../common/src/proto/komp_ac.ecb.rs"
));
}
}
use auth::auth_service_client::AuthServiceClient;
use definitions::{
ecb,
table_definition::table_definition_client::TableDefinitionClient,
table_script::table_script_client::TableScriptClient,
table_structure::table_structure_service_client::TableStructureServiceClient,
table_validation::table_validation_service_client::TableValidationServiceClient,
tables_data::tables_data_client::TablesDataClient,
};
use tonic::transport::Channel;
use analytics::analytics_service_client::AnalyticsServiceClient;
use ecb::ecb_service_client::EcbServiceClient;
const APP_CSS: &str = include_str!("../static/app.css");
/// The gRPC clients every handler shares. One lazily connected channel backs
/// all of them.
#[derive(Clone)]
pub(crate) struct AppState {
analytics: AnalyticsServiceClient<Channel>,
auth: AuthServiceClient<Channel>,
definitions: TableDefinitionClient<Channel>,
scripts: TableScriptClient<Channel>,
structures: TableStructureServiceClient<Channel>,
validations: TableValidationServiceClient<Channel>,
tables_data: TablesDataClient<Channel>,
ecb: EcbServiceClient<Channel>,
}
/// Starts the web UI as a detached task on the current Tokio runtime.
///
/// The web UI still uses the existing gRPC endpoints for now. Keeping startup
/// here limits the server integration to a single call until the application
/// is switched to the in-process analytics runtime.
pub fn spawn() -> tokio::task::JoinHandle<()> {
tokio::spawn(async {
if let Err(error) = serve().await {
eprintln!("Web UI stopped: {error}");
}
})
}
/// Serves the web UI until the task is cancelled or the listener fails.
pub async fn serve() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let grpc_endpoint =
env::var("ANALYTICS_GRPC_ENDPOINT").unwrap_or_else(|_| "http://[::1]:50051".into());
let listen_address = env::var("LISTEN_ADDRESS")
.unwrap_or_else(|_| "127.0.0.1:3000".into())
.parse::<SocketAddr>()?;
let channel = Channel::from_shared(grpc_endpoint.clone())?.connect_lazy();
let state = AppState {
analytics: AnalyticsServiceClient::new(channel.clone()),
auth: AuthServiceClient::new(channel.clone()),
definitions: TableDefinitionClient::new(channel.clone()),
scripts: TableScriptClient::new(channel.clone()),
validations: TableValidationServiceClient::new(channel.clone()),
tables_data: TablesDataClient::new(channel.clone()),
ecb: EcbServiceClient::new(channel.clone()),
structures: TableStructureServiceClient::new(channel),
};
let listener = tokio::net::TcpListener::bind(listen_address).await?;
println!("Web UI: http://{listen_address}");
println!("Analytics gRPC endpoint: {grpc_endpoint}");
axum::serve(listener, router(state)).await?;
Ok(())
}
/// Every route in the site. One `router()` per page module, each of which
/// documents the templates its endpoints render.
fn router(state: AppState) -> Router {
Router::new()
.route("/static/app.css", get(stylesheet))
.merge(pages::analytics::router())
.merge(pages::login::router())
.merge(pages::register::router())
.merge(pages::admin::admin::router())
.merge(pages::permissions::router())
.merge(pages::admin::table_definition::router())
.merge(pages::admin::ecb::router())
.merge(pages::add_table::router())
.merge(pages::add_logic::router())
.merge(pages::add_validation::router())
.merge(pages::import_export::router())
.with_state(state)
}
/// The one stylesheet every page links, so no template inlines CSS.
async fn stylesheet() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, HeaderValue::from_static("text/css"))],
APP_CSS,
)
}
pub(crate) fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers
.get(header::COOKIE)?
.to_str()
.ok()?
.split(';')
.map(str::trim)
.find_map(|cookie| cookie.strip_prefix(name)?.strip_prefix('='))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{body::Body, http::Request};
use tower::ServiceExt;
/// The channel is lazy, so routes that do not call gRPC serve fine
/// without a backend.
fn test_router() -> Router {
let channel = Channel::from_static("http://[::1]:50051").connect_lazy();
router(AppState {
analytics: AnalyticsServiceClient::new(channel.clone()),
auth: AuthServiceClient::new(channel.clone()),
definitions: TableDefinitionClient::new(channel.clone()),
scripts: TableScriptClient::new(channel.clone()),
validations: TableValidationServiceClient::new(channel.clone()),
tables_data: TablesDataClient::new(channel.clone()),
ecb: EcbServiceClient::new(channel.clone()),
structures: TableStructureServiceClient::new(channel),
})
}
async fn get(path: &str) -> (axum::http::StatusCode, String) {
let response = test_router()
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap();
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
(status, String::from_utf8(body.to_vec()).unwrap())
}
#[tokio::test]
async fn every_backend_free_page_renders_the_shared_shell() {
for path in ["/", "/login", "/register"] {
let (status, body) = get(path).await;
assert!(status.is_success(), "{path} returned {status}");
// Penguin UI's navbar, so what identifies it is its Alpine
// state rather than a class this app wrote.
assert!(
body.contains(r#"x-data="{ mobileMenuIsOpen: false }""#),
"{path} is missing the shared navbar"
);
assert!(
body.contains("href=\"/static/app.css\""),
"{path} is missing the shared stylesheet"
);
// The shell carries exactly two <style> elements, and neither is
// page styling: the cascade-order declaration that puts app.css
// between Tailwind's preflight and its utilities, and Penguin UI's
// @theme. Any third one is a page inlining CSS instead of
// linking the stylesheet.
assert_eq!(
body.matches("<style").count(),
2,
"{path} inlines CSS instead of linking the stylesheet"
);
assert!(
body.contains("@layer theme, base, app, components, utilities;"),
"{path} lost the cascade order app.css depends on"
);
// Penguin UI's toast stack, included straight from
// penguinui-components. A success anywhere on the page dispatches
// `notify` at it, so a page without it swallows the message.
assert!(
body.contains(r#"x-on:notify.window="addNotification("#),
"{path} is missing the toast stack"
);
}
}
#[tokio::test]
async fn signed_out_navbar_offers_login_instead_of_logout() {
let (_, body) = get("/").await;
assert!(body.contains("href=\"/login\""));
assert!(body.contains("href=\"/register\""));
assert!(!body.contains("hx-post=\"/logout\""));
}
#[tokio::test]
async fn password_page_requires_a_session() {
let (status, _) = get("/password").await;
assert_eq!(status, axum::http::StatusCode::SEE_OTHER);
}
/// The register form carries every user-provided `RegisterRequest` field.
/// Role assignment belongs to the administrator permissions page.
#[tokio::test]
async fn the_register_form_offers_the_same_fields_as_the_client() {
let (_, body) = get("/register").await;
for field in [
"username",
"email",
"password",
"password_confirmation",
"timezone",
"phone_country",
] {
assert!(
body.contains(&format!("name=\"{field}\"")),
"the register form is missing the {field} field"
);
}
assert!(!body.contains("name=\"role\""));
assert!(body.contains("value=\"Europe/Bratislava\""));
assert!(body.contains("value=\"SK\""));
}
/// Every table-definition endpoint is mounted, and every one of them is
/// behind a session: without a cookie there is no request to sign, so each
/// answers with the redirect to the login page rather than a 404 or a call
/// to the backend.
#[tokio::test]
async fn the_table_definition_pages_are_mounted_and_need_a_session() {
for path in [
"/admin/tables/columns/add",
"/admin/tables/presentation",
"/admin/tables/delete",
"/admin/profiles/copy",
"/admin/profiles/history",
"/admin/tables/from-template",
] {
let (status, _) = get(path).await;
assert_eq!(
status,
axum::http::StatusCode::SEE_OTHER,
"{path} did not send an anonymous visitor to the login page"
);
}
// The workspace these came out of is gone, and anything still pointing
// at it lands on the browser rather than on a 404.
let (status, _) = get("/admin/table-definition").await;
assert_eq!(status, axum::http::StatusCode::PERMANENT_REDIRECT);
for path in [
"/admin/tables/columns/add",
"/admin/tables/columns/add/builder",
"/admin/tables/presentation",
"/admin/tables/delete",
"/admin/profiles/copy",
"/admin/tables/from-template",
] {
let response = test_router()
.oneshot(
Request::builder()
.method("POST")
.uri(path)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("profile=billing&table=invoice"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
axum::http::StatusCode::SEE_OTHER,
"{path} did not send an anonymous visitor to the login page"
);
}
}
/// The exchange-rate page is read-only and behind a session like every
/// other page, so an anonymous visitor is sent to the login page rather
/// than to the backend.
#[tokio::test]
async fn the_exchange_rate_pages_are_mounted_and_need_a_session() {
for path in ["/admin/ecb", "/admin/ecb/status"] {
let (status, _) = get(path).await;
assert_eq!(
status,
axum::http::StatusCode::SEE_OTHER,
"{path} did not send an anonymous visitor to the login page"
);
}
}
/// Permissions is a nav section of its own, so its three pages are mounted
/// at the top level rather than under /admin — and each of them, like every
/// other page behind a session, sends an anonymous visitor to the login
/// page instead of calling the backend.
#[tokio::test]
async fn the_permission_sections_are_mounted_and_need_a_session() {
for path in [
"/permissions",
"/permissions/roles",
"/permissions/users",
"/permissions/grants",
] {
let (status, _) = get(path).await;
assert_eq!(
status,
axum::http::StatusCode::SEE_OTHER,
"{path} did not send an anonymous visitor to the login page"
);
}
// The forms answer a lost session the way every form does: with the
// failure rendered into the page, not a 404 from an unmounted route.
for (path, body) in [
("/permissions/roles/create", "name=sales&access=none"),
("/permissions/roles/remove", "role=sales"),
("/permissions/users/role", "username=alice&role=sales"),
(
"/permissions/users/password",
"username=alice&new_password=a&new_password_confirmation=a",
),
(
"/permissions/grants/apply",
"role=sales&mode=grant&pair=data%3A*%7Cread",
),
] {
let response = test_router()
.oneshot(
Request::builder()
.method("POST")
.uri(path)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
axum::http::StatusCode::UNPROCESSABLE_ENTITY,
"{path} is not mounted"
);
}
}
/// Every POST refuses a request another site made the browser send, and
/// refuses it *first*.
///
/// The backend channel here points at a port nothing is listening on, so a
/// handler that loaded the page before checking would answer with the
/// failure of that call — a 502 or a redirect — instead of the refusal.
/// Only a handler that checks before it works can answer 403.
#[tokio::test]
async fn a_cross_site_post_is_refused_before_any_backend_call() {
for (path, body) in [
("/login", "identifier=alice&password=secret"),
(
"/register",
"username=alice&email=a%40b.c&password=x&password_confirmation=x\
&timezone=UTC&phone_country=SK",
),
("/logout", ""),
("/api/catalog", "profile_name=billing"),
(
"/api/query",
"profile_name=billing&sql=select%201&chart_type=table",
),
("/permissions/roles/create", "name=sales&access=none"),
("/permissions/roles/remove", "role=sales"),
("/permissions/users/role", "username=alice&role=sales"),
(
"/permissions/users/password",
"username=alice&new_password=a&new_password_confirmation=a",
),
(
"/permissions/grants/apply",
"role=sales&mode=grant&pair=data%3A*%7Cread",
),
("/admin/tables/delete", "profile=billing&table=invoice"),
("/admin/profiles/copy", "profile=billing&table=invoice"),
("/admin/tables/presentation", "profile=billing&table=invoice"),
("/admin/tables/columns/add", "profile=billing&table=invoice"),
("/admin/tables/builder", ""),
("/admin/tables", ""),
("/admin/logic", ""),
("/admin/validation", ""),
("/admin/validation/rules", ""),
("/admin/validation/sets", ""),
("/admin/import", ""),
("/admin/export.csv", ""),
] {
let response = test_router()
.oneshot(
Request::builder()
.method("POST")
.uri(path)
.header("content-type", "application/x-www-form-urlencoded")
.header("sec-fetch-site", "cross-site")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
axum::http::StatusCode::FORBIDDEN,
"{path} served a cross-site POST"
);
}
}
#[tokio::test]
async fn stylesheet_is_served_once_for_every_page() {
let (status, body) = get("/static/app.css").await;
assert!(status.is_success());
// The navbar and the alerts are Penguin UI's own Tailwind classes now,
// so what is left in this file is the site's layout.
assert!(body.contains(".form-card"));
}
}