// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Embedded assets, disk overrides, and the theme registry. //! //! `base.css`, `fabrica.js`, `htmx.min.js`, and the embedded themes are compiled //! into the binary with `rust-embed`. Any of them can be overridden by dropping a //! file of the same path in `ui.assets_dir`; themes additionally come from //! `ui.themes_dir`. Disk always wins over embedded, and path traversal is rejected. use std::path::{Path, PathBuf}; use rust_embed::RustEmbed; /// The compiled-in assets, rooted at the repository `assets/` directory /// (relative to this crate's manifest). #[derive(RustEmbed)] #[folder = "../../assets"] struct Embedded; /// Whether a theme is light or dark, for the `color-scheme` hint and the picker. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Scheme { /// A dark theme. Dark, /// A light theme. Light, } impl Scheme { /// The CSS `color-scheme` keyword. #[must_use] pub fn as_str(self) -> &'static str { match self { Self::Dark => "dark", Self::Light => "light", } } } /// A theme available for selection. #[derive(Debug, Clone)] pub struct ThemeInfo { /// The theme name (its file stem). pub name: String, /// Light or dark. pub scheme: Scheme, } /// Resolves assets and themes from disk (override) then the embedded set. #[derive(Clone)] #[allow(clippy::struct_field_names)] // `assets_dir` mirrors the `ui.assets_dir` config key. pub struct Assets { assets_dir: PathBuf, themes_dir: PathBuf, themes: Vec, } impl Assets { /// Build the registry, scanning `themes_dir` and the embedded themes once. #[must_use] pub fn new(assets_dir: PathBuf, themes_dir: PathBuf) -> Self { let mut this = Self { assets_dir, themes_dir, themes: Vec::new(), }; this.rescan(); this } /// Re-scan the themes directory (called on `SIGHUP`). pub fn rescan(&mut self) { let mut names: std::collections::BTreeMap = std::collections::BTreeMap::new(); // Embedded themes first (lower priority). for path in Embedded::iter() { if let Some(name) = theme_name(&path) { let scheme = Embedded::get(&path).map_or(Scheme::Dark, |f| scheme_of(&f.data)); names.insert(name, scheme); } } // Disk themes override by name. if let Ok(entries) = std::fs::read_dir(&self.themes_dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) == Some("css") && let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { let scheme = std::fs::read(&path).map_or(Scheme::Dark, |d| scheme_of(&d)); names.insert(stem.to_string(), scheme); } } } self.themes = names .into_iter() .map(|(name, scheme)| ThemeInfo { name, scheme }) .collect(); } /// The selectable themes, sorted by name. #[must_use] pub fn themes(&self) -> &[ThemeInfo] { &self.themes } /// Look up a theme by name, returning its info. #[must_use] pub fn theme(&self, name: &str) -> Option<&ThemeInfo> { self.themes.iter().find(|t| t.name == name) } /// The CSS bytes for a theme, disk overriding embedded. #[must_use] pub fn theme_css(&self, name: &str) -> Option> { if !is_safe_component(name) { return None; } let disk = self.themes_dir.join(format!("{name}.css")); if let Ok(bytes) = std::fs::read(&disk) { return Some(bytes); } Embedded::get(&format!("themes/{name}.css")).map(|f| f.data.into_owned()) } /// Resolve a general asset by its request path (e.g. `base.css`, /// `htmx.min.js`, `logo.svg`), disk overriding embedded. Returns the bytes and /// a content type. Path traversal is rejected. #[must_use] pub fn asset(&self, rel: &str) -> Option<(Vec, &'static str)> { let rel = rel.trim_start_matches('/'); if !is_safe_path(rel) { return None; } let content_type = content_type_of(rel); let disk = self.assets_dir.join(rel); if disk.starts_with(&self.assets_dir) && let Ok(bytes) = std::fs::read(&disk) { return Some((bytes, content_type)); } Embedded::get(rel).map(|f| (f.data.into_owned(), content_type)) } } /// The theme name for an embedded path like `themes/dark.css`, or `None`. fn theme_name(path: &str) -> Option { let rest = path.strip_prefix("themes/")?; let stem = rest.strip_suffix(".css")?; (!stem.is_empty() && !stem.contains('/')).then(|| stem.to_string()) } /// Read the `--fb-color-scheme` declaration from theme CSS, defaulting to dark. fn scheme_of(css: &[u8]) -> Scheme { let text = String::from_utf8_lossy(css); if let Some(idx) = text.find("--fb-color-scheme:") { let after = text[idx + "--fb-color-scheme:".len()..].trim_start(); if after.starts_with("light") { return Scheme::Light; } } Scheme::Dark } /// Whether a single path component is safe (no separators, no `..`, non-empty). fn is_safe_component(name: &str) -> bool { !name.is_empty() && !name.contains('/') && !name.contains('\\') && name != ".." && name != "." } /// Whether a relative asset path is safe (no traversal, no absolute, no `..`). fn is_safe_path(rel: &str) -> bool { if rel.is_empty() { return false; } !Path::new(rel).components().any(|c| { matches!( c, std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_) ) }) } /// A content type for a file, by extension. fn content_type_of(path: &str) -> &'static str { match path.rsplit('.').next() { Some("css") => "text/css; charset=utf-8", Some("js") => "text/javascript; charset=utf-8", Some("svg") => "image/svg+xml", Some("ico") => "image/x-icon", Some("png") => "image/png", Some("woff2") => "font/woff2", Some("json") => "application/json", _ => "application/octet-stream", } } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] use super::*; fn assets() -> Assets { Assets::new( PathBuf::from("/nonexistent-assets"), PathBuf::from("/nonexistent-themes"), ) } #[test] fn embedded_themes_are_registered_with_schemes() { let a = assets(); let names: Vec<&str> = a.themes().iter().map(|t| t.name.as_str()).collect(); assert!(names.contains(&"dark"), "themes: {names:?}"); assert!(names.contains(&"light")); assert_eq!(a.theme("dark").unwrap().scheme, Scheme::Dark); assert_eq!(a.theme("light").unwrap().scheme, Scheme::Light); } #[test] fn embedded_assets_resolve_with_content_types() { let a = assets(); let (css, ct) = a.asset("base.css").unwrap(); assert!(!css.is_empty()); assert_eq!(ct, "text/css; charset=utf-8"); let (js, ct) = a.asset("htmx.min.js").unwrap(); assert!(!js.is_empty()); assert_eq!(ct, "text/javascript; charset=utf-8"); assert!(a.theme_css("dark").is_some()); } #[test] fn path_traversal_is_rejected() { let a = assets(); assert!(a.asset("../Cargo.toml").is_none()); assert!(a.asset("/etc/passwd").is_none()); assert!(a.theme_css("../secret").is_none()); } }