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;
|
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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
);
|
|
||||||
|
|
||||||
|
(true, Some(x)) => match TransformerChain::from_str(x) {
|
||||||
|
Ok(x) => Some(x),
|
||||||
|
Err(err) => {
|
||||||
return Rendered {
|
return Rendered {
|
||||||
code: StatusCode::OK,
|
code: StatusCode::BAD_REQUEST,
|
||||||
headers,
|
body: RenderedBody::String(err),
|
||||||
body: RenderedBody::Static(self.bytes),
|
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,
|
ttl: None,
|
||||||
immutable: true,
|
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::{
|
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),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ impl Servable for Redirect {
|
|||||||
body: RenderedBody::Empty,
|
body: RenderedBody::Empty,
|
||||||
ttl: None,
|
ttl: None,
|
||||||
immutable: true,
|
immutable: true,
|
||||||
|
mime: None,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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%;"
|
||||||
{}
|
{}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user