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(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(css))
}
}
fn css_response(css: String) -> Response {
Response::ok()
.with_body(css.into_bytes())
.with_header("content-type", "text/css; charset=utf-8")
.with_header("cache-control", "public, max-age=60")
}
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("body{}".to_string());
assert_eq!(response.headers.get("content-type"), Some("text/css; charset=utf-8"));
assert_eq!(response.body_string(), "body{}");
}
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");
}
}
}