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
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:
@@ -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>,
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -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()),
|
||||
};
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ impl Servable for Redirect {
|
||||
body: RenderedBody::Empty,
|
||||
ttl: None,
|
||||
immutable: true,
|
||||
mime: None,
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;"
|
||||
{}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user