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;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RequestContext {
pub client_info: ClientInfo,
pub route: String,
pub query: BTreeMap<String, String>,
}
//

View File

@@ -1,9 +1,9 @@
use axum::http::{
HeaderMap, HeaderValue, StatusCode,
header::{self},
};
use std::pin::Pin;
use axum::http::{HeaderMap, StatusCode};
use chrono::TimeDelta;
use pixel_transform::TransformerChain;
use std::{pin::Pin, str::FromStr};
use toolbox::mime::MimeType;
use tracing::{error, trace};
use crate::{Rendered, RenderedBody, RequestContext, Servable};
@@ -15,30 +15,102 @@ pub struct StaticAsset {
impl Servable for StaticAsset {
fn render<'a>(
&'a self,
_ctx: &'a RequestContext,
ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = crate::Rendered> + 'a + Send + Sync>> {
Box::pin(async {
let mut headers = HeaderMap::with_capacity(3);
let ttl = Some(TimeDelta::days(30));
#[expect(clippy::unwrap_used)]
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_str(&self.mime.to_string()).unwrap(),
);
// Automatically provide transformation if this is an image
let is_image = TransformerChain::mime_is_image(&self.mime);
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_str(&format!("immutable, public, max-age={}", 60 * 60 * 24 * 30))
.unwrap(),
);
let transform = match (is_image, ctx.query.get("t")) {
(false, _) | (_, None) => None,
return Rendered {
code: StatusCode::OK,
headers,
body: RenderedBody::Static(self.bytes),
ttl: None,
immutable: true,
(true, Some(x)) => match TransformerChain::from_str(x) {
Ok(x) => Some(x),
Err(err) => {
return Rendered {
code: StatusCode::BAD_REQUEST,
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::{
HeaderMap, HeaderValue, StatusCode,
header::{self},
};
use axum::http::{HeaderMap, StatusCode};
use chrono::TimeDelta;
use maud::{Markup, Render, html};
use serde::Deserialize;
use std::pin::Pin;
use toolbox::mime::MimeType;
use crate::{Rendered, RenderedBody, RequestContext, Servable};
@@ -116,20 +114,15 @@ impl Servable for Page {
ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = crate::Rendered> + 'a + Send + Sync>> {
Box::pin(async {
let mut headers = HeaderMap::with_capacity(3);
let html = self.generate_html(ctx).await;
headers.append(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
return Rendered {
code: StatusCode::OK,
headers,
body: RenderedBody::Markup(html),
ttl: self.html_ttl,
immutable: self.immutable,
headers: HeaderMap::new(),
mime: Some(MimeType::Html),
};
})
}

View File

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

View File

@@ -1,6 +1,6 @@
use axum::{
Router,
extract::{ConnectInfo, Path, State},
extract::{ConnectInfo, Path, Query, State},
http::{HeaderMap, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
routing::get,
@@ -10,7 +10,14 @@ use libservice::ServiceConnectInfo;
use lru::LruCache;
use maud::Markup;
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 tracing::trace;
@@ -20,6 +27,8 @@ use crate::{ClientInfo, RequestContext};
pub enum RenderedBody {
Markup(Markup),
Static(&'static [u8]),
Bytes(Vec<u8>),
String(String),
Empty,
}
@@ -28,7 +37,10 @@ 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,
}
@@ -123,6 +135,7 @@ impl PageServer {
async fn handler(
Path(route): Path<String>,
Query(query): Query<BTreeMap<String, String>>,
State(state): State<Arc<Self>>,
ConnectInfo(addr): ConnectInfo<ServiceConnectInfo>,
headers: HeaderMap,
@@ -177,10 +190,12 @@ impl PageServer {
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)
@@ -190,6 +205,7 @@ impl PageServer {
};
if html_expires.is_none() {
cached = false;
html_expires = match state.render_page("request", &route, ctx).await {
Some(x) => Some(x.clone()),
None => {
@@ -207,6 +223,7 @@ impl PageServer {
addr = ?addr.addr,
user_agent = ua,
device_type = ?client_info.device_type,
cached,
time_ns = start.elapsed().as_nanos()
);
@@ -224,7 +241,6 @@ impl PageServer {
None => 1,
};
#[expect(clippy::unwrap_used)]
let mut value = String::new();
if html.immutable {
value.push_str("immutable, ");
@@ -233,9 +249,10 @@ impl PageServer {
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(),
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"));
}
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(),
};
}
@@ -271,8 +299,8 @@ impl PageServer {
Router::new()
.route(
"/",
get(|state, conn, headers| async {
Self::handler(Path(String::new()), state, conn, headers).await
get(|state, query, conn, headers| async {
Self::handler(Path(String::new()), query, state, conn, headers).await
}),
)
.route("/{*path}", get(Self::handler))

View File

@@ -31,4 +31,4 @@ A snippet of the [_Endless Sky_][es] map is below.
<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" }
div {
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%;"
{}

View File

@@ -24,21 +24,21 @@ pub fn links() -> Page {
page_from_markdown(
include_str!("links.md"),
Some("/assets/img/icon.png".to_string()),
Some("/assets/img/icon.png".to_owned()),
)
}
pub fn betalupi() -> Page {
page_from_markdown(
include_str!("betalupi.md"),
Some("/assets/img/icon.png".to_string()),
Some("/assets/img/icon.png".to_owned()),
)
}
pub fn htwah_typesetting() -> Page {
page_from_markdown(
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)
html {
head {
meta charset="UTF" {}
meta charset="UTF8" {}
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" {}
@@ -108,6 +108,28 @@ pub fn page_wrapper<'a>(
(&meta)
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 {

View File

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