527 lines
20 KiB
Rust
527 lines
20 KiB
Rust
use std::{env, net::SocketAddr};
|
|
|
|
use axum::{
|
|
Router,
|
|
http::{HeaderMap, HeaderValue, header},
|
|
response::IntoResponse,
|
|
routing::get,
|
|
};
|
|
|
|
mod authz;
|
|
mod i18n;
|
|
mod pages;
|
|
mod schema;
|
|
mod services;
|
|
mod ui;
|
|
// 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/grpc_error.rs"]
|
|
#[allow(dead_code)]
|
|
mod grpc_error;
|
|
#[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"
|
|
));
|
|
}
|
|
pub mod exchange_rates {
|
|
include!(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../common/src/proto/komp_ac.exchange_rates.rs"
|
|
));
|
|
}
|
|
}
|
|
|
|
use auth::auth_service_client::AuthServiceClient;
|
|
use definitions::{
|
|
ecb, exchange_rates::exchange_rate_service_client::ExchangeRateServiceClient,
|
|
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>,
|
|
exchange_rates: ExchangeRateServiceClient<Channel>,
|
|
/// The imports this process is running, which the import page polls. Not a
|
|
/// client: an import outlives the request that started it, so where it has
|
|
/// got to has to live somewhere both the task and the next request can see.
|
|
imports: pages::import_export::import::progress::ImportJobs,
|
|
}
|
|
|
|
/// 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()),
|
|
exchange_rates: ExchangeRateServiceClient::new(channel.clone()),
|
|
structures: TableStructureServiceClient::new(channel),
|
|
imports: Default::default(),
|
|
};
|
|
|
|
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()),
|
|
exchange_rates: ExchangeRateServiceClient::new(channel.clone()),
|
|
structures: TableStructureServiceClient::new(channel),
|
|
imports: Default::default(),
|
|
})
|
|
}
|
|
|
|
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/alias",
|
|
"/admin/tables/presentation/order",
|
|
"/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"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Exchange-rate administration is behind a session; the former ECB page
|
|
/// remains as a stable redirect into the provider section.
|
|
#[tokio::test]
|
|
async fn the_exchange_rate_pages_are_mounted_and_need_a_session() {
|
|
for path in ["/admin/exchange-rates", "/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"
|
|
);
|
|
}
|
|
|
|
let (status, _) = get("/admin/ecb").await;
|
|
assert_eq!(status, axum::http::StatusCode::PERMANENT_REDIRECT);
|
|
}
|
|
|
|
/// 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/alias",
|
|
"profile=billing&table=invoice&column_id=1&alias=amount",
|
|
),
|
|
(
|
|
"/admin/tables/presentation/order",
|
|
"profile=billing&table=invoice&column_ids=1&column_ids=2",
|
|
),
|
|
("/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/import/source", ""),
|
|
("/admin/import/prepare", ""),
|
|
("/admin/import/preview", ""),
|
|
("/admin/import/prepared.csv", ""),
|
|
("/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"));
|
|
}
|
|
}
|