From c13618e958c9e6a3507320c2e29bfe86ad064e4a Mon Sep 17 00:00:00 2001 From: rm-dr <96270320+rm-dr@users.noreply.github.com> Date: Sat, 8 Nov 2025 13:11:25 -0800 Subject: [PATCH] Transform images + placeholders --- crates/lib/page/src/requestcontext.rs | 3 + crates/lib/page/src/servable/asset.rs | 118 ++++++++++++++---- crates/lib/page/src/servable/page.rs | 15 +-- crates/lib/page/src/servable/redirect.rs | 1 + crates/lib/page/src/server.rs | 40 +++++- .../service-webpage/src/pages/betalupi.md | 2 +- .../service-webpage/src/pages/index.rs | 5 +- .../service/service-webpage/src/pages/mod.rs | 30 ++++- .../service/service-webpage/src/routes/mod.rs | 6 +- 9 files changed, 171 insertions(+), 49 deletions(-) diff --git a/crates/lib/page/src/requestcontext.rs b/crates/lib/page/src/requestcontext.rs index 565a848..0806fb2 100644 --- a/crates/lib/page/src/requestcontext.rs +++ b/crates/lib/page/src/requestcontext.rs @@ -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, } // diff --git a/crates/lib/page/src/servable/asset.rs b/crates/lib/page/src/servable/asset.rs index 2799809..b7a591a 100644 --- a/crates/lib/page/src/servable/asset.rs +++ b/crates/lib/page/src/servable/asset.rs @@ -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 + '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()), + }; + } + } }) } } diff --git a/crates/lib/page/src/servable/page.rs b/crates/lib/page/src/servable/page.rs index f7888ca..f4be1d2 100644 --- a/crates/lib/page/src/servable/page.rs +++ b/crates/lib/page/src/servable/page.rs @@ -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 + '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), }; }) } diff --git a/crates/lib/page/src/servable/redirect.rs b/crates/lib/page/src/servable/redirect.rs index b687596..64e3479 100644 --- a/crates/lib/page/src/servable/redirect.rs +++ b/crates/lib/page/src/servable/redirect.rs @@ -34,6 +34,7 @@ impl Servable for Redirect { body: RenderedBody::Empty, ttl: None, immutable: true, + mime: None, }; }) } diff --git a/crates/lib/page/src/server.rs b/crates/lib/page/src/server.rs index 3fd661f..47c48c8 100644 --- a/crates/lib/page/src/server.rs +++ b/crates/lib/page/src/server.rs @@ -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), + String(String), Empty, } @@ -28,7 +37,10 @@ pub struct Rendered { pub code: StatusCode, pub headers: HeaderMap, pub body: RenderedBody, + pub mime: Option, + /// How long to cache this response. + /// If none, don't cache. pub ttl: Option, pub immutable: bool, } @@ -123,6 +135,7 @@ impl PageServer { async fn handler( Path(route): Path, + Query(query): Query>, State(state): State>, ConnectInfo(addr): ConnectInfo, 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)) diff --git a/crates/service/service-webpage/src/pages/betalupi.md b/crates/service/service-webpage/src/pages/betalupi.md index 88e6ef3..364e18b 100644 --- a/crates/service/service-webpage/src/pages/betalupi.md +++ b/crates/service/service-webpage/src/pages/betalupi.md @@ -31,4 +31,4 @@ A snippet of the [_Endless Sky_][es] map is below.
-betalupi map + diff --git a/crates/service/service-webpage/src/pages/index.rs b/crates/service/service-webpage/src/pages/index.rs index 6da22a7..6f77601 100644 --- a/crates/service/service-webpage/src/pages/index.rs +++ b/crates/service/service-webpage/src/pages/index.rs @@ -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%;" {} diff --git a/crates/service/service-webpage/src/pages/mod.rs b/crates/service/service-webpage/src/pages/mod.rs index 492a96b..a526fc8 100644 --- a/crates/service/service-webpage/src/pages/mod.rs +++ b/crates/service/service-webpage/src/pages/mod.rs @@ -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 { diff --git a/crates/service/service-webpage/src/routes/mod.rs b/crates/service/service-webpage/src/routes/mod.rs index 78d254c..26ba875 100644 --- a/crates/service/service-webpage/src/routes/mod.rs +++ b/crates/service/service-webpage/src/routes/mod.rs @@ -33,21 +33,21 @@ fn build_server() -> Arc { "/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, }, ) //