| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | use std::path::{Path, PathBuf}; |
| 13 | |
| 14 | use rust_embed::RustEmbed; |
| 15 | |
| 16 | |
| 17 | |
| 18 | #[derive(RustEmbed)] |
| 19 | #[folder = "../../assets"] |
| 20 | struct Embedded; |
| 21 | |
| 22 | |
| 23 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 24 | pub enum Scheme { |
| 25 | |
| 26 | Dark, |
| 27 | |
| 28 | Light, |
| 29 | } |
| 30 | |
| 31 | impl Scheme { |
| 32 | |
| 33 | #[must_use] |
| 34 | pub fn as_str(self) -> &'static str { |
| 35 | match self { |
| 36 | Self::Dark => "dark", |
| 37 | Self::Light => "light", |
| 38 | } |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | |
| 43 | #[derive(Debug, Clone)] |
| 44 | pub struct ThemeInfo { |
| 45 | |
| 46 | pub name: String, |
| 47 | |
| 48 | pub scheme: Scheme, |
| 49 | } |
| 50 | |
| 51 | |
| 52 | #[derive(Clone)] |
| 53 | #[allow(clippy::struct_field_names)] |
| 54 | pub struct Assets { |
| 55 | assets_dir: PathBuf, |
| 56 | themes_dir: PathBuf, |
| 57 | themes: Vec<ThemeInfo>, |
| 58 | } |
| 59 | |
| 60 | impl Assets { |
| 61 | |
| 62 | #[must_use] |
| 63 | pub fn new(assets_dir: PathBuf, themes_dir: PathBuf) -> Self { |
| 64 | let mut this = Self { |
| 65 | assets_dir, |
| 66 | themes_dir, |
| 67 | themes: Vec::new(), |
| 68 | }; |
| 69 | this.rescan(); |
| 70 | this |
| 71 | } |
| 72 | |
| 73 | |
| 74 | pub fn rescan(&mut self) { |
| 75 | let mut names: std::collections::BTreeMap<String, Scheme> = |
| 76 | std::collections::BTreeMap::new(); |
| 77 | |
| 78 | |
| 79 | for path in Embedded::iter() { |
| 80 | if let Some(name) = theme_name(&path) { |
| 81 | let scheme = Embedded::get(&path).map_or(Scheme::Dark, |f| scheme_of(&f.data)); |
| 82 | names.insert(name, scheme); |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | if let Ok(entries) = std::fs::read_dir(&self.themes_dir) { |
| 87 | for entry in entries.flatten() { |
| 88 | let path = entry.path(); |
| 89 | if path.extension().and_then(|e| e.to_str()) == Some("css") |
| 90 | && let Some(stem) = path.file_stem().and_then(|s| s.to_str()) |
| 91 | { |
| 92 | let scheme = std::fs::read(&path).map_or(Scheme::Dark, |d| scheme_of(&d)); |
| 93 | names.insert(stem.to_string(), scheme); |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | self.themes = names |
| 99 | .into_iter() |
| 100 | .map(|(name, scheme)| ThemeInfo { name, scheme }) |
| 101 | .collect(); |
| 102 | } |
| 103 | |
| 104 | |
| 105 | #[must_use] |
| 106 | pub fn themes(&self) -> &[ThemeInfo] { |
| 107 | &self.themes |
| 108 | } |
| 109 | |
| 110 | |
| 111 | #[must_use] |
| 112 | pub fn theme(&self, name: &str) -> Option<&ThemeInfo> { |
| 113 | self.themes.iter().find(|t| t.name == name) |
| 114 | } |
| 115 | |
| 116 | |
| 117 | #[must_use] |
| 118 | pub fn theme_css(&self, name: &str) -> Option<Vec<u8>> { |
| 119 | if !is_safe_component(name) { |
| 120 | return None; |
| 121 | } |
| 122 | let disk = self.themes_dir.join(format!("{name}.css")); |
| 123 | if let Ok(bytes) = std::fs::read(&disk) { |
| 124 | return Some(bytes); |
| 125 | } |
| 126 | Embedded::get(&format!("themes/{name}.css")).map(|f| f.data.into_owned()) |
| 127 | } |
| 128 | |
| 129 | |
| 130 | |
| 131 | |
| 132 | #[must_use] |
| 133 | pub fn asset(&self, rel: &str) -> Option<(Vec<u8>, &'static str)> { |
| 134 | let rel = rel.trim_start_matches('/'); |
| 135 | if !is_safe_path(rel) { |
| 136 | return None; |
| 137 | } |
| 138 | let content_type = content_type_of(rel); |
| 139 | let disk = self.assets_dir.join(rel); |
| 140 | if disk.starts_with(&self.assets_dir) |
| 141 | && let Ok(bytes) = std::fs::read(&disk) |
| 142 | { |
| 143 | return Some((bytes, content_type)); |
| 144 | } |
| 145 | Embedded::get(rel).map(|f| (f.data.into_owned(), content_type)) |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | |
| 150 | fn theme_name(path: &str) -> Option<String> { |
| 151 | let rest = path.strip_prefix("themes/")?; |
| 152 | let stem = rest.strip_suffix(".css")?; |
| 153 | (!stem.is_empty() && !stem.contains('/')).then(|| stem.to_string()) |
| 154 | } |
| 155 | |
| 156 | |
| 157 | fn scheme_of(css: &[u8]) -> Scheme { |
| 158 | let text = String::from_utf8_lossy(css); |
| 159 | if let Some(idx) = text.find("--fb-color-scheme:") { |
| 160 | let after = text[idx + "--fb-color-scheme:".len()..].trim_start(); |
| 161 | if after.starts_with("light") { |
| 162 | return Scheme::Light; |
| 163 | } |
| 164 | } |
| 165 | Scheme::Dark |
| 166 | } |
| 167 | |
| 168 | |
| 169 | fn is_safe_component(name: &str) -> bool { |
| 170 | !name.is_empty() && !name.contains('/') && !name.contains('\\') && name != ".." && name != "." |
| 171 | } |
| 172 | |
| 173 | |
| 174 | fn is_safe_path(rel: &str) -> bool { |
| 175 | if rel.is_empty() { |
| 176 | return false; |
| 177 | } |
| 178 | !Path::new(rel).components().any(|c| { |
| 179 | matches!( |
| 180 | c, |
| 181 | std::path::Component::ParentDir |
| 182 | | std::path::Component::RootDir |
| 183 | | std::path::Component::Prefix(_) |
| 184 | ) |
| 185 | }) |
| 186 | } |
| 187 | |
| 188 | |
| 189 | fn content_type_of(path: &str) -> &'static str { |
| 190 | match path.rsplit('.').next() { |
| 191 | Some("css") => "text/css; charset=utf-8", |
| 192 | Some("js") => "text/javascript; charset=utf-8", |
| 193 | Some("svg") => "image/svg+xml", |
| 194 | Some("ico") => "image/x-icon", |
| 195 | Some("png") => "image/png", |
| 196 | Some("woff2") => "font/woff2", |
| 197 | Some("json") => "application/json", |
| 198 | _ => "application/octet-stream", |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | #[cfg(test)] |
| 203 | mod tests { |
| 204 | #![allow(clippy::unwrap_used)] |
| 205 | |
| 206 | use super::*; |
| 207 | |
| 208 | fn assets() -> Assets { |
| 209 | Assets::new( |
| 210 | PathBuf::from("/nonexistent-assets"), |
| 211 | PathBuf::from("/nonexistent-themes"), |
| 212 | ) |
| 213 | } |
| 214 | |
| 215 | #[test] |
| 216 | fn embedded_themes_are_registered_with_schemes() { |
| 217 | let a = assets(); |
| 218 | let names: Vec<&str> = a.themes().iter().map(|t| t.name.as_str()).collect(); |
| 219 | assert!(names.contains(&"dark"), "themes: {names:?}"); |
| 220 | assert!(names.contains(&"light")); |
| 221 | assert_eq!(a.theme("dark").unwrap().scheme, Scheme::Dark); |
| 222 | assert_eq!(a.theme("light").unwrap().scheme, Scheme::Light); |
| 223 | } |
| 224 | |
| 225 | #[test] |
| 226 | fn embedded_assets_resolve_with_content_types() { |
| 227 | let a = assets(); |
| 228 | let (css, ct) = a.asset("base.css").unwrap(); |
| 229 | assert!(!css.is_empty()); |
| 230 | assert_eq!(ct, "text/css; charset=utf-8"); |
| 231 | let (js, ct) = a.asset("htmx.min.js").unwrap(); |
| 232 | assert!(!js.is_empty()); |
| 233 | assert_eq!(ct, "text/javascript; charset=utf-8"); |
| 234 | assert!(a.theme_css("dark").is_some()); |
| 235 | } |
| 236 | |
| 237 | #[test] |
| 238 | fn path_traversal_is_rejected() { |
| 239 | let a = assets(); |
| 240 | assert!(a.asset("../Cargo.toml").is_none()); |
| 241 | assert!(a.asset("/etc/passwd").is_none()); |
| 242 | assert!(a.theme_css("../secret").is_none()); |
| 243 | } |
| 244 | } |