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
+553 −10
10 files changed
- M Cargo.toml +1 −1
- M README.md +68 −0
- M assets/css/components.css +17 −0
- M dist/ferrenium.toml +4 −0
- M src/config.rs +198 −2
- M src/handlers/mod.rs +18 −1
- M src/server/assets.rs +144 −4
- M src/server/mod.rs +3 −0
- M templates/base.html +5 −2
- M tests/http.rs +95 −0
| @@ -32,7 +32,7 @@ sd-notify = "0.5.0" | |||
| 32 | 32 | serde = { version = "1.0.229", features = ["derive"] } | |
| 33 | 33 | syntect = { version = "5.3", default-features = false, features = ["parsing", "html", "regex-fancy"] } | |
| 34 | 34 | 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"] } | |
| 36 | 36 | tokio-util = { version = "0.7", features = ["io", "io-util"] } | |
| 37 | 37 | toml = "1.2.0" | |
| 38 | 38 | tower = { version = "0.5.3", features = ["util"] } | |
| @@ -8,6 +8,9 @@ Pages: repo index, summary + README, tree, file (highlighted), raw, log | |||
| 8 | 8 | finder, `.tar.gz`/`.zip` snapshots, Atom feed. `git clone` over HTTPS | |
| 9 | 9 | (read-only). | |
| 10 | 10 | ||
| 11 | + | Themeable without rebuilding: your own stylesheet, images and fonts, plus a | |
| 12 | + | favicon, logo and accent color (see [Appearance](#appearance)). | |
| 13 | + | ||
| 11 | 14 | ## Install | |
| 12 | 15 | ||
| 13 | 16 | 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: | |||
| 93 | 96 | sudo -u git git -C /home/git/secret.git config ferrenium.hidden true | |
| 94 | 97 | ``` | |
| 95 | 98 | ||
| 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 | + | ||
| 96 | 164 | ### Limits | |
| 97 | 165 | ||
| 98 | 166 | Defaults are sized for a small VPS: | |
| @@ -88,12 +88,29 @@ | |||
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | 90 | .topbar-home { | |
| 91 | + | display: flex; | |
| 92 | + | align-items: center; | |
| 93 | + | gap: var(--space-sm); | |
| 94 | + | min-width: 0; | |
| 91 | 95 | color: var(--text-display); | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | .topbar-home .label { | |
| 99 | + | color: inherit; | |
| 92 | 100 | white-space: nowrap; | |
| 93 | 101 | overflow: hidden; | |
| 94 | 102 | text-overflow: ellipsis; | |
| 95 | 103 | } | |
| 96 | 104 | ||
| 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 | + | ||
| 97 | 114 | .topbar-home:hover { | |
| 98 | 115 | text-decoration: none; | |
| 99 | 116 | } | |
| @@ -17,6 +17,9 @@ title = "ferrenium" | |||
| 17 | 17 | # custom_css = "custom.css" | |
| 18 | 18 | # header = "header.html" | |
| 19 | 19 | # 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") | |
| 20 | 23 | ||
| 21 | 24 | [repos] | |
| 22 | 25 | scan_path = "/home/git" | |
| @@ -42,6 +45,7 @@ https_clone = true | |||
| 42 | 45 | theme = "auto" # auto | dark | light | |
| 43 | 46 | index_sort = "activity" # repository index order: activity | name | |
| 44 | 47 | code_font = "Space Mono" | |
| 48 | + | # accent = "#d71921" # hex accent color; default: Nothing red | |
| 45 | 49 | max_blob_kb = 1024 | |
| 46 | 50 | max_highlight_kb = 256 | |
| 47 | 51 | log_page = 50 # commits per log page | |
| @@ -45,8 +45,9 @@ pub struct Config { | |||
| 45 | 45 | /// File the config was loaded from (`None` = built-in defaults). | |
| 46 | 46 | #[serde(skip)] | |
| 47 | 47 | 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. | |
| 50 | 51 | #[serde(skip)] | |
| 51 | 52 | pub custom: Customization, | |
| 52 | 53 | } | |
| @@ -56,8 +57,22 @@ pub struct Customization { | |||
| 56 | 57 | pub css: Option<Arc<str>>, | |
| 57 | 58 | pub header: Option<Arc<str>>, | |
| 58 | 59 | pub footer: Option<Arc<str>>, | |
| 60 | + | pub favicon: Option<SiteImage>, | |
| 61 | + | pub logo: Option<SiteImage>, | |
| 59 | 62 | } | |
| 60 | 63 | ||
| 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 | + | ||
| 61 | 76 | #[derive(Debug, Clone, Deserialize)] | |
| 62 | 77 | #[serde(deny_unknown_fields, default)] | |
| 63 | 78 | pub struct ServerConfig { | |
| @@ -89,6 +104,10 @@ pub struct SiteConfig { | |||
| 89 | 104 | pub custom_css: Option<PathBuf>, | |
| 90 | 105 | pub header: Option<PathBuf>, | |
| 91 | 106 | 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>, | |
| 92 | 111 | } | |
| 93 | 112 | ||
| 94 | 113 | impl Default for SiteConfig { | |
| @@ -100,6 +119,9 @@ impl Default for SiteConfig { | |||
| 100 | 119 | custom_css: None, | |
| 101 | 120 | header: None, | |
| 102 | 121 | footer: None, | |
| 122 | + | favicon: None, | |
| 123 | + | logo: None, | |
| 124 | + | assets_dir: None, | |
| 103 | 125 | } | |
| 104 | 126 | } | |
| 105 | 127 | } | |
| @@ -157,6 +179,8 @@ pub struct UiConfig { | |||
| 157 | 179 | /// Default order of the repository index (`?sort=` overrides it). | |
| 158 | 180 | pub index_sort: IndexSort, | |
| 159 | 181 | pub code_font: String, | |
| 182 | + | /// `#rgb` or `#rrggbb`; replaces the default red accent. | |
| 183 | + | pub accent: Option<String>, | |
| 160 | 184 | pub max_blob_kb: u64, | |
| 161 | 185 | pub max_highlight_kb: u64, | |
| 162 | 186 | /// Commits per log page. | |
| @@ -169,6 +193,7 @@ impl Default for UiConfig { | |||
| 169 | 193 | theme: Theme::Auto, | |
| 170 | 194 | index_sort: IndexSort::Activity, | |
| 171 | 195 | code_font: DEFAULT_CODE_FONT.into(), | |
| 196 | + | accent: None, | |
| 172 | 197 | max_blob_kb: 1024, | |
| 173 | 198 | max_highlight_kb: 256, | |
| 174 | 199 | log_page: 50, | |
| @@ -441,8 +466,30 @@ impl Config { | |||
| 441 | 466 | ), | |
| 442 | 467 | header: read_custom(&base_dir, "site.header", &mut self.site.header, &mut errors), | |
| 443 | 468 | 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), | |
| 444 | 476 | }; | |
| 445 | 477 | ||
| 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 | + | ||
| 446 | 493 | // repos | |
| 447 | 494 | if self.repos.scan_path.is_relative() { | |
| 448 | 495 | self.repos.scan_path = base_dir.join(&self.repos.scan_path); | |
| @@ -483,6 +530,14 @@ impl Config { | |||
| 483 | 530 | self.ui.code_font | |
| 484 | 531 | )); | |
| 485 | 532 | } | |
| 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 | + | } | |
| 486 | 541 | if self.ui.max_blob_kb == 0 { | |
| 487 | 542 | errors.push("ui.max_blob_kb must be greater than 0".into()); | |
| 488 | 543 | } | |
| @@ -559,6 +614,81 @@ fn read_custom( | |||
| 559 | 614 | } | |
| 560 | 615 | } | |
| 561 | 616 | ||
| 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 | + | ||
| 562 | 692 | #[cfg(test)] | |
| 563 | 693 | mod tests { | |
| 564 | 694 | use super::*; | |
| @@ -699,4 +829,70 @@ mod tests { | |||
| 699 | 829 | let err = Config::load(Some(&path)).unwrap_err().to_string(); | |
| 700 | 830 | assert!(err.contains("site.footer"), "{err}"); | |
| 701 | 831 | } | |
| 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 | + | } | |
| 702 | 898 | } | |
| @@ -40,6 +40,10 @@ pub struct Layout { | |||
| 40 | 40 | pub site_css: Option<String>, | |
| 41 | 41 | pub header: Option<Arc<str>>, | |
| 42 | 42 | 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>, | |
| 43 | 47 | pub version: &'static str, | |
| 44 | 48 | /// Atom feed advertised in `<head>`. | |
| 45 | 49 | pub feed: Option<String>, | |
| @@ -50,8 +54,21 @@ impl Layout { | |||
| 50 | 54 | pub fn new(state: &AppState, config: &Config, title: Option<&str>) -> Self { | |
| 51 | 55 | let base = config.server.base_path.clone(); | |
| 52 | 56 | let site_title = config.site.title.clone(); | |
| 57 | + | let static_base = format!("{base}-/static/{}/", state.assets.hash()); | |
| 53 | 58 | 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, | |
| 55 | 72 | site_css: assets::site_css_version(config).map(|v| format!("{base}-/site.css?v={v}")), | |
| 56 | 73 | page_title: match title { | |
| 57 | 74 | Some(t) => format!("{t} · {site_title}"), | |
| @@ -8,9 +8,9 @@ | |||
| 8 | 8 | use std::hash::{Hash, Hasher}; | |
| 9 | 9 | use std::sync::Arc; | |
| 10 | 10 | ||
| 11 | − | use axum::body::Bytes; | |
| 11 | + | use axum::body::{Body, Bytes}; | |
| 12 | 12 | use axum::extract::{Path, State}; | |
| 13 | − | use axum::http::{HeaderValue, StatusCode, header}; | |
| 13 | + | use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; | |
| 14 | 14 | use axum::response::{IntoResponse, Response}; | |
| 15 | 15 | use rust_embed::RustEmbed; | |
| 16 | 16 | ||
| @@ -127,8 +127,8 @@ pub async fn serve( | |||
| 127 | 127 | .into_response() | |
| 128 | 128 | } | |
| 129 | 129 | ||
| 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 | |
| 132 | 132 | /// serve; versioned by [`site_css_version`]. | |
| 133 | 133 | pub async fn site_css(State(state): State<Arc<AppState>>) -> Response { | |
| 134 | 134 | let config = state.config.load(); | |
| @@ -157,12 +157,129 @@ pub fn site_css_body(config: &crate::config::Config) -> Option<String> { | |||
| 157 | 157 | config.ui.code_font | |
| 158 | 158 | )); | |
| 159 | 159 | } | |
| 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 | + | } | |
| 160 | 168 | if let Some(custom) = &config.custom.css { | |
| 161 | 169 | css.push_str(custom); | |
| 162 | 170 | } | |
| 163 | 171 | (!css.is_empty()).then_some(css) | |
| 164 | 172 | } | |
| 165 | 173 | ||
| 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 | + | ||
| 166 | 283 | /// Short content hash for the `?v=` query of the site stylesheet link. | |
| 167 | 284 | pub fn site_css_version(config: &crate::config::Config) -> Option<String> { | |
| 168 | 285 | let body = site_css_body(config)?; | |
| @@ -187,6 +304,29 @@ mod tests { | |||
| 187 | 304 | assert!(text.contains("--text-display")); | |
| 188 | 305 | } | |
| 189 | 306 | ||
| 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 | + | ||
| 190 | 330 | #[test] | |
| 191 | 331 | fn content_types() { | |
| 192 | 332 | assert_eq!(content_type("css/app.css"), "text/css; charset=utf-8"); | |
| @@ -42,6 +42,9 @@ pub fn app(state: Arc<AppState>) -> Router { | |||
| 42 | 42 | .route("/", get(handlers::index::index)) | |
| 43 | 43 | .route("/-/health", get(health)) | |
| 44 | 44 | .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)) | |
| 45 | 48 | .route("/-/static/{hash}/{*path}", get(assets::serve)) | |
| 46 | 49 | .route("/robots.txt", get(robots)) | |
| 47 | 50 | .route("/{*path}", any(dispatch::dispatch)) | |
| @@ -14,14 +14,17 @@ | |||
| 14 | 14 | {%- if let Some(feed) = layout.feed %} | |
| 15 | 15 | <link rel="alternate" type="application/atom+xml" title="Commits" href="{{ feed }}"> | |
| 16 | 16 | {%- 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 }}"> | |
| 18 | 18 | <script src="{{ layout.static_base }}js/theme.js"></script> | |
| 19 | 19 | <script src="{{ layout.static_base }}js/app.js" defer></script> | |
| 20 | 20 | {%- block head %}{% endblock %} | |
| 21 | 21 | </head> | |
| 22 | 22 | <body> | |
| 23 | 23 | <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> | |
| 25 | 28 | <div class="segmented theme-switch" role="group" aria-label="Color theme"> | |
| 26 | 29 | <button type="button" data-theme-choice="auto" aria-pressed="false">Auto</button> | |
| 27 | 30 | <button type="button" data-theme-choice="light" aria-pressed="false">Light</button> | |
| @@ -188,6 +188,101 @@ async fn site_css_carries_code_font_and_custom_css() { | |||
| 188 | 188 | assert!(css.contains("--font-code: \"Berkeley Mono\"")); | |
| 189 | 189 | } | |
| 190 | 190 | ||
| 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 | + | ||
| 191 | 286 | #[tokio::test] | |
| 192 | 287 | async fn compression_skips_fonts() { | |
| 193 | 288 | let (app, state) = app_with(""); | |