use rustlavel::prelude::*;
use crate::support::settings::Settings;
pub struct ThemeController;
impl ThemeController {
pub async fn stylesheet(req: Request) -> Result<Response> {
let Some(settings) = req.state::<Settings>() else {
return Ok(css_response(&req, String::new()));
};
let mut css = String::from(
"/* Generated from Settings → Appearance. Edit it there, not here. */\n:root {\n",
);
css.push_str(&crate::support::palette::brand_ramp(&settings.get("theme.brand").await));
for (variable, key) in [
("--login-from", "theme.login.light.from"),
("--login-to", "theme.login.light.to"),
("--sidebar-bg", "theme.sidebar.light.bg"),
("--sidebar-text", "theme.sidebar.light.text"),
("--sidebar-active-bg", "theme.sidebar.light.active_bg"),
("--sidebar-active-text", "theme.sidebar.light.active_text"),
] {
css.push_str(&format!(" {variable}: {};\n", colour(&settings.get(key).await)));
}
css.push_str("}\n\n");
let dark = {
let mut block = String::new();
for (variable, key) in [
("--login-from", "theme.login.dark.from"),
("--login-to", "theme.login.dark.to"),
("--sidebar-bg", "theme.sidebar.dark.bg"),
("--sidebar-text", "theme.sidebar.dark.text"),
("--sidebar-active-bg", "theme.sidebar.dark.active_bg"),
("--sidebar-active-text", "theme.sidebar.dark.active_text"),
] {
block.push_str(&format!(" {variable}: {};\n", colour(&settings.get(key).await)));
}
block
};
css.push_str(&format!(
"@media (prefers-color-scheme: dark) {{\n :root:not([data-theme=\"light\"]) {{\n{dark} }}\n}}\n\n\
:root.dark {{\n{dark}}}\n"
));
Ok(css_response(&req, css))
}
}
fn css_response(req: &Request, css: String) -> Response {
let etag = format!("W/\"{:016x}\"", fingerprint(css.as_bytes()));
if req.header("if-none-match").is_some_and(|sent| sent.split(',').any(|one| one.trim() == etag)) {
return Response::new(Status::NOT_MODIFIED)
.with_header("cache-control", "no-cache")
.with_header("etag", etag);
}
Response::ok()
.with_body(css.into_bytes())
.with_header("content-type", "text/css; charset=utf-8")
.with_header("cache-control", "no-cache")
.with_header("etag", etag)
}
fn fingerprint(bytes: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
pub fn colour(value: &str) -> String {
let candidate = value.trim();
let body = candidate.strip_prefix('#').unwrap_or("");
let valid = matches!(body.len(), 3 | 6) && body.chars().all(|c| c.is_ascii_hexdigit());
if valid { format!("#{body}") } else { "#000000".to_string() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_stylesheet_is_served_as_css_not_as_plain_text() {
let response = css_response(&Request::new(Method::Get, "/css/theme.css"), "body{}".to_string());
assert_eq!(response.headers.get("content-type"), Some("text/css; charset=utf-8"));
assert_eq!(response.body_string(), "body{}");
}
#[test]
fn the_stylesheet_is_revalidated_rather_than_held() {
let request = Request::new(Method::Get, "/css/theme.css");
let response = css_response(&request, "body{}".to_string());
assert_eq!(response.headers.get("cache-control"), Some("no-cache"));
let etag = response.headers.get("etag").expect("no etag").to_string();
let conditional =
Request::new(Method::Get, "/css/theme.css").with_header("if-none-match", etag.clone());
let again = css_response(&conditional, "body{}".to_string());
assert_eq!(again.status, Status::NOT_MODIFIED);
assert!(again.body_string().is_empty(), "a 304 carries no body");
let other = css_response(&conditional, "body{color:red}".to_string());
assert_ne!(other.status, Status::NOT_MODIFIED, "changed CSS must not answer 304");
}
use super::colour;
#[test]
fn only_a_hex_colour_reaches_the_stylesheet() {
assert_eq!(colour("#3b82f6"), "#3b82f6");
assert_eq!(colour(" #FFF "), "#FFF");
for hostile in [
"red; } body { display: none",
"#fff; background: url(https://evil.example/x)",
"url(javascript:alert(1))",
"",
"#12345",
"#gggggg",
] {
assert_eq!(colour(hostile), "#000000", "{hostile} should not have been accepted");
}
}
}