Transform images + placeholders
Some checks failed
CI / Check typos (push) Successful in 8s
CI / Check links (push) Failing after 10s
CI / Clippy (push) Successful in 56s
CI / Build and test (push) Successful in 1m22s
CI / Build container (push) Successful in 1m4s
CI / Deploy on waypoint (push) Successful in 46s

This commit is contained in:
2025-11-08 13:11:25 -08:00
parent 1329539059
commit c13618e958
9 changed files with 171 additions and 49 deletions

View File

@@ -1,9 +1,12 @@
use std::collections::BTreeMap;
use axum::http::HeaderMap; use axum::http::HeaderMap;
#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RequestContext { pub struct RequestContext {
pub client_info: ClientInfo, pub client_info: ClientInfo,
pub route: String, pub route: String,
pub query: BTreeMap<String, String>,
} }
// //

View File

@@ -1,9 +1,9 @@
use axum::http::{ use axum::http::{HeaderMap, StatusCode};
HeaderMap, HeaderValue, StatusCode, use chrono::TimeDelta;
header::{self}, use pixel_transform::TransformerChain;
}; use std::{pin::Pin, str::FromStr};
use std::pin::Pin;
use toolbox::mime::MimeType; use toolbox::mime::MimeType;
use tracing::{error, trace};
use crate::{Rendered, RenderedBody, RequestContext, Servable}; use crate::{Rendered, RenderedBody, RequestContext, Servable};
@@ -15,30 +15,102 @@ pub struct StaticAsset {
impl Servable for StaticAsset { impl Servable for StaticAsset {
fn render<'a>( fn render<'a>(
&'a self, &'a self,
_ctx: &'a RequestContext, ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = crate::Rendered> + 'a + Send + Sync>> { ) -> Pin<Box<dyn Future<Output = crate::Rendered> + 'a + Send + Sync>> {
Box::pin(async { Box::pin(async {
let mut headers = HeaderMap::with_capacity(3); let ttl = Some(TimeDelta::days(30));
#[expect(clippy::unwrap_used)] // Automatically provide transformation if this is an image
headers.insert( let is_image = TransformerChain::mime_is_image(&self.mime);
header::CONTENT_TYPE,
HeaderValue::from_str(&self.mime.to_string()).unwrap(),
);
headers.insert( let transform = match (is_image, ctx.query.get("t")) {
header::CACHE_CONTROL, (false, _) | (_, None) => None,
HeaderValue::from_str(&format!("immutable, public, max-age={}", 60 * 60 * 24 * 30))
.unwrap(),
);
return Rendered { (true, Some(x)) => match TransformerChain::from_str(x) {
code: StatusCode::OK, Ok(x) => Some(x),
headers, Err(err) => {
body: RenderedBody::Static(self.bytes), return Rendered {
ttl: None, code: StatusCode::BAD_REQUEST,
immutable: true, body: RenderedBody::String(err),
ttl,
immutable: true,
headers: HeaderMap::new(),
mime: None,
};
}
},
}; };
match transform {
Some(transform) => {
trace!(message = "Transforming image", ?transform);
let task = {
let mime = Some(self.mime.clone());
let bytes = self.bytes;
tokio::task::spawn_blocking(move || {
transform.transform_bytes(bytes, mime.as_ref())
})
};
let res = match task.await {
Ok(x) => x,
Err(error) => {
error!(message = "Error while transforming image", ?error);
return Rendered {
code: StatusCode::INTERNAL_SERVER_ERROR,
body: RenderedBody::String(format!(
"Error while transforming image: {error:?}"
)),
ttl: None,
immutable: true,
headers: HeaderMap::new(),
mime: None,
};
}
};
match res {
Ok((mime, bytes)) => {
return Rendered {
code: StatusCode::OK,
body: RenderedBody::Bytes(bytes),
ttl,
immutable: true,
headers: HeaderMap::new(),
mime: Some(mime),
};
}
Err(err) => {
return Rendered {
code: StatusCode::INTERNAL_SERVER_ERROR,
body: RenderedBody::String(format!("{err}")),
ttl,
immutable: true,
headers: HeaderMap::new(),
mime: None,
};
}
}
}
None => {
return Rendered {
code: StatusCode::OK,
body: RenderedBody::Static(self.bytes),
ttl,
immutable: true,
headers: HeaderMap::new(),
mime: Some(self.mime.clone()),
};
}
}
}) })
} }
} }

View File

@@ -1,11 +1,9 @@
use axum::http::{ use axum::http::{HeaderMap, StatusCode};
HeaderMap, HeaderValue, StatusCode,
header::{self},
};
use chrono::TimeDelta; use chrono::TimeDelta;
use maud::{Markup, Render, html}; use maud::{Markup, Render, html};
use serde::Deserialize; use serde::Deserialize;
use std::pin::Pin; use std::pin::Pin;
use toolbox::mime::MimeType;
use crate::{Rendered, RenderedBody, RequestContext, Servable}; use crate::{Rendered, RenderedBody, RequestContext, Servable};
@@ -116,20 +114,15 @@ impl Servable for Page {
ctx: &'a RequestContext, ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = crate::Rendered> + 'a + Send + Sync>> { ) -> Pin<Box<dyn Future<Output = crate::Rendered> + 'a + Send + Sync>> {
Box::pin(async { Box::pin(async {
let mut headers = HeaderMap::with_capacity(3);
let html = self.generate_html(ctx).await; let html = self.generate_html(ctx).await;
headers.append(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
return Rendered { return Rendered {
code: StatusCode::OK, code: StatusCode::OK,
headers,
body: RenderedBody::Markup(html), body: RenderedBody::Markup(html),
ttl: self.html_ttl, ttl: self.html_ttl,
immutable: self.immutable, immutable: self.immutable,
headers: HeaderMap::new(),
mime: Some(MimeType::Html),
}; };
}) })
} }

View File

@@ -34,6 +34,7 @@ impl Servable for Redirect {
body: RenderedBody::Empty, body: RenderedBody::Empty,
ttl: None, ttl: None,
immutable: true, immutable: true,
mime: None,
}; };
}) })
} }

View File

@@ -1,6 +1,6 @@
use axum::{ use axum::{
Router, Router,
extract::{ConnectInfo, Path, State}, extract::{ConnectInfo, Path, Query, State},
http::{HeaderMap, HeaderValue, StatusCode, header}, http::{HeaderMap, HeaderValue, StatusCode, header},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::get, routing::get,
@@ -10,7 +10,14 @@ use libservice::ServiceConnectInfo;
use lru::LruCache; use lru::LruCache;
use maud::Markup; use maud::Markup;
use parking_lot::Mutex; use parking_lot::Mutex;
use std::{collections::HashMap, num::NonZero, pin::Pin, sync::Arc, time::Instant}; use std::{
collections::{BTreeMap, HashMap},
num::NonZero,
pin::Pin,
sync::Arc,
time::Instant,
};
use toolbox::mime::MimeType;
use tower_http::compression::{CompressionLayer, DefaultPredicate}; use tower_http::compression::{CompressionLayer, DefaultPredicate};
use tracing::trace; use tracing::trace;
@@ -20,6 +27,8 @@ use crate::{ClientInfo, RequestContext};
pub enum RenderedBody { pub enum RenderedBody {
Markup(Markup), Markup(Markup),
Static(&'static [u8]), Static(&'static [u8]),
Bytes(Vec<u8>),
String(String),
Empty, Empty,
} }
@@ -28,7 +37,10 @@ pub struct Rendered {
pub code: StatusCode, pub code: StatusCode,
pub headers: HeaderMap, pub headers: HeaderMap,
pub body: RenderedBody, pub body: RenderedBody,
pub mime: Option<MimeType>,
/// How long to cache this response.
/// If none, don't cache.
pub ttl: Option<TimeDelta>, pub ttl: Option<TimeDelta>,
pub immutable: bool, pub immutable: bool,
} }
@@ -123,6 +135,7 @@ impl PageServer {
async fn handler( async fn handler(
Path(route): Path<String>, Path(route): Path<String>,
Query(query): Query<BTreeMap<String, String>>,
State(state): State<Arc<Self>>, State(state): State<Arc<Self>>,
ConnectInfo(addr): ConnectInfo<ServiceConnectInfo>, ConnectInfo(addr): ConnectInfo<ServiceConnectInfo>,
headers: HeaderMap, headers: HeaderMap,
@@ -177,10 +190,12 @@ impl PageServer {
let ctx = RequestContext { let ctx = RequestContext {
client_info, client_info,
route: format!("/{route}"), route: format!("/{route}"),
query,
}; };
let now = Utc::now(); let now = Utc::now();
let mut html_expires = None; let mut html_expires = None;
let mut cached = true;
// Get from cache, if available // Get from cache, if available
if let Some((html, expires)) = state.page_cache.lock().get(&ctx) if let Some((html, expires)) = state.page_cache.lock().get(&ctx)
@@ -190,6 +205,7 @@ impl PageServer {
}; };
if html_expires.is_none() { if html_expires.is_none() {
cached = false;
html_expires = match state.render_page("request", &route, ctx).await { html_expires = match state.render_page("request", &route, ctx).await {
Some(x) => Some(x.clone()), Some(x) => Some(x.clone()),
None => { None => {
@@ -207,6 +223,7 @@ impl PageServer {
addr = ?addr.addr, addr = ?addr.addr,
user_agent = ua, user_agent = ua,
device_type = ?client_info.device_type, device_type = ?client_info.device_type,
cached,
time_ns = start.elapsed().as_nanos() time_ns = start.elapsed().as_nanos()
); );
@@ -224,7 +241,6 @@ impl PageServer {
None => 1, None => 1,
}; };
#[expect(clippy::unwrap_used)]
let mut value = String::new(); let mut value = String::new();
if html.immutable { if html.immutable {
value.push_str("immutable, "); value.push_str("immutable, ");
@@ -233,9 +249,10 @@ impl PageServer {
value.push_str("public, "); value.push_str("public, ");
value.push_str(&format!("max-age={}, ", max_age)); value.push_str(&format!("max-age={}, ", max_age));
#[expect(clippy::unwrap_used)]
html.headers.insert( html.headers.insert(
header::CACHE_CONTROL, header::CACHE_CONTROL,
HeaderValue::from_str(&value.trim().trim_end_matches(',')).unwrap(), HeaderValue::from_str(value.trim().trim_end_matches(',')).unwrap(),
); );
} }
@@ -244,18 +261,29 @@ impl PageServer {
.insert("Accept-CH", HeaderValue::from_static("Sec-CH-UA-Mobile")); .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!( trace!(
message = "Served route", message = "Served route",
route, route,
addr = ?addr.addr, addr = ?addr.addr,
user_agent = ua, user_agent = ua,
device_type = ?client_info.device_type, device_type = ?client_info.device_type,
cached,
time_ns = start.elapsed().as_nanos() time_ns = start.elapsed().as_nanos()
); );
return match html.body { return match html.body {
RenderedBody::Markup(markup) => (html.code, html.headers, markup.0).into_response(), RenderedBody::Markup(markup) => (html.code, html.headers, markup.0).into_response(),
RenderedBody::Static(data) => (html.code, html.headers, data).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(), RenderedBody::Empty => (html.code, html.headers).into_response(),
}; };
} }
@@ -271,8 +299,8 @@ impl PageServer {
Router::new() Router::new()
.route( .route(
"/", "/",
get(|state, conn, headers| async { get(|state, query, conn, headers| async {
Self::handler(Path(String::new()), state, conn, headers).await Self::handler(Path(String::new()), query, state, conn, headers).await
}), }),
) )
.route("/{*path}", get(Self::handler)) .route("/{*path}", get(Self::handler))

View File

@@ -31,4 +31,4 @@ A snippet of the [_Endless Sky_][es] map is below.
<br/> <br/>
<img alt="betalupi map" src="/assets/img/betalupi.png"></img> <img class="img-placeholder" src="/assets/img/betalupi.png?t=maxdim(10,10)" data-large="/assets/img/betalupi.png" style="width:100%;height=10rem;"></img>

View File

@@ -27,8 +27,11 @@ pub fn index() -> Page {
h2 id="about" { "About" } h2 id="about" { "About" }
div { div {
img img
src="/assets/img/cover-small.jpg" class="img-placeholder"
src="/assets/img/cover-small.jpg?t=maxdim(10,10)"
data-large="/assets/img/cover-small.jpg"
style="float:left;margin:10px 10px 10px 10px;display:block;width:25%;" style="float:left;margin:10px 10px 10px 10px;display:block;width:25%;"
{} {}

View File

@@ -24,21 +24,21 @@ pub fn links() -> Page {
page_from_markdown( page_from_markdown(
include_str!("links.md"), include_str!("links.md"),
Some("/assets/img/icon.png".to_string()), Some("/assets/img/icon.png".to_owned()),
) )
} }
pub fn betalupi() -> Page { pub fn betalupi() -> Page {
page_from_markdown( page_from_markdown(
include_str!("betalupi.md"), include_str!("betalupi.md"),
Some("/assets/img/icon.png".to_string()), Some("/assets/img/icon.png".to_owned()),
) )
} }
pub fn htwah_typesetting() -> Page { pub fn htwah_typesetting() -> Page {
page_from_markdown( page_from_markdown(
include_str!("htwah-typesetting.md"), include_str!("htwah-typesetting.md"),
Some("/assets/img/icon.png".to_string()), Some("/assets/img/icon.png".to_owned()),
) )
} }
@@ -99,7 +99,7 @@ pub fn page_wrapper<'a>(
(DOCTYPE) (DOCTYPE)
html { html {
head { head {
meta charset="UTF" {} meta charset="UTF8" {}
meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" {} 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 content="text/html; charset=UTF-8" http-equiv="content-type" {}
meta property="og:type" content="website" {} meta property="og:type" content="website" {}
@@ -108,6 +108,28 @@ pub fn page_wrapper<'a>(
(&meta) (&meta)
title { (PreEscaped(meta.title.clone())) } title { (PreEscaped(meta.title.clone())) }
script {
(PreEscaped("
window.onload = function() {
var imgs = document.querySelectorAll('.img-placeholder');
imgs.forEach(img => {
img.style.border = 'none';
img.style.filter = 'blur(10px)';
img.style.transition = 'filter 0.3s';
var lg = new Image();
lg.src = img.dataset.large;
lg.onload = function () {
img.src = img.dataset.large;
img.style.filter = 'blur(0px)';
};
})
}
"))
}
} }
body { body {

View File

@@ -33,21 +33,21 @@ fn build_server() -> Arc<PageServer> {
"/assets/img/cover-small.jpg", "/assets/img/cover-small.jpg",
StaticAsset { StaticAsset {
bytes: include_bytes!("../../assets/images/cover-small.jpg"), bytes: include_bytes!("../../assets/images/cover-small.jpg"),
mime: MimeType::Css, mime: MimeType::Jpg,
}, },
) )
.add_page( .add_page(
"/assets/img/betalupi.png", "/assets/img/betalupi.png",
StaticAsset { StaticAsset {
bytes: include_bytes!("../../assets/images/betalupi-map.png"), bytes: include_bytes!("../../assets/images/betalupi-map.png"),
mime: MimeType::Css, mime: MimeType::Png,
}, },
) )
.add_page( .add_page(
"/assets/img/icon.png", "/assets/img/icon.png",
StaticAsset { StaticAsset {
bytes: include_bytes!("../../assets/images/icon.png"), bytes: include_bytes!("../../assets/images/icon.png"),
mime: MimeType::Css, mime: MimeType::Png,
}, },
) )
// //