aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat
-rw-r--r--src/backends/zig.rs1+1 −0
-rw-r--r--src/main.rs31+21 −10
-rw-r--r--src/web.rs135+115 −20
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<Body>| {
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 <me@govorov.online>
// 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<Arc<Self>>,
- ) -> Result<response::Response, http::StatusCode> {
- 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<axum::response::Html<String>, 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<S> Layer<S> for LastModifiedLayer {
+ type Service = LastModifiedService<S>;
+ 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<S> {
+ inner: S,
+ last_moidified: DateTime<Utc>,
+}
+impl<S> Service<Request<Body>> for LastModifiedService<S>
+where
+ S: Service<Request<Body>, Response = Response<Body>> + Clone + Send + 'static,
+ S::Future: Send + 'static,
+ S::Error: Send + 'static,
+{
+ type Response = Response<Body>;
+ type Error = S::Error;
+ type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
+
+ fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
+ self.inner.poll_ready(cx)
}
- async fn styles() -> Result<axum_extra::response::Css<&'static str>, http::StatusCode> {
- Ok(axum_extra::response::Css(CSS))
+ fn call(&mut self, req: Request<Body>) -> 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<DateTime<Utc>> {
+ // 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#"
<!DOCTYPE html>
<html lang="en">