Krolyxon
Commit ce0f374

ferreniumce0f374

Add site customization: favicon, logo, accent and assets_dir

- site.favicon / site.logo: .ico, .png, .svg, .webp, .gif or .jpg up to
  1 MiB, read on load/reload and served from memory at /-/favicon and
  /-/logo with content-hash ?v= URLs. The logo sits before the title in
  the top bar.
- ui.accent: a hex color that replaces --accent and --accent-subtle via
  /-/site.css.
- site.assets_dir: a directory served at /-/site/ so custom_css can load
  its own images, fonts and stylesheets (url("site/…")). Files are
  streamed from disk with a weak ETag; only image, font and .css types
  are served, and hidden files, traversal and symlinks leading outside
  the directory are 404.
- README: an Appearance section covering every customization key and the
  main design tokens to override.

Fixes #2
Author
krolyxon
Date
2026-11-06 11:28 +0530
Parent
3cb9866
Commit
ce0f374bcfba05975f51deae58e8527f7d5d4406

+553 −10

10 files changed

  1. M Cargo.toml +1 −1
  2. M README.md +68 −0
  3. M assets/css/components.css +17 −0
  4. M dist/ferrenium.toml +4 −0
  5. M src/config.rs +198 −2
  6. M src/handlers/mod.rs +18 −1
  7. M src/server/assets.rs +144 −4
  8. M src/server/mod.rs +3 −0
  9. M templates/base.html +5 −2
  10. M tests/http.rs +95 −0
M Cargo.toml View
@@ -32,7 +32,7 @@ sd-notify = "0.5.0"
3232 serde = { version = "1.0.229", features = ["derive"] }
3333 syntect = { version = "5.3", default-features = false, features = ["parsing", "html", "regex-fancy"] }
3434 thiserror = "2.0.21"
35−tokio = { version = "1.54.1", features = ["rt-multi-thread", "macros", "net", "signal", "sync", "time", "process", "io-util"] }
35+tokio = { version = "1.54.1", features = ["rt-multi-thread", "macros", "net", "signal", "sync", "time", "process", "io-util", "fs"] }
3636 tokio-util = { version = "0.7", features = ["io", "io-util"] }
3737 toml = "1.2.0"
3838 tower = { version = "0.5.3", features = ["util"] }
M README.md View
@@ -8,6 +8,9 @@ Pages: repo index, summary + README, tree, file (highlighted), raw, log
88 finder, `.tar.gz`/`.zip` snapshots, Atom feed. `git clone` over HTTPS
99 (read-only).
1010
11+Themeable without rebuilding: your own stylesheet, images and fonts, plus a
12+favicon, logo and accent color (see [Appearance](#appearance)).
13+
1114 ## Install
1215
1316 On a Linux host with systemd (x86_64 or aarch64, glibc 2.28+):
@@ -93,6 +96,71 @@ Per-repository metadata comes from the first source that sets it:
9396 sudo -u git git -C /home/git/secret.git config ferrenium.hidden true
9497 ```
9598
99+### Appearance
100+
101+These keys are optional. Files are read when the config is loaded or reloaded,
102+and relative paths are resolved against the config file's directory, e.g.
103+`/etc/ferrenium/`.
104+
105+| Key | |
106+|---|---|
107+| `site.title` | name in the top bar and page titles |
108+| `site.favicon` | `.ico`, `.png`, `.svg`, `.webp`, `.gif` or `.jpg`, up to 1 MiB |
109+| `site.logo` | image shown before the title in the top bar (same formats) |
110+| `site.custom_css` | stylesheet loaded after the built-in one |
111+| `site.assets_dir` | directory of images, fonts and stylesheets the custom CSS can load |
112+| `site.header` / `site.footer` | HTML inserted below the top bar / in the footer |
113+| `ui.theme` | default theme: `auto`, `dark` or `light` |
114+| `ui.accent` | accent color as hex, e.g. `"#d71921"` |
115+| `ui.code_font` | font family for code (must be installed on the client or loaded by `custom_css`) |
116+
117+The custom stylesheet overrides the built-in one, so it only needs the rules
118+you want to change: design tokens, buttons, backgrounds, layout. Files in
119+`assets_dir` are served at `/-/site/` (images, fonts and `.css` only; hidden
120+files and paths leading outside the directory are refused), and the
121+stylesheet refers to them with relative URLs:
122+
123+```toml
124+[site]
125+custom_css = "style.css" # /etc/ferrenium/style.css
126+assets_dir = "assets" # /etc/ferrenium/assets/
127+```
128+
129+```css
130+@font-face { font-family: "Inter"; src: url("site/inter.woff2") format("woff2"); }
131+:root { --font-body: "Inter", sans-serif; --interactive: light-dark(#0b57d0, #8ab4f8); }
132+body { background: url("site/bg.jpg") center / cover fixed; }
133+.segmented { border-radius: 4px; }
134+```
135+
136+Edits to the stylesheet, favicon and logo need `systemctl reload ferrenium`;
137+files in `assets_dir` are read on each request. Images from other `https://`
138+sites work too, but fonts must come from `assets_dir` (the content security
139+policy allows only same-origin fonts). Inline `<script>`/`<style>` in
140+`header`/`footer` is blocked for the same reason; put styles in `custom_css`.
141+
142+Most of the look is driven by these CSS variables, so overriding a few of
143+them on `:root` goes a long way. Colors are written as
144+`light-dark(LIGHT, DARK)` to follow the theme switch; a single color applies
145+to both themes.
146+
147+| Variable | Used for |
148+|---|---|
149+| `--black` | page background |
150+| `--surface`, `--surface-raised` | panels, menus, code blocks |
151+| `--border`, `--border-visible` | hairlines, button and field outlines |
152+| `--text-display`, `--text-primary`, `--text-secondary`, `--text-disabled` | text, brightest to dimmest; syntax highlighting uses the same scale |
153+| `--accent`, `--accent-subtle` | active markers, highlights and removed lines in diffs (`ui.accent` sets both) |
154+| `--interactive` | links |
155+| `--success` | added lines in diffs |
156+| `--error` | `[ERROR: …]` messages |
157+| `--font-body`, `--font-mono`, `--font-display`, `--font-code` | type families |
158+| `--page-max`, `--gutter` | column width and side padding |
159+
160+The full list is in [`assets/css/tokens.css`](assets/css/tokens.css). For
161+anything else, use the browser's inspector to find the element's class and
162+override its rules.
163+
96164 ### Limits
97165
98166 Defaults are sized for a small VPS:
M assets/css/components.css View
@@ -88,12 +88,29 @@
8888 }
8989
9090 .topbar-home {
91+ display: flex;
92+ align-items: center;
93+ gap: var(--space-sm);
94+ min-width: 0;
9195 color: var(--text-display);
96+}
97+
98+.topbar-home .label {
99+ color: inherit;
92100 white-space: nowrap;
93101 overflow: hidden;
94102 text-overflow: ellipsis;
95103 }
96104
105+/* `site.logo`: sized by height so any aspect ratio fits the bar. */
106+.topbar-logo {
107+ flex: none;
108+ height: 28px;
109+ width: auto;
110+ max-width: 160px;
111+ object-fit: contain;
112+}
113+
97114 .topbar-home:hover {
98115 text-decoration: none;
99116 }
M dist/ferrenium.toml View
@@ -17,6 +17,9 @@ title = "ferrenium"
1717 # custom_css = "custom.css"
1818 # header = "header.html"
1919 # footer = "footer.html"
20+# favicon = "favicon.png" # .ico, .png, .svg, .webp, .gif or .jpg, up to 1 MiB
21+# logo = "logo.svg" # shown before the title in the top bar
22+# assets_dir = "assets" # served at /-/site/; in custom_css: url("site/bg.jpg")
2023
2124 [repos]
2225 scan_path = "/home/git"
@@ -42,6 +45,7 @@ https_clone = true
4245 theme = "auto" # auto | dark | light
4346 index_sort = "activity" # repository index order: activity | name
4447 code_font = "Space Mono"
48+# accent = "#d71921" # hex accent color; default: Nothing red
4549 max_blob_kb = 1024
4650 max_highlight_kb = 256
4751 log_page = 50 # commits per log page
M src/config.rs View
@@ -45,8 +45,9 @@ pub struct Config {
4545 /// File the config was loaded from (`None` = built-in defaults).
4646 #[serde(skip)]
4747 pub source: Option<PathBuf>,
48− /// Contents of `custom_css` / `header` / `footer`, read at load time so
49− /// that a reload picks up edits and requests never touch the filesystem.
48+ /// Contents of `custom_css` / `header` / `footer` / `favicon` / `logo`,
49+ /// read at load time so that a reload picks up edits and requests never
50+ /// touch the filesystem.
5051 #[serde(skip)]
5152 pub custom: Customization,
5253 }
@@ -56,8 +57,22 @@ pub struct Customization {
5657 pub css: Option<Arc<str>>,
5758 pub header: Option<Arc<str>>,
5859 pub footer: Option<Arc<str>>,
60+ pub favicon: Option<SiteImage>,
61+ pub logo: Option<SiteImage>,
5962 }
6063
64+/// An image from `site.favicon` / `site.logo`, served from memory.
65+#[derive(Debug, Clone)]
66+pub struct SiteImage {
67+ pub data: bytes::Bytes,
68+ pub content_type: &'static str,
69+ /// Short content hash for the `?v=` cache-busting query.
70+ pub version: String,
71+}
72+
73+/// Largest accepted `site.favicon` / `site.logo` file.
74+const MAX_IMAGE_BYTES: u64 = 1024 * 1024;
75+
6176 #[derive(Debug, Clone, Deserialize)]
6277 #[serde(deny_unknown_fields, default)]
6378 pub struct ServerConfig {
@@ -89,6 +104,10 @@ pub struct SiteConfig {
89104 pub custom_css: Option<PathBuf>,
90105 pub header: Option<PathBuf>,
91106 pub footer: Option<PathBuf>,
107+ pub favicon: Option<PathBuf>,
108+ pub logo: Option<PathBuf>,
109+ /// Directory served at `/-/site/` for files used by `custom_css`.
110+ pub assets_dir: Option<PathBuf>,
92111 }
93112
94113 impl Default for SiteConfig {
@@ -100,6 +119,9 @@ impl Default for SiteConfig {
100119 custom_css: None,
101120 header: None,
102121 footer: None,
122+ favicon: None,
123+ logo: None,
124+ assets_dir: None,
103125 }
104126 }
105127 }
@@ -157,6 +179,8 @@ pub struct UiConfig {
157179 /// Default order of the repository index (`?sort=` overrides it).
158180 pub index_sort: IndexSort,
159181 pub code_font: String,
182+ /// `#rgb` or `#rrggbb`; replaces the default red accent.
183+ pub accent: Option<String>,
160184 pub max_blob_kb: u64,
161185 pub max_highlight_kb: u64,
162186 /// Commits per log page.
@@ -169,6 +193,7 @@ impl Default for UiConfig {
169193 theme: Theme::Auto,
170194 index_sort: IndexSort::Activity,
171195 code_font: DEFAULT_CODE_FONT.into(),
196+ accent: None,
172197 max_blob_kb: 1024,
173198 max_highlight_kb: 256,
174199 log_page: 50,
@@ -441,8 +466,30 @@ impl Config {
441466 ),
442467 header: read_custom(&base_dir, "site.header", &mut self.site.header, &mut errors),
443468 footer: read_custom(&base_dir, "site.footer", &mut self.site.footer, &mut errors),
469+ favicon: read_image(
470+ &base_dir,
471+ "site.favicon",
472+ &mut self.site.favicon,
473+ &mut errors,
474+ ),
475+ logo: read_image(&base_dir, "site.logo", &mut self.site.logo, &mut errors),
444476 };
445477
478+ if let Some(dir) = &mut self.site.assets_dir {
479+ // Canonical, so requests can check that symlinks stay inside it.
480+ match base_dir.join(&*dir).canonicalize() {
481+ Ok(canonical) if canonical.is_dir() => *dir = canonical,
482+ Ok(canonical) => errors.push(format!(
483+ "site.assets_dir: {} is not a directory",
484+ canonical.display()
485+ )),
486+ Err(e) => errors.push(format!(
487+ "site.assets_dir: cannot open {}: {e}",
488+ base_dir.join(&*dir).display()
489+ )),
490+ }
491+ }
492+
446493 // repos
447494 if self.repos.scan_path.is_relative() {
448495 self.repos.scan_path = base_dir.join(&self.repos.scan_path);
@@ -483,6 +530,14 @@ impl Config {
483530 self.ui.code_font
484531 ));
485532 }
533+ if let Some(accent) = &mut self.ui.accent {
534+ *accent = accent.trim().to_ascii_lowercase();
535+ if parse_hex_color(accent).is_none() {
536+ errors.push(format!(
537+ "ui.accent must be a hex color like \"#d71921\": {accent:?}"
538+ ));
539+ }
540+ }
486541 if self.ui.max_blob_kb == 0 {
487542 errors.push("ui.max_blob_kb must be greater than 0".into());
488543 }
@@ -559,6 +614,81 @@ fn read_custom(
559614 }
560615 }
561616
617+/// Resolve an image path against `base_dir`, check its type and size, and
618+/// read it.
619+fn read_image(
620+ base_dir: &Path,
621+ key: &str,
622+ path: &mut Option<PathBuf>,
623+ errors: &mut Vec<String>,
624+) -> Option<SiteImage> {
625+ let p = path.as_mut()?;
626+ if p.is_relative() {
627+ *p = base_dir.join(&*p);
628+ }
629+ let ext = p
630+ .extension()
631+ .and_then(|e| e.to_str())
632+ .map(str::to_ascii_lowercase);
633+ let content_type = match ext.as_deref() {
634+ Some("ico") => "image/x-icon",
635+ Some("png") => "image/png",
636+ Some("svg") => "image/svg+xml",
637+ Some("webp") => "image/webp",
638+ Some("gif") => "image/gif",
639+ Some("jpg" | "jpeg") => "image/jpeg",
640+ _ => {
641+ errors.push(format!(
642+ "{key}: unsupported image type (use .ico, .png, .svg, .webp, .gif or .jpg): {}",
643+ p.display()
644+ ));
645+ return None;
646+ }
647+ };
648+ let data = match std::fs::metadata(&*p) {
649+ Ok(m) if m.len() > MAX_IMAGE_BYTES => {
650+ errors.push(format!(
651+ "{key}: {} is larger than {} KiB",
652+ p.display(),
653+ MAX_IMAGE_BYTES / 1024
654+ ));
655+ return None;
656+ }
657+ Ok(_) => std::fs::read(&*p),
658+ Err(e) => Err(e),
659+ };
660+ match data {
661+ Ok(data) => {
662+ use std::hash::{Hash, Hasher};
663+ let mut hasher = std::hash::DefaultHasher::new();
664+ data.hash(&mut hasher);
665+ Some(SiteImage {
666+ data: data.into(),
667+ content_type,
668+ version: format!("{:08x}", hasher.finish() as u32),
669+ })
670+ }
671+ Err(e) => {
672+ errors.push(format!("{key}: cannot read {}: {e}", p.display()));
673+ None
674+ }
675+ }
676+}
677+
678+/// `#rgb` / `#rrggbb` to its components.
679+pub fn parse_hex_color(s: &str) -> Option<[u8; 3]> {
680+ let hex = s.strip_prefix('#')?;
681+ if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
682+ return None;
683+ }
684+ let digit = |i: usize, len: usize| u8::from_str_radix(&hex[i * len..(i + 1) * len], 16).ok();
685+ match hex.len() {
686+ 3 => Some([digit(0, 1)? * 17, digit(1, 1)? * 17, digit(2, 1)? * 17]),
687+ 6 => Some([digit(0, 2)?, digit(1, 2)?, digit(2, 2)?]),
688+ _ => None,
689+ }
690+}
691+
562692 #[cfg(test)]
563693 mod tests {
564694 use super::*;
@@ -699,4 +829,70 @@ mod tests {
699829 let err = Config::load(Some(&path)).unwrap_err().to_string();
700830 assert!(err.contains("site.footer"), "{err}");
701831 }
832+
833+ #[test]
834+ fn images_are_read_and_checked() {
835+ let dir = tempfile::tempdir().unwrap();
836+ std::fs::write(dir.path().join("icon.PNG"), b"\x89PNG").unwrap();
837+ std::fs::write(dir.path().join("logo.bmp"), b"BM").unwrap();
838+ std::fs::write(
839+ dir.path().join("big.svg"),
840+ vec![b' '; MAX_IMAGE_BYTES as usize + 1],
841+ )
842+ .unwrap();
843+ let path = dir.path().join("ferrenium.toml");
844+ std::fs::write(&path, "[site]\nfavicon = \"icon.PNG\"\n").unwrap();
845+ let c = Config::load(Some(&path)).unwrap();
846+ let icon = c.custom.favicon.unwrap();
847+ assert_eq!(icon.content_type, "image/png");
848+ assert_eq!(&icon.data[..], b"\x89PNG");
849+ assert_eq!(icon.version.len(), 8);
850+
851+ std::fs::write(
852+ &path,
853+ "[site]\nfavicon = \"big.svg\"\nlogo = \"logo.bmp\"\n",
854+ )
855+ .unwrap();
856+ let err = Config::load(Some(&path)).unwrap_err().to_string();
857+ assert!(
858+ err.contains("site.favicon") && err.contains("larger"),
859+ "{err}"
860+ );
861+ assert!(
862+ err.contains("site.logo") && err.contains("unsupported"),
863+ "{err}"
864+ );
865+ }
866+
867+ #[test]
868+ fn assets_dir_must_be_a_directory() {
869+ let dir = tempfile::tempdir().unwrap();
870+ std::fs::create_dir(dir.path().join("assets")).unwrap();
871+ std::fs::write(dir.path().join("file"), "").unwrap();
872+ let path = dir.path().join("ferrenium.toml");
873+ std::fs::write(&path, "[site]\nassets_dir = \"assets\"\n").unwrap();
874+ let c = Config::load(Some(&path)).unwrap();
875+ assert_eq!(
876+ c.site.assets_dir,
877+ Some(dir.path().join("assets").canonicalize().unwrap())
878+ );
879+ for bad in ["file", "missing"] {
880+ std::fs::write(&path, format!("[site]\nassets_dir = {bad:?}\n")).unwrap();
881+ let err = Config::load(Some(&path)).unwrap_err().to_string();
882+ assert!(err.contains("site.assets_dir"), "{err}");
883+ }
884+ }
885+
886+ #[test]
887+ fn accent_colors() {
888+ assert_eq!(parse_hex_color("#d71921"), Some([215, 25, 33]));
889+ assert_eq!(parse_hex_color("#fA0"), Some([255, 170, 0]));
890+ for bad in ["d71921", "#d7192", "#gggggg", "red", "#+1+2+3"] {
891+ assert_eq!(parse_hex_color(bad), None, "{bad}");
892+ }
893+ let c = finish("[ui]\naccent = \" #FF5500 \"\n").unwrap();
894+ assert_eq!(c.ui.accent.as_deref(), Some("#ff5500"));
895+ let err = finish("[ui]\naccent = \"red; } body {\"\n").unwrap_err();
896+ assert!(err.to_string().contains("ui.accent"), "{err}");
897+ }
702898 }
M src/handlers/mod.rs View
@@ -40,6 +40,10 @@ pub struct Layout {
4040 pub site_css: Option<String>,
4141 pub header: Option<Arc<str>>,
4242 pub footer: Option<Arc<str>>,
43+ /// Favicon URL and media type: `site.favicon` or the built-in icon.
44+ pub favicon: (String, &'static str),
45+ /// Versioned URL of `site.logo`.
46+ pub logo: Option<String>,
4347 pub version: &'static str,
4448 /// Atom feed advertised in `<head>`.
4549 pub feed: Option<String>,
@@ -50,8 +54,21 @@ impl Layout {
5054 pub fn new(state: &AppState, config: &Config, title: Option<&str>) -> Self {
5155 let base = config.server.base_path.clone();
5256 let site_title = config.site.title.clone();
57+ let static_base = format!("{base}-/static/{}/", state.assets.hash());
5358 Layout {
54− static_base: format!("{base}-/static/{}/", state.assets.hash()),
59+ favicon: match &config.custom.favicon {
60+ Some(icon) => (
61+ format!("{base}-/favicon?v={}", icon.version),
62+ icon.content_type,
63+ ),
64+ None => (format!("{static_base}icons/favicon.svg"), "image/svg+xml"),
65+ },
66+ logo: config
67+ .custom
68+ .logo
69+ .as_ref()
70+ .map(|logo| format!("{base}-/logo?v={}", logo.version)),
71+ static_base,
5572 site_css: assets::site_css_version(config).map(|v| format!("{base}-/site.css?v={v}")),
5673 page_title: match title {
5774 Some(t) => format!("{t} · {site_title}"),
M src/server/assets.rs View
@@ -8,9 +8,9 @@
88 use std::hash::{Hash, Hasher};
99 use std::sync::Arc;
1010
11−use axum::body::Bytes;
11+use axum::body::{Body, Bytes};
1212 use axum::extract::{Path, State};
13−use axum::http::{HeaderValue, StatusCode, header};
13+use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
1414 use axum::response::{IntoResponse, Response};
1515 use rust_embed::RustEmbed;
1616
@@ -127,8 +127,8 @@ pub async fn serve(
127127 .into_response()
128128 }
129129
130−/// `GET /-/site.css?v=…` — per-site overrides: the configured code font and
131−/// the contents of `site.custom_css`. Only linked when there is something to
130+/// `GET /-/site.css?v=…` — per-site overrides: the configured code font,
131+/// accent color and the contents of `site.custom_css`. Only linked when there is something to
132132 /// serve; versioned by [`site_css_version`].
133133 pub async fn site_css(State(state): State<Arc<AppState>>) -> Response {
134134 let config = state.config.load();
@@ -157,12 +157,129 @@ pub fn site_css_body(config: &crate::config::Config) -> Option<String> {
157157 config.ui.code_font
158158 ));
159159 }
160+ if let Some(accent) = &config.ui.accent {
161+ // Validated as a hex color in `Config::finish`.
162+ if let Some([r, g, b]) = crate::config::parse_hex_color(accent) {
163+ css.push_str(&format!(
164+ ":root {{ --accent: {accent}; --accent-subtle: rgba({r}, {g}, {b}, 0.15); }}\n"
165+ ));
166+ }
167+ }
160168 if let Some(custom) = &config.custom.css {
161169 css.push_str(custom);
162170 }
163171 (!css.is_empty()).then_some(css)
164172 }
165173
174+/// `GET /-/favicon?v=…` — `site.favicon`.
175+pub async fn favicon(State(state): State<Arc<AppState>>) -> Response {
176+ site_image(state.config.load().custom.favicon.as_ref())
177+}
178+
179+/// `GET /-/logo?v=…` — `site.logo`.
180+pub async fn logo(State(state): State<Arc<AppState>>) -> Response {
181+ site_image(state.config.load().custom.logo.as_ref())
182+}
183+
184+fn site_image(image: Option<&crate::config::SiteImage>) -> Response {
185+ let Some(image) = image else {
186+ return StatusCode::NOT_FOUND.into_response();
187+ };
188+ (
189+ [
190+ (
191+ header::CONTENT_TYPE,
192+ HeaderValue::from_static(image.content_type),
193+ ),
194+ (header::CACHE_CONTROL, HeaderValue::from_static(NO_CACHE)),
195+ ],
196+ image.data.clone(),
197+ )
198+ .into_response()
199+}
200+
201+/// File types served from `site.assets_dir`: what a stylesheet can load.
202+const SITE_ASSET_TYPES: &[&str] = &[
203+ "css", "png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "ico", "woff2", "woff", "ttf", "otf",
204+];
205+
206+/// `GET /-/site/{*path}` — a file from `site.assets_dir`, streamed from disk.
207+/// Hidden files, other types and paths (or symlinks) leading outside the
208+/// directory are 404.
209+pub async fn site_asset(
210+ State(state): State<Arc<AppState>>,
211+ Path(path): Path<String>,
212+ headers: HeaderMap,
213+) -> Response {
214+ let config = state.config.load();
215+ let Some(dir) = config.site.assets_dir.clone() else {
216+ return StatusCode::NOT_FOUND.into_response();
217+ };
218+ let Some(rel) = site_asset_path(&path).map(str::to_owned) else {
219+ return StatusCode::NOT_FOUND.into_response();
220+ };
221+ let opened = tokio::task::spawn_blocking(move || {
222+ let full = dir.join(rel).canonicalize().ok()?;
223+ if !full.starts_with(&dir) {
224+ return None;
225+ }
226+ let file = std::fs::File::open(&full).ok()?;
227+ let meta = file.metadata().ok()?;
228+ meta.is_file().then_some((file, meta))
229+ })
230+ .await
231+ .ok()
232+ .flatten();
233+ let Some((file, meta)) = opened else {
234+ return StatusCode::NOT_FOUND.into_response();
235+ };
236+
237+ let mut hasher = std::hash::DefaultHasher::new();
238+ meta.len().hash(&mut hasher);
239+ meta.modified().ok().hash(&mut hasher);
240+ let etag = format!("W/\"{:016x}\"", hasher.finish());
241+ let etag = HeaderValue::from_str(&etag).expect("hex ETag is a valid header");
242+ let cache = [
243+ (header::ETAG, etag.clone()),
244+ (header::CACHE_CONTROL, HeaderValue::from_static(NO_CACHE)),
245+ ];
246+ let bare = |t: &[u8]| {
247+ t.trim_ascii()
248+ .strip_prefix(b"W/")
249+ .unwrap_or(t.trim_ascii())
250+ .to_vec()
251+ };
252+ if headers.get(header::IF_NONE_MATCH).is_some_and(|v| {
253+ v.as_bytes()
254+ .split(|&b| b == b',')
255+ .any(|t| bare(t) == bare(etag.as_bytes()))
256+ }) {
257+ return (StatusCode::NOT_MODIFIED, cache).into_response();
258+ }
259+ let body = Body::from_stream(tokio_util::io::ReaderStream::new(
260+ tokio::fs::File::from_std(file),
261+ ));
262+ (
263+ cache,
264+ [
265+ (header::CONTENT_TYPE, content_type(&path)),
266+ (header::CONTENT_LENGTH, HeaderValue::from(meta.len())),
267+ ],
268+ body,
269+ )
270+ .into_response()
271+}
272+
273+/// Validate a request path for [`site_asset`]: plain segments only, no
274+/// hidden files, and an allowed file type.
275+fn site_asset_path(path: &str) -> Option<&str> {
276+ let clean = path
277+ .split('/')
278+ .all(|seg| !seg.is_empty() && !seg.starts_with('.') && !seg.contains(['\\', '\0', ':']));
279+ let ext = path.rsplit_once('.')?.1.to_ascii_lowercase();
280+ (clean && SITE_ASSET_TYPES.contains(&ext.as_str())).then_some(path)
281+}
282+
166283 /// Short content hash for the `?v=` query of the site stylesheet link.
167284 pub fn site_css_version(config: &crate::config::Config) -> Option<String> {
168285 let body = site_css_body(config)?;
@@ -187,6 +304,29 @@ mod tests {
187304 assert!(text.contains("--text-display"));
188305 }
189306
307+ #[test]
308+ fn site_asset_paths() {
309+ for ok in ["bg.jpg", "img/Bg.PNG", "fonts/x.woff2", "theme.css"] {
310+ assert_eq!(site_asset_path(ok), Some(ok), "{ok}");
311+ }
312+ for bad in [
313+ "",
314+ "../x.png",
315+ "a/../x.png",
316+ "./x.png",
317+ ".x.png",
318+ "a//x.png",
319+ "/x.png",
320+ "x.html",
321+ "x.svgz",
322+ "x",
323+ "a\\x.png",
324+ "c:x.png",
325+ ] {
326+ assert_eq!(site_asset_path(bad), None, "{bad}");
327+ }
328+ }
329+
190330 #[test]
191331 fn content_types() {
192332 assert_eq!(content_type("css/app.css"), "text/css; charset=utf-8");
M src/server/mod.rs View
@@ -42,6 +42,9 @@ pub fn app(state: Arc<AppState>) -> Router {
4242 .route("/", get(handlers::index::index))
4343 .route("/-/health", get(health))
4444 .route("/-/site.css", get(assets::site_css))
45+ .route("/-/favicon", get(assets::favicon))
46+ .route("/-/logo", get(assets::logo))
47+ .route("/-/site/{*path}", get(assets::site_asset))
4548 .route("/-/static/{hash}/{*path}", get(assets::serve))
4649 .route("/robots.txt", get(robots))
4750 .route("/{*path}", any(dispatch::dispatch))
M templates/base.html View
@@ -14,14 +14,17 @@
1414 {%- if let Some(feed) = layout.feed %}
1515 <link rel="alternate" type="application/atom+xml" title="Commits" href="{{ feed }}">
1616 {%- endif %}
17−<link rel="icon" href="{{ layout.static_base }}icons/favicon.svg" type="image/svg+xml">
17+<link rel="icon" href="{{ layout.favicon.0 }}" type="{{ layout.favicon.1 }}">
1818 <script src="{{ layout.static_base }}js/theme.js"></script>
1919 <script src="{{ layout.static_base }}js/app.js" defer></script>
2020 {%- block head %}{% endblock %}
2121 </head>
2222 <body>
2323 <header class="topbar shell">
24− <a class="topbar-home label" href="{{ layout.base }}">{{ layout.site_title }}</a>
24+ <a class="topbar-home" href="{{ layout.base }}">
25+ {%- if let Some(logo) = layout.logo %}<img class="topbar-logo" src="{{ logo }}" alt="">{% endif -%}
26+ <span class="label">{{ layout.site_title }}</span>
27+ </a>
2528 <div class="segmented theme-switch" role="group" aria-label="Color theme">
2629 <button type="button" data-theme-choice="auto" aria-pressed="false">Auto</button>
2730 <button type="button" data-theme-choice="light" aria-pressed="false">Light</button>
M tests/http.rs View
@@ -188,6 +188,101 @@ async fn site_css_carries_code_font_and_custom_css() {
188188 assert!(css.contains("--font-code: \"Berkeley Mono\""));
189189 }
190190
191+#[tokio::test]
192+async fn site_favicon_logo_and_accent() {
193+ let (_, _, body) = get(&app_with("").0, "/").await;
194+ assert!(body.contains("icons/favicon.svg\" type=\"image/svg+xml\""));
195+ assert!(!body.contains("topbar-logo"));
196+
197+ let dir = tempfile::tempdir().unwrap();
198+ std::fs::write(dir.path().join("icon.ico"), b"ICO").unwrap();
199+ std::fs::write(dir.path().join("logo.svg"), b"<svg/>").unwrap();
200+ let (app, _) = app_with(&format!(
201+ "[site]\nfavicon = {:?}\nlogo = {:?}\n[ui]\naccent = \"#0a0\"\n",
202+ dir.path().join("icon.ico"),
203+ dir.path().join("logo.svg"),
204+ ));
205+ let (_, _, body) = get(&app, "/").await;
206+ let link = |prefix: &str| {
207+ body.split('"')
208+ .find(|s| s.starts_with(prefix))
209+ .unwrap_or_else(|| panic!("no {prefix} link in page"))
210+ .to_string()
211+ };
212+
213+ assert!(body.contains("type=\"image/x-icon\""));
214+ let (status, headers, icon) = get(&app, &link("/-/favicon?v=")).await;
215+ assert_eq!(status, StatusCode::OK);
216+ assert_eq!(headers[header::CONTENT_TYPE], "image/x-icon");
217+ assert_eq!(icon, "ICO");
218+
219+ assert!(body.contains("class=\"topbar-logo\""));
220+ let (status, headers, logo) = get(&app, &link("/-/logo?v=")).await;
221+ assert_eq!(status, StatusCode::OK);
222+ assert_eq!(headers[header::CONTENT_TYPE], "image/svg+xml");
223+ assert!(headers.contains_key(header::CONTENT_SECURITY_POLICY));
224+ assert_eq!(logo, "<svg/>");
225+
226+ let (_, _, css) = get(&app, &link("/-/site.css?v=")).await;
227+ assert!(css.contains("--accent: #0a0; --accent-subtle: rgba(0, 170, 0, 0.15);"));
228+
229+ let (app, _) = app_with("");
230+ assert_eq!(get(&app, "/-/favicon").await.0, StatusCode::NOT_FOUND);
231+ assert_eq!(get(&app, "/-/logo").await.0, StatusCode::NOT_FOUND);
232+}
233+
234+#[tokio::test]
235+async fn site_assets_dir() {
236+ let root = tempfile::tempdir().unwrap();
237+ let assets = root.path().join("assets");
238+ std::fs::create_dir_all(assets.join("img")).unwrap();
239+ std::fs::write(assets.join("img/bg.png"), b"PNG").unwrap();
240+ std::fs::write(assets.join(".hidden.png"), b"x").unwrap();
241+ std::fs::write(assets.join("page.html"), b"<p>").unwrap();
242+ std::fs::write(root.path().join("secret.png"), b"secret").unwrap();
243+ std::os::unix::fs::symlink(root.path().join("secret.png"), assets.join("escape.png")).unwrap();
244+ std::os::unix::fs::symlink(assets.join("img/bg.png"), assets.join("alias.png")).unwrap();
245+
246+ let (app, _) = app_with(&format!("[site]\nassets_dir = {assets:?}\n"));
247+ let (status, headers, body) = get(&app, "/-/site/img/bg.png").await;
248+ assert_eq!(status, StatusCode::OK);
249+ assert_eq!(headers[header::CONTENT_TYPE], "image/png");
250+ assert_eq!(headers[header::CACHE_CONTROL], "no-cache");
251+ assert_eq!(body, "PNG");
252+ assert_eq!(get(&app, "/-/site/alias.png").await.2, "PNG");
253+
254+ let etag = headers[header::ETAG].clone();
255+ let res = app
256+ .clone()
257+ .oneshot(
258+ Request::get("/-/site/img/bg.png")
259+ .header(header::IF_NONE_MATCH, etag)
260+ .body(Body::empty())
261+ .unwrap(),
262+ )
263+ .await
264+ .unwrap();
265+ assert_eq!(res.status(), StatusCode::NOT_MODIFIED);
266+
267+ for path in [
268+ "/-/site/escape.png",
269+ "/-/site/.hidden.png",
270+ "/-/site/page.html",
271+ "/-/site/img/../../secret.png",
272+ "/-/site/img/%2e%2e/%2e%2e/secret.png",
273+ "/-/site/missing.png",
274+ "/-/site/img",
275+ ] {
276+ assert_eq!(get(&app, path).await.0, StatusCode::NOT_FOUND, "{path}");
277+ }
278+
279+ let (app, _) = app_with("");
280+ assert_eq!(
281+ get(&app, "/-/site/img/bg.png").await.0,
282+ StatusCode::NOT_FOUND
283+ );
284+}
285+
191286 #[tokio::test]
192287 async fn compression_skips_fonts() {
193288 let (app, state) = app_with("");