page rewrite
Some checks failed
CI / Check typos (push) Failing after 9s
CI / Check links (push) Failing after 14s
CI / Clippy (push) Successful in 53s
CI / Build and test (push) Successful in 1m19s
CI / Build container (push) Has been skipped
CI / Deploy on waypoint (push) Has been skipped
Some checks failed
CI / Check typos (push) Failing after 9s
CI / Check links (push) Failing after 14s
CI / Clippy (push) Successful in 53s
CI / Build and test (push) Successful in 1m19s
CI / Build container (push) Has been skipped
CI / Deploy on waypoint (push) Has been skipped
This commit is contained in:
@@ -9,7 +9,6 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
toolbox = { workspace = true }
|
||||
libservice = { workspace = true }
|
||||
pixel-transform = { workspace = true }
|
||||
|
||||
axum = { workspace = true }
|
||||
@@ -17,7 +16,6 @@ tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
maud = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
lru = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
tower = { workspace = true }
|
||||
serde_urlencoded = { workspace = true }
|
||||
|
||||
1
crates/lib/page/htmx/htmx-2.0.8.min.js
vendored
Normal file
1
crates/lib/page/htmx/htmx-2.0.8.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
11
crates/lib/page/htmx/json-enc-1.9.12.js
Normal file
11
crates/lib/page/htmx/json-enc-1.9.12.js
Normal file
@@ -0,0 +1,11 @@
|
||||
htmx.defineExtension('json-enc', {
|
||||
onEvent: function (name, evt) {
|
||||
if (name === "htmx:configRequest") {
|
||||
evt.detail.headers['Content-Type'] = "application/json";
|
||||
}
|
||||
},
|
||||
encodeParameters: function (xhr, parameters, elt) {
|
||||
xhr.overrideMimeType('text/json');
|
||||
return (JSON.stringify(parameters));
|
||||
}
|
||||
});
|
||||
@@ -1,8 +1,25 @@
|
||||
mod servable;
|
||||
pub use servable::*;
|
||||
//! A web stack for embedded uis.
|
||||
//!
|
||||
//! Featuring:
|
||||
//! - htmx
|
||||
//! - axum
|
||||
//! - rust
|
||||
//! - and maud
|
||||
|
||||
mod requestcontext;
|
||||
pub use requestcontext::*;
|
||||
pub mod servable;
|
||||
|
||||
mod server;
|
||||
pub use server::*;
|
||||
mod types;
|
||||
pub use types::*;
|
||||
|
||||
mod route;
|
||||
pub use route::*;
|
||||
|
||||
pub const HTMX_2_0_8: servable::StaticAsset = servable::StaticAsset {
|
||||
bytes: include_str!("../htmx/htmx-2.0.8.min.js").as_bytes(),
|
||||
mime: toolbox::mime::MimeType::Javascript,
|
||||
};
|
||||
|
||||
pub const EXT_JSON_1_19_12: servable::StaticAsset = servable::StaticAsset {
|
||||
bytes: include_str!("../htmx/json-enc-1.9.12.js").as_bytes(),
|
||||
mime: toolbox::mime::MimeType::Javascript,
|
||||
};
|
||||
|
||||
275
crates/lib/page/src/route.rs
Normal file
275
crates/lib/page/src/route.rs
Normal file
@@ -0,0 +1,275 @@
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::TimeDelta;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
convert::Infallible,
|
||||
net::SocketAddr,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
time::Instant,
|
||||
};
|
||||
use toolbox::mime::MimeType;
|
||||
use tower::Service;
|
||||
use tracing::trace;
|
||||
|
||||
use crate::{ClientInfo, RenderContext, Rendered, RenderedBody, servable::Servable};
|
||||
|
||||
struct Default404 {}
|
||||
impl Servable for Default404 {
|
||||
fn head<'a>(
|
||||
&'a self,
|
||||
_ctx: &'a RenderContext,
|
||||
) -> Pin<Box<dyn Future<Output = Rendered<()>> + 'a + Send + Sync>> {
|
||||
Box::pin(async {
|
||||
return Rendered {
|
||||
code: StatusCode::NOT_FOUND,
|
||||
body: (),
|
||||
ttl: Some(TimeDelta::days(1)),
|
||||
immutable: true,
|
||||
headers: HeaderMap::new(),
|
||||
mime: Some(MimeType::Html),
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
fn render<'a>(
|
||||
&'a self,
|
||||
ctx: &'a RenderContext,
|
||||
) -> Pin<Box<dyn Future<Output = Rendered<RenderedBody>> + 'a + Send + Sync>> {
|
||||
Box::pin(async { self.head(ctx).await.with_body(RenderedBody::Empty) })
|
||||
}
|
||||
}
|
||||
|
||||
/// A set of related [Servable]s under one route.
|
||||
///
|
||||
/// Use as follows:
|
||||
/// ```ignore
|
||||
///
|
||||
/// // Add compression, for example.
|
||||
/// // Also consider CORS and timeout.
|
||||
/// let compression: CompressionLayer = CompressionLayer::new()
|
||||
/// .br(true)
|
||||
/// .deflate(true)
|
||||
/// .gzip(true)
|
||||
/// .zstd(true)
|
||||
/// .compress_when(DefaultPredicate::new());
|
||||
///
|
||||
/// let route = ServableRoute::new()
|
||||
/// .add_page(
|
||||
/// "/page",
|
||||
/// StaticAsset {
|
||||
/// bytes: "I am a page".as_bytes(),
|
||||
/// mime: MimeType::Text,
|
||||
/// },
|
||||
/// );
|
||||
///
|
||||
/// Router::new()
|
||||
/// .nest_service("/", route)
|
||||
/// .layer(compression.clone());
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct ServableRoute {
|
||||
pages: Arc<HashMap<String, Arc<dyn Servable>>>,
|
||||
notfound: Arc<dyn Servable>,
|
||||
}
|
||||
|
||||
impl ServableRoute {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pages: Arc::new(HashMap::new()),
|
||||
notfound: Arc::new(Default404 {}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set this server's "not found" page
|
||||
pub fn with_404<S: Servable + 'static>(mut self, page: S) -> Self {
|
||||
self.notfound = Arc::new(page);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a page to this server at the given route.
|
||||
/// - panics if route does not start with a `/`, ends with a `/`, or contains `//`.
|
||||
/// - urls are normalized, routes that violate this condition will never be served.
|
||||
/// - `/` is an exception, it is valid.
|
||||
/// - panics if called after this service is started
|
||||
/// - overwrites existing pages
|
||||
pub fn add_page<S: Servable + 'static>(mut self, route: impl Into<String>, page: S) -> Self {
|
||||
let route = route.into();
|
||||
|
||||
if !route.starts_with("/") {
|
||||
panic!("route must start with /")
|
||||
};
|
||||
|
||||
if route.ends_with("/") && route != "/" {
|
||||
panic!("route must not end with /")
|
||||
};
|
||||
|
||||
if route.contains("//") {
|
||||
panic!("route must not contain //")
|
||||
};
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
Arc::get_mut(&mut self.pages)
|
||||
.expect("add_pages called after service was started")
|
||||
.insert(route, Arc::new(page));
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Convenience method.
|
||||
/// Turns this service into a router.
|
||||
///
|
||||
/// Equivalent to:
|
||||
/// ```ignore
|
||||
/// Router::new().fallback_service(self)
|
||||
/// ```
|
||||
pub fn into_router<T: Clone + Send + Sync + 'static>(self) -> Router<T> {
|
||||
Router::new().fallback_service(self)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: impl Service
|
||||
//
|
||||
|
||||
impl Service<Request<Body>> for ServableRoute {
|
||||
type Response = Response;
|
||||
type Error = Infallible;
|
||||
type Future =
|
||||
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
|
||||
|
||||
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn call(&mut self, req: Request<Body>) -> Self::Future {
|
||||
if req.method() != Method::GET && req.method() != Method::HEAD {
|
||||
let mut headers = HeaderMap::with_capacity(1);
|
||||
headers.insert(header::ACCEPT, HeaderValue::from_static("GET,HEAD"));
|
||||
return Box::pin(async {
|
||||
Ok((StatusCode::METHOD_NOT_ALLOWED, headers).into_response())
|
||||
});
|
||||
}
|
||||
|
||||
let pages = self.pages.clone();
|
||||
let notfound = self.notfound.clone();
|
||||
Box::pin(async move {
|
||||
let addr = req.extensions().get::<SocketAddr>().copied();
|
||||
let route = req.uri().path().to_owned();
|
||||
let headers = req.headers().clone();
|
||||
let query: BTreeMap<String, String> =
|
||||
serde_urlencoded::from_str(req.uri().query().unwrap_or("")).unwrap_or_default();
|
||||
|
||||
let start = Instant::now();
|
||||
let client_info = ClientInfo::from_headers(&headers);
|
||||
let ua = headers
|
||||
.get("user-agent")
|
||||
.and_then(|x| x.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
trace!(
|
||||
message = "Serving route",
|
||||
route,
|
||||
addr = ?addr,
|
||||
user_agent = ua,
|
||||
device_type = ?client_info.device_type
|
||||
);
|
||||
|
||||
// Normalize url with redirect
|
||||
if (route.ends_with('/') && route != "/") || route.contains("//") {
|
||||
let mut new_route = route.clone();
|
||||
while new_route.contains("//") {
|
||||
new_route = new_route.replace("//", "/");
|
||||
}
|
||||
let new_route = new_route.trim_matches('/');
|
||||
|
||||
trace!(
|
||||
message = "Redirecting",
|
||||
route,
|
||||
new_route,
|
||||
addr = ?addr,
|
||||
user_agent = ua,
|
||||
device_type = ?client_info.device_type
|
||||
);
|
||||
|
||||
let mut headers = HeaderMap::with_capacity(1);
|
||||
match HeaderValue::from_str(&format!("/{new_route}")) {
|
||||
Ok(x) => headers.append(header::LOCATION, x),
|
||||
Err(_) => return Ok(StatusCode::BAD_REQUEST.into_response()),
|
||||
};
|
||||
return Ok((StatusCode::PERMANENT_REDIRECT, headers).into_response());
|
||||
}
|
||||
|
||||
let ctx = RenderContext {
|
||||
client_info,
|
||||
route,
|
||||
query,
|
||||
};
|
||||
|
||||
let page = pages.get(&ctx.route).unwrap_or(¬found);
|
||||
let mut rend = match req.method() == Method::HEAD {
|
||||
true => page.head(&ctx).await.with_body(RenderedBody::Empty),
|
||||
false => page.render(&ctx).await,
|
||||
};
|
||||
|
||||
// Tweak headers
|
||||
{
|
||||
if !rend.headers.contains_key(header::CACHE_CONTROL) {
|
||||
let max_age = rend.ttl.map(|x| x.num_seconds()).unwrap_or(1).max(1);
|
||||
|
||||
let mut value = String::new();
|
||||
if rend.immutable {
|
||||
value.push_str("immutable, ");
|
||||
}
|
||||
|
||||
value.push_str("public, ");
|
||||
value.push_str(&format!("max-age={}, ", max_age));
|
||||
|
||||
#[expect(clippy::unwrap_used)]
|
||||
rend.headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_str(value.trim().trim_end_matches(',')).unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
if !rend.headers.contains_key("Accept-CH") {
|
||||
rend.headers
|
||||
.insert("Accept-CH", HeaderValue::from_static("Sec-CH-UA-Mobile"));
|
||||
}
|
||||
|
||||
if !rend.headers.contains_key(header::CONTENT_TYPE)
|
||||
&& let Some(mime) = &rend.mime
|
||||
{
|
||||
#[expect(clippy::unwrap_used)]
|
||||
rend.headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_str(&mime.to_string()).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
trace!(
|
||||
message = "Served route",
|
||||
route = ctx.route,
|
||||
addr = ?addr,
|
||||
user_agent = ua,
|
||||
device_type = ?client_info.device_type,
|
||||
time_ns = start.elapsed().as_nanos()
|
||||
);
|
||||
|
||||
Ok(match rend.body {
|
||||
RenderedBody::Markup(m) => (rend.code, rend.headers, m.0).into_response(),
|
||||
RenderedBody::Static(d) => (rend.code, rend.headers, d).into_response(),
|
||||
RenderedBody::Bytes(d) => (rend.code, rend.headers, d).into_response(),
|
||||
RenderedBody::String(s) => (rend.code, rend.headers, s).into_response(),
|
||||
RenderedBody::Empty => (rend.code, rend.headers).into_response(),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use std::{pin::Pin, str::FromStr};
|
||||
use toolbox::mime::MimeType;
|
||||
use tracing::{error, trace};
|
||||
|
||||
use crate::{Rendered, RenderedBody, RequestContext, Servable};
|
||||
use crate::{RenderContext, Rendered, RenderedBody, servable::Servable};
|
||||
|
||||
pub struct StaticAsset {
|
||||
pub bytes: &'static [u8],
|
||||
@@ -13,10 +13,69 @@ pub struct StaticAsset {
|
||||
}
|
||||
|
||||
impl Servable for StaticAsset {
|
||||
fn head<'a>(
|
||||
&'a self,
|
||||
ctx: &'a RenderContext,
|
||||
) -> Pin<Box<dyn Future<Output = Rendered<()>> + 'a + Send + Sync>> {
|
||||
Box::pin(async {
|
||||
let ttl = Some(TimeDelta::days(30));
|
||||
let is_image = TransformerChain::mime_is_image(&self.mime);
|
||||
|
||||
let transform = match (is_image, ctx.query.get("t")) {
|
||||
(false, _) | (_, None) => None,
|
||||
|
||||
(true, Some(x)) => match TransformerChain::from_str(x) {
|
||||
Ok(x) => Some(x),
|
||||
Err(_err) => {
|
||||
return Rendered {
|
||||
code: StatusCode::BAD_REQUEST,
|
||||
body: (),
|
||||
ttl,
|
||||
immutable: true,
|
||||
|
||||
headers: HeaderMap::new(),
|
||||
mime: None,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
match transform {
|
||||
Some(transform) => {
|
||||
return Rendered {
|
||||
code: StatusCode::OK,
|
||||
body: (),
|
||||
ttl,
|
||||
immutable: true,
|
||||
|
||||
headers: HeaderMap::new(),
|
||||
mime: Some(
|
||||
transform
|
||||
.output_mime(&self.mime)
|
||||
.unwrap_or(self.mime.clone()),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
None => {
|
||||
return Rendered {
|
||||
code: StatusCode::OK,
|
||||
body: (),
|
||||
ttl,
|
||||
immutable: true,
|
||||
|
||||
headers: HeaderMap::new(),
|
||||
mime: Some(self.mime.clone()),
|
||||
};
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn render<'a>(
|
||||
&'a self,
|
||||
ctx: &'a RequestContext,
|
||||
) -> Pin<Box<dyn Future<Output = crate::Rendered> + 'a + Send + Sync>> {
|
||||
ctx: &'a RenderContext,
|
||||
) -> Pin<Box<dyn Future<Output = Rendered<RenderedBody>> + 'a + Send + Sync>> {
|
||||
Box::pin(async {
|
||||
let ttl = Some(TimeDelta::days(30));
|
||||
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
pub mod asset;
|
||||
pub mod page;
|
||||
pub mod redirect;
|
||||
mod asset;
|
||||
pub use asset::*;
|
||||
|
||||
mod page;
|
||||
pub use page::*;
|
||||
|
||||
mod redirect;
|
||||
pub use redirect::*;
|
||||
|
||||
/// Something that may be served over http.
|
||||
pub trait Servable: Send + Sync {
|
||||
/// Return the same response as [Servable::render], but with an empty body.
|
||||
/// Used to respond to `HEAD` requests.
|
||||
fn head<'a>(
|
||||
&'a self,
|
||||
ctx: &'a crate::RenderContext,
|
||||
) -> std::pin::Pin<Box<dyn Future<Output = crate::Rendered<()>> + 'a + Send + Sync>>;
|
||||
|
||||
/// Render this page
|
||||
fn render<'a>(
|
||||
&'a self,
|
||||
ctx: &'a crate::RenderContext,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn Future<Output = crate::Rendered<crate::RenderedBody>> + 'a + Send + Sync>,
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use chrono::TimeDelta;
|
||||
use maud::{Markup, Render, html};
|
||||
use maud::{DOCTYPE, Markup, PreEscaped, html};
|
||||
use serde::Deserialize;
|
||||
use std::pin::Pin;
|
||||
use std::{pin::Pin, sync::Arc};
|
||||
use toolbox::mime::MimeType;
|
||||
|
||||
use crate::{Rendered, RenderedBody, RequestContext, Servable};
|
||||
use crate::{RenderContext, Rendered, RenderedBody, servable::Servable};
|
||||
|
||||
//
|
||||
// MARK: metadata
|
||||
@@ -17,7 +17,6 @@ pub struct PageMetadata {
|
||||
pub author: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub backlinks: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for PageMetadata {
|
||||
@@ -27,42 +26,15 @@ impl Default for PageMetadata {
|
||||
author: None,
|
||||
description: None,
|
||||
image: None,
|
||||
backlinks: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for PageMetadata {
|
||||
fn render(&self) -> Markup {
|
||||
let empty = String::new();
|
||||
let title = &self.title;
|
||||
let author = &self.author.as_ref().unwrap_or(&empty);
|
||||
let description = &self.description.as_ref().unwrap_or(&empty);
|
||||
let image = &self.image.as_ref().unwrap_or(&empty);
|
||||
|
||||
html !(
|
||||
meta property="og:site_name" content=(title) {}
|
||||
meta name="title" content=(title) {}
|
||||
meta property="og:title" content=(title) {}
|
||||
meta property="twitter:title" content=(title) {}
|
||||
|
||||
meta name="author" content=(author) {}
|
||||
|
||||
meta name="description" content=(description) {}
|
||||
meta property="og:description" content=(description) {}
|
||||
meta property="twitter:description" content=(description) {}
|
||||
|
||||
meta content=(image) property="og:image" {}
|
||||
link rel="shortcut icon" href=(image) type="image/x-icon" {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: page
|
||||
//
|
||||
|
||||
// Some HTML
|
||||
#[derive(Clone)]
|
||||
pub struct Page {
|
||||
pub meta: PageMetadata,
|
||||
pub immutable: bool,
|
||||
@@ -79,47 +51,101 @@ pub struct Page {
|
||||
/// or the contents of a wrapper element (defined in the page server struct).
|
||||
///
|
||||
/// This closure must never return `<html>` or `<head>`.
|
||||
pub generate_html: Box<
|
||||
pub generate_html: Arc<
|
||||
dyn Send
|
||||
+ Sync
|
||||
+ 'static
|
||||
+ for<'a> Fn(
|
||||
&'a Page,
|
||||
&'a RequestContext,
|
||||
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>,
|
||||
&'a RenderContext,
|
||||
) -> Pin<Box<dyn Future<Output = Markup> + Send + Sync + 'a>>,
|
||||
>,
|
||||
|
||||
pub response_code: StatusCode,
|
||||
|
||||
pub scripts_inline: Vec<String>,
|
||||
pub scripts_linked: Vec<String>,
|
||||
pub styles_linked: Vec<String>,
|
||||
pub styles_inline: Vec<String>,
|
||||
|
||||
/// `name`, `content` for extra `<meta>` tags
|
||||
pub extra_meta: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Default for Page {
|
||||
fn default() -> Self {
|
||||
Page {
|
||||
// No cache by default
|
||||
html_ttl: None,
|
||||
immutable: false,
|
||||
|
||||
meta: Default::default(),
|
||||
html_ttl: Some(TimeDelta::days(1)),
|
||||
generate_html: Box::new(|_, _| Box::pin(async { html!() })),
|
||||
immutable: true,
|
||||
generate_html: Arc::new(|_, _| Box::pin(async { html!() })),
|
||||
response_code: StatusCode::OK,
|
||||
scripts_inline: Vec::new(),
|
||||
scripts_linked: Vec::new(),
|
||||
styles_inline: Vec::new(),
|
||||
styles_linked: Vec::new(),
|
||||
extra_meta: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Page {
|
||||
pub async fn generate_html(&self, ctx: &RequestContext) -> Markup {
|
||||
pub async fn generate_html(&self, ctx: &RenderContext) -> Markup {
|
||||
(self.generate_html)(self, ctx).await
|
||||
}
|
||||
|
||||
pub fn immutable(mut self, immutable: bool) -> Self {
|
||||
self.immutable = immutable;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn html_ttl(mut self, html_ttl: Option<TimeDelta>) -> Self {
|
||||
self.html_ttl = html_ttl;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn response_code(mut self, response_code: StatusCode) -> Self {
|
||||
self.response_code = response_code;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_script_inline(mut self, script: impl Into<String>) -> Self {
|
||||
self.scripts_inline.push(script.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_script_linked(mut self, url: impl Into<String>) -> Self {
|
||||
self.scripts_linked.push(url.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_style_inline(mut self, style: impl Into<String>) -> Self {
|
||||
self.styles_inline.push(style.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_style_linked(mut self, url: impl Into<String>) -> Self {
|
||||
self.styles_linked.push(url.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_extra_meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
self.extra_meta.push((key.into(), value.into()));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Servable for Page {
|
||||
fn render<'a>(
|
||||
fn head<'a>(
|
||||
&'a self,
|
||||
ctx: &'a RequestContext,
|
||||
) -> Pin<Box<dyn Future<Output = crate::Rendered> + 'a + Send + Sync>> {
|
||||
_ctx: &'a RenderContext,
|
||||
) -> Pin<Box<dyn Future<Output = Rendered<()>> + 'a + Send + Sync>> {
|
||||
Box::pin(async {
|
||||
let html = self.generate_html(ctx).await;
|
||||
|
||||
return Rendered {
|
||||
code: self.response_code,
|
||||
body: RenderedBody::Markup(html),
|
||||
body: (),
|
||||
ttl: self.html_ttl,
|
||||
immutable: self.immutable,
|
||||
headers: HeaderMap::new(),
|
||||
@@ -127,4 +153,157 @@ impl Servable for Page {
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
fn render<'a>(
|
||||
&'a self,
|
||||
ctx: &'a RenderContext,
|
||||
) -> Pin<Box<dyn Future<Output = Rendered<RenderedBody>> + 'a + Send + Sync>> {
|
||||
Box::pin(async {
|
||||
let inner_html = self.generate_html(ctx).await;
|
||||
|
||||
let html = html! {
|
||||
(DOCTYPE)
|
||||
html {
|
||||
head {
|
||||
meta charset="UTF-8";
|
||||
meta name="viewport" content="width=device-width, initial-scale=1,user-scalable=no";
|
||||
meta content="text/html; charset=UTF-8" http-equiv="content-type";
|
||||
meta property="og:type" content="website";
|
||||
@for (name, content) in &self.extra_meta {
|
||||
meta name=(name) content=(content);
|
||||
}
|
||||
|
||||
//
|
||||
// Metadata
|
||||
//
|
||||
title { (PreEscaped(self.meta.title.clone())) }
|
||||
meta property="og:site_name" content=(self.meta.title);
|
||||
meta name="title" content=(self.meta.title);
|
||||
meta property="og:title" content=(self.meta.title);
|
||||
meta property="twitter:title" content=(self.meta.title);
|
||||
|
||||
@if let Some(author) = &self.meta.author {
|
||||
meta name="author" content=(author);
|
||||
}
|
||||
|
||||
@if let Some(desc) = &self.meta.description {
|
||||
meta name="description" content=(desc);
|
||||
meta property="og:description" content=(desc);
|
||||
meta property="twitter:description" content=(desc);
|
||||
}
|
||||
|
||||
@if let Some(image) = &self.meta.image {
|
||||
meta content=(image) property="og:image";
|
||||
link rel="shortcut icon" href=(image) type="image/x-icon";
|
||||
}
|
||||
|
||||
//
|
||||
// Scripts & styles
|
||||
//
|
||||
@for script in &self.scripts_linked {
|
||||
script src=(script) {}
|
||||
}
|
||||
@for style in &self.styles_linked {
|
||||
link rel="stylesheet" type="text/css" href=(style);
|
||||
}
|
||||
|
||||
@for script in &self.scripts_inline {
|
||||
script { (PreEscaped(script)) }
|
||||
}
|
||||
@for style in &self.styles_inline {
|
||||
style { (PreEscaped(style)) }
|
||||
}
|
||||
}
|
||||
|
||||
body { main { (inner_html) } }
|
||||
}
|
||||
};
|
||||
|
||||
return self.head(ctx).await.with_body(RenderedBody::Markup(html));
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: template
|
||||
//
|
||||
|
||||
pub struct PageTemplate {
|
||||
pub immutable: bool,
|
||||
pub html_ttl: Option<TimeDelta>,
|
||||
pub response_code: StatusCode,
|
||||
|
||||
pub scripts_inline: &'static [&'static str],
|
||||
pub scripts_linked: &'static [&'static str],
|
||||
pub styles_inline: &'static [&'static str],
|
||||
pub styles_linked: &'static [&'static str],
|
||||
pub extra_meta: &'static [(&'static str, &'static str)],
|
||||
}
|
||||
|
||||
impl Default for PageTemplate {
|
||||
fn default() -> Self {
|
||||
Self::const_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl PageTemplate {
|
||||
pub const fn const_default() -> Self {
|
||||
Self {
|
||||
html_ttl: Some(TimeDelta::days(1)),
|
||||
immutable: true,
|
||||
response_code: StatusCode::OK,
|
||||
|
||||
scripts_inline: &[],
|
||||
scripts_linked: &[],
|
||||
styles_inline: &[],
|
||||
styles_linked: &[],
|
||||
extra_meta: &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new page using this template,
|
||||
/// with the given metadata and renderer.
|
||||
pub fn derive<
|
||||
R: Send
|
||||
+ Sync
|
||||
+ 'static
|
||||
+ for<'a> Fn(
|
||||
&'a Page,
|
||||
&'a RenderContext,
|
||||
) -> Pin<Box<dyn Future<Output = Markup> + Send + Sync + 'a>>,
|
||||
>(
|
||||
&self,
|
||||
meta: PageMetadata,
|
||||
generate_html: R,
|
||||
) -> Page {
|
||||
Page {
|
||||
meta,
|
||||
immutable: self.immutable,
|
||||
html_ttl: self.html_ttl,
|
||||
response_code: self.response_code,
|
||||
|
||||
scripts_inline: self
|
||||
.scripts_inline
|
||||
.iter()
|
||||
.map(|x| (*x).to_owned())
|
||||
.collect(),
|
||||
|
||||
scripts_linked: self
|
||||
.scripts_linked
|
||||
.iter()
|
||||
.map(|x| (*x).to_owned())
|
||||
.collect(),
|
||||
|
||||
styles_inline: self.styles_inline.iter().map(|x| (*x).to_owned()).collect(),
|
||||
styles_linked: self.styles_linked.iter().map(|x| (*x).to_owned()).collect(),
|
||||
|
||||
extra_meta: self
|
||||
.extra_meta
|
||||
.iter()
|
||||
.map(|(a, b)| ((*a).to_owned(), (*b).to_owned()))
|
||||
.collect(),
|
||||
|
||||
generate_html: Arc::new(generate_html),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use axum::http::{
|
||||
header::{self, InvalidHeaderValue},
|
||||
};
|
||||
|
||||
use crate::{Rendered, RenderedBody, RequestContext, Servable};
|
||||
use crate::{RenderContext, Rendered, RenderedBody, servable::Servable};
|
||||
|
||||
pub struct Redirect {
|
||||
to: HeaderValue,
|
||||
@@ -20,10 +20,10 @@ impl Redirect {
|
||||
}
|
||||
|
||||
impl Servable for Redirect {
|
||||
fn render<'a>(
|
||||
fn head<'a>(
|
||||
&'a self,
|
||||
_ctx: &'a RequestContext,
|
||||
) -> Pin<Box<dyn Future<Output = Rendered> + 'a + Send + Sync>> {
|
||||
_ctx: &'a RenderContext,
|
||||
) -> Pin<Box<dyn Future<Output = Rendered<()>> + 'a + Send + Sync>> {
|
||||
Box::pin(async {
|
||||
let mut headers = HeaderMap::with_capacity(1);
|
||||
headers.append(header::LOCATION, self.to.clone());
|
||||
@@ -31,11 +31,18 @@ impl Servable for Redirect {
|
||||
return Rendered {
|
||||
code: StatusCode::PERMANENT_REDIRECT,
|
||||
headers,
|
||||
body: RenderedBody::Empty,
|
||||
body: (),
|
||||
ttl: None,
|
||||
immutable: true,
|
||||
mime: None,
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
fn render<'a>(
|
||||
&'a self,
|
||||
ctx: &'a RenderContext,
|
||||
) -> Pin<Box<dyn Future<Output = Rendered<RenderedBody>> + 'a + Send + Sync>> {
|
||||
Box::pin(async { self.head(ctx).await.with_body(RenderedBody::Empty) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{ConnectInfo, Path, Query, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use libservice::ServiceConnectInfo;
|
||||
use lru::LruCache;
|
||||
use maud::Markup;
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
num::NonZero,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
time::Instant,
|
||||
};
|
||||
use toolbox::mime::MimeType;
|
||||
use tower_http::compression::{CompressionLayer, DefaultPredicate};
|
||||
use tracing::trace;
|
||||
|
||||
use crate::{ClientInfo, RequestContext};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum RenderedBody {
|
||||
Markup(Markup),
|
||||
Static(&'static [u8]),
|
||||
Bytes(Vec<u8>),
|
||||
String(String),
|
||||
Empty,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Rendered {
|
||||
pub code: StatusCode,
|
||||
pub headers: HeaderMap,
|
||||
pub body: RenderedBody,
|
||||
pub mime: Option<MimeType>,
|
||||
|
||||
/// How long to cache this response.
|
||||
/// If none, don't cache.
|
||||
pub ttl: Option<TimeDelta>,
|
||||
pub immutable: bool,
|
||||
}
|
||||
|
||||
pub trait Servable: Send + Sync {
|
||||
fn render<'a>(
|
||||
&'a self,
|
||||
ctx: &'a RequestContext,
|
||||
) -> Pin<Box<dyn Future<Output = Rendered> + 'a + Send + Sync>>;
|
||||
}
|
||||
|
||||
pub struct Default404 {}
|
||||
impl Servable for Default404 {
|
||||
fn render<'a>(
|
||||
&'a self,
|
||||
_ctx: &'a RequestContext,
|
||||
) -> Pin<Box<dyn Future<Output = Rendered> + 'a + Send + Sync>> {
|
||||
Box::pin(async {
|
||||
return Rendered {
|
||||
code: StatusCode::NOT_FOUND,
|
||||
body: RenderedBody::String("page not found".into()),
|
||||
ttl: Some(TimeDelta::days(1)),
|
||||
immutable: true,
|
||||
headers: HeaderMap::new(),
|
||||
mime: Some(MimeType::Html),
|
||||
};
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: server
|
||||
//
|
||||
|
||||
pub struct PageServer {
|
||||
/// If true, expired pages will be rerendered before being sent to the user.
|
||||
/// If false, requests never trigger rerenders. We rely on the rerender task.
|
||||
///
|
||||
/// If true, we deliver fresher pages but delay responses.
|
||||
/// TODO: replace this with a smarter rendering strategy?
|
||||
never_rerender_on_request: bool,
|
||||
|
||||
/// Map of `{ route: page }`
|
||||
pages: Mutex<HashMap<String, Arc<dyn Servable>>>,
|
||||
|
||||
notfound: Mutex<Arc<dyn Servable>>,
|
||||
|
||||
/// Map of `{ route: (page data, expire time) }`
|
||||
///
|
||||
/// We use an LruCache for bounded memory usage.
|
||||
page_cache: Mutex<LruCache<RequestContext, (Rendered, DateTime<Utc>)>>,
|
||||
}
|
||||
|
||||
impl PageServer {
|
||||
pub fn new() -> Arc<Self> {
|
||||
#[expect(clippy::unwrap_used)]
|
||||
let cache_size = NonZero::new(128).unwrap();
|
||||
|
||||
Arc::new(Self {
|
||||
pages: Mutex::new(HashMap::new()),
|
||||
page_cache: Mutex::new(LruCache::new(cache_size)),
|
||||
never_rerender_on_request: true,
|
||||
notfound: Mutex::new(Arc::new(Default404 {})),
|
||||
})
|
||||
}
|
||||
|
||||
/// Set this server's "not found" page
|
||||
pub fn with_404<S: Servable + 'static>(&self, page: S) -> &Self {
|
||||
*self.notfound.lock() = Arc::new(page);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn add_page<S: Servable + 'static>(&self, route: impl Into<String>, page: S) -> &Self {
|
||||
#[expect(clippy::expect_used)]
|
||||
let route = route
|
||||
.into()
|
||||
.strip_prefix("/")
|
||||
.expect("route must start with /")
|
||||
.to_owned();
|
||||
|
||||
self.pages.lock().insert(route, Arc::new(page));
|
||||
self
|
||||
}
|
||||
|
||||
/// Re-render the page at `route`, regardless of cache state.
|
||||
/// Does nothing if there is no page at `route`.
|
||||
///
|
||||
/// Returns the rendered page's content.
|
||||
async fn render_page(
|
||||
&self,
|
||||
reason: &'static str,
|
||||
route: &str,
|
||||
ctx: RequestContext,
|
||||
) -> (Rendered, Option<DateTime<Utc>>) {
|
||||
let now = Utc::now();
|
||||
let start = Instant::now();
|
||||
|
||||
let page = match self.pages.lock().get(route) {
|
||||
Some(x) => x.clone(),
|
||||
None => self.notfound.lock().clone(),
|
||||
};
|
||||
|
||||
trace!(
|
||||
message = "Rendering page",
|
||||
route = route.to_owned(),
|
||||
reason,
|
||||
lock_time_ms = start.elapsed().as_millis()
|
||||
);
|
||||
|
||||
let rendered = page.render(&ctx).await;
|
||||
|
||||
let mut expires = None;
|
||||
if let Some(ttl) = rendered.ttl {
|
||||
expires = Some(now + ttl);
|
||||
self.page_cache
|
||||
.lock()
|
||||
.put(ctx, (rendered.clone(), now + ttl));
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed().as_millis();
|
||||
trace!(
|
||||
message = "Rendered page",
|
||||
route = route.to_owned(),
|
||||
reason,
|
||||
time_ms = elapsed
|
||||
);
|
||||
return (rendered, expires);
|
||||
}
|
||||
|
||||
async fn handler(
|
||||
Path(route): Path<String>,
|
||||
Query(query): Query<BTreeMap<String, String>>,
|
||||
State(state): State<Arc<Self>>,
|
||||
ConnectInfo(addr): ConnectInfo<ServiceConnectInfo>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let start = Instant::now();
|
||||
let client_info = ClientInfo::from_headers(&headers);
|
||||
let ua = headers
|
||||
.get("user-agent")
|
||||
.and_then(|x| x.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
trace!(
|
||||
message = "Serving route",
|
||||
route,
|
||||
addr = ?addr.addr,
|
||||
user_agent = ua,
|
||||
device_type = ?client_info.device_type
|
||||
);
|
||||
|
||||
// Normalize url with redirect
|
||||
if route.ends_with('/') || route.contains("//") || route.starts_with('/') {
|
||||
let mut new_route = route.clone();
|
||||
while new_route.contains("//") {
|
||||
new_route = new_route.replace("//", "/");
|
||||
}
|
||||
let new_route = new_route.trim_matches('/');
|
||||
|
||||
trace!(
|
||||
message = "Redirecting",
|
||||
route,
|
||||
new_route,
|
||||
addr = ?addr.addr,
|
||||
user_agent = ua,
|
||||
device_type = ?client_info.device_type
|
||||
);
|
||||
|
||||
let mut headers = HeaderMap::with_capacity(2);
|
||||
|
||||
let new_route = match HeaderValue::from_str(&format!("/{new_route}")) {
|
||||
Ok(x) => x,
|
||||
Err(_) => {
|
||||
// Be extra careful, this is user-provided data
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
headers.append(header::LOCATION, new_route);
|
||||
headers.append("Accept-CH", HeaderValue::from_static("Sec-CH-UA-Mobile"));
|
||||
return (StatusCode::PERMANENT_REDIRECT, headers).into_response();
|
||||
}
|
||||
|
||||
let ctx = RequestContext {
|
||||
client_info,
|
||||
route: format!("/{route}"),
|
||||
query,
|
||||
};
|
||||
|
||||
let now = Utc::now();
|
||||
let mut html_expires = None;
|
||||
let mut cached = true;
|
||||
|
||||
// Get from cache, if available
|
||||
if let Some((html, expires)) = state.page_cache.lock().get(&ctx)
|
||||
&& (*expires > now || state.never_rerender_on_request)
|
||||
{
|
||||
html_expires = Some((html.clone(), Some(*expires)));
|
||||
};
|
||||
|
||||
if html_expires.is_none() {
|
||||
cached = false;
|
||||
html_expires = Some(state.render_page("request", &route, ctx).await);
|
||||
}
|
||||
|
||||
#[expect(clippy::unwrap_used)]
|
||||
let (mut html, expires) = html_expires.unwrap();
|
||||
|
||||
if !html.headers.contains_key(header::CACHE_CONTROL) {
|
||||
let max_age = match expires {
|
||||
Some(expires) => (expires - now).num_seconds().max(1),
|
||||
None => 1,
|
||||
};
|
||||
|
||||
let mut value = String::new();
|
||||
if html.immutable {
|
||||
value.push_str("immutable, ");
|
||||
}
|
||||
|
||||
value.push_str("public, ");
|
||||
value.push_str(&format!("max-age={}, ", max_age));
|
||||
|
||||
#[expect(clippy::unwrap_used)]
|
||||
html.headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_str(value.trim().trim_end_matches(',')).unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
if !html.headers.contains_key("Accept-CH") {
|
||||
html.headers
|
||||
.insert("Accept-CH", HeaderValue::from_static("Sec-CH-UA-Mobile"));
|
||||
}
|
||||
|
||||
if let Some(mime) = &html.mime {
|
||||
#[expect(clippy::unwrap_used)]
|
||||
html.headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_str(&mime.to_string()).unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
trace!(
|
||||
message = "Served route",
|
||||
route,
|
||||
addr = ?addr.addr,
|
||||
user_agent = ua,
|
||||
device_type = ?client_info.device_type,
|
||||
cached,
|
||||
time_ns = start.elapsed().as_nanos()
|
||||
);
|
||||
|
||||
return match html.body {
|
||||
RenderedBody::Markup(markup) => (html.code, html.headers, markup.0).into_response(),
|
||||
RenderedBody::Static(data) => (html.code, html.headers, data).into_response(),
|
||||
RenderedBody::Bytes(data) => (html.code, html.headers, data).into_response(),
|
||||
RenderedBody::String(s) => (html.code, html.headers, s).into_response(),
|
||||
RenderedBody::Empty => (html.code, html.headers).into_response(),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn into_router(self: Arc<Self>) -> Router<()> {
|
||||
let compression: CompressionLayer = CompressionLayer::new()
|
||||
.br(true)
|
||||
.deflate(true)
|
||||
.gzip(true)
|
||||
.zstd(true)
|
||||
.compress_when(DefaultPredicate::new());
|
||||
|
||||
Router::new()
|
||||
.route(
|
||||
"/",
|
||||
get(|state, query, conn, headers| async {
|
||||
Self::handler(Path(String::new()), query, state, conn, headers).await
|
||||
}),
|
||||
)
|
||||
.route("/{*path}", get(Self::handler))
|
||||
.layer(compression)
|
||||
.with_state(self)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,66 @@
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use chrono::TimeDelta;
|
||||
use maud::Markup;
|
||||
use std::collections::BTreeMap;
|
||||
use toolbox::mime::MimeType;
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
//
|
||||
// MARK: rendered
|
||||
//
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum RenderedBody {
|
||||
Markup(Markup),
|
||||
Static(&'static [u8]),
|
||||
Bytes(Vec<u8>),
|
||||
String(String),
|
||||
Empty,
|
||||
}
|
||||
|
||||
pub trait RenderedBodyType {}
|
||||
impl RenderedBodyType for () {}
|
||||
impl RenderedBodyType for RenderedBody {}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Rendered<T: RenderedBodyType> {
|
||||
pub code: StatusCode,
|
||||
pub headers: HeaderMap,
|
||||
pub body: T,
|
||||
pub mime: Option<MimeType>,
|
||||
|
||||
/// How long to cache this response.
|
||||
/// If none, don't cache.
|
||||
pub ttl: Option<TimeDelta>,
|
||||
pub immutable: bool,
|
||||
}
|
||||
|
||||
impl Rendered<()> {
|
||||
/// Turn this [Rendered] into a [Rendered] with a body.
|
||||
pub fn with_body(self, body: RenderedBody) -> Rendered<RenderedBody> {
|
||||
Rendered {
|
||||
code: self.code,
|
||||
headers: self.headers,
|
||||
body,
|
||||
mime: self.mime,
|
||||
ttl: self.ttl,
|
||||
immutable: self.immutable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: context
|
||||
//
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct RequestContext {
|
||||
pub struct RenderContext {
|
||||
pub client_info: ClientInfo,
|
||||
pub route: String,
|
||||
pub query: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
// MARK: clientinfo
|
||||
//
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -25,10 +75,6 @@ impl Default for DeviceType {
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: clientinfo
|
||||
//
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct ClientInfo {
|
||||
/// This is an estimate, but it's probably good enough.
|
||||
Reference in New Issue
Block a user