commit d2e15101190f6ade30fd37b466d94cef85a2189f from: msi date: Sat Nov 15 13:53:37 2025 UTC Add validation extract commit - e8fe7f5532aee563a949b78098208da9e48445f2 commit + d2e15101190f6ade30fd37b466d94cef85a2189f blob - 9b0bebcc211a37fb3c319e992a6b8b8919de1f93 blob + e9d1ba5ff092fc6401f81e38f4cb452e7869f2fe --- web/README.md +++ web/README.md @@ -12,4 +12,5 @@ * [x] Messages (like flask) * [x] Sessions * [x] CSRF +* [x] Validation * [ ] 404 blob - d266dac95ef710f4e07d61d0565c34bc52ca0566 blob + b7d3f32466ba2542cdc0e80e42a5e7062a020d9d --- web/template/Cargo.toml +++ web/template/Cargo.toml @@ -17,9 +17,11 @@ metrics = { version = "=0.24.2", default-features = fa metrics-exporter-prometheus = { version = "=0.17.2", default-features = false } minijinja = "=2.12.0" serde = { version = "=1.0.228", features = ["derive"] } +thiserror = "2.0.17" time = "=0.3.44" tokio = { version = "=1.48.0", features = ["macros", "rt-multi-thread", "signal"] } tower-http = { version = "=0.6.6", features = ["timeout", "trace", "fs", "request-id"] } tower-sessions = "=0.14.0" tracing = "=0.1.41" tracing-subscriber = { version = "=0.3.20", features = ["env-filter"] } +validator = { version = "=0.20.0", features = ["derive"] } blob - d1f56d93282c97bddded2b3c6399390911d2364c blob + 1693167744b435b06ce0ff681c4a17473b2e6c40 --- web/template/src/main.rs +++ web/template/src/main.rs @@ -45,6 +45,10 @@ async fn start_main_server() -> anyhow::Result<()> { env.add_template("content", include_str!("../templates/content.jinja"))?; env.add_template("about", include_str!("../templates/about.jinja"))?; env.add_template("csrf", include_str!("../templates/csrf.jinja"))?; + env.add_template( + "validation", + include_str!("../templates/validation.jinja"), + )?; let app_state = Arc::new(state::AppState { env }); blob - ca2f2a800fa47ac104c61617c1a9d127a84700b4 blob + d500121b1749957b78c80b02e8cb69c0029480cd --- web/template/src/router.rs +++ web/template/src/router.rs @@ -13,21 +13,24 @@ // ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF // OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. // + use std::sync::Arc; use axum::{ - Form, Router, - extract::State, - http::{HeaderName, Request, StatusCode}, + Router, + extract::{Form, FromRequest, Request, State, rejection::FormRejection}, + http::{self, HeaderName, StatusCode}, middleware, - response::{Html, IntoResponse, Redirect}, + response::{Html, IntoResponse, Redirect, Response}, routing::get, }; use axum_client_ip::{ClientIp, ClientIpSource}; use axum_csrf::{CsrfConfig, CsrfLayer, CsrfToken, Key}; use axum_messages::{Messages, MessagesManagerLayer}; use minijinja::context; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +use thiserror::Error; use time::Duration; use tower_http::{ request_id::{ @@ -39,6 +42,7 @@ use tower_http::{ }; use tower_sessions::{Expiry, MemoryStore, Session, SessionManagerLayer}; use tracing::{error, info_span}; +use validator::Validate; use crate::metric::track_metrics; use crate::state::AppState; @@ -75,13 +79,17 @@ pub(crate) fn route(app_state: Arc) -> Route .route("/read-messages", get(read_messages_handler)) .route("/csrf", get(csrf_root).post(csrf_check_key)) .route("/ip", get(ip_handler)) + .route( + "/validation", + get(get_validation_handler).post(post_validation_handler), + ) .layer(MessagesManagerLayer) // TODO(msi): from config folder asssets .nest_service("/assets", ServeDir::new("assets")) .layer(( SetRequestIdLayer::new(x_request_id.clone(), MakeRequestUuid), TraceLayer::new_for_http().make_span_with( - |request: &Request<_>| { + |request: &http::Request<_>| { // Log the request id as generated. let request_id = request.headers().get(REQUEST_ID_HEADER); @@ -112,6 +120,74 @@ pub(crate) fn route(app_state: Arc) -> Route .with_state(app_state) } +#[derive(Debug, Deserialize, Validate)] +pub struct NameInput { + #[validate(length(min = 2, message = "Can not be empty"))] + pub name: String, +} + +async fn get_validation_handler( + State(state): State>, +) -> Result, ServerError> { + let template = state.env.get_template("validation").unwrap(); + + let rendered = template.render(context! {}).unwrap(); + + Ok(Html(rendered)) +} + +async fn post_validation_handler( + ValidatedForm(input): ValidatedForm, +) -> Html { + Html(format!("

Hello, {}!

", input.name)) +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct ValidatedForm(pub T); + +impl FromRequest for ValidatedForm +where + T: DeserializeOwned + Validate, + S: Send + Sync, + Form: FromRequest, +{ + type Rejection = ServerError; + + async fn from_request( + req: Request, + state: &S, + ) -> Result { + let Form(value) = Form::::from_request(req, state).await?; + value.validate()?; + Ok(ValidatedForm(value)) + } +} + +#[derive(Debug, Error)] +pub enum ServerError { + #[error(transparent)] + ValidationError(#[from] validator::ValidationErrors), + + #[error(transparent)] + AxumFormRejection(#[from] FormRejection), +} + +impl IntoResponse for ServerError { + fn into_response(self) -> Response { + match self { + ServerError::ValidationError(_) => { + let message = format!("Input validation error: [{self}]") + .replace('\n', ", "); + (StatusCode::BAD_REQUEST, message) + } + ServerError::AxumFormRejection(_) => { + (StatusCode::BAD_REQUEST, self.to_string()) + } + } + .into_response() + } +} + async fn ip_handler(ClientIp(ip): ClientIp) -> String { ip.to_string() } blob - 6abc2fda11620fbecb3d72b3b37cc2b3dac02d2d blob + ee0649398dbcf65170b3ec8c1a23ab209f7d4cc0 --- web/template/templates/layout.jinja +++ web/template/templates/layout.jinja @@ -13,6 +13,7 @@
  • Read Messages
  • Csrf
  • Ip
  • +
  • Validation
  • Hello, World web =]

    blob - /dev/null blob + e121f990b9365500592811c80c9fb26acd4dbbe4 (mode 644) --- /dev/null +++ web/template/templates/validation.jinja @@ -0,0 +1,10 @@ +{% extends "layout" %} +{% block title %}{{ super() }} | {{ title }} {% endblock %} +{% block body %} +

    {{ title }}

    +

    {{ about_text }}

    +
    + + +
    +{% endblock %}