fabrica

hanna/fabrica

8004 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Embedded assets, disk overrides, and the theme registry.
6//!
7//! `base.css`, `fabrica.js`, `htmx.min.js`, and the embedded themes are compiled
8//! into the binary with `rust-embed`. Any of them can be overridden by dropping a
9//! file of the same path in `ui.assets_dir`; themes additionally come from
10//! `ui.themes_dir`. Disk always wins over embedded, and path traversal is rejected.
11
12use std::path::{Path, PathBuf};
13
14use rust_embed::RustEmbed;
15
16/// The compiled-in assets, rooted at the repository `assets/` directory
17/// (relative to this crate's manifest).
18#[derive(RustEmbed)]
19#[folder = "../../assets"]
20struct Embedded;
21
22/// Whether a theme is light or dark, for the `color-scheme` hint and the picker.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Scheme {
25 /// A dark theme.
26 Dark,
27 /// A light theme.
28 Light,
29}
30
31impl Scheme {
32 /// The CSS `color-scheme` keyword.
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/// A theme available for selection.
43#[derive(Debug, Clone)]
44pub struct ThemeInfo {
45 /// The theme name (its file stem).
46 pub name: String,
47 /// Light or dark.
48 pub scheme: Scheme,
49}
50
51/// Resolves assets and themes from disk (override) then the embedded set.
52#[derive(Clone)]
53#[allow(clippy::struct_field_names)] // `assets_dir` mirrors the `ui.assets_dir` config key.
54pub struct Assets {
55 assets_dir: PathBuf,
56 themes_dir: PathBuf,
57 themes: Vec<ThemeInfo>,
58}
59
60impl Assets {
61 /// Build the registry, scanning `themes_dir` and the embedded themes once.
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 /// Re-scan the themes directory (called on `SIGHUP`).
74 pub fn rescan(&mut self) {
75 let mut names: std::collections::BTreeMap<String, Scheme> =
76 std::collections::BTreeMap::new();
77
78 // Embedded themes first (lower priority).
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 // Disk themes override by name.
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 /// The selectable themes, sorted by name.
105 #[must_use]
106 pub fn themes(&self) -> &[ThemeInfo] {
107 &self.themes
108 }
109
110 /// Look up a theme by name, returning its info.
111 #[must_use]
112 pub fn theme(&self, name: &str) -> Option<&ThemeInfo> {
113 self.themes.iter().find(|t| t.name == name)
114 }
115
116 /// The CSS bytes for a theme, disk overriding embedded.
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 /// Resolve a general asset by its request path (e.g. `base.css`,
130 /// `htmx.min.js`, `logo.svg`), disk overriding embedded. Returns the bytes and
131 /// a content type. Path traversal is rejected.
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/// The theme name for an embedded path like `themes/dark.css`, or `None`.
150fn 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/// Read the `--fb-color-scheme` declaration from theme CSS, defaulting to dark.
157fn 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/// Whether a single path component is safe (no separators, no `..`, non-empty).
169fn is_safe_component(name: &str) -> bool {
170 !name.is_empty() && !name.contains('/') && !name.contains('\\') && name != ".." && name != "."
171}
172
173/// Whether a relative asset path is safe (no traversal, no absolute, no `..`).
174fn 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/// A content type for a file, by extension.
189fn 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)]
203mod 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}