From 6e333e0ab892351a2d4a0a282434119dd7af014e Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Mon, 19 Jan 2026 11:52:14 +0000 Subject: Web optimization: compression, cache and meta headers --- src/backends/zig.rs | 1 + src/main.rs | 31 ++++++---- src/web.rs | 135 +++++++++++++++++++++++++++++++++++++------- 3 files changed, 137 insertions(+), 30 deletions(-) diff --git a/src/backends/zig.rs b/src/backends/zig.rs index 5920645..9131511 100644 --- a/src/backends/zig.rs +++ b/src/backends/zig.rs @@ -209,6 +209,7 @@ impl ZigController { } fn build_response(status: http::StatusCode, bytes: bytes::Bytes) -> response::Response { + // TODO: add etag response::Response::builder() .status(status) .header(http::header::CONTENT_TYPE, "application/octet-stream") diff --git a/src/main.rs b/src/main.rs index 405a9a4..fee8d4f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -135,6 +135,7 @@ async fn main() { )); const REQUEST_ID_HEADER: http::HeaderName = http::HeaderName::from_static("x-request-id"); + let trace_layer = tower_http::trace::TraceLayer::new_for_http() .make_span_with(|req: &http::Request| { let request_id = req @@ -230,27 +231,37 @@ async fn main() { let app = axum::Router::new() .merge(web_controller.router()) .merge(zig_controller.router()) + // Opt-in layers + .layer(tower_http::compression::CompressionLayer::new()) + // request limits .layer(HostValidationLayer) - .layer(trace_layer) - .layer(tower_http::request_id::PropagateRequestIdLayer::new( - REQUEST_ID_HEADER.clone(), - )) - .layer(tower_http::request_id::SetRequestIdLayer::new( - REQUEST_ID_HEADER.clone(), - tower_http::request_id::MakeRequestUuid, + .layer(tower_http::limit::RequestBodyLimitLayer::new( + config.server().max_body_size.as_u64() as usize, )) .layer(tower_http::timeout::TimeoutLayer::with_status_code( http::StatusCode::REQUEST_TIMEOUT, config.server().request_timeout, )) - .layer(tower_http::limit::RequestBodyLimitLayer::new( - config.server().max_body_size.as_u64() as usize, - )) + // logging + .layer(trace_layer) + // rate-limits .layer(tower_governor::GovernorLayer::new(Arc::new( governor_config, ))) .layer(tower::limit::ConcurrencyLimitLayer::new( config.server().max_concurrent_requests, + )) + // global headers + .layer(tower_http::request_id::PropagateRequestIdLayer::new( + REQUEST_ID_HEADER.clone(), + )) + .layer(tower_http::request_id::SetRequestIdLayer::new( + REQUEST_ID_HEADER.clone(), + tower_http::request_id::MakeRequestUuid, + )) + .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( + http::header::SERVER, + http::HeaderValue::from_static(concat!("zorian/", env!("CARGO_PKG_VERSION"))), )); let mut tasks = tokio::task::JoinSet::new(); diff --git a/src/web.rs b/src/web.rs index d286d35..838cbc1 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1,13 +1,21 @@ // SPDX-FileCopyrightText: 2026 Nikolay Govorov // SPDX-License-Identifier: AGPL-3.0-or-later +use std::future::Future; +use std::pin::Pin; use std::sync::Arc; +use std::task::{Context, Poll}; -use axum::{ - extract::{self}, - http::{self}, - response::{self, IntoResponse}, -}; +use axum::body::Body; +use axum::extract; +use axum::http::{HeaderValue, Method, Request, Response, StatusCode, header}; +use axum::response; +use chrono::{DateTime, Utc}; +use sqlx::types::chrono; +use tower::{Layer, Service}; +use tracing::error; + +const CSP: &str = "default-src 'self'; base-uri 'none'; img-src 'self'; font-src 'self'; style-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'"; /// Handles html pages rendering and static files pub struct WebController { @@ -28,31 +36,118 @@ impl WebController { axum::Router::new() .route("/index.css", axum::routing::get(Self::styles)) .route("/", axum::routing::get(Self::index)) + .layer(LastModifiedLayer {}) + .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( + header::CACHE_CONTROL, + HeaderValue::from_static("no-cache"), + )) + .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static(CSP), + )) .with_state(self) } async fn index( extract::State(controller): extract::State>, - ) -> Result { - let template = controller.jinja.get_template("index").unwrap(); - let rendered = template.render(minijinja::context! {}).unwrap(); - - let mut response = response::Html(rendered).into_response(); - response.headers_mut().insert( - http::header::CONTENT_SECURITY_POLICY, - http::HeaderValue::from_static( - "default-src 'self'; base-uri 'none'; img-src 'self'; font-src 'self'; style-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'", - ), - ); - - Ok(response) + ) -> Result, StatusCode> { + let tmpl = controller.jinja.get_template("index"); + + tmpl.and_then(|template| template.render(minijinja::context! {})) + .map(response::Html) + .map_err(|err| { + error!("html rendering failed: {err}"); + StatusCode::INTERNAL_SERVER_ERROR + }) + } + + async fn styles() -> axum_extra::response::Css<&'static str> { + axum_extra::response::Css(CSS) + } +} + +#[derive(Clone)] +pub struct LastModifiedLayer {} +impl Layer for LastModifiedLayer { + type Service = LastModifiedService; + fn layer(&self, inner: S) -> Self::Service { + LastModifiedService { + inner, + + // Since the static file is packaged in a binary, we cache it across restarts. + // In the future, we can use the build time. + last_moidified: Utc::now(), + } + } +} + +#[derive(Clone)] +pub struct LastModifiedService { + inner: S, + last_moidified: DateTime, +} +impl Service> for LastModifiedService +where + S: Service, Response = Response> + Clone + Send + 'static, + S::Future: Send + 'static, + S::Error: Send + 'static, +{ + type Response = Response; + type Error = S::Error; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) } - async fn styles() -> Result, http::StatusCode> { - Ok(axum_extra::response::Css(CSS)) + fn call(&mut self, req: Request) -> Self::Future { + let method = req.method().clone(); + let headers = req.headers().clone(); + let last_modified = self.last_moidified; + + let mut inner = self.inner.clone(); + Box::pin(async move { + let raw = last_modified + .format("%a, %d %b %Y %H:%M:%S GMT") + .to_string(); + let lmh = HeaderValue::from_str(raw.as_str()).unwrap(); + + if (method == Method::GET || method == Method::HEAD) + && headers + .get(header::IF_MODIFIED_SINCE) + .and_then(|v| v.to_str().ok()) + .and_then(parse_http_date) + .is_some_and(|ims| last_modified <= ims) + { + match Response::builder() + .status(StatusCode::NOT_MODIFIED) + .header(header::LAST_MODIFIED, lmh.clone()) + .body(Body::empty()) + { + Ok(resp) => { + return Ok(resp); + } + Err(err) => { + error!("build response: {err}") + } + } + } + + let mut resp = inner.call(req).await?; + resp.headers_mut().insert(header::LAST_MODIFIED, lmh); + Ok(resp) + }) } } +/// Parse HTTP-date (IMF-fixdate per RFC 7231) or RFC 2822 +fn parse_http_date(value: &str) -> Option> { + // IMF-fixdate: "Tue, 14 Jan 2026 12:34:56 GMT" + DateTime::parse_from_rfc2822(value.trim()) + .map(|d| d.with_timezone(&Utc)) + .ok() +} + const HTML: &str = r#" -- Gilti