ferrenium55c1225
Ferrenium: skeleton, repository index and browsing (M0–M2)
A read-only Git repository browser that runs as a persistent daemon. - M0: clap CLI (serve/check/example), validated TOML config, tcp/unix listeners, graceful shutdown, sd-notify, SIGHUP reload, embedded hashed assets, Nothing-style layout with theme toggle, strict CSP. - M1: repository discovery under scan_path, metadata precedence ([[repo]] > git config > description file), hiding, copy-on-write index, inotify watcher with debounce plus poll fallback, index page. - M2: catch-all dispatcher (longest repo and ref prefix), summary, tree, blob (syntect, monochrome classes, per-line balanced spans), raw with sandbox CSP, README rendering (comrak + ammonia, relative URL rewrite), refs page, branch picker, moka cache, weak ETags with 304s.
+11915 −0
75 files changed
- A .gitignore +4 −0
- A Cargo.lock +3334 −0
- A Cargo.toml +42 −0
- A assets/css/base.css +106 −0
- A assets/css/components.css +423 −0
- A assets/css/fonts.css +95 −0
- A assets/css/repo.css +709 −0
- A assets/css/syntax.css +25 −0
- A assets/css/tokens.css +94 −0
- A assets/fonts/OFL.txt +98 −0
- A assets/fonts/doto-latin-ext-wght.woff2
- A assets/fonts/doto-latin-wght.woff2
- A assets/fonts/space-grotesk-latin-ext-wght.woff2
- A assets/fonts/space-grotesk-latin-wght.woff2
- A assets/fonts/space-grotesk-vietnamese-wght.woff2
- A assets/fonts/space-mono-latin-400.woff2
- A assets/fonts/space-mono-latin-700.woff2
- A assets/fonts/space-mono-latin-ext-400.woff2
- A assets/fonts/space-mono-latin-ext-700.woff2
- A assets/fonts/space-mono-vietnamese-400.woff2
- A assets/fonts/space-mono-vietnamese-700.woff2
- A assets/icons/LICENSE-lucide.txt +15 −0
- A assets/icons/favicon.svg +5 −0
- A assets/icons/sprite.svg +12 −0
- A assets/js/app.js +129 −0
- A assets/js/theme.js +45 −0
- A dist/Caddyfile +5 −0
- A dist/ferrenium.service +43 −0
- A dist/ferrenium.toml +60 −0
- A src/cache.rs +143 −0
- A src/config.rs +643 −0
- A src/examples.rs +5 −0
- A src/git/mod.rs +226 −0
- A src/git/refs.rs +167 −0
- A src/git/tree.rs +125 −0
- A src/handlers/blob.rs +223 −0
- A src/handlers/error.rs +34 −0
- A src/handlers/index.rs +156 −0
- A src/handlers/mod.rs +71 −0
- A src/handlers/raw.rs +102 −0
- A src/handlers/refs.rs +83 −0
- A src/handlers/repo.rs +306 −0
- A src/handlers/summary.rs +178 −0
- A src/handlers/tree.rs +201 −0
- A src/index/meta.rs +167 −0
- A src/index/mod.rs +253 −0
- A src/index/scan.rs +186 −0
- A src/index/watch.rs +266 −0
- A src/lib.rs +12 −0
- A src/logging.rs +56 −0
- A src/main.rs +183 −0
- A src/render/highlight.rs +252 −0
- A src/render/markdown.rs +314 −0
- A src/render/mod.rs +115 −0
- A src/render/time.rs +63 −0
- A src/server/assets.rs +200 −0
- A src/server/dispatch.rs +141 −0
- A src/server/headers.rs +19 −0
- A src/server/mod.rs +297 −0
- A src/state.rs +109 −0
- A templates/base.html +41 −0
- A templates/blob.html +42 −0
- A templates/crumbs.html +4 −0
- A templates/error.html +8 −0
- A templates/index.html +56 −0
- A templates/macros.html +24 −0
- A templates/ref_table.html +16 −0
- A templates/refs.html +23 −0
- A templates/repo_nav.html +36 −0
- A templates/summary.html +66 −0
- A templates/tree.html +11 −0
- A tests/browse.rs +409 −0
- A tests/common/mod.rs +153 −0
- A tests/http.rs +215 −0
- A tests/index.rs +271 −0
| @@ -0,0 +1,4 @@ | |||
| 1 | + | /target | |
| 2 | + | /dev/ | |
| 3 | + | /CLAUDE.md | |
| 4 | + | /PLAN.md | |
[Diff too large] View file
| @@ -0,0 +1,42 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "ferrenium" | |
| 3 | + | version = "0.1.0" | |
| 4 | + | edition = "2024" | |
| 5 | + | rust-version = "1.89" | |
| 6 | + | description = "A fast, read-only Git repository browser" | |
| 7 | + | publish = false | |
| 8 | + | ||
| 9 | + | [dependencies] | |
| 10 | + | ammonia = "4.2" | |
| 11 | + | anyhow = "1.0.104" | |
| 12 | + | arc-swap = "1.9.3" | |
| 13 | + | askama = "0.16.1" | |
| 14 | + | axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio", "query"] } | |
| 15 | + | clap = { version = "4.6.7", features = ["derive", "env"] } | |
| 16 | + | comrak = { version = "0.59", default-features = false } | |
| 17 | + | # No `extras`/`interrupt`: they install their own signal handlers. | |
| 18 | + | gix = { version = "0.90.0", default-features = false, features = ["max-performance", "revision", "blob-diff", "blame", "mailmap", "worktree-archive", "sha1"] } | |
| 19 | + | jiff = "0.2.39" | |
| 20 | + | mime_guess = "2.0.5" | |
| 21 | + | moka = { version = "0.12", features = ["sync"] } | |
| 22 | + | notify = { version = "8.2.1", default-features = false } | |
| 23 | + | rust-embed = "8.13.0" | |
| 24 | + | sd-notify = "0.5.0" | |
| 25 | + | serde = { version = "1.0.229", features = ["derive"] } | |
| 26 | + | syntect = { version = "5.3", default-features = false, features = ["parsing", "html", "regex-fancy"] } | |
| 27 | + | thiserror = "2.0.21" | |
| 28 | + | tokio = { version = "1.54.1", features = ["rt-multi-thread", "macros", "net", "signal", "sync", "time"] } | |
| 29 | + | toml = "1.2.0" | |
| 30 | + | tower = { version = "0.5.3", features = ["util"] } | |
| 31 | + | tower-http = { version = "0.7.1", features = ["trace", "timeout", "catch-panic", "compression-gzip", "compression-br", "set-header"] } | |
| 32 | + | tracing = "0.1.44" | |
| 33 | + | tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt"] } | |
| 34 | + | two-face = { version = "0.5", default-features = false, features = ["syntect-fancy"] } | |
| 35 | + | ||
| 36 | + | [dev-dependencies] | |
| 37 | + | tempfile = "3" | |
| 38 | + | ||
| 39 | + | [profile.release] | |
| 40 | + | lto = "thin" | |
| 41 | + | codegen-units = 1 | |
| 42 | + | strip = true | |
| @@ -0,0 +1,106 @@ | |||
| 1 | + | /* Base: reset, document typography, page shell. */ | |
| 2 | + | ||
| 3 | + | *, | |
| 4 | + | *::before, | |
| 5 | + | *::after { | |
| 6 | + | box-sizing: border-box; | |
| 7 | + | } | |
| 8 | + | ||
| 9 | + | html { | |
| 10 | + | -webkit-text-size-adjust: 100%; | |
| 11 | + | text-size-adjust: 100%; | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | body { | |
| 15 | + | margin: 0; | |
| 16 | + | min-height: 100vh; | |
| 17 | + | display: flex; | |
| 18 | + | flex-direction: column; | |
| 19 | + | background: var(--black); | |
| 20 | + | color: var(--text-primary); | |
| 21 | + | font-family: var(--font-body); | |
| 22 | + | font-size: var(--body); | |
| 23 | + | font-weight: 400; | |
| 24 | + | line-height: 1.5; | |
| 25 | + | -webkit-font-smoothing: antialiased; | |
| 26 | + | -moz-osx-font-smoothing: grayscale; | |
| 27 | + | text-rendering: optimizeLegibility; | |
| 28 | + | font-synthesis: none; | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd, ul, ol { | |
| 32 | + | margin: 0; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | h1, h2, h3, h4, h5, h6 { | |
| 36 | + | font-weight: 500; | |
| 37 | + | color: var(--text-display); | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | ul, ol { | |
| 41 | + | padding: 0; | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | img, svg, video { | |
| 45 | + | display: block; | |
| 46 | + | max-width: 100%; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | button, input, select, textarea { | |
| 50 | + | font: inherit; | |
| 51 | + | color: inherit; | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | button { | |
| 55 | + | cursor: pointer; | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | a { | |
| 59 | + | color: var(--interactive); | |
| 60 | + | text-decoration: none; | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | a:hover { | |
| 64 | + | text-decoration: underline; | |
| 65 | + | text-underline-offset: 3px; | |
| 66 | + | text-decoration-thickness: 1px; | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | code, kbd, samp, pre { | |
| 70 | + | font-family: var(--font-code); | |
| 71 | + | font-size: var(--body-sm); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | ::selection { | |
| 75 | + | background: var(--text-display); | |
| 76 | + | color: var(--black); | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | :focus-visible { | |
| 80 | + | outline: 1px solid var(--text-display); | |
| 81 | + | outline-offset: 2px; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | [hidden] { | |
| 85 | + | display: none !important; | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /* Page shell: one centered column with responsive gutters. */ | |
| 89 | + | .shell { | |
| 90 | + | width: 100%; | |
| 91 | + | max-width: calc(var(--page-max) + 2 * var(--gutter)); | |
| 92 | + | margin-inline: auto; | |
| 93 | + | padding-inline: var(--gutter); | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | main.shell { | |
| 97 | + | flex: 1; | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | @media (prefers-reduced-motion: reduce) { | |
| 101 | + | *, | |
| 102 | + | *::before, | |
| 103 | + | *::after { | |
| 104 | + | transition-duration: 0ms !important; | |
| 105 | + | } | |
| 106 | + | } | |
| @@ -0,0 +1,423 @@ | |||
| 1 | + | /* Components: labels, display type, top bar, theme switch, page patterns. */ | |
| 2 | + | ||
| 3 | + | /* ---- Type roles ------------------------------------------------------- */ | |
| 4 | + | ||
| 5 | + | .label { | |
| 6 | + | font-family: var(--font-mono); | |
| 7 | + | font-size: var(--label); | |
| 8 | + | font-weight: 400; | |
| 9 | + | line-height: 1.2; | |
| 10 | + | letter-spacing: 0.08em; | |
| 11 | + | text-transform: uppercase; | |
| 12 | + | color: var(--text-secondary); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | .dim { | |
| 16 | + | color: var(--text-disabled); | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | .doto { | |
| 20 | + | font-family: var(--font-display); | |
| 21 | + | font-weight: 600; | |
| 22 | + | color: var(--text-display); | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | .display-xl { | |
| 26 | + | font-size: var(--display-xl); | |
| 27 | + | line-height: 1; | |
| 28 | + | letter-spacing: -0.03em; | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | .display-lg { | |
| 32 | + | font-size: var(--display-lg); | |
| 33 | + | line-height: 1.05; | |
| 34 | + | letter-spacing: -0.02em; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | .display-md { | |
| 38 | + | font-size: var(--display-md); | |
| 39 | + | line-height: 1.1; | |
| 40 | + | letter-spacing: -0.02em; | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /* Inline status text: [LOADING], [COPIED], [ERROR: …]. */ | |
| 44 | + | .status { | |
| 45 | + | font-family: var(--font-mono); | |
| 46 | + | font-size: var(--caption); | |
| 47 | + | font-weight: 400; | |
| 48 | + | line-height: 1.4; | |
| 49 | + | letter-spacing: 0.04em; | |
| 50 | + | text-transform: uppercase; | |
| 51 | + | color: var(--text-secondary); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | .status-error { | |
| 55 | + | color: var(--error); | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /* ---- Buttons ---------------------------------------------------------- */ | |
| 59 | + | ||
| 60 | + | .btn-ghost { | |
| 61 | + | display: inline-flex; | |
| 62 | + | align-items: center; | |
| 63 | + | min-height: 44px; | |
| 64 | + | font-family: var(--font-mono); | |
| 65 | + | font-size: 13px; | |
| 66 | + | letter-spacing: 0.06em; | |
| 67 | + | text-transform: uppercase; | |
| 68 | + | color: var(--text-secondary); | |
| 69 | + | background: none; | |
| 70 | + | border: 0; | |
| 71 | + | transition: color var(--dur-micro) var(--ease); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | .btn-ghost:hover { | |
| 75 | + | color: var(--text-display); | |
| 76 | + | text-decoration: none; | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /* ---- Top bar ---------------------------------------------------------- */ | |
| 80 | + | ||
| 81 | + | .topbar { | |
| 82 | + | display: flex; | |
| 83 | + | align-items: center; | |
| 84 | + | justify-content: space-between; | |
| 85 | + | gap: var(--space-md); | |
| 86 | + | min-height: 64px; | |
| 87 | + | padding-block: var(--space-md); | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | .topbar-home { | |
| 91 | + | color: var(--text-display); | |
| 92 | + | white-space: nowrap; | |
| 93 | + | overflow: hidden; | |
| 94 | + | text-overflow: ellipsis; | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | .topbar-home:hover { | |
| 98 | + | text-decoration: none; | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /* ---- Segmented control ------------------------------------------------ */ | |
| 102 | + | ||
| 103 | + | .segmented { | |
| 104 | + | display: inline-flex; | |
| 105 | + | flex: none; | |
| 106 | + | padding: var(--space-2xs); | |
| 107 | + | border: 1px solid var(--border-visible); | |
| 108 | + | border-radius: 999px; | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | .segmented button, | |
| 112 | + | .segmented a { | |
| 113 | + | display: inline-flex; | |
| 114 | + | align-items: center; | |
| 115 | + | height: 30px; | |
| 116 | + | padding: 0 var(--space-md); | |
| 117 | + | border: 0; | |
| 118 | + | border-radius: 999px; | |
| 119 | + | background: transparent; | |
| 120 | + | font-family: var(--font-mono); | |
| 121 | + | font-size: var(--label); | |
| 122 | + | letter-spacing: 0.08em; | |
| 123 | + | text-transform: uppercase; | |
| 124 | + | color: var(--text-secondary); | |
| 125 | + | transition: | |
| 126 | + | background-color 200ms var(--ease), | |
| 127 | + | color 200ms var(--ease); | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | .segmented button:hover, | |
| 131 | + | .segmented a:hover { | |
| 132 | + | color: var(--text-primary); | |
| 133 | + | text-decoration: none; | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | .segmented button[aria-pressed="true"], | |
| 137 | + | .segmented a[aria-current="true"] { | |
| 138 | + | background: var(--text-display); | |
| 139 | + | color: var(--black); | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | .segmented-sm button, | |
| 143 | + | .segmented-sm a { | |
| 144 | + | height: 26px; | |
| 145 | + | padding: 0 var(--space-sm); | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | @media (pointer: coarse) { | |
| 149 | + | .segmented button, | |
| 150 | + | .segmented a { | |
| 151 | + | height: 40px; | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | /* The theme switch needs JS; without it the OS preference applies. */ | |
| 156 | + | html:not(.js) .theme-switch { | |
| 157 | + | display: none; | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /* ---- Site header / footer --------------------------------------------- */ | |
| 161 | + | ||
| 162 | + | .site-header { | |
| 163 | + | padding-block: var(--space-sm); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | .footer { | |
| 167 | + | display: flex; | |
| 168 | + | flex-wrap: wrap; | |
| 169 | + | align-items: flex-end; | |
| 170 | + | justify-content: space-between; | |
| 171 | + | gap: var(--space-md); | |
| 172 | + | margin-top: var(--space-4xl); | |
| 173 | + | padding-block: var(--space-xl); | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | .site-footer { | |
| 177 | + | font-size: var(--body-sm); | |
| 178 | + | color: var(--text-secondary); | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | /* ---- Index ------------------------------------------------------------ */ | |
| 182 | + | ||
| 183 | + | .hero { | |
| 184 | + | display: flex; | |
| 185 | + | flex-wrap: wrap; | |
| 186 | + | align-items: flex-end; | |
| 187 | + | justify-content: space-between; | |
| 188 | + | gap: var(--space-md) var(--space-xl); | |
| 189 | + | padding-top: var(--space-3xl); | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | .hero-main { | |
| 193 | + | min-width: 0; | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | .hero h1 { | |
| 197 | + | overflow-wrap: anywhere; | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | .hero-desc { | |
| 201 | + | margin-top: var(--space-sm); | |
| 202 | + | max-width: 60ch; | |
| 203 | + | color: var(--text-secondary); | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | .hero-meta { | |
| 207 | + | white-space: nowrap; | |
| 208 | + | padding-bottom: var(--space-xs); | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | /* ---- Inputs ----------------------------------------------------------- */ | |
| 212 | + | ||
| 213 | + | .field { | |
| 214 | + | display: flex; | |
| 215 | + | flex-direction: column; | |
| 216 | + | gap: var(--space-sm); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | .field input { | |
| 220 | + | width: 100%; | |
| 221 | + | min-height: 44px; | |
| 222 | + | padding: var(--space-sm) 0; | |
| 223 | + | border: 0; | |
| 224 | + | border-bottom: 1px solid var(--border-visible); | |
| 225 | + | border-radius: 0; | |
| 226 | + | background: transparent; | |
| 227 | + | font-family: var(--font-mono); | |
| 228 | + | font-size: var(--body); | |
| 229 | + | color: var(--text-primary); | |
| 230 | + | outline: none; | |
| 231 | + | transition: border-color var(--dur-micro) var(--ease); | |
| 232 | + | -webkit-appearance: none; | |
| 233 | + | appearance: none; | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | .field input::placeholder { | |
| 237 | + | color: var(--text-disabled); | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | .field input:focus { | |
| 241 | + | border-bottom-color: var(--text-primary); | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | .field input::-webkit-search-cancel-button { | |
| 245 | + | display: none; | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | /* ---- Index: filter, sort, repository rows ----------------------------- */ | |
| 249 | + | ||
| 250 | + | .index-tools { | |
| 251 | + | display: flex; | |
| 252 | + | flex-wrap: wrap; | |
| 253 | + | align-items: flex-end; | |
| 254 | + | justify-content: space-between; | |
| 255 | + | gap: var(--space-md) var(--space-xl); | |
| 256 | + | margin-top: var(--space-2xl); | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | .index-tools .field { | |
| 260 | + | flex: 1 1 240px; | |
| 261 | + | max-width: 360px; | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | /* Bracket nav: [ ACTIVE ] INACTIVE */ | |
| 265 | + | .sort, | |
| 266 | + | .bracket-nav { | |
| 267 | + | display: flex; | |
| 268 | + | gap: var(--space-sm); | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | .sort a, | |
| 272 | + | .bracket-nav a { | |
| 273 | + | display: inline-flex; | |
| 274 | + | align-items: center; | |
| 275 | + | min-height: 44px; | |
| 276 | + | padding-inline: var(--space-xs); | |
| 277 | + | color: var(--text-disabled); | |
| 278 | + | transition: color var(--dur-micro) var(--ease); | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | .sort a:hover, | |
| 282 | + | .bracket-nav a:hover { | |
| 283 | + | color: var(--text-secondary); | |
| 284 | + | text-decoration: none; | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | .sort a.active, | |
| 288 | + | .bracket-nav a.active { | |
| 289 | + | color: var(--text-display); | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | .sort a.active::before, | |
| 293 | + | .bracket-nav a.active::before { | |
| 294 | + | content: "[ "; | |
| 295 | + | white-space: pre; | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | .sort a.active::after, | |
| 299 | + | .bracket-nav a.active::after { | |
| 300 | + | content: " ]"; | |
| 301 | + | white-space: pre; | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | .repo-section { | |
| 305 | + | margin-top: var(--space-2xl); | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | .section-label { | |
| 309 | + | padding-bottom: var(--space-sm); | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | .repo-list { | |
| 313 | + | list-style: none; | |
| 314 | + | border-top: 1px solid var(--border); | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | .repo-row { | |
| 318 | + | display: flex; | |
| 319 | + | align-items: baseline; | |
| 320 | + | justify-content: space-between; | |
| 321 | + | gap: var(--space-md); | |
| 322 | + | border-bottom: 1px solid var(--border); | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | .repo-link { | |
| 326 | + | display: flex; | |
| 327 | + | flex-direction: column; | |
| 328 | + | gap: var(--space-2xs); | |
| 329 | + | flex: 1; | |
| 330 | + | min-width: 0; | |
| 331 | + | padding-block: 14px; | |
| 332 | + | color: var(--text-primary); | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | .repo-link:hover { | |
| 336 | + | text-decoration: none; | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | .repo-name { | |
| 340 | + | font-weight: 500; | |
| 341 | + | color: var(--text-display); | |
| 342 | + | overflow-wrap: anywhere; | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | .repo-link:hover .repo-name { | |
| 346 | + | text-decoration: underline; | |
| 347 | + | text-underline-offset: 3px; | |
| 348 | + | text-decoration-thickness: 1px; | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | .repo-desc { | |
| 352 | + | color: var(--text-secondary); | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | @media (min-width: 768px) { | |
| 356 | + | .repo-link { | |
| 357 | + | flex-direction: row; | |
| 358 | + | align-items: baseline; | |
| 359 | + | gap: var(--space-md); | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | .repo-name { | |
| 363 | + | flex: none; | |
| 364 | + | max-width: 50%; | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | .repo-desc { | |
| 368 | + | overflow: hidden; | |
| 369 | + | white-space: nowrap; | |
| 370 | + | text-overflow: ellipsis; | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | .repo-meta { | |
| 375 | + | display: inline-flex; | |
| 376 | + | align-items: center; | |
| 377 | + | gap: var(--space-sm); | |
| 378 | + | flex: none; | |
| 379 | + | color: var(--text-disabled); | |
| 380 | + | white-space: nowrap; | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | /* Data-status dot: pushed in the last 24 hours. */ | |
| 384 | + | .dot { | |
| 385 | + | width: 6px; | |
| 386 | + | height: 6px; | |
| 387 | + | border-radius: 50%; | |
| 388 | + | background: var(--accent); | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | .no-matches { | |
| 392 | + | padding-block: var(--space-2xl); | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | /* ---- Empty state ------------------------------------------------------ */ | |
| 396 | + | ||
| 397 | + | .empty { | |
| 398 | + | padding-block: var(--space-4xl); | |
| 399 | + | text-align: center; | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | .empty-title { | |
| 403 | + | color: var(--text-secondary); | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | .empty-desc { | |
| 407 | + | margin-top: var(--space-xs); | |
| 408 | + | color: var(--text-disabled); | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | /* ---- Error page ------------------------------------------------------- */ | |
| 412 | + | ||
| 413 | + | .error-page { | |
| 414 | + | display: flex; | |
| 415 | + | flex-direction: column; | |
| 416 | + | align-items: flex-start; | |
| 417 | + | gap: var(--space-md); | |
| 418 | + | padding-top: var(--space-4xl); | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | .error-page .btn-ghost { | |
| 422 | + | margin-top: var(--space-xl); | |
| 423 | + | } | |
| @@ -0,0 +1,95 @@ | |||
| 1 | + | /* Self-hosted fonts (OFL, see fonts/OFL.txt). Subsets via Fontsource. */ | |
| 2 | + | ||
| 3 | + | /* Doto — dot-matrix display face, hero moments only (36px+). */ | |
| 4 | + | @font-face { | |
| 5 | + | font-family: "Doto"; | |
| 6 | + | font-style: normal; | |
| 7 | + | font-weight: 100 900; | |
| 8 | + | font-display: block; | |
| 9 | + | src: url(../fonts/doto-latin-ext-wght.woff2) format("woff2"); | |
| 10 | + | unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; | |
| 11 | + | } | |
| 12 | + | @font-face { | |
| 13 | + | font-family: "Doto"; | |
| 14 | + | font-style: normal; | |
| 15 | + | font-weight: 100 900; | |
| 16 | + | font-display: block; | |
| 17 | + | src: url(../fonts/doto-latin-wght.woff2) format("woff2"); | |
| 18 | + | unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | /* Space Grotesk — body and UI (variable 300–700). */ | |
| 22 | + | @font-face { | |
| 23 | + | font-family: "Space Grotesk"; | |
| 24 | + | font-style: normal; | |
| 25 | + | font-weight: 300 700; | |
| 26 | + | font-display: swap; | |
| 27 | + | src: url(../fonts/space-grotesk-vietnamese-wght.woff2) format("woff2"); | |
| 28 | + | unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB; | |
| 29 | + | } | |
| 30 | + | @font-face { | |
| 31 | + | font-family: "Space Grotesk"; | |
| 32 | + | font-style: normal; | |
| 33 | + | font-weight: 300 700; | |
| 34 | + | font-display: swap; | |
| 35 | + | src: url(../fonts/space-grotesk-latin-ext-wght.woff2) format("woff2"); | |
| 36 | + | unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; | |
| 37 | + | } | |
| 38 | + | @font-face { | |
| 39 | + | font-family: "Space Grotesk"; | |
| 40 | + | font-style: normal; | |
| 41 | + | font-weight: 300 700; | |
| 42 | + | font-display: swap; | |
| 43 | + | src: url(../fonts/space-grotesk-latin-wght.woff2) format("woff2"); | |
| 44 | + | unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /* Space Mono — data, labels and code. */ | |
| 48 | + | @font-face { | |
| 49 | + | font-family: "Space Mono"; | |
| 50 | + | font-style: normal; | |
| 51 | + | font-weight: 400; | |
| 52 | + | font-display: swap; | |
| 53 | + | src: url(../fonts/space-mono-vietnamese-400.woff2) format("woff2"); | |
| 54 | + | unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB; | |
| 55 | + | } | |
| 56 | + | @font-face { | |
| 57 | + | font-family: "Space Mono"; | |
| 58 | + | font-style: normal; | |
| 59 | + | font-weight: 400; | |
| 60 | + | font-display: swap; | |
| 61 | + | src: url(../fonts/space-mono-latin-ext-400.woff2) format("woff2"); | |
| 62 | + | unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; | |
| 63 | + | } | |
| 64 | + | @font-face { | |
| 65 | + | font-family: "Space Mono"; | |
| 66 | + | font-style: normal; | |
| 67 | + | font-weight: 400; | |
| 68 | + | font-display: swap; | |
| 69 | + | src: url(../fonts/space-mono-latin-400.woff2) format("woff2"); | |
| 70 | + | unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; | |
| 71 | + | } | |
| 72 | + | @font-face { | |
| 73 | + | font-family: "Space Mono"; | |
| 74 | + | font-style: normal; | |
| 75 | + | font-weight: 700; | |
| 76 | + | font-display: swap; | |
| 77 | + | src: url(../fonts/space-mono-vietnamese-700.woff2) format("woff2"); | |
| 78 | + | unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB; | |
| 79 | + | } | |
| 80 | + | @font-face { | |
| 81 | + | font-family: "Space Mono"; | |
| 82 | + | font-style: normal; | |
| 83 | + | font-weight: 700; | |
| 84 | + | font-display: swap; | |
| 85 | + | src: url(../fonts/space-mono-latin-ext-700.woff2) format("woff2"); | |
| 86 | + | unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; | |
| 87 | + | } | |
| 88 | + | @font-face { | |
| 89 | + | font-family: "Space Mono"; | |
| 90 | + | font-style: normal; | |
| 91 | + | font-weight: 700; | |
| 92 | + | font-display: swap; | |
| 93 | + | src: url(../fonts/space-mono-latin-700.woff2) format("woff2"); | |
| 94 | + | unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; | |
| 95 | + | } | |
| @@ -0,0 +1,709 @@ | |||
| 1 | + | /* Repository pages: nav, ref picker, summary, tree, blob, refs, Markdown. */ | |
| 2 | + | ||
| 3 | + | .mono { | |
| 4 | + | font-family: var(--font-mono); | |
| 5 | + | } | |
| 6 | + | ||
| 7 | + | .icon { | |
| 8 | + | width: 16px; | |
| 9 | + | height: 16px; | |
| 10 | + | flex: none; | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | /* ---- Repository bar: [ SUMMARY ] TREE REFS · ref picker ------------- */ | |
| 14 | + | ||
| 15 | + | .repo-bar { | |
| 16 | + | display: flex; | |
| 17 | + | flex-wrap: wrap; | |
| 18 | + | align-items: center; | |
| 19 | + | justify-content: space-between; | |
| 20 | + | gap: var(--space-sm) var(--space-md); | |
| 21 | + | padding-top: var(--space-sm); | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | .bracket-nav { | |
| 25 | + | overflow-x: auto; | |
| 26 | + | scrollbar-width: none; | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | .bracket-nav a { | |
| 30 | + | white-space: nowrap; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | .ref-picker { | |
| 34 | + | position: relative; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | .ref-picker summary { | |
| 38 | + | display: inline-flex; | |
| 39 | + | align-items: center; | |
| 40 | + | gap: var(--space-sm); | |
| 41 | + | min-height: 44px; | |
| 42 | + | padding: 0 var(--space-md); | |
| 43 | + | border: 1px solid var(--border-visible); | |
| 44 | + | border-radius: 999px; | |
| 45 | + | list-style: none; | |
| 46 | + | cursor: pointer; | |
| 47 | + | color: var(--text-secondary); | |
| 48 | + | transition: border-color var(--dur-micro) var(--ease); | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | .ref-picker summary::-webkit-details-marker { | |
| 52 | + | display: none; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | .ref-picker summary:hover, | |
| 56 | + | .ref-picker[open] summary { | |
| 57 | + | border-color: var(--text-secondary); | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | .ref-current { | |
| 61 | + | max-width: 24ch; | |
| 62 | + | overflow: hidden; | |
| 63 | + | text-overflow: ellipsis; | |
| 64 | + | white-space: nowrap; | |
| 65 | + | font-family: var(--font-mono); | |
| 66 | + | font-size: var(--caption); | |
| 67 | + | color: var(--text-primary); | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | .ref-picker[open] .chevron { | |
| 71 | + | transform: rotate(180deg); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | .ref-menu { | |
| 75 | + | position: absolute; | |
| 76 | + | z-index: 10; | |
| 77 | + | right: 0; | |
| 78 | + | top: calc(100% + var(--space-xs)); | |
| 79 | + | width: max-content; | |
| 80 | + | min-width: 240px; | |
| 81 | + | max-width: min(360px, calc(100vw - 2 * var(--gutter))); | |
| 82 | + | max-height: 60vh; | |
| 83 | + | overflow-y: auto; | |
| 84 | + | padding: var(--space-sm) 0; | |
| 85 | + | border: 1px solid var(--border-visible); | |
| 86 | + | border-radius: 8px; | |
| 87 | + | background: var(--surface-raised); | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | .ref-group { | |
| 91 | + | padding: var(--space-sm) var(--space-md) var(--space-xs); | |
| 92 | + | color: var(--text-disabled); | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | .ref-menu ul { | |
| 96 | + | list-style: none; | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | .ref-menu li a { | |
| 100 | + | display: flex; | |
| 101 | + | align-items: center; | |
| 102 | + | min-height: 44px; | |
| 103 | + | padding: 0 var(--space-md); | |
| 104 | + | border-left: 2px solid transparent; | |
| 105 | + | font-family: var(--font-mono); | |
| 106 | + | font-size: var(--caption); | |
| 107 | + | color: var(--text-primary); | |
| 108 | + | overflow-wrap: anywhere; | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | .ref-menu li a:hover { | |
| 112 | + | color: var(--text-display); | |
| 113 | + | text-decoration: none; | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | .ref-menu li a.active { | |
| 117 | + | border-left-color: var(--accent); | |
| 118 | + | color: var(--text-display); | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | .ref-all { | |
| 122 | + | display: flex; | |
| 123 | + | align-items: center; | |
| 124 | + | min-height: 44px; | |
| 125 | + | padding: 0 var(--space-md); | |
| 126 | + | margin-top: var(--space-xs); | |
| 127 | + | border-top: 1px solid var(--border); | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | /* ---- Breadcrumb: secondary path, current segment in display ------------ */ | |
| 131 | + | ||
| 132 | + | .crumbs { | |
| 133 | + | padding-top: var(--space-2xl); | |
| 134 | + | font-size: var(--heading); | |
| 135 | + | font-weight: 400; | |
| 136 | + | line-height: 1.25; | |
| 137 | + | color: var(--text-secondary); | |
| 138 | + | overflow-wrap: anywhere; | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | .crumbs a { | |
| 142 | + | color: var(--text-secondary); | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | .crumbs a:hover { | |
| 146 | + | color: var(--text-display); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | .crumb-sep { | |
| 150 | + | padding: 0 var(--space-sm); | |
| 151 | + | color: var(--text-disabled); | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | .crumb-here { | |
| 155 | + | color: var(--text-display); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /* ---- Summary ------------------------------------------------------------ */ | |
| 159 | + | ||
| 160 | + | .repo-hero { | |
| 161 | + | padding-top: var(--space-2xl); | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | .repo-title { | |
| 165 | + | font-weight: 500; | |
| 166 | + | overflow-wrap: anywhere; | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | .repo-about { | |
| 170 | + | margin-top: var(--space-sm); | |
| 171 | + | max-width: 60ch; | |
| 172 | + | color: var(--text-secondary); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | .repo-grid { | |
| 176 | + | display: grid; | |
| 177 | + | grid-template-columns: minmax(0, 1fr); | |
| 178 | + | gap: var(--space-2xl); | |
| 179 | + | margin-top: var(--space-xl); | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | @media (min-width: 768px) { | |
| 183 | + | .repo-grid { | |
| 184 | + | grid-template-columns: minmax(0, 1fr) var(--rail); | |
| 185 | + | gap: var(--space-xl); | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | @media (min-width: 1200px) { | |
| 190 | + | .repo-grid { | |
| 191 | + | gap: var(--space-2xl); | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | .rail { | |
| 196 | + | display: flex; | |
| 197 | + | flex-direction: column; | |
| 198 | + | gap: var(--space-xl); | |
| 199 | + | min-width: 0; | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | .stats { | |
| 203 | + | border-top: 1px solid var(--border); | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | .stat { | |
| 207 | + | display: flex; | |
| 208 | + | align-items: baseline; | |
| 209 | + | justify-content: space-between; | |
| 210 | + | gap: var(--space-md); | |
| 211 | + | padding-block: 12px; | |
| 212 | + | border-bottom: 1px solid var(--border); | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | .stat dd { | |
| 216 | + | margin: 0; | |
| 217 | + | font-family: var(--font-mono); | |
| 218 | + | font-size: var(--body-sm); | |
| 219 | + | color: var(--text-primary); | |
| 220 | + | text-align: right; | |
| 221 | + | overflow-wrap: anywhere; | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | .rail-head { | |
| 225 | + | display: flex; | |
| 226 | + | align-items: center; | |
| 227 | + | justify-content: space-between; | |
| 228 | + | gap: var(--space-sm); | |
| 229 | + | min-height: 30px; | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | .rail-block > .label { | |
| 233 | + | display: block; | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | .clone-row { | |
| 237 | + | display: flex; | |
| 238 | + | align-items: center; | |
| 239 | + | gap: var(--space-xs); | |
| 240 | + | margin-top: var(--space-sm); | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | .clone-input { | |
| 244 | + | flex: 1; | |
| 245 | + | min-width: 0; | |
| 246 | + | min-height: 44px; | |
| 247 | + | padding: 0; | |
| 248 | + | border: 0; | |
| 249 | + | border-bottom: 1px solid var(--border-visible); | |
| 250 | + | border-radius: 0; | |
| 251 | + | background: transparent; | |
| 252 | + | font-family: var(--font-mono); | |
| 253 | + | font-size: var(--caption); | |
| 254 | + | color: var(--text-primary); | |
| 255 | + | outline: none; | |
| 256 | + | text-overflow: ellipsis; | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | .clone-input:focus { | |
| 260 | + | border-bottom-color: var(--text-primary); | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | .icon-btn { | |
| 264 | + | display: inline-flex; | |
| 265 | + | align-items: center; | |
| 266 | + | justify-content: center; | |
| 267 | + | width: 44px; | |
| 268 | + | height: 44px; | |
| 269 | + | flex: none; | |
| 270 | + | padding: 0; | |
| 271 | + | border: 0; | |
| 272 | + | background: none; | |
| 273 | + | color: var(--text-secondary); | |
| 274 | + | transition: color var(--dur-micro) var(--ease); | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | .icon-btn:hover { | |
| 278 | + | color: var(--text-display); | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | /* Copying and switching protocols need JS; without it every URL shows. */ | |
| 282 | + | html:not(.js) .clone-switch, | |
| 283 | + | html:not(.js) [data-copy], | |
| 284 | + | html:not(.js) .copy-status { | |
| 285 | + | display: none; | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | .copy-status { | |
| 289 | + | min-height: 1.4em; | |
| 290 | + | margin-top: var(--space-xs); | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | .latest-subject { | |
| 294 | + | margin-top: var(--space-sm); | |
| 295 | + | color: var(--text-primary); | |
| 296 | + | overflow-wrap: anywhere; | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | .latest-meta { | |
| 300 | + | margin-top: var(--space-xs); | |
| 301 | + | font-family: var(--font-mono); | |
| 302 | + | font-size: var(--caption); | |
| 303 | + | color: var(--text-secondary); | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | .sha { | |
| 307 | + | font-family: var(--font-mono); | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | /* ---- Tree listing --------------------------------------------------------- */ | |
| 311 | + | ||
| 312 | + | .tree { | |
| 313 | + | list-style: none; | |
| 314 | + | margin-top: var(--space-lg); | |
| 315 | + | border-top: 1px solid var(--border); | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | .repo-main > .tree:first-child { | |
| 319 | + | margin-top: 0; | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | .tree-row { | |
| 323 | + | display: flex; | |
| 324 | + | align-items: center; | |
| 325 | + | justify-content: space-between; | |
| 326 | + | gap: var(--space-md); | |
| 327 | + | min-height: 44px; | |
| 328 | + | border-bottom: 1px solid var(--border); | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | .tree-name { | |
| 332 | + | display: flex; | |
| 333 | + | align-items: center; | |
| 334 | + | gap: var(--space-sm); | |
| 335 | + | flex: 1; | |
| 336 | + | min-width: 0; | |
| 337 | + | min-height: 44px; | |
| 338 | + | color: var(--text-primary); | |
| 339 | + | overflow-wrap: anywhere; | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | .tree-name .icon { | |
| 343 | + | color: var(--text-disabled); | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | .tree-name .icon-folder { | |
| 347 | + | color: var(--text-secondary); | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | .tree-meta { | |
| 351 | + | flex: none; | |
| 352 | + | font-family: var(--font-mono); | |
| 353 | + | font-size: var(--caption); | |
| 354 | + | color: var(--text-disabled); | |
| 355 | + | white-space: nowrap; | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | .tree-foot { | |
| 359 | + | margin-top: var(--space-md); | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | /* ---- README ------------------------------------------------------------- */ | |
| 363 | + | ||
| 364 | + | .readme { | |
| 365 | + | margin-top: var(--space-2xl); | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | .readme-name { | |
| 369 | + | display: inline-block; | |
| 370 | + | margin-bottom: var(--space-md); | |
| 371 | + | color: var(--text-secondary); | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | /* ---- Blob --------------------------------------------------------------- */ | |
| 375 | + | ||
| 376 | + | .blob-bar { | |
| 377 | + | display: flex; | |
| 378 | + | flex-wrap: wrap; | |
| 379 | + | align-items: center; | |
| 380 | + | justify-content: space-between; | |
| 381 | + | gap: var(--space-sm) var(--space-md); | |
| 382 | + | margin-top: var(--space-md); | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | .blob-actions { | |
| 386 | + | display: flex; | |
| 387 | + | flex-wrap: wrap; | |
| 388 | + | align-items: center; | |
| 389 | + | gap: var(--space-md); | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | .blob-note { | |
| 393 | + | padding-block: var(--space-2xl); | |
| 394 | + | border-top: 1px solid var(--border); | |
| 395 | + | overflow-wrap: anywhere; | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | .code-wrap { | |
| 399 | + | margin-top: var(--space-sm); | |
| 400 | + | border-top: 1px solid var(--border); | |
| 401 | + | border-bottom: 1px solid var(--border); | |
| 402 | + | overflow-x: auto; | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | .code { | |
| 406 | + | width: 100%; | |
| 407 | + | border-collapse: collapse; | |
| 408 | + | font-family: var(--font-code); | |
| 409 | + | font-size: 13px; | |
| 410 | + | line-height: 20px; | |
| 411 | + | tab-size: 4; | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | .code td { | |
| 415 | + | padding: 0; | |
| 416 | + | vertical-align: top; | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | .code .ln { | |
| 420 | + | width: 1%; | |
| 421 | + | border-left: 2px solid transparent; | |
| 422 | + | text-align: right; | |
| 423 | + | user-select: none; | |
| 424 | + | -webkit-user-select: none; | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | .code .ln a { | |
| 428 | + | display: block; | |
| 429 | + | padding: 0 var(--space-md) 0 var(--space-sm); | |
| 430 | + | color: var(--text-disabled); | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | .code .ln a:hover { | |
| 434 | + | color: var(--text-secondary); | |
| 435 | + | text-decoration: none; | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | .code .lc { | |
| 439 | + | padding: 0 var(--space-md); | |
| 440 | + | white-space: pre; | |
| 441 | + | color: var(--text-primary); | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | .code tr:first-child td { | |
| 445 | + | padding-top: var(--space-sm); | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | .code tr:last-child td { | |
| 449 | + | padding-bottom: var(--space-sm); | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | /* Selected lines: #L12 (CSS) or #L12-L20 (JS). */ | |
| 453 | + | .code tr:target, | |
| 454 | + | .code tr.selected { | |
| 455 | + | background: var(--surface-raised); | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | .code tr:target .ln, | |
| 459 | + | .code tr.selected .ln { | |
| 460 | + | border-left-color: var(--accent); | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | .code tr:target .ln a, | |
| 464 | + | .code tr.selected .ln a { | |
| 465 | + | color: var(--text-display); | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | /* The one break on the blob page: images float on a dot grid. */ | |
| 469 | + | .image-view { | |
| 470 | + | display: flex; | |
| 471 | + | justify-content: center; | |
| 472 | + | margin-top: var(--space-sm); | |
| 473 | + | padding: var(--space-xl) var(--space-md); | |
| 474 | + | border: 1px solid var(--border); | |
| 475 | + | border-radius: 4px; | |
| 476 | + | background-image: radial-gradient(circle, var(--border-visible) 1px, transparent 1px); | |
| 477 | + | background-size: 16px 16px; | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | .image-view img { | |
| 481 | + | max-height: 70vh; | |
| 482 | + | object-fit: contain; | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | .blob-markdown { | |
| 486 | + | margin-top: var(--space-xl); | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | /* ---- Refs --------------------------------------------------------------- */ | |
| 490 | + | ||
| 491 | + | .refs-section { | |
| 492 | + | margin-top: var(--space-2xl); | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | .table-wrap { | |
| 496 | + | overflow-x: auto; | |
| 497 | + | } | |
| 498 | + | ||
| 499 | + | .data-table { | |
| 500 | + | width: 100%; | |
| 501 | + | border-collapse: collapse; | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | .data-table th { | |
| 505 | + | padding: 12px 16px 12px 0; | |
| 506 | + | border-bottom: 1px solid var(--border-visible); | |
| 507 | + | text-align: left; | |
| 508 | + | font-weight: 400; | |
| 509 | + | white-space: nowrap; | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | .data-table td { | |
| 513 | + | padding: 12px 16px 12px 0; | |
| 514 | + | border-bottom: 1px solid var(--border); | |
| 515 | + | vertical-align: baseline; | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | .data-table .num { | |
| 519 | + | padding-right: 0; | |
| 520 | + | text-align: right; | |
| 521 | + | white-space: nowrap; | |
| 522 | + | font-family: var(--font-mono); | |
| 523 | + | font-size: var(--caption); | |
| 524 | + | color: var(--text-disabled); | |
| 525 | + | } | |
| 526 | + | ||
| 527 | + | .data-table th.num { | |
| 528 | + | font-size: var(--label); | |
| 529 | + | color: var(--text-secondary); | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | .ref-name { | |
| 533 | + | font-family: var(--font-mono); | |
| 534 | + | font-size: var(--body-sm); | |
| 535 | + | overflow-wrap: anywhere; | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | .ref-subject { | |
| 539 | + | color: var(--text-secondary); | |
| 540 | + | overflow-wrap: anywhere; | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | .chip { | |
| 544 | + | display: inline-block; | |
| 545 | + | margin-left: var(--space-sm); | |
| 546 | + | padding: 2px var(--space-sm); | |
| 547 | + | border: 1px solid var(--border-visible); | |
| 548 | + | border-radius: 999px; | |
| 549 | + | font-family: var(--font-mono); | |
| 550 | + | font-size: var(--label); | |
| 551 | + | letter-spacing: 0.08em; | |
| 552 | + | text-transform: uppercase; | |
| 553 | + | color: var(--text-secondary); | |
| 554 | + | white-space: nowrap; | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | /* ---- Markdown ------------------------------------------------------------ */ | |
| 558 | + | ||
| 559 | + | .markdown { | |
| 560 | + | max-width: 72ch; | |
| 561 | + | overflow-wrap: break-word; | |
| 562 | + | color: var(--text-primary); | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | .markdown > * + * { | |
| 566 | + | margin-top: var(--space-md); | |
| 567 | + | } | |
| 568 | + | ||
| 569 | + | .markdown h1, | |
| 570 | + | .markdown h2, | |
| 571 | + | .markdown h3, | |
| 572 | + | .markdown h4, | |
| 573 | + | .markdown h5, | |
| 574 | + | .markdown h6 { | |
| 575 | + | margin-top: var(--space-xl); | |
| 576 | + | line-height: 1.25; | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | .markdown > :first-child { | |
| 580 | + | margin-top: 0; | |
| 581 | + | } | |
| 582 | + | ||
| 583 | + | .markdown h1 { | |
| 584 | + | font-size: var(--heading); | |
| 585 | + | } | |
| 586 | + | ||
| 587 | + | .markdown h2 { | |
| 588 | + | font-size: var(--subheading); | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | .markdown h3, | |
| 592 | + | .markdown h4, | |
| 593 | + | .markdown h5, | |
| 594 | + | .markdown h6 { | |
| 595 | + | font-size: var(--body); | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | .markdown a.anchor { | |
| 599 | + | display: none; | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | .markdown ul, | |
| 603 | + | .markdown ol { | |
| 604 | + | padding-left: var(--space-lg); | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | .markdown li + li, | |
| 608 | + | .markdown li > ul, | |
| 609 | + | .markdown li > ol { | |
| 610 | + | margin-top: var(--space-xs); | |
| 611 | + | } | |
| 612 | + | ||
| 613 | + | .markdown .contains-task-list { | |
| 614 | + | list-style: none; | |
| 615 | + | padding-left: var(--space-xs); | |
| 616 | + | } | |
| 617 | + | ||
| 618 | + | .markdown input[type="checkbox"] { | |
| 619 | + | margin: 0 var(--space-sm) 0 0; | |
| 620 | + | vertical-align: middle; | |
| 621 | + | accent-color: var(--text-display); | |
| 622 | + | } | |
| 623 | + | ||
| 624 | + | .markdown code { | |
| 625 | + | padding: 1px var(--space-xs); | |
| 626 | + | border-radius: 4px; | |
| 627 | + | background: var(--surface-raised); | |
| 628 | + | font-size: 0.875em; | |
| 629 | + | } | |
| 630 | + | ||
| 631 | + | .markdown pre { | |
| 632 | + | padding: var(--space-md); | |
| 633 | + | border: 1px solid var(--border); | |
| 634 | + | border-radius: 8px; | |
| 635 | + | background: var(--surface); | |
| 636 | + | overflow-x: auto; | |
| 637 | + | line-height: 20px; | |
| 638 | + | tab-size: 4; | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | .markdown pre code { | |
| 642 | + | padding: 0; | |
| 643 | + | background: none; | |
| 644 | + | font-size: 13px; | |
| 645 | + | } | |
| 646 | + | ||
| 647 | + | .markdown pre.plain { | |
| 648 | + | max-width: none; | |
| 649 | + | white-space: pre-wrap; | |
| 650 | + | } | |
| 651 | + | ||
| 652 | + | .markdown blockquote { | |
| 653 | + | padding-left: var(--space-md); | |
| 654 | + | border-left: 2px solid var(--border-visible); | |
| 655 | + | color: var(--text-secondary); | |
| 656 | + | } | |
| 657 | + | ||
| 658 | + | .markdown hr { | |
| 659 | + | border: 0; | |
| 660 | + | border-top: 1px solid var(--border); | |
| 661 | + | } | |
| 662 | + | ||
| 663 | + | .markdown img { | |
| 664 | + | display: inline-block; | |
| 665 | + | } | |
| 666 | + | ||
| 667 | + | .markdown table { | |
| 668 | + | display: block; | |
| 669 | + | max-width: 100%; | |
| 670 | + | overflow-x: auto; | |
| 671 | + | border-collapse: collapse; | |
| 672 | + | } | |
| 673 | + | ||
| 674 | + | .markdown th, | |
| 675 | + | .markdown td { | |
| 676 | + | padding: var(--space-sm) var(--space-md) var(--space-sm) 0; | |
| 677 | + | border-bottom: 1px solid var(--border); | |
| 678 | + | } | |
| 679 | + | ||
| 680 | + | .markdown th { | |
| 681 | + | border-bottom-color: var(--border-visible); | |
| 682 | + | font-weight: 500; | |
| 683 | + | text-align: left; | |
| 684 | + | } | |
| 685 | + | ||
| 686 | + | .markdown .markdown-alert { | |
| 687 | + | padding-left: var(--space-md); | |
| 688 | + | border-left: 2px solid var(--border-visible); | |
| 689 | + | } | |
| 690 | + | ||
| 691 | + | .markdown .markdown-alert-warning, | |
| 692 | + | .markdown .markdown-alert-caution { | |
| 693 | + | border-left-color: var(--accent); | |
| 694 | + | } | |
| 695 | + | ||
| 696 | + | .markdown .markdown-alert-title { | |
| 697 | + | font-family: var(--font-mono); | |
| 698 | + | font-size: var(--label); | |
| 699 | + | letter-spacing: 0.08em; | |
| 700 | + | text-transform: uppercase; | |
| 701 | + | color: var(--text-secondary); | |
| 702 | + | } | |
| 703 | + | ||
| 704 | + | .markdown .footnotes { | |
| 705 | + | font-size: var(--body-sm); | |
| 706 | + | color: var(--text-secondary); | |
| 707 | + | border-top: 1px solid var(--border); | |
| 708 | + | padding-top: var(--space-md); | |
| 709 | + | } | |
| @@ -0,0 +1,25 @@ | |||
| 1 | + | /* | |
| 2 | + | * Syntax highlighting: monochrome, by weight of gray. The classes come from | |
| 3 | + | * src/render/highlight.rs and are shared by the blob view and README code. | |
| 4 | + | */ | |
| 5 | + | ||
| 6 | + | .hl-k { | |
| 7 | + | color: var(--text-display); | |
| 8 | + | font-weight: 700; | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | .hl-d { | |
| 12 | + | color: var(--text-display); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | .hl-s { | |
| 16 | + | color: var(--text-secondary); | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | .hl-c { | |
| 20 | + | color: var(--text-disabled); | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | .hl-x { | |
| 24 | + | color: var(--accent); | |
| 25 | + | } | |
| @@ -0,0 +1,94 @@ | |||
| 1 | + | /* | |
| 2 | + | * Design tokens — Nothing design system. | |
| 3 | + | * | |
| 4 | + | * Every color is written once as light-dark(LIGHT, DARK). The active side is | |
| 5 | + | * picked by `color-scheme`: the OS preference by default, or forced by the | |
| 6 | + | * [data-theme] attribute set from the server config / theme toggle. | |
| 7 | + | */ | |
| 8 | + | ||
| 9 | + | :root { | |
| 10 | + | color-scheme: light dark; | |
| 11 | + | } | |
| 12 | + | :root[data-theme="light"] { | |
| 13 | + | color-scheme: light; | |
| 14 | + | } | |
| 15 | + | :root[data-theme="dark"] { | |
| 16 | + | color-scheme: dark; | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | :root { | |
| 20 | + | /* Surfaces: OLED black / printed-manual off-white. */ | |
| 21 | + | --black: light-dark(#F5F5F5, #000000); | |
| 22 | + | --surface: light-dark(#FFFFFF, #111111); | |
| 23 | + | --surface-raised: light-dark(#F0F0F0, #1A1A1A); | |
| 24 | + | --border: light-dark(#E8E8E8, #222222); | |
| 25 | + | --border-visible: light-dark(#CCCCCC, #333333); | |
| 26 | + | ||
| 27 | + | /* Text: the gray scale is the hierarchy. */ | |
| 28 | + | --text-disabled: light-dark(#999999, #666666); | |
| 29 | + | --text-secondary: light-dark(#666666, #999999); | |
| 30 | + | --text-primary: light-dark(#1A1A1A, #E8E8E8); | |
| 31 | + | --text-display: light-dark(#000000, #FFFFFF); | |
| 32 | + | ||
| 33 | + | /* Accent & status — identical in both modes. */ | |
| 34 | + | --accent: #D71921; | |
| 35 | + | --accent-subtle: rgba(215, 25, 33, 0.15); | |
| 36 | + | --success: #4A9E5C; | |
| 37 | + | --warning: #D4A843; | |
| 38 | + | --error: #D71921; | |
| 39 | + | --info: var(--text-secondary); | |
| 40 | + | --interactive: light-dark(#007AFF, #5B9BF6); | |
| 41 | + | ||
| 42 | + | /* Empty segment in segmented bars. */ | |
| 43 | + | --segment-empty: light-dark(#E0E0E0, #222222); | |
| 44 | + | ||
| 45 | + | /* Type families. --font-code may be overridden via ui.code_font. */ | |
| 46 | + | --font-display: "Doto", "Space Mono", monospace; | |
| 47 | + | --font-body: "Space Grotesk", "DM Sans", system-ui, sans-serif; | |
| 48 | + | --font-mono: "Space Mono", "JetBrains Mono", "SF Mono", monospace; | |
| 49 | + | --font-code: var(--font-mono); | |
| 50 | + | ||
| 51 | + | /* Type scale. */ | |
| 52 | + | --display-xl: 72px; | |
| 53 | + | --display-lg: 48px; | |
| 54 | + | --display-md: 36px; | |
| 55 | + | --heading: 24px; | |
| 56 | + | --subheading: 18px; | |
| 57 | + | --body: 16px; | |
| 58 | + | --body-sm: 14px; | |
| 59 | + | --caption: 12px; | |
| 60 | + | --label: 11px; | |
| 61 | + | ||
| 62 | + | /* Spacing (8px base). */ | |
| 63 | + | --space-2xs: 2px; | |
| 64 | + | --space-xs: 4px; | |
| 65 | + | --space-sm: 8px; | |
| 66 | + | --space-md: 16px; | |
| 67 | + | --space-lg: 24px; | |
| 68 | + | --space-xl: 32px; | |
| 69 | + | --space-2xl: 48px; | |
| 70 | + | --space-3xl: 64px; | |
| 71 | + | --space-4xl: 96px; | |
| 72 | + | ||
| 73 | + | /* Layout. */ | |
| 74 | + | --gutter: var(--space-md); | |
| 75 | + | --page-max: 1200px; | |
| 76 | + | --rail: 280px; | |
| 77 | + | ||
| 78 | + | /* Motion: subtle ease-out only. */ | |
| 79 | + | --ease: cubic-bezier(0.25, 0.1, 0.25, 1); | |
| 80 | + | --dur-micro: 150ms; | |
| 81 | + | --dur: 300ms; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | @media (min-width: 768px) { | |
| 85 | + | :root { | |
| 86 | + | --gutter: var(--space-xl); | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | @media (min-width: 1200px) { | |
| 91 | + | :root { | |
| 92 | + | --gutter: var(--space-2xl); | |
| 93 | + | } | |
| 94 | + | } | |
| @@ -0,0 +1,98 @@ | |||
| 1 | + | Fonts bundled with Ferrenium (woff2 subsets via Fontsource): | |
| 2 | + | ||
| 3 | + | Doto — Copyright 2024 The Doto Project Authors (https://github.com/oliverlalan/Doto) | |
| 4 | + | Space Grotesk — Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk) | |
| 5 | + | Space Mono — Copyright 2016 The Space Mono Project Authors (https://github.com/googlefonts/spacemono) | |
| 6 | + | ||
| 7 | + | ||
| 8 | + | This Font Software is licensed under the SIL Open Font License, Version 1.1. | |
| 9 | + | This license is copied below, and is also available with a FAQ at: | |
| 10 | + | http://scripts.sil.org/OFL | |
| 11 | + | ||
| 12 | + | ||
| 13 | + | ----------------------------------------------------------- | |
| 14 | + | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 | |
| 15 | + | ----------------------------------------------------------- | |
| 16 | + | ||
| 17 | + | PREAMBLE | |
| 18 | + | The goals of the Open Font License (OFL) are to stimulate worldwide | |
| 19 | + | development of collaborative font projects, to support the font creation | |
| 20 | + | efforts of academic and linguistic communities, and to provide a free and | |
| 21 | + | open framework in which fonts may be shared and improved in partnership | |
| 22 | + | with others. | |
| 23 | + | ||
| 24 | + | The OFL allows the licensed fonts to be used, studied, modified and | |
| 25 | + | redistributed freely as long as they are not sold by themselves. The | |
| 26 | + | fonts, including any derivative works, can be bundled, embedded, | |
| 27 | + | redistributed and/or sold with any software provided that any reserved | |
| 28 | + | names are not used by derivative works. The fonts and derivatives, | |
| 29 | + | however, cannot be released under any other type of license. The | |
| 30 | + | requirement for fonts to remain under this license does not apply | |
| 31 | + | to any document created using the fonts or their derivatives. | |
| 32 | + | ||
| 33 | + | DEFINITIONS | |
| 34 | + | "Font Software" refers to the set of files released by the Copyright | |
| 35 | + | Holder(s) under this license and clearly marked as such. This may | |
| 36 | + | include source files, build scripts and documentation. | |
| 37 | + | ||
| 38 | + | "Reserved Font Name" refers to any names specified as such after the | |
| 39 | + | copyright statement(s). | |
| 40 | + | ||
| 41 | + | "Original Version" refers to the collection of Font Software components as | |
| 42 | + | distributed by the Copyright Holder(s). | |
| 43 | + | ||
| 44 | + | "Modified Version" refers to any derivative made by adding to, deleting, | |
| 45 | + | or substituting -- in part or in whole -- any of the components of the | |
| 46 | + | Original Version, by changing formats or by porting the Font Software to a | |
| 47 | + | new environment. | |
| 48 | + | ||
| 49 | + | "Author" refers to any designer, engineer, programmer, technical | |
| 50 | + | writer or other person who contributed to the Font Software. | |
| 51 | + | ||
| 52 | + | PERMISSION & CONDITIONS | |
| 53 | + | Permission is hereby granted, free of charge, to any person obtaining | |
| 54 | + | a copy of the Font Software, to use, study, copy, merge, embed, modify, | |
| 55 | + | redistribute, and sell modified and unmodified copies of the Font | |
| 56 | + | Software, subject to the following conditions: | |
| 57 | + | ||
| 58 | + | 1) Neither the Font Software nor any of its individual components, | |
| 59 | + | in Original or Modified Versions, may be sold by itself. | |
| 60 | + | ||
| 61 | + | 2) Original or Modified Versions of the Font Software may be bundled, | |
| 62 | + | redistributed and/or sold with any software, provided that each copy | |
| 63 | + | contains the above copyright notice and this license. These can be | |
| 64 | + | included either as stand-alone text files, human-readable headers or | |
| 65 | + | in the appropriate machine-readable metadata fields within text or | |
| 66 | + | binary files as long as those fields can be easily viewed by the user. | |
| 67 | + | ||
| 68 | + | 3) No Modified Version of the Font Software may use the Reserved Font | |
| 69 | + | Name(s) unless explicit written permission is granted by the corresponding | |
| 70 | + | Copyright Holder. This restriction only applies to the primary font name as | |
| 71 | + | presented to the users. | |
| 72 | + | ||
| 73 | + | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font | |
| 74 | + | Software shall not be used to promote, endorse or advertise any | |
| 75 | + | Modified Version, except to acknowledge the contribution(s) of the | |
| 76 | + | Copyright Holder(s) and the Author(s) or with their explicit written | |
| 77 | + | permission. | |
| 78 | + | ||
| 79 | + | 5) The Font Software, modified or unmodified, in part or in whole, | |
| 80 | + | must be distributed entirely under this license, and must not be | |
| 81 | + | distributed under any other license. The requirement for fonts to | |
| 82 | + | remain under this license does not apply to any document created | |
| 83 | + | using the Font Software. | |
| 84 | + | ||
| 85 | + | TERMINATION | |
| 86 | + | This license becomes null and void if any of the above conditions are | |
| 87 | + | not met. | |
| 88 | + | ||
| 89 | + | DISCLAIMER | |
| 90 | + | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
| 91 | + | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF | |
| 92 | + | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT | |
| 93 | + | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE | |
| 94 | + | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, | |
| 95 | + | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL | |
| 96 | + | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING | |
| 97 | + | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM | |
| 98 | + | OTHER DEALINGS IN THE FONT SOFTWARE. | |
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
| @@ -0,0 +1,15 @@ | |||
| 1 | + | ISC License | |
| 2 | + | ||
| 3 | + | Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022. | |
| 4 | + | ||
| 5 | + | Permission to use, copy, modify, and/or distribute this software for any | |
| 6 | + | purpose with or without fee is hereby granted, provided that the above | |
| 7 | + | copyright notice and this permission notice appear in all copies. | |
| 8 | + | ||
| 9 | + | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES | |
| 10 | + | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF | |
| 11 | + | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR | |
| 12 | + | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES | |
| 13 | + | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN | |
| 14 | + | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF | |
| 15 | + | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. | |
| @@ -0,0 +1,5 @@ | |||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> | |
| 2 | + | <style>g{fill:#000}@media (prefers-color-scheme:dark){g{fill:#fff}}</style> | |
| 3 | + | <g><circle cx="8" cy="4" r="1.6"/><circle cx="12" cy="4" r="1.6"/><circle cx="16" cy="4" r="1.6"/><circle cx="20" cy="4" r="1.6"/><circle cx="24" cy="4" r="1.6"/><circle cx="8" cy="8" r="1.6"/><circle cx="8" cy="12" r="1.6"/><circle cx="8" cy="16" r="1.6"/><circle cx="12" cy="16" r="1.6"/><circle cx="16" cy="16" r="1.6"/><circle cx="20" cy="16" r="1.6"/><circle cx="8" cy="20" r="1.6"/><circle cx="8" cy="24" r="1.6"/><circle cx="8" cy="28" r="1.6"/></g> | |
| 4 | + | <circle cx="24" cy="28" r="2" fill="#D71921"/> | |
| 5 | + | </svg> | |
| @@ -0,0 +1,12 @@ | |||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg"> | |
| 2 | + | <!-- Lucide icons (ISC license, see LICENSE-lucide.txt): 24px grid, 1.5px stroke, no fill. --> | |
| 3 | + | <defs> | |
| 4 | + | <symbol id="folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></symbol> | |
| 5 | + | <symbol id="file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></symbol> | |
| 6 | + | <symbol id="link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></symbol> | |
| 7 | + | <symbol id="package" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></symbol> | |
| 8 | + | <symbol id="copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol> | |
| 9 | + | <symbol id="git-branch" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol> | |
| 10 | + | <symbol id="chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol> | |
| 11 | + | </defs> | |
| 12 | + | </svg> | |
| @@ -0,0 +1,129 @@ | |||
| 1 | + | // Progressive enhancements. Everything works without this file. | |
| 2 | + | (function () { | |
| 3 | + | "use strict"; | |
| 4 | + | ||
| 5 | + | // Index: instant filter; "/" focuses it, Escape clears it. | |
| 6 | + | var input = document.querySelector("[data-filter]"); | |
| 7 | + | if (input) { | |
| 8 | + | var rows = Array.prototype.slice.call(document.querySelectorAll("[data-search]")); | |
| 9 | + | var sections = Array.prototype.slice.call(document.querySelectorAll("[data-section]")); | |
| 10 | + | var empty = document.querySelector("[data-filter-empty]"); | |
| 11 | + | ||
| 12 | + | var apply = function () { | |
| 13 | + | var terms = input.value.toLowerCase().split(/\s+/).filter(Boolean); | |
| 14 | + | var shown = 0; | |
| 15 | + | rows.forEach(function (row) { | |
| 16 | + | var text = row.getAttribute("data-search"); | |
| 17 | + | var match = terms.every(function (t) { return text.indexOf(t) !== -1; }); | |
| 18 | + | row.hidden = !match; | |
| 19 | + | if (match) shown++; | |
| 20 | + | }); | |
| 21 | + | sections.forEach(function (section) { | |
| 22 | + | section.hidden = !section.querySelector("[data-search]:not([hidden])"); | |
| 23 | + | }); | |
| 24 | + | if (empty) empty.hidden = shown > 0; | |
| 25 | + | }; | |
| 26 | + | ||
| 27 | + | input.addEventListener("input", apply); | |
| 28 | + | input.addEventListener("keydown", function (e) { | |
| 29 | + | if (e.key === "Escape") { | |
| 30 | + | input.value = ""; | |
| 31 | + | apply(); | |
| 32 | + | input.blur(); | |
| 33 | + | } | |
| 34 | + | }); | |
| 35 | + | document.addEventListener("keydown", function (e) { | |
| 36 | + | if (e.key !== "/" || e.ctrlKey || e.metaKey || e.altKey) return; | |
| 37 | + | var t = e.target; | |
| 38 | + | if (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)) return; | |
| 39 | + | e.preventDefault(); | |
| 40 | + | input.focus(); | |
| 41 | + | input.select(); | |
| 42 | + | }); | |
| 43 | + | } | |
| 44 | + | // Blob: line ranges. #L12 works without JS (:target); here shift-click | |
| 45 | + | // extends to #L12-L20 and the range is highlighted on load. | |
| 46 | + | var code = document.querySelector("[data-lines]"); | |
| 47 | + | if (code) { | |
| 48 | + | var anchor = null; | |
| 49 | + | var select = function (from, to) { | |
| 50 | + | Array.prototype.forEach.call(code.querySelectorAll("tr.selected"), function (tr) { | |
| 51 | + | tr.classList.remove("selected"); | |
| 52 | + | }); | |
| 53 | + | if (!from) return; | |
| 54 | + | if (to < from) { var t = from; from = to; to = t; } | |
| 55 | + | for (var n = from; n <= to; n++) { | |
| 56 | + | var row = document.getElementById("L" + n); | |
| 57 | + | if (row) row.classList.add("selected"); | |
| 58 | + | } | |
| 59 | + | }; | |
| 60 | + | var fromHash = function () { | |
| 61 | + | var m = /^#L(\d+)(?:-L(\d+))?$/.exec(location.hash); | |
| 62 | + | if (!m) return select(0); | |
| 63 | + | anchor = +m[1]; | |
| 64 | + | select(anchor, +(m[2] || m[1])); | |
| 65 | + | if (m[2]) { | |
| 66 | + | var row = document.getElementById("L" + Math.min(+m[1], +m[2])); | |
| 67 | + | if (row) row.scrollIntoView({ block: "center" }); | |
| 68 | + | } | |
| 69 | + | }; | |
| 70 | + | code.addEventListener("click", function (e) { | |
| 71 | + | var a = e.target.closest && e.target.closest(".ln a"); | |
| 72 | + | if (!a) return; | |
| 73 | + | var n = +a.textContent; | |
| 74 | + | e.preventDefault(); | |
| 75 | + | var hash = "#L" + n; | |
| 76 | + | if (e.shiftKey && anchor && anchor !== n) { | |
| 77 | + | hash = "#L" + Math.min(anchor, n) + "-L" + Math.max(anchor, n); | |
| 78 | + | } else { | |
| 79 | + | anchor = n; | |
| 80 | + | } | |
| 81 | + | history.replaceState(null, "", hash); | |
| 82 | + | fromHash(); | |
| 83 | + | }); | |
| 84 | + | window.addEventListener("hashchange", fromHash); | |
| 85 | + | fromHash(); | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // Summary: clone protocol switch and copy button. | |
| 89 | + | var clone = document.querySelector("[data-clone]"); | |
| 90 | + | if (clone) { | |
| 91 | + | var KEY = "ferrenium-clone"; | |
| 92 | + | var status = clone.querySelector("[data-copy-status]"); | |
| 93 | + | var choose = function (i) { | |
| 94 | + | var rows = clone.querySelectorAll("[data-clone-url]"); | |
| 95 | + | if (!rows[i]) i = 0; | |
| 96 | + | Array.prototype.forEach.call(rows, function (row, j) { row.hidden = j !== i; }); | |
| 97 | + | Array.prototype.forEach.call(clone.querySelectorAll("[data-clone-choice]"), function (b, j) { | |
| 98 | + | b.setAttribute("aria-pressed", j === i ? "true" : "false"); | |
| 99 | + | }); | |
| 100 | + | }; | |
| 101 | + | var saved = 0; | |
| 102 | + | try { saved = +(localStorage.getItem(KEY) || 0); } catch (e) {} | |
| 103 | + | choose(saved); | |
| 104 | + | clone.addEventListener("click", function (e) { | |
| 105 | + | var choice = e.target.closest("[data-clone-choice]"); | |
| 106 | + | if (choice) { | |
| 107 | + | var i = +choice.getAttribute("data-clone-choice"); | |
| 108 | + | choose(i); | |
| 109 | + | try { localStorage.setItem(KEY, i); } catch (err) {} | |
| 110 | + | return; | |
| 111 | + | } | |
| 112 | + | var copy = e.target.closest("[data-copy]"); | |
| 113 | + | if (!copy) return; | |
| 114 | + | var input = copy.parentNode.querySelector("input"); | |
| 115 | + | var done = function (ok) { | |
| 116 | + | status.textContent = ok ? "[Copied]" : "[Error: copy failed]"; | |
| 117 | + | status.classList.toggle("status-error", !ok); | |
| 118 | + | clearTimeout(done.t); | |
| 119 | + | done.t = setTimeout(function () { status.textContent = ""; }, 2000); | |
| 120 | + | }; | |
| 121 | + | if (navigator.clipboard) { | |
| 122 | + | navigator.clipboard.writeText(input.value).then(function () { done(true); }, function () { done(false); }); | |
| 123 | + | } else { | |
| 124 | + | input.select(); | |
| 125 | + | done(document.execCommand("copy")); | |
| 126 | + | } | |
| 127 | + | }); | |
| 128 | + | } | |
| 129 | + | })(); | |
| @@ -0,0 +1,45 @@ | |||
| 1 | + | // Theme: runs synchronously in <head> so the stored choice applies before | |
| 2 | + | // first paint. "auto" follows the OS; the server default comes from | |
| 3 | + | // data-default-theme (ui.theme). | |
| 4 | + | (function () { | |
| 5 | + | "use strict"; | |
| 6 | + | var KEY = "ferrenium-theme"; | |
| 7 | + | var root = document.documentElement; | |
| 8 | + | var fallback = root.getAttribute("data-default-theme") || "auto"; | |
| 9 | + | var current = fallback; | |
| 10 | + | ||
| 11 | + | try { | |
| 12 | + | current = localStorage.getItem(KEY) || fallback; | |
| 13 | + | } catch (e) {} | |
| 14 | + | ||
| 15 | + | function apply(choice) { | |
| 16 | + | current = choice; | |
| 17 | + | if (choice === "light" || choice === "dark") { | |
| 18 | + | root.setAttribute("data-theme", choice); | |
| 19 | + | } else { | |
| 20 | + | root.removeAttribute("data-theme"); | |
| 21 | + | } | |
| 22 | + | var buttons = document.querySelectorAll("[data-theme-choice]"); | |
| 23 | + | for (var i = 0; i < buttons.length; i++) { | |
| 24 | + | var on = buttons[i].getAttribute("data-theme-choice") === choice; | |
| 25 | + | buttons[i].setAttribute("aria-pressed", on ? "true" : "false"); | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | root.classList.add("js"); | |
| 30 | + | apply(current); | |
| 31 | + | ||
| 32 | + | document.addEventListener("DOMContentLoaded", function () { | |
| 33 | + | apply(current); | |
| 34 | + | document.addEventListener("click", function (event) { | |
| 35 | + | var button = event.target.closest && event.target.closest("[data-theme-choice]"); | |
| 36 | + | if (!button) return; | |
| 37 | + | var choice = button.getAttribute("data-theme-choice"); | |
| 38 | + | try { | |
| 39 | + | if (choice === fallback) localStorage.removeItem(KEY); | |
| 40 | + | else localStorage.setItem(KEY, choice); | |
| 41 | + | } catch (e) {} | |
| 42 | + | apply(choice); | |
| 43 | + | }); | |
| 44 | + | }); | |
| 45 | + | })(); | |
| @@ -0,0 +1,5 @@ | |||
| 1 | + | git.krolyxon.com { | |
| 2 | + | reverse_proxy 127.0.0.1:8080 | |
| 3 | + | # or, with listen = "unix:/run/ferrenium/ferrenium.sock": | |
| 4 | + | # reverse_proxy unix//run/ferrenium/ferrenium.sock | |
| 5 | + | } | |
| @@ -0,0 +1,43 @@ | |||
| 1 | + | [Unit] | |
| 2 | + | Description=Ferrenium git repository browser | |
| 3 | + | After=network.target | |
| 4 | + | ||
| 5 | + | [Service] | |
| 6 | + | Type=notify | |
| 7 | + | ExecStart=/usr/local/bin/ferrenium serve --config /etc/ferrenium/ferrenium.toml | |
| 8 | + | ExecReload=/bin/kill -HUP $MAINPID | |
| 9 | + | Restart=on-failure | |
| 10 | + | TimeoutStopSec=15 | |
| 11 | + | ||
| 12 | + | User=git | |
| 13 | + | Group=git | |
| 14 | + | RuntimeDirectory=ferrenium | |
| 15 | + | RuntimeDirectoryMode=0755 | |
| 16 | + | ||
| 17 | + | # Read-only sandbox: ferrenium never writes to disk. | |
| 18 | + | ProtectSystem=strict | |
| 19 | + | ProtectHome=read-only | |
| 20 | + | PrivateTmp=yes | |
| 21 | + | PrivateDevices=yes | |
| 22 | + | ProtectKernelTunables=yes | |
| 23 | + | ProtectKernelModules=yes | |
| 24 | + | ProtectKernelLogs=yes | |
| 25 | + | ProtectControlGroups=yes | |
| 26 | + | ProtectClock=yes | |
| 27 | + | ProtectHostname=yes | |
| 28 | + | ProtectProc=invisible | |
| 29 | + | ProcSubset=pid | |
| 30 | + | NoNewPrivileges=yes | |
| 31 | + | CapabilityBoundingSet= | |
| 32 | + | AmbientCapabilities= | |
| 33 | + | RestrictNamespaces=yes | |
| 34 | + | RestrictRealtime=yes | |
| 35 | + | RestrictSUIDSGID=yes | |
| 36 | + | LockPersonality=yes | |
| 37 | + | MemoryDenyWriteExecute=yes | |
| 38 | + | SystemCallArchitectures=native | |
| 39 | + | SystemCallFilter=@system-service | |
| 40 | + | RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 | |
| 41 | + | ||
| 42 | + | [Install] | |
| 43 | + | WantedBy=multi-user.target | |
| @@ -0,0 +1,60 @@ | |||
| 1 | + | # Ferrenium configuration. Every key is optional; the values shown are the | |
| 2 | + | # defaults unless noted. Unknown keys are an error — run `ferrenium check`. | |
| 3 | + | # Relative paths are resolved against the directory of this file. | |
| 4 | + | ||
| 5 | + | [server] | |
| 6 | + | listen = "127.0.0.1:8080" # or "unix:/run/ferrenium/ferrenium.sock" | |
| 7 | + | base_url = "https://git.krolyxon.com" # used for absolute URLs (feeds, clone URLs); default: unset | |
| 8 | + | base_path = "/" # serve under a sub-path, e.g. "/git/" | |
| 9 | + | trusted_proxy = true # log X-Forwarded-For; default: false | |
| 10 | + | compression = true # gzip + brotli | |
| 11 | + | ||
| 12 | + | [site] | |
| 13 | + | title = "krolyxon" # default: "ferrenium" | |
| 14 | + | description = "Personal git repositories" | |
| 15 | + | clone_urls = ["https://git.krolyxon.com/{name}", "git@git.krolyxon.com:{name}"] | |
| 16 | + | # custom_css = "custom.css" | |
| 17 | + | # header = "header.html" | |
| 18 | + | # footer = "footer.html" | |
| 19 | + | ||
| 20 | + | [repos] | |
| 21 | + | scan_path = "/home/git" | |
| 22 | + | max_depth = 3 | |
| 23 | + | follow_symlinks = false | |
| 24 | + | section_from_path = false | |
| 25 | + | hide = [] # globs over repo names, e.g. ["private/*"] | |
| 26 | + | snapshots = ["tar.gz", "zip"] | |
| 27 | + | https_clone = true | |
| 28 | + | ||
| 29 | + | # Optional per-repo overrides (take precedence over git config and the | |
| 30 | + | # repository's description file). | |
| 31 | + | # [[repo]] | |
| 32 | + | # name = "dotfiles" | |
| 33 | + | # description = "My dotfiles" | |
| 34 | + | # section = "config" | |
| 35 | + | # owner = "krolyxon" | |
| 36 | + | # hidden = false | |
| 37 | + | # readme = "docs/README.md" | |
| 38 | + | # default_branch = "main" | |
| 39 | + | ||
| 40 | + | [ui] | |
| 41 | + | theme = "auto" # auto | dark | light | |
| 42 | + | code_font = "Space Mono" | |
| 43 | + | max_blob_kb = 1024 | |
| 44 | + | max_highlight_kb = 256 | |
| 45 | + | ||
| 46 | + | [cache] | |
| 47 | + | size_mb = 64 | |
| 48 | + | ||
| 49 | + | [watch] | |
| 50 | + | enabled = true | |
| 51 | + | debounce_ms = 500 | |
| 52 | + | poll_interval_secs = 300 # 0 disables the periodic full rescan | |
| 53 | + | ||
| 54 | + | [limits] | |
| 55 | + | git_parallel = 0 # 0 = 2 × CPUs | |
| 56 | + | heavy_parallel = 2 | |
| 57 | + | request_timeout_secs = 30 | |
| 58 | + | ||
| 59 | + | [log] | |
| 60 | + | level = "info" # RUST_LOG takes precedence when set | |
| @@ -0,0 +1,143 @@ | |||
| 1 | + | //! One byte-weighted in-memory cache for rendered and computed data. | |
| 2 | + | //! | |
| 3 | + | //! Keys are content-addressed (blob/tree/commit ids) and never need | |
| 4 | + | //! invalidation. Ref-dependent entries include the repository's | |
| 5 | + | //! `generation`, so a refresh retires them naturally. | |
| 6 | + | ||
| 7 | + | use std::sync::Arc; | |
| 8 | + | ||
| 9 | + | use crate::git::ObjectId; | |
| 10 | + | use crate::git::refs::RefList; | |
| 11 | + | use crate::git::tree::TreeEntry; | |
| 12 | + | ||
| 13 | + | #[derive(Debug, Clone, PartialEq, Eq, Hash)] | |
| 14 | + | pub enum Key { | |
| 15 | + | /// Highlighted lines of a blob with a given syntax. | |
| 16 | + | Highlight { | |
| 17 | + | blob: ObjectId, | |
| 18 | + | syntax: String, | |
| 19 | + | }, | |
| 20 | + | /// Rendered Markdown; `context` is everything relative links depend on. | |
| 21 | + | Markdown { | |
| 22 | + | blob: ObjectId, | |
| 23 | + | context: String, | |
| 24 | + | }, | |
| 25 | + | Tree(ObjectId), | |
| 26 | + | CommitCount(ObjectId), | |
| 27 | + | Refs { | |
| 28 | + | repo: String, | |
| 29 | + | generation: u64, | |
| 30 | + | }, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | #[derive(Clone)] | |
| 34 | + | pub enum Value { | |
| 35 | + | Lines(Arc<Vec<String>>), | |
| 36 | + | Html(Arc<str>), | |
| 37 | + | Tree(Arc<Vec<TreeEntry>>), | |
| 38 | + | Count(u64), | |
| 39 | + | Refs(Arc<RefList>), | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | impl Value { | |
| 43 | + | fn weight(&self) -> usize { | |
| 44 | + | match self { | |
| 45 | + | Value::Lines(lines) => lines.iter().map(|l| l.len() + 24).sum(), | |
| 46 | + | Value::Html(html) => html.len(), | |
| 47 | + | Value::Tree(entries) => entries.iter().map(|e| e.name.len() + 64).sum(), | |
| 48 | + | Value::Count(_) => 8, | |
| 49 | + | Value::Refs(refs) => refs.weight(), | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | pub struct Cache { | |
| 55 | + | inner: moka::sync::Cache<Key, Value>, | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | impl Cache { | |
| 59 | + | pub fn new(size_mb: u64) -> Self { | |
| 60 | + | let inner = moka::sync::Cache::builder() | |
| 61 | + | .max_capacity(size_mb.saturating_mul(1024 * 1024)) | |
| 62 | + | .weigher(|key: &Key, value: &Value| { | |
| 63 | + | let key = match key { | |
| 64 | + | Key::Highlight { syntax, .. } => syntax.len(), | |
| 65 | + | Key::Markdown { context, .. } => context.len(), | |
| 66 | + | Key::Refs { repo, .. } => repo.len(), | |
| 67 | + | _ => 0, | |
| 68 | + | }; | |
| 69 | + | u32::try_from(64 + key + value.weight()).unwrap_or(u32::MAX) | |
| 70 | + | }) | |
| 71 | + | .build(); | |
| 72 | + | Cache { inner } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | /// Cached value for `key`, computing (and storing) it on a miss. | |
| 76 | + | /// Errors are not cached. | |
| 77 | + | fn get_or<E>(&self, key: Key, compute: impl FnOnce() -> Result<Value, E>) -> Result<Value, E> { | |
| 78 | + | if let Some(value) = self.inner.get(&key) { | |
| 79 | + | return Ok(value); | |
| 80 | + | } | |
| 81 | + | let value = compute()?; | |
| 82 | + | self.inner.insert(key, value.clone()); | |
| 83 | + | Ok(value) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | pub fn lines<E>( | |
| 87 | + | &self, | |
| 88 | + | key: Key, | |
| 89 | + | compute: impl FnOnce() -> Result<Vec<String>, E>, | |
| 90 | + | ) -> Result<Arc<Vec<String>>, E> { | |
| 91 | + | match self.get_or(key, || compute().map(|v| Value::Lines(Arc::new(v))))? { | |
| 92 | + | Value::Lines(v) => Ok(v), | |
| 93 | + | _ => unreachable!("cache key/value kind mismatch"), | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | pub fn html<E>( | |
| 98 | + | &self, | |
| 99 | + | key: Key, | |
| 100 | + | compute: impl FnOnce() -> Result<String, E>, | |
| 101 | + | ) -> Result<Arc<str>, E> { | |
| 102 | + | match self.get_or(key, || compute().map(|v| Value::Html(v.into())))? { | |
| 103 | + | Value::Html(v) => Ok(v), | |
| 104 | + | _ => unreachable!("cache key/value kind mismatch"), | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | pub fn tree<E>( | |
| 109 | + | &self, | |
| 110 | + | tree: ObjectId, | |
| 111 | + | compute: impl FnOnce() -> Result<Vec<TreeEntry>, E>, | |
| 112 | + | ) -> Result<Arc<Vec<TreeEntry>>, E> { | |
| 113 | + | match self.get_or(Key::Tree(tree), || { | |
| 114 | + | compute().map(|v| Value::Tree(Arc::new(v))) | |
| 115 | + | })? { | |
| 116 | + | Value::Tree(v) => Ok(v), | |
| 117 | + | _ => unreachable!("cache key/value kind mismatch"), | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | pub fn count<E>(&self, key: Key, compute: impl FnOnce() -> Result<u64, E>) -> Result<u64, E> { | |
| 122 | + | match self.get_or(key, || compute().map(Value::Count))? { | |
| 123 | + | Value::Count(v) => Ok(v), | |
| 124 | + | _ => unreachable!("cache key/value kind mismatch"), | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | pub fn refs<E>( | |
| 129 | + | &self, | |
| 130 | + | key: Key, | |
| 131 | + | compute: impl FnOnce() -> Result<RefList, E>, | |
| 132 | + | ) -> Result<Arc<RefList>, E> { | |
| 133 | + | match self.get_or(key, || compute().map(|v| Value::Refs(Arc::new(v))))? { | |
| 134 | + | Value::Refs(v) => Ok(v), | |
| 135 | + | _ => unreachable!("cache key/value kind mismatch"), | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | pub fn entry_count(&self) -> u64 { | |
| 140 | + | self.inner.run_pending_tasks(); | |
| 141 | + | self.inner.entry_count() | |
| 142 | + | } | |
| 143 | + | } | |
| @@ -0,0 +1,643 @@ | |||
| 1 | + | //! Configuration schema, defaults, validation and loading. | |
| 2 | + | //! | |
| 3 | + | //! Every key is optional. Unknown keys are rejected so typos surface at load | |
| 4 | + | //! time instead of being silently ignored. | |
| 5 | + | ||
| 6 | + | use std::fmt; | |
| 7 | + | use std::net::SocketAddr; | |
| 8 | + | use std::path::{Path, PathBuf}; | |
| 9 | + | use std::str::FromStr; | |
| 10 | + | use std::sync::Arc; | |
| 11 | + | ||
| 12 | + | use serde::Deserialize; | |
| 13 | + | ||
| 14 | + | /// Well-known config location used in production. | |
| 15 | + | pub const SYSTEM_CONFIG: &str = "/etc/ferrenium/ferrenium.toml"; | |
| 16 | + | ||
| 17 | + | #[derive(Debug, thiserror::Error)] | |
| 18 | + | pub enum ConfigError { | |
| 19 | + | #[error("cannot read {path}: {source}")] | |
| 20 | + | Io { | |
| 21 | + | path: PathBuf, | |
| 22 | + | source: std::io::Error, | |
| 23 | + | }, | |
| 24 | + | #[error("{path}: {message}")] | |
| 25 | + | Parse { path: PathBuf, message: String }, | |
| 26 | + | #[error("invalid configuration:\n - {}", .0.join("\n - "))] | |
| 27 | + | Invalid(Vec<String>), | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | #[derive(Debug, Clone, Default, Deserialize)] | |
| 31 | + | #[serde(deny_unknown_fields, default)] | |
| 32 | + | pub struct Config { | |
| 33 | + | pub server: ServerConfig, | |
| 34 | + | pub site: SiteConfig, | |
| 35 | + | pub repos: ReposConfig, | |
| 36 | + | /// `[[repo]]` per-repository overrides. | |
| 37 | + | #[serde(rename = "repo")] | |
| 38 | + | pub repo_overrides: Vec<RepoOverride>, | |
| 39 | + | pub ui: UiConfig, | |
| 40 | + | pub cache: CacheConfig, | |
| 41 | + | pub watch: WatchConfig, | |
| 42 | + | pub limits: LimitsConfig, | |
| 43 | + | pub log: LogConfig, | |
| 44 | + | ||
| 45 | + | /// File the config was loaded from (`None` = built-in defaults). | |
| 46 | + | #[serde(skip)] | |
| 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. | |
| 50 | + | #[serde(skip)] | |
| 51 | + | pub custom: Customization, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | #[derive(Debug, Clone, Default)] | |
| 55 | + | pub struct Customization { | |
| 56 | + | pub css: Option<Arc<str>>, | |
| 57 | + | pub header: Option<Arc<str>>, | |
| 58 | + | pub footer: Option<Arc<str>>, | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | #[derive(Debug, Clone, Deserialize)] | |
| 62 | + | #[serde(deny_unknown_fields, default)] | |
| 63 | + | pub struct ServerConfig { | |
| 64 | + | pub listen: Listen, | |
| 65 | + | pub base_url: Option<String>, | |
| 66 | + | pub base_path: String, | |
| 67 | + | pub trusted_proxy: bool, | |
| 68 | + | pub compression: bool, | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | impl Default for ServerConfig { | |
| 72 | + | fn default() -> Self { | |
| 73 | + | Self { | |
| 74 | + | listen: Listen::Tcp(SocketAddr::from(([127, 0, 0, 1], 8080))), | |
| 75 | + | base_url: None, | |
| 76 | + | base_path: "/".into(), | |
| 77 | + | trusted_proxy: false, | |
| 78 | + | compression: true, | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | #[derive(Debug, Clone, Deserialize)] | |
| 84 | + | #[serde(deny_unknown_fields, default)] | |
| 85 | + | pub struct SiteConfig { | |
| 86 | + | pub title: String, | |
| 87 | + | pub description: String, | |
| 88 | + | pub clone_urls: Vec<String>, | |
| 89 | + | pub custom_css: Option<PathBuf>, | |
| 90 | + | pub header: Option<PathBuf>, | |
| 91 | + | pub footer: Option<PathBuf>, | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | impl Default for SiteConfig { | |
| 95 | + | fn default() -> Self { | |
| 96 | + | Self { | |
| 97 | + | title: "ferrenium".into(), | |
| 98 | + | description: String::new(), | |
| 99 | + | clone_urls: Vec::new(), | |
| 100 | + | custom_css: None, | |
| 101 | + | header: None, | |
| 102 | + | footer: None, | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | #[derive(Debug, Clone, Deserialize)] | |
| 108 | + | #[serde(deny_unknown_fields, default)] | |
| 109 | + | pub struct ReposConfig { | |
| 110 | + | pub scan_path: PathBuf, | |
| 111 | + | pub max_depth: u32, | |
| 112 | + | pub follow_symlinks: bool, | |
| 113 | + | pub section_from_path: bool, | |
| 114 | + | pub hide: Vec<String>, | |
| 115 | + | pub snapshots: Vec<SnapshotFormat>, | |
| 116 | + | pub https_clone: bool, | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | impl Default for ReposConfig { | |
| 120 | + | fn default() -> Self { | |
| 121 | + | Self { | |
| 122 | + | scan_path: "/home/git".into(), | |
| 123 | + | max_depth: 3, | |
| 124 | + | follow_symlinks: false, | |
| 125 | + | section_from_path: false, | |
| 126 | + | hide: Vec::new(), | |
| 127 | + | snapshots: vec![SnapshotFormat::TarGz, SnapshotFormat::Zip], | |
| 128 | + | https_clone: true, | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] | |
| 134 | + | pub enum SnapshotFormat { | |
| 135 | + | #[serde(rename = "tar.gz")] | |
| 136 | + | TarGz, | |
| 137 | + | #[serde(rename = "zip")] | |
| 138 | + | Zip, | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | #[derive(Debug, Clone, Default, Deserialize)] | |
| 142 | + | #[serde(deny_unknown_fields)] | |
| 143 | + | pub struct RepoOverride { | |
| 144 | + | pub name: String, | |
| 145 | + | pub description: Option<String>, | |
| 146 | + | pub owner: Option<String>, | |
| 147 | + | pub section: Option<String>, | |
| 148 | + | pub hidden: Option<bool>, | |
| 149 | + | pub readme: Option<String>, | |
| 150 | + | pub default_branch: Option<String>, | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | #[derive(Debug, Clone, Deserialize)] | |
| 154 | + | #[serde(deny_unknown_fields, default)] | |
| 155 | + | pub struct UiConfig { | |
| 156 | + | pub theme: Theme, | |
| 157 | + | pub code_font: String, | |
| 158 | + | pub max_blob_kb: u64, | |
| 159 | + | pub max_highlight_kb: u64, | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | impl Default for UiConfig { | |
| 163 | + | fn default() -> Self { | |
| 164 | + | Self { | |
| 165 | + | theme: Theme::Auto, | |
| 166 | + | code_font: DEFAULT_CODE_FONT.into(), | |
| 167 | + | max_blob_kb: 1024, | |
| 168 | + | max_highlight_kb: 256, | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | pub const DEFAULT_CODE_FONT: &str = "Space Mono"; | |
| 174 | + | ||
| 175 | + | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] | |
| 176 | + | #[serde(rename_all = "lowercase")] | |
| 177 | + | pub enum Theme { | |
| 178 | + | #[default] | |
| 179 | + | Auto, | |
| 180 | + | Dark, | |
| 181 | + | Light, | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | impl Theme { | |
| 185 | + | pub fn as_str(self) -> &'static str { | |
| 186 | + | match self { | |
| 187 | + | Theme::Auto => "auto", | |
| 188 | + | Theme::Dark => "dark", | |
| 189 | + | Theme::Light => "light", | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | #[derive(Debug, Clone, Deserialize)] | |
| 195 | + | #[serde(deny_unknown_fields, default)] | |
| 196 | + | pub struct CacheConfig { | |
| 197 | + | pub size_mb: u64, | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | impl Default for CacheConfig { | |
| 201 | + | fn default() -> Self { | |
| 202 | + | Self { size_mb: 64 } | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | #[derive(Debug, Clone, Deserialize)] | |
| 207 | + | #[serde(deny_unknown_fields, default)] | |
| 208 | + | pub struct WatchConfig { | |
| 209 | + | pub enabled: bool, | |
| 210 | + | pub debounce_ms: u64, | |
| 211 | + | /// Full rescan interval; 0 disables polling. | |
| 212 | + | pub poll_interval_secs: u64, | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | impl Default for WatchConfig { | |
| 216 | + | fn default() -> Self { | |
| 217 | + | Self { | |
| 218 | + | enabled: true, | |
| 219 | + | debounce_ms: 500, | |
| 220 | + | poll_interval_secs: 300, | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | #[derive(Debug, Clone, Deserialize)] | |
| 226 | + | #[serde(deny_unknown_fields, default)] | |
| 227 | + | pub struct LimitsConfig { | |
| 228 | + | /// 0 = 2 × available CPUs. | |
| 229 | + | pub git_parallel: usize, | |
| 230 | + | pub heavy_parallel: usize, | |
| 231 | + | pub request_timeout_secs: u64, | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | impl Default for LimitsConfig { | |
| 235 | + | fn default() -> Self { | |
| 236 | + | Self { | |
| 237 | + | git_parallel: 0, | |
| 238 | + | heavy_parallel: 2, | |
| 239 | + | request_timeout_secs: 30, | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | #[derive(Debug, Clone, Deserialize)] | |
| 245 | + | #[serde(deny_unknown_fields, default)] | |
| 246 | + | pub struct LogConfig { | |
| 247 | + | pub level: String, | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | impl Default for LogConfig { | |
| 251 | + | fn default() -> Self { | |
| 252 | + | Self { | |
| 253 | + | level: "info".into(), | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | /// Where the server listens: a TCP socket address or `unix:/abs/path`. | |
| 259 | + | #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] | |
| 260 | + | #[serde(try_from = "String")] | |
| 261 | + | pub enum Listen { | |
| 262 | + | Tcp(SocketAddr), | |
| 263 | + | Unix(PathBuf), | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | impl FromStr for Listen { | |
| 267 | + | type Err = String; | |
| 268 | + | ||
| 269 | + | fn from_str(s: &str) -> Result<Self, Self::Err> { | |
| 270 | + | if let Some(path) = s.strip_prefix("unix:") { | |
| 271 | + | let path = PathBuf::from(path); | |
| 272 | + | if !path.is_absolute() { | |
| 273 | + | return Err(format!("unix socket path must be absolute: {s:?}")); | |
| 274 | + | } | |
| 275 | + | return Ok(Listen::Unix(path)); | |
| 276 | + | } | |
| 277 | + | s.parse::<SocketAddr>().map(Listen::Tcp).map_err(|_| { | |
| 278 | + | format!("invalid listen address {s:?} (expected HOST:PORT or unix:/path/to.sock)") | |
| 279 | + | }) | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | impl TryFrom<String> for Listen { | |
| 284 | + | type Error = String; | |
| 285 | + | ||
| 286 | + | fn try_from(s: String) -> Result<Self, Self::Error> { | |
| 287 | + | s.parse() | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | impl fmt::Display for Listen { | |
| 292 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 293 | + | match self { | |
| 294 | + | Listen::Tcp(addr) => write!(f, "http://{addr}"), | |
| 295 | + | Listen::Unix(path) => write!(f, "unix:{}", path.display()), | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | /// Find the config file: explicit path (flag or `$FERRENIUM_CONFIG`), then the | |
| 301 | + | /// system location, then `$XDG_CONFIG_HOME/ferrenium/ferrenium.toml`. | |
| 302 | + | /// | |
| 303 | + | /// Returns `None` when no explicit path was given and no file exists, in which | |
| 304 | + | /// case the built-in defaults apply. | |
| 305 | + | pub fn resolve_path(explicit: Option<&Path>) -> Option<PathBuf> { | |
| 306 | + | if let Some(path) = explicit { | |
| 307 | + | return Some(path.to_path_buf()); | |
| 308 | + | } | |
| 309 | + | let system = PathBuf::from(SYSTEM_CONFIG); | |
| 310 | + | if system.is_file() { | |
| 311 | + | return Some(system); | |
| 312 | + | } | |
| 313 | + | let xdg = std::env::var_os("XDG_CONFIG_HOME") | |
| 314 | + | .map(PathBuf::from) | |
| 315 | + | .filter(|p| p.is_absolute()) | |
| 316 | + | .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?; | |
| 317 | + | let user = xdg.join("ferrenium/ferrenium.toml"); | |
| 318 | + | user.is_file().then_some(user) | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | impl Config { | |
| 322 | + | /// Load, normalize and validate the config at `path` (or the defaults). | |
| 323 | + | pub fn load(path: Option<&Path>) -> Result<Config, ConfigError> { | |
| 324 | + | let mut config = match path { | |
| 325 | + | Some(path) => { | |
| 326 | + | let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io { | |
| 327 | + | path: path.to_path_buf(), | |
| 328 | + | source, | |
| 329 | + | })?; | |
| 330 | + | let mut config = Config::parse(&text).map_err(|message| ConfigError::Parse { | |
| 331 | + | path: path.to_path_buf(), | |
| 332 | + | message, | |
| 333 | + | })?; | |
| 334 | + | config.source = Some(path.to_path_buf()); | |
| 335 | + | config | |
| 336 | + | } | |
| 337 | + | None => Config::default(), | |
| 338 | + | }; | |
| 339 | + | config.finish()?; | |
| 340 | + | Ok(config) | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | /// Parse TOML without validation. Errors carry line/column and a snippet. | |
| 344 | + | pub fn parse(text: &str) -> Result<Config, String> { | |
| 345 | + | toml::from_str(text).map_err(|e| e.to_string()) | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | /// Directory that relative paths in the config are resolved against. | |
| 349 | + | pub fn base_dir(&self) -> PathBuf { | |
| 350 | + | match self.source.as_deref().and_then(Path::parent) { | |
| 351 | + | Some(dir) if !dir.as_os_str().is_empty() => dir.to_path_buf(), | |
| 352 | + | _ => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | /// Normalize values, resolve relative paths, read customization files and | |
| 357 | + | /// validate. All problems are reported together. | |
| 358 | + | pub fn finish(&mut self) -> Result<(), ConfigError> { | |
| 359 | + | let mut errors = Vec::new(); | |
| 360 | + | let base_dir = self.base_dir(); | |
| 361 | + | ||
| 362 | + | // server | |
| 363 | + | let bp = self.server.base_path.trim(); | |
| 364 | + | if !bp.starts_with('/') { | |
| 365 | + | errors.push(format!("server.base_path must start with '/': {bp:?}")); | |
| 366 | + | } else if bp.contains("//") || bp.split('/').any(|s| s == "." || s == "..") { | |
| 367 | + | errors.push(format!("server.base_path is not a clean path: {bp:?}")); | |
| 368 | + | } | |
| 369 | + | let mut bp = bp.trim_end_matches('/').to_string(); | |
| 370 | + | bp.push('/'); | |
| 371 | + | self.server.base_path = bp; | |
| 372 | + | ||
| 373 | + | if let Some(url) = &mut self.server.base_url { | |
| 374 | + | let trimmed = url.trim().trim_end_matches('/').to_string(); | |
| 375 | + | if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) { | |
| 376 | + | errors.push(format!( | |
| 377 | + | "server.base_url must start with http:// or https://: {url:?}" | |
| 378 | + | )); | |
| 379 | + | } | |
| 380 | + | *url = trimmed; | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | // site | |
| 384 | + | if self.site.title.trim().is_empty() { | |
| 385 | + | errors.push("site.title must not be empty".into()); | |
| 386 | + | } | |
| 387 | + | for (i, url) in self.site.clone_urls.iter().enumerate() { | |
| 388 | + | if !url.contains("{name}") { | |
| 389 | + | errors.push(format!( | |
| 390 | + | "site.clone_urls[{i}] must contain the {{name}} placeholder: {url:?}" | |
| 391 | + | )); | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | self.custom = Customization { | |
| 395 | + | css: read_custom( | |
| 396 | + | &base_dir, | |
| 397 | + | "site.custom_css", | |
| 398 | + | &mut self.site.custom_css, | |
| 399 | + | &mut errors, | |
| 400 | + | ), | |
| 401 | + | header: read_custom(&base_dir, "site.header", &mut self.site.header, &mut errors), | |
| 402 | + | footer: read_custom(&base_dir, "site.footer", &mut self.site.footer, &mut errors), | |
| 403 | + | }; | |
| 404 | + | ||
| 405 | + | // repos | |
| 406 | + | if self.repos.scan_path.is_relative() { | |
| 407 | + | self.repos.scan_path = base_dir.join(&self.repos.scan_path); | |
| 408 | + | } | |
| 409 | + | if !(1..=16).contains(&self.repos.max_depth) { | |
| 410 | + | errors.push(format!( | |
| 411 | + | "repos.max_depth must be between 1 and 16, got {}", | |
| 412 | + | self.repos.max_depth | |
| 413 | + | )); | |
| 414 | + | } | |
| 415 | + | let mut seen = std::collections::HashSet::new(); | |
| 416 | + | for (i, repo) in self.repo_overrides.iter_mut().enumerate() { | |
| 417 | + | let name = repo.name.trim().trim_matches('/').trim_end_matches(".git"); | |
| 418 | + | if name.is_empty() { | |
| 419 | + | errors.push(format!("[[repo]] #{} has an empty name", i + 1)); | |
| 420 | + | continue; | |
| 421 | + | } | |
| 422 | + | repo.name = name.to_string(); | |
| 423 | + | if !seen.insert(repo.name.clone()) { | |
| 424 | + | errors.push(format!( | |
| 425 | + | "[[repo]] {:?} is defined more than once", | |
| 426 | + | repo.name | |
| 427 | + | )); | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | // ui | |
| 432 | + | if self.ui.code_font.trim().is_empty() { | |
| 433 | + | errors.push("ui.code_font must not be empty".into()); | |
| 434 | + | } else if self | |
| 435 | + | .ui | |
| 436 | + | .code_font | |
| 437 | + | .chars() | |
| 438 | + | .any(|c| matches!(c, '"' | '\'' | ';' | '{' | '}' | '<' | '>' | '\\')) | |
| 439 | + | { | |
| 440 | + | errors.push(format!( | |
| 441 | + | "ui.code_font contains characters not allowed in a font name: {:?}", | |
| 442 | + | self.ui.code_font | |
| 443 | + | )); | |
| 444 | + | } | |
| 445 | + | if self.ui.max_blob_kb == 0 { | |
| 446 | + | errors.push("ui.max_blob_kb must be greater than 0".into()); | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | // cache / watch / limits | |
| 450 | + | if self.cache.size_mb == 0 { | |
| 451 | + | errors.push("cache.size_mb must be greater than 0".into()); | |
| 452 | + | } | |
| 453 | + | if self.watch.debounce_ms > 60_000 { | |
| 454 | + | errors.push("watch.debounce_ms must be at most 60000".into()); | |
| 455 | + | } | |
| 456 | + | if self.limits.heavy_parallel == 0 { | |
| 457 | + | errors.push("limits.heavy_parallel must be at least 1".into()); | |
| 458 | + | } | |
| 459 | + | if self.limits.request_timeout_secs == 0 { | |
| 460 | + | errors.push("limits.request_timeout_secs must be at least 1".into()); | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | // log | |
| 464 | + | if let Err(e) = tracing_subscriber::EnvFilter::try_new(&self.log.level) { | |
| 465 | + | errors.push(format!("log.level {:?} is invalid: {e}", self.log.level)); | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | if errors.is_empty() { | |
| 469 | + | Ok(()) | |
| 470 | + | } else { | |
| 471 | + | Err(ConfigError::Invalid(errors)) | |
| 472 | + | } | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | /// Effective number of concurrent git workers. | |
| 476 | + | pub fn git_parallel(&self) -> usize { | |
| 477 | + | match self.limits.git_parallel { | |
| 478 | + | 0 => std::thread::available_parallelism().map_or(4, |n| n.get() * 2), | |
| 479 | + | n => n, | |
| 480 | + | } | |
| 481 | + | } | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | /// Resolve a customization file path against `base_dir` and read it. | |
| 485 | + | fn read_custom( | |
| 486 | + | base_dir: &Path, | |
| 487 | + | key: &str, | |
| 488 | + | path: &mut Option<PathBuf>, | |
| 489 | + | errors: &mut Vec<String>, | |
| 490 | + | ) -> Option<Arc<str>> { | |
| 491 | + | let p = path.as_mut()?; | |
| 492 | + | if p.is_relative() { | |
| 493 | + | *p = base_dir.join(&*p); | |
| 494 | + | } | |
| 495 | + | match std::fs::read_to_string(&*p) { | |
| 496 | + | Ok(text) => Some(text.into()), | |
| 497 | + | Err(e) => { | |
| 498 | + | errors.push(format!("{key}: cannot read {}: {e}", p.display())); | |
| 499 | + | None | |
| 500 | + | } | |
| 501 | + | } | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | #[cfg(test)] | |
| 505 | + | mod tests { | |
| 506 | + | use super::*; | |
| 507 | + | ||
| 508 | + | fn finish(text: &str) -> Result<Config, ConfigError> { | |
| 509 | + | let mut c = Config::parse(text).map_err(|message| ConfigError::Parse { | |
| 510 | + | path: "test.toml".into(), | |
| 511 | + | message, | |
| 512 | + | })?; | |
| 513 | + | c.finish()?; | |
| 514 | + | Ok(c) | |
| 515 | + | } | |
| 516 | + | ||
| 517 | + | #[test] | |
| 518 | + | fn empty_config_uses_defaults() { | |
| 519 | + | let c = finish("").unwrap(); | |
| 520 | + | assert_eq!(c.server.listen, "127.0.0.1:8080".parse().unwrap()); | |
| 521 | + | assert_eq!(c.server.base_path, "/"); | |
| 522 | + | assert_eq!(c.repos.scan_path, PathBuf::from("/home/git")); | |
| 523 | + | assert_eq!(c.repos.max_depth, 3); | |
| 524 | + | assert_eq!(c.ui.theme, Theme::Auto); | |
| 525 | + | assert_eq!(c.cache.size_mb, 64); | |
| 526 | + | assert_eq!( | |
| 527 | + | c.repos.snapshots, | |
| 528 | + | vec![SnapshotFormat::TarGz, SnapshotFormat::Zip] | |
| 529 | + | ); | |
| 530 | + | assert!(c.git_parallel() >= 1); | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | #[test] | |
| 534 | + | fn example_config_is_valid() { | |
| 535 | + | let c = finish(crate::examples::CONFIG).unwrap(); | |
| 536 | + | assert_eq!(c.site.title, "krolyxon"); | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | #[test] | |
| 540 | + | fn unknown_keys_are_rejected_with_position() { | |
| 541 | + | let err = finish("[server]\nlisten = \"127.0.0.1:1\"\nlistn = 1\n").unwrap_err(); | |
| 542 | + | let msg = err.to_string(); | |
| 543 | + | assert!(msg.contains("unknown field `listn`"), "{msg}"); | |
| 544 | + | assert!(msg.contains("line 3"), "{msg}"); | |
| 545 | + | ||
| 546 | + | let err = finish("[sever]\n").unwrap_err().to_string(); | |
| 547 | + | assert!(err.contains("unknown field `sever`"), "{err}"); | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | #[test] | |
| 551 | + | fn listen_parsing() { | |
| 552 | + | assert_eq!( | |
| 553 | + | "unix:/run/ferrenium/ferrenium.sock".parse::<Listen>(), | |
| 554 | + | Ok(Listen::Unix("/run/ferrenium/ferrenium.sock".into())) | |
| 555 | + | ); | |
| 556 | + | assert!("unix:relative.sock".parse::<Listen>().is_err()); | |
| 557 | + | assert!("localhost".parse::<Listen>().is_err()); | |
| 558 | + | assert_eq!( | |
| 559 | + | "[::1]:9000".parse::<Listen>(), | |
| 560 | + | Ok(Listen::Tcp("[::1]:9000".parse().unwrap())) | |
| 561 | + | ); | |
| 562 | + | let err = finish("[server]\nlisten = \"nope\"\n") | |
| 563 | + | .unwrap_err() | |
| 564 | + | .to_string(); | |
| 565 | + | assert!(err.contains("invalid listen address"), "{err}"); | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | #[test] | |
| 569 | + | fn base_path_and_url_are_normalized() { | |
| 570 | + | let c = finish("[server]\nbase_path = \"/git\"\nbase_url = \"https://x.org/\"\n").unwrap(); | |
| 571 | + | assert_eq!(c.server.base_path, "/git/"); | |
| 572 | + | assert_eq!(c.server.base_url.as_deref(), Some("https://x.org")); | |
| 573 | + | assert_eq!( | |
| 574 | + | finish("[server]\nbase_path = \"/\"\n") | |
| 575 | + | .unwrap() | |
| 576 | + | .server | |
| 577 | + | .base_path, | |
| 578 | + | "/" | |
| 579 | + | ); | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | #[test] | |
| 583 | + | fn validation_reports_all_errors() { | |
| 584 | + | let err = finish( | |
| 585 | + | r#" | |
| 586 | + | [server] | |
| 587 | + | base_path = "git" | |
| 588 | + | base_url = "ftp://x" | |
| 589 | + | [repos] | |
| 590 | + | max_depth = 0 | |
| 591 | + | [ui] | |
| 592 | + | theme = "dark" | |
| 593 | + | code_font = "x; } body { display: none" | |
| 594 | + | [cache] | |
| 595 | + | size_mb = 0 | |
| 596 | + | [[repo]] | |
| 597 | + | name = "a.git" | |
| 598 | + | [[repo]] | |
| 599 | + | name = "a" | |
| 600 | + | "#, | |
| 601 | + | ) | |
| 602 | + | .unwrap_err(); | |
| 603 | + | let ConfigError::Invalid(errors) = err else { | |
| 604 | + | panic!("expected validation errors, got {err}"); | |
| 605 | + | }; | |
| 606 | + | let all = errors.join("\n"); | |
| 607 | + | for needle in [ | |
| 608 | + | "base_path", | |
| 609 | + | "base_url", | |
| 610 | + | "max_depth", | |
| 611 | + | "code_font", | |
| 612 | + | "size_mb", | |
| 613 | + | "defined more than once", | |
| 614 | + | ] { | |
| 615 | + | assert!(all.contains(needle), "missing {needle:?} in:\n{all}"); | |
| 616 | + | } | |
| 617 | + | } | |
| 618 | + | ||
| 619 | + | #[test] | |
| 620 | + | fn invalid_enum_values_are_rejected() { | |
| 621 | + | assert!(finish("[ui]\ntheme = \"purple\"\n").is_err()); | |
| 622 | + | assert!(finish("[repos]\nsnapshots = [\"rar\"]\n").is_err()); | |
| 623 | + | } | |
| 624 | + | ||
| 625 | + | #[test] | |
| 626 | + | fn custom_files_resolve_relative_to_config() { | |
| 627 | + | let dir = tempfile::tempdir().unwrap(); | |
| 628 | + | std::fs::write(dir.path().join("custom.css"), "body{}").unwrap(); | |
| 629 | + | let path = dir.path().join("ferrenium.toml"); | |
| 630 | + | std::fs::write( | |
| 631 | + | &path, | |
| 632 | + | "[site]\ncustom_css = \"custom.css\"\n[repos]\nscan_path = \"repos\"\n", | |
| 633 | + | ) | |
| 634 | + | .unwrap(); | |
| 635 | + | let c = Config::load(Some(&path)).unwrap(); | |
| 636 | + | assert_eq!(c.custom.css.as_deref(), Some("body{}")); | |
| 637 | + | assert_eq!(c.repos.scan_path, dir.path().join("repos")); | |
| 638 | + | ||
| 639 | + | std::fs::write(&path, "[site]\nfooter = \"missing.html\"\n").unwrap(); | |
| 640 | + | let err = Config::load(Some(&path)).unwrap_err().to_string(); | |
| 641 | + | assert!(err.contains("site.footer"), "{err}"); | |
| 642 | + | } | |
| 643 | + | } | |
| @@ -0,0 +1,5 @@ | |||
| 1 | + | //! Ready-to-use deployment templates, printed by `ferrenium example`. | |
| 2 | + | ||
| 3 | + | pub const CONFIG: &str = include_str!("../dist/ferrenium.toml"); | |
| 4 | + | pub const SYSTEMD: &str = include_str!("../dist/ferrenium.service"); | |
| 5 | + | pub const CADDY: &str = include_str!("../dist/Caddyfile"); | |
| @@ -0,0 +1,226 @@ | |||
| 1 | + | //! All repository access goes through this module; nothing else touches gix. | |
| 2 | + | ||
| 3 | + | pub mod refs; | |
| 4 | + | pub mod tree; | |
| 5 | + | ||
| 6 | + | use std::hash::{Hash, Hasher}; | |
| 7 | + | use std::path::Path; | |
| 8 | + | ||
| 9 | + | use anyhow::Context; | |
| 10 | + | use gix::bstr::ByteSlice; | |
| 11 | + | ||
| 12 | + | pub use gix::ObjectId; | |
| 13 | + | pub use gix::Repository as Handle; | |
| 14 | + | pub use gix::ThreadSafeRepository; | |
| 15 | + | ||
| 16 | + | /// A handle for use on the current thread. | |
| 17 | + | pub fn handle(repo: &ThreadSafeRepository) -> Handle { | |
| 18 | + | repo.to_thread_local() | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | /// Open a repository by its git dir (the bare repo, or a worktree's `.git`). | |
| 22 | + | /// | |
| 23 | + | /// Only the repository's own config is read: no system/global config and no | |
| 24 | + | /// `GIT_*` environment, so behavior doesn't depend on who runs the server. | |
| 25 | + | pub fn open(git_dir: &Path) -> anyhow::Result<ThreadSafeRepository> { | |
| 26 | + | if git_dir.join("reftable").is_dir() { | |
| 27 | + | anyhow::bail!("uses the reftable ref format, which is not supported"); | |
| 28 | + | } | |
| 29 | + | let options = gix::open::Options::isolated().open_path_as_is(true); | |
| 30 | + | let repo = ThreadSafeRepository::open_opts(git_dir, options) | |
| 31 | + | .with_context(|| format!("cannot open {}", git_dir.display()))?; | |
| 32 | + | let repo_local = repo.to_thread_local(); | |
| 33 | + | if let Some(storage) = repo_local.config_snapshot().string("extensions.refStorage") | |
| 34 | + | && storage != "files" | |
| 35 | + | { | |
| 36 | + | anyhow::bail!("uses the {storage} ref format, which is not supported"); | |
| 37 | + | } | |
| 38 | + | Ok(repo) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// Repository metadata stored in its git config. | |
| 42 | + | /// | |
| 43 | + | /// `ferrenium.*` keys win; `gitweb.*` and `cgit.*` are read for painless | |
| 44 | + | /// migration from those tools. | |
| 45 | + | #[derive(Debug, Default, Clone, PartialEq, Eq)] | |
| 46 | + | pub struct ConfigMeta { | |
| 47 | + | pub description: Option<String>, | |
| 48 | + | pub owner: Option<String>, | |
| 49 | + | pub section: Option<String>, | |
| 50 | + | pub hidden: Option<bool>, | |
| 51 | + | pub readme: Option<String>, | |
| 52 | + | pub default_branch: Option<String>, | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | pub fn config_meta(repo: &gix::Repository) -> ConfigMeta { | |
| 56 | + | let config = repo.config_snapshot(); | |
| 57 | + | let string = |keys: &[&str]| { | |
| 58 | + | keys.iter().find_map(|key| { | |
| 59 | + | let value = config.string(*key)?; | |
| 60 | + | let value = value.to_str_lossy().trim().to_string(); | |
| 61 | + | (!value.is_empty()).then_some(value) | |
| 62 | + | }) | |
| 63 | + | }; | |
| 64 | + | ConfigMeta { | |
| 65 | + | description: string(&["ferrenium.description", "gitweb.description", "cgit.desc"]), | |
| 66 | + | owner: string(&["ferrenium.owner", "gitweb.owner", "cgit.owner"]), | |
| 67 | + | section: string(&["ferrenium.section", "gitweb.category", "cgit.section"]), | |
| 68 | + | hidden: ["ferrenium.hidden", "cgit.hide"] | |
| 69 | + | .iter() | |
| 70 | + | .find_map(|key| config.boolean(*key)), | |
| 71 | + | // cgit allows `branch:path`; we always read from the default branch. | |
| 72 | + | readme: string(&["ferrenium.readme", "cgit.readme"]) | |
| 73 | + | .map(|r| { | |
| 74 | + | r.rsplit_once(':') | |
| 75 | + | .map_or(r.clone(), |(_, path)| path.to_string()) | |
| 76 | + | }) | |
| 77 | + | .filter(|r| !r.is_empty()), | |
| 78 | + | default_branch: string(&["ferrenium.defaultBranch", "cgit.defbranch"]), | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// Short branch name HEAD points to (also when that branch is unborn). | |
| 83 | + | pub fn head_branch(repo: &gix::Repository) -> Option<String> { | |
| 84 | + | let name = repo.head_name().ok()??; | |
| 85 | + | name.as_bstr() | |
| 86 | + | .strip_prefix(b"refs/heads/") | |
| 87 | + | .map(|b| b.to_str_lossy().into_owned()) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /// Counts and a fingerprint over all refs. | |
| 91 | + | #[derive(Debug, Default, Clone, PartialEq, Eq)] | |
| 92 | + | pub struct RefSummary { | |
| 93 | + | pub branches: usize, | |
| 94 | + | pub tags: usize, | |
| 95 | + | /// Newest committer time across branch tips (unix seconds). | |
| 96 | + | pub last_activity: Option<i64>, | |
| 97 | + | /// Changes whenever any ref (or HEAD) changes. | |
| 98 | + | pub fingerprint: u64, | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | pub fn ref_summary(repo: &gix::Repository) -> anyhow::Result<RefSummary> { | |
| 102 | + | let mut summary = RefSummary::default(); | |
| 103 | + | let mut hasher = std::hash::DefaultHasher::new(); | |
| 104 | + | if let Ok(head) = repo.find_reference("HEAD") { | |
| 105 | + | format!("{:?}", head.target()).hash(&mut hasher); | |
| 106 | + | } | |
| 107 | + | let platform = repo.references().context("cannot read refs")?; | |
| 108 | + | for reference in platform.all().context("cannot read refs")? { | |
| 109 | + | let Ok(mut reference) = reference else { | |
| 110 | + | continue; | |
| 111 | + | }; | |
| 112 | + | let name = reference.name().as_bstr().to_owned(); | |
| 113 | + | name.hash(&mut hasher); | |
| 114 | + | format!("{:?}", reference.target()).hash(&mut hasher); | |
| 115 | + | ||
| 116 | + | if name.starts_with(b"refs/heads/") { | |
| 117 | + | summary.branches += 1; | |
| 118 | + | if let Ok(commit) = reference.peel_to_commit() | |
| 119 | + | && let Ok(time) = commit.time() | |
| 120 | + | { | |
| 121 | + | summary.last_activity = summary.last_activity.max(Some(time.seconds)); | |
| 122 | + | } | |
| 123 | + | } else if name.starts_with(b"refs/tags/") { | |
| 124 | + | summary.tags += 1; | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | summary.fingerprint = hasher.finish(); | |
| 128 | + | Ok(summary) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// Commit id a branch points to, if the branch exists. | |
| 132 | + | pub fn branch_commit(repo: &gix::Repository, branch: &str) -> Option<ObjectId> { | |
| 133 | + | let mut reference = repo.find_reference(&format!("refs/heads/{branch}")).ok()?; | |
| 134 | + | Some(reference.peel_to_commit().ok()?.id) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /// Names tried for the README, in order of preference (case-insensitive). | |
| 138 | + | const README_NAMES: &[&str] = &[ | |
| 139 | + | "readme.md", | |
| 140 | + | "readme.markdown", | |
| 141 | + | "readme", | |
| 142 | + | "readme.txt", | |
| 143 | + | "readme.rst", | |
| 144 | + | "readme.adoc", | |
| 145 | + | "readme.org", | |
| 146 | + | ]; | |
| 147 | + | ||
| 148 | + | /// Locate the README blob in `commit`'s tree: `preferred` (a path) if given, | |
| 149 | + | /// otherwise the best match among root entries. | |
| 150 | + | pub fn find_readme( | |
| 151 | + | repo: &gix::Repository, | |
| 152 | + | commit: ObjectId, | |
| 153 | + | preferred: Option<&str>, | |
| 154 | + | ) -> Option<(String, ObjectId)> { | |
| 155 | + | let tree = repo.find_commit(commit).ok()?.tree().ok()?; | |
| 156 | + | if let Some(path) = preferred { | |
| 157 | + | let entry = tree.lookup_entry_by_path(path).ok()??; | |
| 158 | + | return entry | |
| 159 | + | .mode() | |
| 160 | + | .is_blob() | |
| 161 | + | .then(|| (path.to_string(), entry.object_id())); | |
| 162 | + | } | |
| 163 | + | let mut best: Option<(usize, String, ObjectId)> = None; | |
| 164 | + | for entry in tree.iter().flatten() { | |
| 165 | + | if !entry.mode().is_blob() { | |
| 166 | + | continue; | |
| 167 | + | } | |
| 168 | + | let name = entry.filename().to_str_lossy(); | |
| 169 | + | let lower = name.to_ascii_lowercase(); | |
| 170 | + | if let Some(rank) = README_NAMES.iter().position(|n| *n == lower) | |
| 171 | + | && best.as_ref().is_none_or(|(r, _, _)| rank < *r) | |
| 172 | + | { | |
| 173 | + | best = Some((rank, name.into_owned(), entry.oid().to_owned())); | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | best.map(|(_, name, oid)| (name, oid)) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | /// The parts of a commit shown in listings. | |
| 180 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 181 | + | pub struct CommitInfo { | |
| 182 | + | pub id: ObjectId, | |
| 183 | + | pub subject: String, | |
| 184 | + | pub author_name: String, | |
| 185 | + | pub author_email: String, | |
| 186 | + | /// Unix seconds. | |
| 187 | + | pub author_time: i64, | |
| 188 | + | pub committer_time: i64, | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | impl CommitInfo { | |
| 192 | + | pub fn weight(&self) -> usize { | |
| 193 | + | 64 + self.subject.len() + self.author_name.len() + self.author_email.len() | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | pub fn commit_info(repo: &gix::Repository, id: ObjectId) -> anyhow::Result<CommitInfo> { | |
| 198 | + | let commit = repo.find_commit(id).context("commit not found")?; | |
| 199 | + | let decoded = commit.decode().context("malformed commit")?; | |
| 200 | + | let author = decoded.author().context("malformed author")?; | |
| 201 | + | let committer = decoded.committer().context("malformed committer")?; | |
| 202 | + | Ok(CommitInfo { | |
| 203 | + | id, | |
| 204 | + | subject: decoded | |
| 205 | + | .message() | |
| 206 | + | .summary() | |
| 207 | + | .to_str_lossy() | |
| 208 | + | .trim() | |
| 209 | + | .to_string(), | |
| 210 | + | author_name: author.name.to_str_lossy().trim().to_string(), | |
| 211 | + | author_email: author.email.to_str_lossy().trim().to_string(), | |
| 212 | + | author_time: author.seconds(), | |
| 213 | + | committer_time: committer.seconds(), | |
| 214 | + | }) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | /// Number of commits reachable from `tip` (uses the commit-graph when the | |
| 218 | + | /// repository has one). | |
| 219 | + | pub fn commit_count(repo: &gix::Repository, tip: ObjectId) -> anyhow::Result<u64> { | |
| 220 | + | let mut count = 0; | |
| 221 | + | for info in repo.rev_walk([tip]).all()? { | |
| 222 | + | info?; | |
| 223 | + | count += 1; | |
| 224 | + | } | |
| 225 | + | Ok(count) | |
| 226 | + | } | |
| @@ -0,0 +1,167 @@ | |||
| 1 | + | //! Resolving revisions from URLs and listing refs. | |
| 2 | + | ||
| 3 | + | use gix::bstr::{BStr, ByteSlice}; | |
| 4 | + | ||
| 5 | + | use super::{CommitInfo, ObjectId, commit_info}; | |
| 6 | + | ||
| 7 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 8 | + | pub enum RefKind { | |
| 9 | + | Branch, | |
| 10 | + | Tag, | |
| 11 | + | /// A (possibly abbreviated) commit id written in the URL. | |
| 12 | + | Commit, | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | /// A revision named in a URL, resolved to a commit. | |
| 16 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 17 | + | pub struct Rev { | |
| 18 | + | /// The name as written in the URL (`main`, `feature/x`, `v1.0`, an oid). | |
| 19 | + | pub name: String, | |
| 20 | + | pub kind: RefKind, | |
| 21 | + | pub commit: ObjectId, | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | impl Rev { | |
| 25 | + | /// URLs naming a full commit id always show the same content. | |
| 26 | + | pub fn is_immutable(&self) -> bool { | |
| 27 | + | self.kind == RefKind::Commit && self.name.len() == self.commit.kind().len_in_hex() | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /// Resolve `name` to a commit: a branch, then a tag, then a hex commit id | |
| 32 | + | /// (at least 4 digits). | |
| 33 | + | pub fn resolve(repo: &gix::Repository, name: &str) -> Option<Rev> { | |
| 34 | + | if name.is_empty() || name.starts_with('-') { | |
| 35 | + | return None; | |
| 36 | + | } | |
| 37 | + | for (prefix, kind) in [ | |
| 38 | + | ("refs/heads/", RefKind::Branch), | |
| 39 | + | ("refs/tags/", RefKind::Tag), | |
| 40 | + | ] { | |
| 41 | + | if let Ok(mut reference) = repo.find_reference(&format!("{prefix}{name}")) | |
| 42 | + | && let Ok(commit) = reference.peel_to_commit() | |
| 43 | + | { | |
| 44 | + | return Some(Rev { | |
| 45 | + | name: name.to_string(), | |
| 46 | + | kind, | |
| 47 | + | commit: commit.id, | |
| 48 | + | }); | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | if (4..=40).contains(&name.len()) && name.bytes().all(|b| b.is_ascii_hexdigit()) { | |
| 52 | + | let id = repo.rev_parse_single(BStr::new(name)).ok()?; | |
| 53 | + | let commit = id.object().ok()?.peel_to_commit().ok()?; | |
| 54 | + | return Some(Rev { | |
| 55 | + | name: name.to_string(), | |
| 56 | + | kind: RefKind::Commit, | |
| 57 | + | commit: commit.id, | |
| 58 | + | }); | |
| 59 | + | } | |
| 60 | + | None | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// Resolve the longest prefix of `segments` that names a revision; the | |
| 64 | + | /// remaining segments are the path. Ref names may contain `/`. | |
| 65 | + | pub fn resolve_prefix<'a>( | |
| 66 | + | repo: &gix::Repository, | |
| 67 | + | segments: &'a [Vec<u8>], | |
| 68 | + | ) -> Option<(Rev, &'a [Vec<u8>])> { | |
| 69 | + | (1..=segments.len()).rev().find_map(|n| { | |
| 70 | + | let parts: Option<Vec<&str>> = segments[..n] | |
| 71 | + | .iter() | |
| 72 | + | .map(|s| std::str::from_utf8(s).ok()) | |
| 73 | + | .collect(); | |
| 74 | + | let rev = resolve(repo, &parts?.join("/"))?; | |
| 75 | + | Some((rev, &segments[n..])) | |
| 76 | + | }) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | #[derive(Debug, Clone)] | |
| 80 | + | pub struct RefEntry { | |
| 81 | + | /// Short name (`main`, `v1.0`). | |
| 82 | + | pub name: String, | |
| 83 | + | pub commit: ObjectId, | |
| 84 | + | pub info: CommitInfo, | |
| 85 | + | /// Tag message subject, for annotated tags. | |
| 86 | + | pub tag_subject: Option<String>, | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | #[derive(Debug, Clone, Default)] | |
| 90 | + | pub struct RefList { | |
| 91 | + | /// Newest commit first. | |
| 92 | + | pub branches: Vec<RefEntry>, | |
| 93 | + | /// Newest first. | |
| 94 | + | pub tags: Vec<RefEntry>, | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | impl RefList { | |
| 98 | + | pub fn weight(&self) -> usize { | |
| 99 | + | let entry = |e: &RefEntry| { | |
| 100 | + | 64 + e.name.len() + e.info.weight() + e.tag_subject.as_ref().map_or(0, String::len) | |
| 101 | + | }; | |
| 102 | + | self.branches.iter().chain(&self.tags).map(entry).sum() | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /// Every branch and tag that points (possibly through tags) at a commit. | |
| 107 | + | pub fn list(repo: &gix::Repository) -> anyhow::Result<RefList> { | |
| 108 | + | let mut list = RefList::default(); | |
| 109 | + | let platform = repo.references()?; | |
| 110 | + | for reference in platform.all()? { | |
| 111 | + | let Ok(mut reference) = reference else { | |
| 112 | + | continue; | |
| 113 | + | }; | |
| 114 | + | let full = reference.name().as_bstr().to_owned(); | |
| 115 | + | let (name, is_tag) = if let Some(n) = full.strip_prefix(b"refs/heads/") { | |
| 116 | + | (n, false) | |
| 117 | + | } else if let Some(n) = full.strip_prefix(b"refs/tags/") { | |
| 118 | + | (n, true) | |
| 119 | + | } else { | |
| 120 | + | continue; | |
| 121 | + | }; | |
| 122 | + | let Ok(name) = name.to_str() else { | |
| 123 | + | continue; | |
| 124 | + | }; | |
| 125 | + | let tag_subject = is_tag | |
| 126 | + | .then(|| { | |
| 127 | + | let id = reference.target().try_id()?.to_owned(); | |
| 128 | + | let tag = repo.find_object(id).ok()?.try_into_tag().ok()?; | |
| 129 | + | let tag = tag.decode().ok()?; | |
| 130 | + | let subject = tag | |
| 131 | + | .message | |
| 132 | + | .lines() | |
| 133 | + | .next()? | |
| 134 | + | .to_str_lossy() | |
| 135 | + | .trim() | |
| 136 | + | .to_string(); | |
| 137 | + | (!subject.is_empty()).then_some(subject) | |
| 138 | + | }) | |
| 139 | + | .flatten(); | |
| 140 | + | let Ok(commit) = reference.peel_to_commit() else { | |
| 141 | + | continue; | |
| 142 | + | }; | |
| 143 | + | let Ok(info) = commit_info(repo, commit.id) else { | |
| 144 | + | continue; | |
| 145 | + | }; | |
| 146 | + | let entry = RefEntry { | |
| 147 | + | name: name.to_string(), | |
| 148 | + | commit: commit.id, | |
| 149 | + | info, | |
| 150 | + | tag_subject, | |
| 151 | + | }; | |
| 152 | + | if is_tag { | |
| 153 | + | list.tags.push(entry); | |
| 154 | + | } else { | |
| 155 | + | list.branches.push(entry); | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | let newest_first = |a: &RefEntry, b: &RefEntry| { | |
| 159 | + | b.info | |
| 160 | + | .committer_time | |
| 161 | + | .cmp(&a.info.committer_time) | |
| 162 | + | .then_with(|| a.name.cmp(&b.name)) | |
| 163 | + | }; | |
| 164 | + | list.branches.sort_by(newest_first); | |
| 165 | + | list.tags.sort_by(newest_first); | |
| 166 | + | Ok(list) | |
| 167 | + | } | |
| @@ -0,0 +1,125 @@ | |||
| 1 | + | //! Trees and blobs at a commit. | |
| 2 | + | ||
| 3 | + | use std::ffi::OsStr; | |
| 4 | + | use std::os::unix::ffi::OsStrExt; | |
| 5 | + | use std::path::Path; | |
| 6 | + | ||
| 7 | + | use anyhow::Context; | |
| 8 | + | use gix::bstr::{BString, ByteSlice}; | |
| 9 | + | use gix::object::tree::EntryKind as GixKind; | |
| 10 | + | ||
| 11 | + | use super::ObjectId; | |
| 12 | + | ||
| 13 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 14 | + | pub enum EntryKind { | |
| 15 | + | Tree, | |
| 16 | + | Blob, | |
| 17 | + | Executable, | |
| 18 | + | Symlink, | |
| 19 | + | Submodule, | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | impl EntryKind { | |
| 23 | + | fn from_gix(kind: GixKind) -> Self { | |
| 24 | + | match kind { | |
| 25 | + | GixKind::Tree => EntryKind::Tree, | |
| 26 | + | GixKind::Blob => EntryKind::Blob, | |
| 27 | + | GixKind::BlobExecutable => EntryKind::Executable, | |
| 28 | + | GixKind::Link => EntryKind::Symlink, | |
| 29 | + | GixKind::Commit => EntryKind::Submodule, | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | pub fn is_blob(self) -> bool { | |
| 34 | + | matches!( | |
| 35 | + | self, | |
| 36 | + | EntryKind::Blob | EntryKind::Executable | EntryKind::Symlink | |
| 37 | + | ) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | #[derive(Debug, Clone)] | |
| 42 | + | pub struct TreeEntry { | |
| 43 | + | pub name: BString, | |
| 44 | + | pub kind: EntryKind, | |
| 45 | + | pub oid: ObjectId, | |
| 46 | + | /// Blob size in bytes (blobs and symlinks only). | |
| 47 | + | pub size: Option<u64>, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// What a path inside a commit's tree points to. | |
| 51 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 52 | + | pub enum PathTarget { | |
| 53 | + | Tree(ObjectId), | |
| 54 | + | Blob(ObjectId, EntryKind), | |
| 55 | + | Submodule(ObjectId), | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | fn as_path(bytes: &[u8]) -> &Path { | |
| 59 | + | Path::new(OsStr::from_bytes(bytes)) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// Look up `path` (slash-separated, empty for the root) in `commit`'s tree. | |
| 63 | + | pub fn lookup( | |
| 64 | + | repo: &gix::Repository, | |
| 65 | + | commit: ObjectId, | |
| 66 | + | path: &[u8], | |
| 67 | + | ) -> anyhow::Result<Option<PathTarget>> { | |
| 68 | + | let tree = repo | |
| 69 | + | .find_commit(commit) | |
| 70 | + | .context("commit not found")? | |
| 71 | + | .tree() | |
| 72 | + | .context("commit has no tree")?; | |
| 73 | + | if path.is_empty() { | |
| 74 | + | return Ok(Some(PathTarget::Tree(tree.id))); | |
| 75 | + | } | |
| 76 | + | let Some(entry) = tree.lookup_entry_by_path(as_path(path))? else { | |
| 77 | + | return Ok(None); | |
| 78 | + | }; | |
| 79 | + | let oid = entry.object_id(); | |
| 80 | + | Ok(Some(match EntryKind::from_gix(entry.mode().kind()) { | |
| 81 | + | EntryKind::Tree => PathTarget::Tree(oid), | |
| 82 | + | EntryKind::Submodule => PathTarget::Submodule(oid), | |
| 83 | + | kind => PathTarget::Blob(oid, kind), | |
| 84 | + | })) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /// Entries of a tree: directories first, then files, each by name. | |
| 88 | + | pub fn list(repo: &gix::Repository, tree: ObjectId) -> anyhow::Result<Vec<TreeEntry>> { | |
| 89 | + | let tree = repo.find_tree(tree).context("tree not found")?; | |
| 90 | + | let mut entries = Vec::new(); | |
| 91 | + | for entry in tree.iter() { | |
| 92 | + | let entry = entry?; | |
| 93 | + | let kind = EntryKind::from_gix(entry.mode().kind()); | |
| 94 | + | let oid = entry.oid().to_owned(); | |
| 95 | + | let size = kind | |
| 96 | + | .is_blob() | |
| 97 | + | .then(|| repo.find_header(oid).ok().map(|h| h.size())) | |
| 98 | + | .flatten(); | |
| 99 | + | entries.push(TreeEntry { | |
| 100 | + | name: entry.filename().to_owned(), | |
| 101 | + | kind, | |
| 102 | + | oid, | |
| 103 | + | size, | |
| 104 | + | }); | |
| 105 | + | } | |
| 106 | + | entries.sort_by(|a, b| { | |
| 107 | + | let dir = |e: &TreeEntry| e.kind != EntryKind::Tree; | |
| 108 | + | dir(a) | |
| 109 | + | .cmp(&dir(b)) | |
| 110 | + | .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) | |
| 111 | + | .then_with(|| a.name.cmp(&b.name)) | |
| 112 | + | }); | |
| 113 | + | Ok(entries) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /// Full contents of a blob. | |
| 117 | + | pub fn blob(repo: &gix::Repository, oid: ObjectId) -> anyhow::Result<Vec<u8>> { | |
| 118 | + | let mut blob = repo.find_blob(oid).context("blob not found")?; | |
| 119 | + | Ok(blob.take_data()) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | /// Size of an object without reading its contents. | |
| 123 | + | pub fn size(repo: &gix::Repository, oid: ObjectId) -> anyhow::Result<u64> { | |
| 124 | + | Ok(repo.find_header(oid)?.size()) | |
| 125 | + | } | |
| @@ -0,0 +1,223 @@ | |||
| 1 | + | //! `/{repo}/blob/{rev}/{path…}` — a file: highlighted code with line | |
| 2 | + | //! anchors, rendered Markdown, inline images. | |
| 3 | + | ||
| 4 | + | use std::sync::Arc; | |
| 5 | + | ||
| 6 | + | use askama::Template; | |
| 7 | + | use axum::http::StatusCode; | |
| 8 | + | use axum::response::Response; | |
| 9 | + | use gix::bstr::ByteSlice; | |
| 10 | + | ||
| 11 | + | use super::repo::{Active, Crumb, Ctx, RepoNav, crumbs, redirect, set_cache_headers}; | |
| 12 | + | use super::{Layout, render}; | |
| 13 | + | use crate::cache::Key; | |
| 14 | + | use crate::git::tree::{self, EntryKind, PathTarget}; | |
| 15 | + | use crate::render::markdown::{self, Links}; | |
| 16 | + | use crate::render::{encode_path, highlight, human_size}; | |
| 17 | + | ||
| 18 | + | /// Extensions shown inline as images (served by the raw endpoint). | |
| 19 | + | const IMAGES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico"]; | |
| 20 | + | ||
| 21 | + | pub enum Body { | |
| 22 | + | Code { lines: Arc<Vec<String>> }, | |
| 23 | + | Markdown { html: Arc<str> }, | |
| 24 | + | Image { src: String }, | |
| 25 | + | Symlink { target: String, url: Option<String> }, | |
| 26 | + | Binary, | |
| 27 | + | TooLarge, | |
| 28 | + | Empty, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | pub struct ViewToggle { | |
| 32 | + | pub preview_url: String, | |
| 33 | + | pub source_url: String, | |
| 34 | + | pub source: bool, | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | #[derive(Template)] | |
| 38 | + | #[template(path = "blob.html")] | |
| 39 | + | pub struct BlobPage { | |
| 40 | + | pub layout: Layout, | |
| 41 | + | pub nav: RepoNav, | |
| 42 | + | pub crumbs: Vec<Crumb>, | |
| 43 | + | pub here: Option<String>, | |
| 44 | + | /// `412 lines`, `12.4 KB`, `Rust`. | |
| 45 | + | pub facts: Vec<String>, | |
| 46 | + | pub raw_url: String, | |
| 47 | + | pub download_url: String, | |
| 48 | + | pub toggle: Option<ViewToggle>, | |
| 49 | + | pub body: Body, | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response { | |
| 53 | + | let (rev, path) = match ctx.resolve(segments) { | |
| 54 | + | Ok(found) => found, | |
| 55 | + | Err(res) => return *res, | |
| 56 | + | }; | |
| 57 | + | let (oid, kind) = match tree::lookup(&ctx.git, rev.commit, &path) { | |
| 58 | + | Ok(Some(PathTarget::Blob(oid, kind))) => (oid, kind), | |
| 59 | + | Ok(Some(PathTarget::Tree(_))) => return redirect(&ctx.url("tree", &rev.name, &path)), | |
| 60 | + | Ok(Some(PathTarget::Submodule(_))) => return ctx.not_found("PATH IS A SUBMODULE"), | |
| 61 | + | Ok(None) => return ctx.not_found(&format!("PATH NOT FOUND IN {}", rev.name)), | |
| 62 | + | Err(e) => return ctx.internal_error(e), | |
| 63 | + | }; | |
| 64 | + | let source = ctx.query.view.as_deref() == Some("source"); | |
| 65 | + | let etag = ctx.etag(("blob", oid, &rev.name, source)); | |
| 66 | + | let immutable = rev.is_immutable(); | |
| 67 | + | if let Some(res) = ctx.not_modified(&etag, immutable) { | |
| 68 | + | return res; | |
| 69 | + | } | |
| 70 | + | let size = match tree::size(&ctx.git, oid) { | |
| 71 | + | Ok(size) => size, | |
| 72 | + | Err(e) => return ctx.internal_error(e), | |
| 73 | + | }; | |
| 74 | + | ||
| 75 | + | let path_str = path.to_str_lossy().into_owned(); | |
| 76 | + | let name = path_str.rsplit('/').next().unwrap_or(&path_str).to_string(); | |
| 77 | + | let dir = path.rsplit_once_str("/").map_or(&b""[..], |(d, _)| d); | |
| 78 | + | let raw_url = ctx.url("raw", &rev.name, &path); | |
| 79 | + | let mut lines_fact = None; | |
| 80 | + | let mut language = None; | |
| 81 | + | let mut toggle = None; | |
| 82 | + | let ext = name | |
| 83 | + | .rsplit_once('.') | |
| 84 | + | .map(|(_, e)| e.to_ascii_lowercase()) | |
| 85 | + | .unwrap_or_default(); | |
| 86 | + | ||
| 87 | + | let body = if size == 0 { | |
| 88 | + | Body::Empty | |
| 89 | + | } else if IMAGES.contains(&ext.as_str()) && kind != EntryKind::Symlink { | |
| 90 | + | Body::Image { | |
| 91 | + | src: raw_url.clone(), | |
| 92 | + | } | |
| 93 | + | } else if size > ctx.config.ui.max_blob_kb * 1024 { | |
| 94 | + | Body::TooLarge | |
| 95 | + | } else { | |
| 96 | + | let data = match tree::blob(&ctx.git, oid) { | |
| 97 | + | Ok(data) => data, | |
| 98 | + | Err(e) => return ctx.internal_error(e), | |
| 99 | + | }; | |
| 100 | + | if kind == EntryKind::Symlink { | |
| 101 | + | let target = data.to_str_lossy().into_owned(); | |
| 102 | + | let url = resolve_link(dir, &data).map(|p| ctx.url("blob", &rev.name, &p)); | |
| 103 | + | Body::Symlink { target, url } | |
| 104 | + | } else if data[..data.len().min(8000)].contains(&0) { | |
| 105 | + | Body::Binary | |
| 106 | + | } else { | |
| 107 | + | let text = String::from_utf8_lossy(&data); | |
| 108 | + | lines_fact = Some(line_count(&text)); | |
| 109 | + | let is_md = markdown::is_markdown(&name); | |
| 110 | + | if is_md { | |
| 111 | + | let base = ctx.url("blob", &rev.name, &path); | |
| 112 | + | toggle = Some(ViewToggle { | |
| 113 | + | source_url: format!("{base}?view=source"), | |
| 114 | + | preview_url: base, | |
| 115 | + | source, | |
| 116 | + | }); | |
| 117 | + | } | |
| 118 | + | if is_md && !source { | |
| 119 | + | let links = Links { | |
| 120 | + | repo_url: ctx.repo_url(), | |
| 121 | + | rev: encode_path(&rev.name), | |
| 122 | + | dir: dir.to_vec(), | |
| 123 | + | }; | |
| 124 | + | let key = Key::Markdown { | |
| 125 | + | blob: oid, | |
| 126 | + | context: links.key(), | |
| 127 | + | }; | |
| 128 | + | let html = ctx.state.cache.html(key, || { | |
| 129 | + | Ok::<_, std::convert::Infallible>(markdown::render(&text, &links)) | |
| 130 | + | }); | |
| 131 | + | Body::Markdown { | |
| 132 | + | html: html.unwrap_or_else(|never| match never {}), | |
| 133 | + | } | |
| 134 | + | } else { | |
| 135 | + | let first_line = text.lines().next().unwrap_or_default(); | |
| 136 | + | let syntax = highlight::detect(&path_str, first_line); | |
| 137 | + | language = syntax.map(|s| s.name.clone()); | |
| 138 | + | let lines = match syntax { | |
| 139 | + | Some(syntax) if size <= ctx.config.ui.max_highlight_kb * 1024 => { | |
| 140 | + | let key = Key::Highlight { | |
| 141 | + | blob: oid, | |
| 142 | + | syntax: syntax.name.clone(), | |
| 143 | + | }; | |
| 144 | + | ctx.state | |
| 145 | + | .cache | |
| 146 | + | .lines(key, || { | |
| 147 | + | Ok::<_, std::convert::Infallible>(highlight::lines(&text, syntax)) | |
| 148 | + | }) | |
| 149 | + | .unwrap_or_else(|never| match never {}) | |
| 150 | + | } | |
| 151 | + | _ => Arc::new(highlight::plain_lines(&text)), | |
| 152 | + | }; | |
| 153 | + | Body::Code { lines } | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | }; | |
| 157 | + | let facts = [lines_fact, Some(human_size(size)), language] | |
| 158 | + | .into_iter() | |
| 159 | + | .flatten() | |
| 160 | + | .collect(); | |
| 161 | + | ||
| 162 | + | let (crumbs, _) = crumbs(ctx, &rev.name, &path); | |
| 163 | + | let page = BlobPage { | |
| 164 | + | layout: Layout::new( | |
| 165 | + | ctx.state, | |
| 166 | + | ctx.config, | |
| 167 | + | Some(&format!("{path_str} · {}", ctx.repo.name)), | |
| 168 | + | ), | |
| 169 | + | nav: ctx.nav(Active::Tree, Some(&rev), |name| { | |
| 170 | + | ctx.url("blob", name, &path) | |
| 171 | + | }), | |
| 172 | + | crumbs, | |
| 173 | + | here: Some(name), | |
| 174 | + | facts, | |
| 175 | + | download_url: format!("{raw_url}?download"), | |
| 176 | + | raw_url, | |
| 177 | + | toggle, | |
| 178 | + | body, | |
| 179 | + | }; | |
| 180 | + | let mut res = render(StatusCode::OK, &page); | |
| 181 | + | set_cache_headers(&mut res, &etag, immutable); | |
| 182 | + | res | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | fn line_count(text: &str) -> String { | |
| 186 | + | let n = text.lines().count(); | |
| 187 | + | format!("{n} {}", if n == 1 { "line" } else { "lines" }) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /// Resolve a relative symlink target against the link's directory. `None` | |
| 191 | + | /// for absolute targets or ones leaving the repository. | |
| 192 | + | fn resolve_link(dir: &[u8], target: &[u8]) -> Option<Vec<u8>> { | |
| 193 | + | if target.starts_with(b"/") { | |
| 194 | + | return None; | |
| 195 | + | } | |
| 196 | + | let mut parts: Vec<&[u8]> = dir | |
| 197 | + | .split(|b| *b == b'/') | |
| 198 | + | .filter(|p| !p.is_empty()) | |
| 199 | + | .collect(); | |
| 200 | + | for part in target.split(|b| *b == b'/') { | |
| 201 | + | match part { | |
| 202 | + | b"" | b"." => {} | |
| 203 | + | b".." => { | |
| 204 | + | parts.pop()?; | |
| 205 | + | } | |
| 206 | + | part => parts.push(part), | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | Some(parts.join(&b'/')) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | #[cfg(test)] | |
| 213 | + | mod tests { | |
| 214 | + | use super::*; | |
| 215 | + | ||
| 216 | + | #[test] | |
| 217 | + | fn symlink_targets() { | |
| 218 | + | assert_eq!(resolve_link(b"a/b", b"../c.txt").unwrap(), b"a/c.txt"); | |
| 219 | + | assert_eq!(resolve_link(b"", b"./x").unwrap(), b"x"); | |
| 220 | + | assert!(resolve_link(b"", b"../x").is_none()); | |
| 221 | + | assert!(resolve_link(b"a", b"/etc/passwd").is_none()); | |
| 222 | + | } | |
| 223 | + | } | |
| @@ -0,0 +1,34 @@ | |||
| 1 | + | //! Error pages. | |
| 2 | + | ||
| 3 | + | use std::sync::Arc; | |
| 4 | + | ||
| 5 | + | use askama::Template; | |
| 6 | + | use axum::extract::State; | |
| 7 | + | use axum::http::StatusCode; | |
| 8 | + | use axum::response::Response; | |
| 9 | + | ||
| 10 | + | use super::{Layout, render}; | |
| 11 | + | use crate::state::AppState; | |
| 12 | + | ||
| 13 | + | #[derive(Template)] | |
| 14 | + | #[template(path = "error.html")] | |
| 15 | + | pub struct ErrorPage { | |
| 16 | + | pub layout: Layout, | |
| 17 | + | pub code: u16, | |
| 18 | + | pub message: String, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | pub fn page(state: &AppState, status: StatusCode, message: impl Into<String>) -> Response { | |
| 22 | + | let config = state.config.load(); | |
| 23 | + | let code = status.as_u16(); | |
| 24 | + | let page = ErrorPage { | |
| 25 | + | layout: Layout::new(state, &config, Some(&code.to_string())), | |
| 26 | + | code, | |
| 27 | + | message: message.into(), | |
| 28 | + | }; | |
| 29 | + | render(status, &page) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | pub async fn not_found(State(state): State<Arc<AppState>>) -> Response { | |
| 33 | + | page(&state, StatusCode::NOT_FOUND, "PAGE NOT FOUND") | |
| 34 | + | } | |
| @@ -0,0 +1,156 @@ | |||
| 1 | + | //! `/` — repository index. | |
| 2 | + | ||
| 3 | + | use std::sync::Arc; | |
| 4 | + | ||
| 5 | + | use askama::Template; | |
| 6 | + | use axum::extract::{Query, State}; | |
| 7 | + | use axum::http::StatusCode; | |
| 8 | + | use axum::response::Response; | |
| 9 | + | use serde::Deserialize; | |
| 10 | + | ||
| 11 | + | use super::{Layout, render}; | |
| 12 | + | use crate::index::Repo; | |
| 13 | + | use crate::render::{encode_path, time}; | |
| 14 | + | use crate::state::AppState; | |
| 15 | + | ||
| 16 | + | /// Repos pushed within this window get the activity dot. | |
| 17 | + | const FRESH_SECS: i64 = 24 * 3600; | |
| 18 | + | ||
| 19 | + | #[derive(Debug, Default, Deserialize)] | |
| 20 | + | pub struct IndexQuery { | |
| 21 | + | #[serde(default)] | |
| 22 | + | q: String, | |
| 23 | + | #[serde(default)] | |
| 24 | + | sort: String, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | pub struct Row { | |
| 28 | + | pub name: String, | |
| 29 | + | pub url: String, | |
| 30 | + | pub description: Option<String>, | |
| 31 | + | pub age: Option<String>, | |
| 32 | + | pub iso: Option<String>, | |
| 33 | + | pub fresh: bool, | |
| 34 | + | /// Lowercased text the client-side filter matches against. | |
| 35 | + | pub search: String, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | pub struct Section { | |
| 39 | + | pub name: Option<String>, | |
| 40 | + | pub rows: Vec<Row>, | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | #[derive(Template)] | |
| 44 | + | #[template(path = "index.html")] | |
| 45 | + | pub struct IndexPage { | |
| 46 | + | pub layout: Layout, | |
| 47 | + | pub description: String, | |
| 48 | + | /// Visible repositories in total. | |
| 49 | + | pub total: usize, | |
| 50 | + | /// Repositories matching the filter. | |
| 51 | + | pub shown: usize, | |
| 52 | + | pub q: String, | |
| 53 | + | pub by_activity: bool, | |
| 54 | + | pub sort_name_url: String, | |
| 55 | + | pub sort_activity_url: String, | |
| 56 | + | pub sections: Vec<Section>, | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | pub async fn index( | |
| 60 | + | State(state): State<Arc<AppState>>, | |
| 61 | + | Query(query): Query<IndexQuery>, | |
| 62 | + | ) -> Response { | |
| 63 | + | let config = state.config.load(); | |
| 64 | + | let index = state.index.load(); | |
| 65 | + | let by_activity = query.sort == "activity"; | |
| 66 | + | let q = query.q.trim().to_string(); | |
| 67 | + | let terms: Vec<String> = q.split_whitespace().map(str::to_lowercase).collect(); | |
| 68 | + | let now = time::now(); | |
| 69 | + | let base = &config.server.base_path; | |
| 70 | + | ||
| 71 | + | let mut repos: Vec<&Arc<Repo>> = index | |
| 72 | + | .repos | |
| 73 | + | .values() | |
| 74 | + | .filter(|repo| { | |
| 75 | + | let text = search_text(repo); | |
| 76 | + | terms.iter().all(|t| text.contains(t.as_str())) | |
| 77 | + | }) | |
| 78 | + | .collect(); | |
| 79 | + | let shown = repos.len(); | |
| 80 | + | ||
| 81 | + | if by_activity { | |
| 82 | + | repos.sort_by(|a, b| { | |
| 83 | + | b.meta | |
| 84 | + | .last_activity | |
| 85 | + | .cmp(&a.meta.last_activity) | |
| 86 | + | .then_with(|| a.name.cmp(&b.name)) | |
| 87 | + | }); | |
| 88 | + | } else { | |
| 89 | + | repos.sort_by_cached_key(|r| { | |
| 90 | + | ( | |
| 91 | + | r.meta.section.clone().map(|s| s.to_lowercase()), | |
| 92 | + | r.name.to_lowercase(), | |
| 93 | + | ) | |
| 94 | + | }); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | let row = |repo: &Arc<Repo>| Row { | |
| 98 | + | name: repo.name.clone(), | |
| 99 | + | url: format!("{base}{}", encode_path(&repo.name)), | |
| 100 | + | description: repo.meta.description.clone(), | |
| 101 | + | age: repo.meta.last_activity.map(|t| time::relative(t, now)), | |
| 102 | + | iso: repo.meta.last_activity.map(time::iso), | |
| 103 | + | fresh: repo | |
| 104 | + | .meta | |
| 105 | + | .last_activity | |
| 106 | + | .is_some_and(|t| now - t < FRESH_SECS), | |
| 107 | + | search: search_text(repo), | |
| 108 | + | }; | |
| 109 | + | ||
| 110 | + | let mut sections: Vec<Section> = Vec::new(); | |
| 111 | + | for repo in repos { | |
| 112 | + | // Sections only apply to the name ordering; activity is one list. | |
| 113 | + | let section = if by_activity { | |
| 114 | + | None | |
| 115 | + | } else { | |
| 116 | + | repo.meta.section.clone() | |
| 117 | + | }; | |
| 118 | + | match sections.last_mut() { | |
| 119 | + | Some(last) if last.name == section => last.rows.push(row(repo)), | |
| 120 | + | _ => sections.push(Section { | |
| 121 | + | name: section, | |
| 122 | + | rows: vec![row(repo)], | |
| 123 | + | }), | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | let sort_url = |sort: &str| { | |
| 128 | + | let mut url = format!("{base}?sort={sort}"); | |
| 129 | + | if !q.is_empty() { | |
| 130 | + | url.push_str("&q="); | |
| 131 | + | url.push_str(&encode_path(&q)); | |
| 132 | + | } | |
| 133 | + | url | |
| 134 | + | }; | |
| 135 | + | let page = IndexPage { | |
| 136 | + | layout: Layout::new(&state, &config, None), | |
| 137 | + | description: config.site.description.clone(), | |
| 138 | + | total: index.repos.len(), | |
| 139 | + | shown, | |
| 140 | + | sort_name_url: sort_url("name"), | |
| 141 | + | sort_activity_url: sort_url("activity"), | |
| 142 | + | q, | |
| 143 | + | by_activity, | |
| 144 | + | sections, | |
| 145 | + | }; | |
| 146 | + | render(StatusCode::OK, &page) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | fn search_text(repo: &Repo) -> String { | |
| 150 | + | let mut text = repo.name.to_lowercase(); | |
| 151 | + | if let Some(d) = &repo.meta.description { | |
| 152 | + | text.push(' '); | |
| 153 | + | text.push_str(&d.to_lowercase()); | |
| 154 | + | } | |
| 155 | + | text | |
| 156 | + | } | |
| @@ -0,0 +1,71 @@ | |||
| 1 | + | //! Request handlers. Each page is an askama template that embeds a [`Layout`]. | |
| 2 | + | ||
| 3 | + | pub mod blob; | |
| 4 | + | pub mod error; | |
| 5 | + | pub mod index; | |
| 6 | + | pub mod raw; | |
| 7 | + | pub mod refs; | |
| 8 | + | pub mod repo; | |
| 9 | + | pub mod summary; | |
| 10 | + | pub mod tree; | |
| 11 | + | ||
| 12 | + | use std::sync::Arc; | |
| 13 | + | ||
| 14 | + | use askama::Template; | |
| 15 | + | use axum::http::StatusCode; | |
| 16 | + | use axum::response::{Html, IntoResponse, Response}; | |
| 17 | + | ||
| 18 | + | use crate::config::Config; | |
| 19 | + | use crate::server::assets; | |
| 20 | + | use crate::state::AppState; | |
| 21 | + | ||
| 22 | + | /// Data shared by every page: site chrome, asset URLs and customization. | |
| 23 | + | pub struct Layout { | |
| 24 | + | /// `server.base_path`, always ending in `/`. | |
| 25 | + | pub base: String, | |
| 26 | + | /// `{base}-/static/{hash}/` | |
| 27 | + | pub static_base: String, | |
| 28 | + | pub site_title: String, | |
| 29 | + | /// Full `<title>` text. | |
| 30 | + | pub page_title: String, | |
| 31 | + | /// Server default theme: `auto`, `dark` or `light`. | |
| 32 | + | pub theme: &'static str, | |
| 33 | + | /// Versioned URL of `/-/site.css`, when there is anything in it. | |
| 34 | + | pub site_css: Option<String>, | |
| 35 | + | pub header: Option<Arc<str>>, | |
| 36 | + | pub footer: Option<Arc<str>>, | |
| 37 | + | pub version: &'static str, | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | impl Layout { | |
| 41 | + | /// `title` is the page-specific part; the site title is appended. | |
| 42 | + | pub fn new(state: &AppState, config: &Config, title: Option<&str>) -> Self { | |
| 43 | + | let base = config.server.base_path.clone(); | |
| 44 | + | let site_title = config.site.title.clone(); | |
| 45 | + | Layout { | |
| 46 | + | static_base: format!("{base}-/static/{}/", state.assets.hash()), | |
| 47 | + | site_css: assets::site_css_version(config).map(|v| format!("{base}-/site.css?v={v}")), | |
| 48 | + | page_title: match title { | |
| 49 | + | Some(t) => format!("{t} · {site_title}"), | |
| 50 | + | None => site_title.clone(), | |
| 51 | + | }, | |
| 52 | + | site_title, | |
| 53 | + | base, | |
| 54 | + | theme: config.ui.theme.as_str(), | |
| 55 | + | header: config.custom.header.clone(), | |
| 56 | + | footer: config.custom.footer.clone(), | |
| 57 | + | version: env!("CARGO_PKG_VERSION"), | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// Render a template into an HTML response with the given status. | |
| 63 | + | pub fn render(status: StatusCode, page: &impl Template) -> Response { | |
| 64 | + | match page.render() { | |
| 65 | + | Ok(html) => (status, Html(html)).into_response(), | |
| 66 | + | Err(e) => { | |
| 67 | + | tracing::error!("template error: {e}"); | |
| 68 | + | (StatusCode::INTERNAL_SERVER_ERROR, "internal error\n").into_response() | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | } | |
| @@ -0,0 +1,102 @@ | |||
| 1 | + | //! `/{repo}/raw/{rev}/{path…}` — file contents as stored. | |
| 2 | + | //! | |
| 3 | + | //! Nothing served here may run in our origin: text (HTML and SVG included) | |
| 4 | + | //! is `text/plain`, only raster images keep their type, and every response | |
| 5 | + | //! is sandboxed and `nosniff`. `?download` sends an attachment instead. | |
| 6 | + | ||
| 7 | + | use axum::body::Body; | |
| 8 | + | use axum::http::{HeaderValue, StatusCode, header}; | |
| 9 | + | use axum::response::{IntoResponse, Response}; | |
| 10 | + | use gix::bstr::ByteSlice; | |
| 11 | + | ||
| 12 | + | use super::repo::{Ctx, set_cache_headers}; | |
| 13 | + | use crate::git::tree::{self, PathTarget}; | |
| 14 | + | use crate::render::encode_path; | |
| 15 | + | ||
| 16 | + | pub const RAW_CSP: &str = "default-src 'none'; img-src 'self'; sandbox"; | |
| 17 | + | ||
| 18 | + | /// Image types safe to serve with their real content type. | |
| 19 | + | const IMAGE_TYPES: &[(&str, &str)] = &[ | |
| 20 | + | ("png", "image/png"), | |
| 21 | + | ("jpg", "image/jpeg"), | |
| 22 | + | ("jpeg", "image/jpeg"), | |
| 23 | + | ("gif", "image/gif"), | |
| 24 | + | ("webp", "image/webp"), | |
| 25 | + | ("avif", "image/avif"), | |
| 26 | + | ("bmp", "image/bmp"), | |
| 27 | + | ("ico", "image/x-icon"), | |
| 28 | + | ]; | |
| 29 | + | ||
| 30 | + | pub fn serve(ctx: &Ctx, segments: &[Vec<u8>]) -> Response { | |
| 31 | + | let (rev, path) = match ctx.resolve(segments) { | |
| 32 | + | Ok(found) => found, | |
| 33 | + | Err(res) => return *res, | |
| 34 | + | }; | |
| 35 | + | let oid = match tree::lookup(&ctx.git, rev.commit, &path) { | |
| 36 | + | Ok(Some(PathTarget::Blob(oid, _))) => oid, | |
| 37 | + | Ok(Some(_)) => return ctx.not_found("NOT A FILE"), | |
| 38 | + | Ok(None) => return ctx.not_found(&format!("PATH NOT FOUND IN {}", rev.name)), | |
| 39 | + | Err(e) => return ctx.internal_error(e), | |
| 40 | + | }; | |
| 41 | + | let download = ctx.query.download.is_some(); | |
| 42 | + | let etag = format!("W/\"{oid}{}\"", if download { "-d" } else { "" }); | |
| 43 | + | let immutable = rev.is_immutable(); | |
| 44 | + | if let Some(res) = ctx.not_modified(&etag, immutable) { | |
| 45 | + | return res; | |
| 46 | + | } | |
| 47 | + | let data = match tree::blob(&ctx.git, oid) { | |
| 48 | + | Ok(data) => data, | |
| 49 | + | Err(e) => return ctx.internal_error(e), | |
| 50 | + | }; | |
| 51 | + | ||
| 52 | + | let name = path | |
| 53 | + | .rsplit_once_str("/") | |
| 54 | + | .map_or(&path[..], |(_, n)| n) | |
| 55 | + | .to_str_lossy() | |
| 56 | + | .into_owned(); | |
| 57 | + | let ext = name | |
| 58 | + | .rsplit_once('.') | |
| 59 | + | .map(|(_, e)| e.to_ascii_lowercase()) | |
| 60 | + | .unwrap_or_default(); | |
| 61 | + | let is_text = !data[..data.len().min(8000)].contains(&0); | |
| 62 | + | let content_type = match IMAGE_TYPES.iter().find(|(e, _)| *e == ext) { | |
| 63 | + | Some((_, mime)) => mime, | |
| 64 | + | None if download => "application/octet-stream", | |
| 65 | + | None if is_text => "text/plain; charset=utf-8", | |
| 66 | + | None => "application/octet-stream", | |
| 67 | + | }; | |
| 68 | + | ||
| 69 | + | let mut res = Body::from(data).into_response(); | |
| 70 | + | *res.status_mut() = StatusCode::OK; | |
| 71 | + | let headers = res.headers_mut(); | |
| 72 | + | headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); | |
| 73 | + | headers.insert( | |
| 74 | + | header::CONTENT_SECURITY_POLICY, | |
| 75 | + | HeaderValue::from_static(RAW_CSP), | |
| 76 | + | ); | |
| 77 | + | headers.insert( | |
| 78 | + | header::X_CONTENT_TYPE_OPTIONS, | |
| 79 | + | HeaderValue::from_static("nosniff"), | |
| 80 | + | ); | |
| 81 | + | if download { | |
| 82 | + | let ascii: String = name | |
| 83 | + | .chars() | |
| 84 | + | .map(|c| { | |
| 85 | + | if c.is_ascii_graphic() && c != '"' && c != '\\' { | |
| 86 | + | c | |
| 87 | + | } else { | |
| 88 | + | '_' | |
| 89 | + | } | |
| 90 | + | }) | |
| 91 | + | .collect(); | |
| 92 | + | let value = format!( | |
| 93 | + | "attachment; filename=\"{ascii}\"; filename*=UTF-8''{}", | |
| 94 | + | encode_path(&name) | |
| 95 | + | ); | |
| 96 | + | if let Ok(value) = HeaderValue::from_str(&value) { | |
| 97 | + | headers.insert(header::CONTENT_DISPOSITION, value); | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | set_cache_headers(&mut res, &etag, immutable); | |
| 101 | + | res | |
| 102 | + | } | |
| @@ -0,0 +1,83 @@ | |||
| 1 | + | //! `/{repo}/refs` — branches and tags. | |
| 2 | + | ||
| 3 | + | use askama::Template; | |
| 4 | + | use axum::http::StatusCode; | |
| 5 | + | use axum::response::Response; | |
| 6 | + | ||
| 7 | + | use super::repo::{Active, Crumb, Ctx, RepoNav, set_cache_headers}; | |
| 8 | + | use super::{Layout, render}; | |
| 9 | + | use crate::git::refs::RefEntry; | |
| 10 | + | use crate::render::time; | |
| 11 | + | ||
| 12 | + | pub struct RefRow { | |
| 13 | + | pub name: String, | |
| 14 | + | pub url: String, | |
| 15 | + | pub subject: String, | |
| 16 | + | pub age: String, | |
| 17 | + | pub iso: String, | |
| 18 | + | pub is_default: bool, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | #[derive(Template)] | |
| 22 | + | #[template(path = "refs.html")] | |
| 23 | + | pub struct RefsPage { | |
| 24 | + | pub layout: Layout, | |
| 25 | + | pub nav: RepoNav, | |
| 26 | + | pub crumbs: Vec<Crumb>, | |
| 27 | + | pub here: Option<String>, | |
| 28 | + | pub branches: Vec<RefRow>, | |
| 29 | + | pub tags: Vec<RefRow>, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | pub fn page(ctx: &Ctx) -> Response { | |
| 33 | + | let etag = ctx.etag("refs"); | |
| 34 | + | if let Some(res) = ctx.not_modified(&etag, false) { | |
| 35 | + | return res; | |
| 36 | + | } | |
| 37 | + | let refs = match ctx.refs() { | |
| 38 | + | Ok(refs) => refs, | |
| 39 | + | Err(e) => return ctx.internal_error(e), | |
| 40 | + | }; | |
| 41 | + | let now = time::now(); | |
| 42 | + | let default = ctx.repo.meta.default_branch.as_deref(); | |
| 43 | + | let row = |r: &RefEntry| RefRow { | |
| 44 | + | name: r.name.clone(), | |
| 45 | + | url: ctx.url("tree", &r.name, b""), | |
| 46 | + | subject: r | |
| 47 | + | .tag_subject | |
| 48 | + | .clone() | |
| 49 | + | .unwrap_or_else(|| r.info.subject.clone()), | |
| 50 | + | age: time::relative(r.info.committer_time, now), | |
| 51 | + | iso: time::iso(r.info.committer_time), | |
| 52 | + | is_default: false, | |
| 53 | + | }; | |
| 54 | + | let mut branches: Vec<RefRow> = refs.branches.iter().map(row).collect(); | |
| 55 | + | // The default branch leads the list. | |
| 56 | + | if let Some(i) = branches | |
| 57 | + | .iter() | |
| 58 | + | .position(|b| Some(b.name.as_str()) == default) | |
| 59 | + | { | |
| 60 | + | let mut b = branches.remove(i); | |
| 61 | + | b.is_default = true; | |
| 62 | + | branches.insert(0, b); | |
| 63 | + | } | |
| 64 | + | let crumbs = vec![Crumb { | |
| 65 | + | name: ctx.repo.name.clone(), | |
| 66 | + | url: ctx.repo_url(), | |
| 67 | + | }]; | |
| 68 | + | let page = RefsPage { | |
| 69 | + | layout: Layout::new( | |
| 70 | + | ctx.state, | |
| 71 | + | ctx.config, | |
| 72 | + | Some(&format!("Refs · {}", ctx.repo.name)), | |
| 73 | + | ), | |
| 74 | + | nav: ctx.nav(Active::Refs, None, |_| String::new()), | |
| 75 | + | crumbs, | |
| 76 | + | here: Some("Refs".into()), | |
| 77 | + | branches, | |
| 78 | + | tags: refs.tags.iter().map(row).collect(), | |
| 79 | + | }; | |
| 80 | + | let mut res = render(StatusCode::OK, &page); | |
| 81 | + | set_cache_headers(&mut res, &etag, false); | |
| 82 | + | res | |
| 83 | + | } | |
| @@ -0,0 +1,306 @@ | |||
| 1 | + | //! Shared plumbing for repository pages: routing by action, revision | |
| 2 | + | //! resolution, ETags and the repository nav. | |
| 3 | + | //! | |
| 4 | + | //! Everything here runs on the blocking pool (see [`AppState::blocking`]). | |
| 5 | + | ||
| 6 | + | use std::hash::{Hash, Hasher}; | |
| 7 | + | use std::sync::Arc; | |
| 8 | + | use std::sync::atomic::Ordering; | |
| 9 | + | ||
| 10 | + | use axum::http::{HeaderValue, StatusCode, header}; | |
| 11 | + | use axum::response::{IntoResponse, Redirect, Response}; | |
| 12 | + | use serde::Deserialize; | |
| 13 | + | ||
| 14 | + | use super::{blob, error, raw, refs, summary, tree}; | |
| 15 | + | use crate::cache::Key; | |
| 16 | + | use crate::config::Config; | |
| 17 | + | use crate::git::refs::{RefKind, RefList, Rev}; | |
| 18 | + | use crate::git::{self, Handle}; | |
| 19 | + | use crate::index::Repo; | |
| 20 | + | use crate::render::{encode_bytes, encode_path}; | |
| 21 | + | use crate::state::AppState; | |
| 22 | + | ||
| 23 | + | /// Tags listed in the ref picker; the rest are on the refs page. | |
| 24 | + | const PICKER_TAGS: usize = 20; | |
| 25 | + | ||
| 26 | + | #[derive(Debug, Default, Deserialize)] | |
| 27 | + | pub struct PageQuery { | |
| 28 | + | /// Blob view: `source` shows Markdown as code. | |
| 29 | + | pub view: Option<String>, | |
| 30 | + | /// Raw: serve as an attachment. | |
| 31 | + | pub download: Option<String>, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | pub struct RepoRequest { | |
| 35 | + | pub repo: Arc<Repo>, | |
| 36 | + | /// Path segments after the repository name. | |
| 37 | + | pub segments: Vec<Vec<u8>>, | |
| 38 | + | pub query: PageQuery, | |
| 39 | + | pub if_none_match: Option<HeaderValue>, | |
| 40 | + | pub config: Arc<Config>, | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /// Everything a repository page handler needs. | |
| 44 | + | pub struct Ctx<'a> { | |
| 45 | + | pub state: &'a AppState, | |
| 46 | + | pub config: &'a Config, | |
| 47 | + | pub repo: &'a Repo, | |
| 48 | + | pub git: Handle, | |
| 49 | + | pub query: &'a PageQuery, | |
| 50 | + | if_none_match: Option<&'a HeaderValue>, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | pub fn handle(state: &AppState, req: RepoRequest) -> Response { | |
| 54 | + | let ctx = Ctx { | |
| 55 | + | state, | |
| 56 | + | config: &req.config, | |
| 57 | + | repo: &req.repo, | |
| 58 | + | git: git::handle(&req.repo.gix), | |
| 59 | + | query: &req.query, | |
| 60 | + | if_none_match: req.if_none_match.as_ref(), | |
| 61 | + | }; | |
| 62 | + | let Some((action, rest)) = req.segments.split_first() else { | |
| 63 | + | return summary::page(&ctx); | |
| 64 | + | }; | |
| 65 | + | match action.as_slice() { | |
| 66 | + | b"tree" => tree::page(&ctx, rest), | |
| 67 | + | b"blob" => blob::page(&ctx, rest), | |
| 68 | + | b"raw" => raw::serve(&ctx, rest), | |
| 69 | + | b"refs" if rest.is_empty() => refs::page(&ctx), | |
| 70 | + | _ => ctx.not_found("PAGE NOT FOUND"), | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | impl Ctx<'_> { | |
| 75 | + | /// `{base}{repo}` without a trailing slash. | |
| 76 | + | pub fn repo_url(&self) -> String { | |
| 77 | + | format!( | |
| 78 | + | "{}{}", | |
| 79 | + | self.config.server.base_path, | |
| 80 | + | encode_path(&self.repo.name) | |
| 81 | + | ) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// `{repo}/{action}/{rev}/{path}` (no trailing slash for the root). | |
| 85 | + | pub fn url(&self, action: &str, rev: &str, path: &[u8]) -> String { | |
| 86 | + | let mut url = format!("{}/{action}/{}", self.repo_url(), encode_path(rev)); | |
| 87 | + | if !path.is_empty() { | |
| 88 | + | url.push('/'); | |
| 89 | + | url.push_str(&encode_bytes(path)); | |
| 90 | + | } | |
| 91 | + | url | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | pub fn not_found(&self, message: &str) -> Response { | |
| 95 | + | error::page(self.state, StatusCode::NOT_FOUND, message) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | pub fn internal_error(&self, e: anyhow::Error) -> Response { | |
| 99 | + | tracing::error!("{}: {e:#}", self.repo.name); | |
| 100 | + | error::page( | |
| 101 | + | self.state, | |
| 102 | + | StatusCode::INTERNAL_SERVER_ERROR, | |
| 103 | + | "INTERNAL ERROR", | |
| 104 | + | ) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /// The revision and path named by `segments`; an empty list means the | |
| 108 | + | /// default branch at the root. | |
| 109 | + | pub fn resolve(&self, segments: &[Vec<u8>]) -> Result<(Rev, Vec<u8>), Box<Response>> { | |
| 110 | + | if segments.is_empty() { | |
| 111 | + | let (Some(branch), Some(head)) = (&self.repo.meta.default_branch, self.repo.meta.head) | |
| 112 | + | else { | |
| 113 | + | return Err(Box::new(self.not_found("REPOSITORY IS EMPTY"))); | |
| 114 | + | }; | |
| 115 | + | let rev = Rev { | |
| 116 | + | name: branch.clone(), | |
| 117 | + | kind: RefKind::Branch, | |
| 118 | + | commit: head, | |
| 119 | + | }; | |
| 120 | + | return Ok((rev, Vec::new())); | |
| 121 | + | } | |
| 122 | + | match git::refs::resolve_prefix(&self.git, segments) { | |
| 123 | + | Some((rev, path)) => Ok((rev, path.join(&b'/'))), | |
| 124 | + | None => Err(Box::new(self.not_found("UNKNOWN REVISION"))), | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | /// Weak ETag over everything a page depends on besides its URL: the | |
| 129 | + | /// content (`parts`), the repository's refs and metadata, the config and | |
| 130 | + | /// the build. | |
| 131 | + | pub fn etag(&self, parts: impl Hash) -> String { | |
| 132 | + | let mut hasher = std::hash::DefaultHasher::new(); | |
| 133 | + | parts.hash(&mut hasher); | |
| 134 | + | self.repo.generation.hash(&mut hasher); | |
| 135 | + | self.state | |
| 136 | + | .config_generation | |
| 137 | + | .load(Ordering::Relaxed) | |
| 138 | + | .hash(&mut hasher); | |
| 139 | + | self.state.assets.hash().hash(&mut hasher); | |
| 140 | + | format!("W/\"{:016x}\"", hasher.finish()) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /// `Some(304)` when the client already has `etag`. | |
| 144 | + | pub fn not_modified(&self, etag: &str, immutable: bool) -> Option<Response> { | |
| 145 | + | let header = self.if_none_match?.to_str().ok()?; | |
| 146 | + | let bare = |t: &str| t.trim().trim_start_matches("W/").to_string(); | |
| 147 | + | let matches = header | |
| 148 | + | .split(',') | |
| 149 | + | .any(|t| t.trim() == "*" || bare(t) == bare(etag)); | |
| 150 | + | matches.then(|| { | |
| 151 | + | let mut res = StatusCode::NOT_MODIFIED.into_response(); | |
| 152 | + | set_cache_headers(&mut res, etag, immutable); | |
| 153 | + | res | |
| 154 | + | }) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /// Branches and tags (with tip commits), cached per refs generation. | |
| 158 | + | pub fn refs(&self) -> anyhow::Result<Arc<RefList>> { | |
| 159 | + | let key = Key::Refs { | |
| 160 | + | repo: self.repo.name.clone(), | |
| 161 | + | generation: self.repo.generation, | |
| 162 | + | }; | |
| 163 | + | self.state.cache.refs(key, || git::refs::list(&self.git)) | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | /// Nav for this repository; `picker` builds ref-picker links for a | |
| 167 | + | /// revision name. | |
| 168 | + | pub fn nav( | |
| 169 | + | &self, | |
| 170 | + | active: Active, | |
| 171 | + | current: Option<&Rev>, | |
| 172 | + | picker: impl Fn(&str) -> String, | |
| 173 | + | ) -> RepoNav { | |
| 174 | + | let tree_url = match current { | |
| 175 | + | Some(rev) => self.url("tree", &rev.name, b""), | |
| 176 | + | None => format!("{}/tree", self.repo_url()), | |
| 177 | + | }; | |
| 178 | + | let picker = current.and_then(|rev| { | |
| 179 | + | let refs = self.refs().ok()?; | |
| 180 | + | let item = |name: &str| PickItem { | |
| 181 | + | name: name.to_string(), | |
| 182 | + | url: picker(name), | |
| 183 | + | active: rev.kind != RefKind::Commit && rev.name == name, | |
| 184 | + | }; | |
| 185 | + | Some(Picker { | |
| 186 | + | current: match rev.kind { | |
| 187 | + | RefKind::Commit => short(&rev.commit.to_string()), | |
| 188 | + | _ => rev.name.clone(), | |
| 189 | + | }, | |
| 190 | + | kind: match rev.kind { | |
| 191 | + | RefKind::Branch => "Branch", | |
| 192 | + | RefKind::Tag => "Tag", | |
| 193 | + | RefKind::Commit => "Commit", | |
| 194 | + | }, | |
| 195 | + | branches: refs.branches.iter().map(|r| item(&r.name)).collect(), | |
| 196 | + | tags: refs | |
| 197 | + | .tags | |
| 198 | + | .iter() | |
| 199 | + | .take(PICKER_TAGS) | |
| 200 | + | .map(|r| item(&r.name)) | |
| 201 | + | .collect(), | |
| 202 | + | more_tags: refs.tags.len() > PICKER_TAGS, | |
| 203 | + | }) | |
| 204 | + | }); | |
| 205 | + | RepoNav { | |
| 206 | + | name: self.repo.name.clone(), | |
| 207 | + | url: self.repo_url(), | |
| 208 | + | tree_url, | |
| 209 | + | refs_url: format!("{}/refs", self.repo_url()), | |
| 210 | + | active: active.as_str(), | |
| 211 | + | picker, | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | /// Set `ETag` and `Cache-Control` for a repository page. | |
| 217 | + | pub fn set_cache_headers(res: &mut Response, etag: &str, immutable: bool) { | |
| 218 | + | if let Ok(value) = HeaderValue::from_str(etag) { | |
| 219 | + | res.headers_mut().insert(header::ETAG, value); | |
| 220 | + | } | |
| 221 | + | let cache = if immutable { | |
| 222 | + | "public, max-age=86400" | |
| 223 | + | } else { | |
| 224 | + | "no-cache" | |
| 225 | + | }; | |
| 226 | + | res.headers_mut() | |
| 227 | + | .insert(header::CACHE_CONTROL, HeaderValue::from_static(cache)); | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | /// Redirect between the tree and blob views of the same path. | |
| 231 | + | pub fn redirect(url: &str) -> Response { | |
| 232 | + | Redirect::temporary(url).into_response() | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | pub fn short(oid: &str) -> String { | |
| 236 | + | oid.chars().take(7).collect() | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 240 | + | pub enum Active { | |
| 241 | + | Summary, | |
| 242 | + | Tree, | |
| 243 | + | Refs, | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | impl Active { | |
| 247 | + | pub fn as_str(self) -> &'static str { | |
| 248 | + | match self { | |
| 249 | + | Active::Summary => "summary", | |
| 250 | + | Active::Tree => "tree", | |
| 251 | + | Active::Refs => "refs", | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | pub struct RepoNav { | |
| 257 | + | pub name: String, | |
| 258 | + | pub url: String, | |
| 259 | + | pub tree_url: String, | |
| 260 | + | pub refs_url: String, | |
| 261 | + | /// `summary`, `tree` or `refs`. | |
| 262 | + | pub active: &'static str, | |
| 263 | + | pub picker: Option<Picker>, | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | pub struct Picker { | |
| 267 | + | /// Label of the current revision (short id for commits). | |
| 268 | + | pub current: String, | |
| 269 | + | pub kind: &'static str, | |
| 270 | + | pub branches: Vec<PickItem>, | |
| 271 | + | pub tags: Vec<PickItem>, | |
| 272 | + | pub more_tags: bool, | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | pub struct PickItem { | |
| 276 | + | pub name: String, | |
| 277 | + | pub url: String, | |
| 278 | + | pub active: bool, | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | /// One breadcrumb segment: the repository name, then each directory. | |
| 282 | + | pub struct Crumb { | |
| 283 | + | pub name: String, | |
| 284 | + | pub url: String, | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | /// Breadcrumbs for `path` at `rev`; the last segment is returned separately | |
| 288 | + | /// (it is the current page and not a link). | |
| 289 | + | pub fn crumbs(ctx: &Ctx, rev: &str, path: &[u8]) -> (Vec<Crumb>, Option<String>) { | |
| 290 | + | let mut crumbs = vec![Crumb { | |
| 291 | + | name: ctx.repo.name.clone(), | |
| 292 | + | url: ctx.url("tree", rev, b""), | |
| 293 | + | }]; | |
| 294 | + | if path.is_empty() { | |
| 295 | + | return (crumbs, None); | |
| 296 | + | } | |
| 297 | + | let parts: Vec<&[u8]> = path.split(|b| *b == b'/').collect(); | |
| 298 | + | let (last, dirs) = parts.split_last().expect("non-empty path"); | |
| 299 | + | for (i, part) in dirs.iter().enumerate() { | |
| 300 | + | crumbs.push(Crumb { | |
| 301 | + | name: String::from_utf8_lossy(part).into_owned(), | |
| 302 | + | url: ctx.url("tree", rev, &parts[..=i].join(&b'/')), | |
| 303 | + | }); | |
| 304 | + | } | |
| 305 | + | (crumbs, Some(String::from_utf8_lossy(last).into_owned())) | |
| 306 | + | } | |
| @@ -0,0 +1,178 @@ | |||
| 1 | + | //! `/{repo}` — root tree, README and a rail with stats, clone URLs and the | |
| 2 | + | //! latest commit. | |
| 3 | + | ||
| 4 | + | use askama::Template; | |
| 5 | + | use axum::http::StatusCode; | |
| 6 | + | use axum::response::Response; | |
| 7 | + | ||
| 8 | + | use super::repo::{Active, Ctx, RepoNav, set_cache_headers, short}; | |
| 9 | + | use super::tree::{Readme, TreeRow, listing, readme, rows}; | |
| 10 | + | use super::{Layout, render}; | |
| 11 | + | use crate::cache::Key; | |
| 12 | + | use crate::git::refs::{RefKind, Rev}; | |
| 13 | + | use crate::git::{self, CommitInfo}; | |
| 14 | + | use crate::render::time; | |
| 15 | + | ||
| 16 | + | pub struct Stat { | |
| 17 | + | pub label: &'static str, | |
| 18 | + | pub value: String, | |
| 19 | + | pub title: Option<String>, | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | pub struct CloneUrl { | |
| 23 | + | pub label: String, | |
| 24 | + | pub url: String, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | pub struct Latest { | |
| 28 | + | pub subject: String, | |
| 29 | + | pub author: String, | |
| 30 | + | pub age: String, | |
| 31 | + | pub iso: String, | |
| 32 | + | pub id: String, | |
| 33 | + | pub url: String, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | #[derive(Template)] | |
| 37 | + | #[template(path = "summary.html")] | |
| 38 | + | pub struct SummaryPage { | |
| 39 | + | pub layout: Layout, | |
| 40 | + | pub nav: RepoNav, | |
| 41 | + | pub name: String, | |
| 42 | + | pub description: Option<String>, | |
| 43 | + | pub stats: Vec<Stat>, | |
| 44 | + | pub clone_urls: Vec<CloneUrl>, | |
| 45 | + | pub latest: Option<Latest>, | |
| 46 | + | /// `None` for an empty repository. | |
| 47 | + | pub rows: Option<Vec<TreeRow>>, | |
| 48 | + | pub tree_url: String, | |
| 49 | + | pub readme: Option<Readme>, | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | pub fn page(ctx: &Ctx) -> Response { | |
| 53 | + | let meta = &ctx.repo.meta; | |
| 54 | + | let rev = meta | |
| 55 | + | .default_branch | |
| 56 | + | .clone() | |
| 57 | + | .zip(meta.head) | |
| 58 | + | .map(|(name, commit)| Rev { | |
| 59 | + | name, | |
| 60 | + | kind: RefKind::Branch, | |
| 61 | + | commit, | |
| 62 | + | }); | |
| 63 | + | let etag = ctx.etag(("summary", rev.as_ref().map(|r| r.commit))); | |
| 64 | + | if let Some(res) = ctx.not_modified(&etag, false) { | |
| 65 | + | return res; | |
| 66 | + | } | |
| 67 | + | let mut page = SummaryPage { | |
| 68 | + | layout: Layout::new(ctx.state, ctx.config, Some(&ctx.repo.name)), | |
| 69 | + | nav: ctx.nav(Active::Summary, rev.as_ref(), |name| { | |
| 70 | + | ctx.url("tree", name, b"") | |
| 71 | + | }), | |
| 72 | + | name: ctx.repo.name.clone(), | |
| 73 | + | description: meta.description.clone(), | |
| 74 | + | stats: Vec::new(), | |
| 75 | + | clone_urls: clone_urls(ctx), | |
| 76 | + | latest: None, | |
| 77 | + | rows: None, | |
| 78 | + | tree_url: String::new(), | |
| 79 | + | readme: None, | |
| 80 | + | }; | |
| 81 | + | ||
| 82 | + | if let Some(rev) = &rev { | |
| 83 | + | let result = (|| -> anyhow::Result<()> { | |
| 84 | + | let commits = ctx.state.cache.count(Key::CommitCount(rev.commit), || { | |
| 85 | + | git::commit_count(&ctx.git, rev.commit) | |
| 86 | + | })?; | |
| 87 | + | let info = git::commit_info(&ctx.git, rev.commit)?; | |
| 88 | + | page.stats = stats(ctx, Some(commits)); | |
| 89 | + | page.latest = Some(latest(ctx, &info)); | |
| 90 | + | let root = git::tree::lookup(&ctx.git, rev.commit, b"")?; | |
| 91 | + | if let Some(git::tree::PathTarget::Tree(tree_id)) = root { | |
| 92 | + | page.rows = Some(rows(ctx, rev, b"", &listing(ctx, tree_id)?)); | |
| 93 | + | } | |
| 94 | + | page.tree_url = ctx.url("tree", &rev.name, b""); | |
| 95 | + | page.readme = meta.readme.as_ref().and_then(|r| { | |
| 96 | + | let (dir, name) = r.path.rsplit_once('/').unwrap_or(("", &r.path)); | |
| 97 | + | readme(ctx, rev, dir.as_bytes(), name, r.oid) | |
| 98 | + | }); | |
| 99 | + | Ok(()) | |
| 100 | + | })(); | |
| 101 | + | if let Err(e) = result { | |
| 102 | + | return ctx.internal_error(e); | |
| 103 | + | } | |
| 104 | + | } else { | |
| 105 | + | page.stats = stats(ctx, None); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | let mut res = render(StatusCode::OK, &page); | |
| 109 | + | set_cache_headers(&mut res, &etag, false); | |
| 110 | + | res | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | fn stats(ctx: &Ctx, commits: Option<u64>) -> Vec<Stat> { | |
| 114 | + | let meta = &ctx.repo.meta; | |
| 115 | + | let mut stats = vec![ | |
| 116 | + | Stat { | |
| 117 | + | label: "Commits", | |
| 118 | + | value: commits.unwrap_or(0).to_string(), | |
| 119 | + | title: None, | |
| 120 | + | }, | |
| 121 | + | Stat { | |
| 122 | + | label: "Branches", | |
| 123 | + | value: meta.branches.to_string(), | |
| 124 | + | title: None, | |
| 125 | + | }, | |
| 126 | + | Stat { | |
| 127 | + | label: "Tags", | |
| 128 | + | value: meta.tags.to_string(), | |
| 129 | + | title: None, | |
| 130 | + | }, | |
| 131 | + | ]; | |
| 132 | + | if let Some(t) = meta.last_activity { | |
| 133 | + | stats.push(Stat { | |
| 134 | + | label: "Updated", | |
| 135 | + | value: time::relative(t, time::now()), | |
| 136 | + | title: Some(time::iso(t)), | |
| 137 | + | }); | |
| 138 | + | } | |
| 139 | + | if let Some(owner) = &meta.owner { | |
| 140 | + | stats.push(Stat { | |
| 141 | + | label: "Owner", | |
| 142 | + | value: owner.clone(), | |
| 143 | + | title: None, | |
| 144 | + | }); | |
| 145 | + | } | |
| 146 | + | stats | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | fn latest(ctx: &Ctx, info: &CommitInfo) -> Latest { | |
| 150 | + | let id = info.id.to_string(); | |
| 151 | + | Latest { | |
| 152 | + | subject: info.subject.clone(), | |
| 153 | + | author: info.author_name.clone(), | |
| 154 | + | age: time::relative(info.committer_time, time::now()), | |
| 155 | + | iso: time::iso(info.committer_time), | |
| 156 | + | url: ctx.url("tree", &id, b""), | |
| 157 | + | id: short(&id), | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /// `site.clone_urls` with `{name}` filled in, labeled by protocol. | |
| 162 | + | fn clone_urls(ctx: &Ctx) -> Vec<CloneUrl> { | |
| 163 | + | ctx.config | |
| 164 | + | .site | |
| 165 | + | .clone_urls | |
| 166 | + | .iter() | |
| 167 | + | .map(|template| { | |
| 168 | + | let url = template.replace("{name}", &ctx.repo.name); | |
| 169 | + | let label = match url.split_once("://") { | |
| 170 | + | Some(("ssh", _)) => "SSH".to_string(), | |
| 171 | + | Some((scheme, _)) => scheme.to_ascii_uppercase(), | |
| 172 | + | // scp-like `user@host:path` | |
| 173 | + | None => "SSH".to_string(), | |
| 174 | + | }; | |
| 175 | + | CloneUrl { label, url } | |
| 176 | + | }) | |
| 177 | + | .collect() | |
| 178 | + | } | |
| @@ -0,0 +1,201 @@ | |||
| 1 | + | //! `/{repo}/tree/{rev}/{path…}` — directory listing, plus the directory's | |
| 2 | + | //! README. Also provides the listing and README rendering used by the | |
| 3 | + | //! summary page. | |
| 4 | + | ||
| 5 | + | use std::sync::Arc; | |
| 6 | + | ||
| 7 | + | use askama::Template; | |
| 8 | + | use axum::http::StatusCode; | |
| 9 | + | use axum::response::Response; | |
| 10 | + | use gix::bstr::ByteSlice; | |
| 11 | + | ||
| 12 | + | use super::repo::{Active, Crumb, Ctx, RepoNav, crumbs, redirect, set_cache_headers, short}; | |
| 13 | + | use super::{Layout, render}; | |
| 14 | + | use crate::cache::Key; | |
| 15 | + | use crate::git::ObjectId; | |
| 16 | + | use crate::git::refs::Rev; | |
| 17 | + | use crate::git::tree::{self, EntryKind, PathTarget, TreeEntry}; | |
| 18 | + | use crate::render::markdown::{self, Links}; | |
| 19 | + | use crate::render::{encode_path, human_size}; | |
| 20 | + | ||
| 21 | + | /// READMEs larger than this are not rendered inline. | |
| 22 | + | const MAX_README: u64 = 512 * 1024; | |
| 23 | + | ||
| 24 | + | pub struct TreeRow { | |
| 25 | + | pub name: String, | |
| 26 | + | /// `None` for submodules (nothing to browse here). | |
| 27 | + | pub url: Option<String>, | |
| 28 | + | pub icon: &'static str, | |
| 29 | + | /// Size for files, short commit id for submodules. | |
| 30 | + | pub meta: String, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | #[derive(Template)] | |
| 34 | + | #[template(path = "tree.html")] | |
| 35 | + | pub struct TreePage { | |
| 36 | + | pub layout: Layout, | |
| 37 | + | pub nav: RepoNav, | |
| 38 | + | pub crumbs: Vec<Crumb>, | |
| 39 | + | pub here: Option<String>, | |
| 40 | + | /// Includes a leading `..` row below the root. | |
| 41 | + | pub rows: Vec<TreeRow>, | |
| 42 | + | pub entries: usize, | |
| 43 | + | pub tree_id: String, | |
| 44 | + | pub readme: Option<Readme>, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | pub struct Readme { | |
| 48 | + | pub name: String, | |
| 49 | + | pub url: String, | |
| 50 | + | pub html: Arc<str>, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response { | |
| 54 | + | let (rev, path) = match ctx.resolve(segments) { | |
| 55 | + | Ok(found) => found, | |
| 56 | + | Err(res) => return *res, | |
| 57 | + | }; | |
| 58 | + | let tree_id = match tree::lookup(&ctx.git, rev.commit, &path) { | |
| 59 | + | Ok(Some(PathTarget::Tree(id))) => id, | |
| 60 | + | Ok(Some(PathTarget::Blob(..))) => return redirect(&ctx.url("blob", &rev.name, &path)), | |
| 61 | + | Ok(Some(PathTarget::Submodule(_))) => return ctx.not_found("PATH IS A SUBMODULE"), | |
| 62 | + | Ok(None) => { | |
| 63 | + | return ctx.not_found(&format!("PATH NOT FOUND IN {}", rev.name)); | |
| 64 | + | } | |
| 65 | + | Err(e) => return ctx.internal_error(e), | |
| 66 | + | }; | |
| 67 | + | ||
| 68 | + | let etag = ctx.etag(("tree", tree_id, &rev.name)); | |
| 69 | + | let immutable = rev.is_immutable(); | |
| 70 | + | if let Some(res) = ctx.not_modified(&etag, immutable) { | |
| 71 | + | return res; | |
| 72 | + | } | |
| 73 | + | let entries = match listing(ctx, tree_id) { | |
| 74 | + | Ok(entries) => entries, | |
| 75 | + | Err(e) => return ctx.internal_error(e), | |
| 76 | + | }; | |
| 77 | + | ||
| 78 | + | let (crumbs, here) = crumbs(ctx, &rev.name, &path); | |
| 79 | + | let mut rows = rows(ctx, &rev, &path, &entries); | |
| 80 | + | if !path.is_empty() { | |
| 81 | + | let up = path.rsplit_once_str("/").map_or(&b""[..], |(dir, _)| dir); | |
| 82 | + | rows.insert( | |
| 83 | + | 0, | |
| 84 | + | TreeRow { | |
| 85 | + | name: "..".into(), | |
| 86 | + | url: Some(ctx.url("tree", &rev.name, up)), | |
| 87 | + | icon: "folder", | |
| 88 | + | meta: String::new(), | |
| 89 | + | }, | |
| 90 | + | ); | |
| 91 | + | } | |
| 92 | + | let title = match &here { | |
| 93 | + | Some(_) => format!("{} · {}", path.to_str_lossy(), ctx.repo.name), | |
| 94 | + | None => ctx.repo.name.clone(), | |
| 95 | + | }; | |
| 96 | + | let page = TreePage { | |
| 97 | + | layout: Layout::new(ctx.state, ctx.config, Some(&title)), | |
| 98 | + | nav: ctx.nav(Active::Tree, Some(&rev), |name| { | |
| 99 | + | ctx.url("tree", name, &path) | |
| 100 | + | }), | |
| 101 | + | crumbs, | |
| 102 | + | here, | |
| 103 | + | rows, | |
| 104 | + | entries: entries.len(), | |
| 105 | + | tree_id: short(&tree_id.to_string()), | |
| 106 | + | readme: dir_readme(ctx, &rev, &path, &entries), | |
| 107 | + | }; | |
| 108 | + | let mut res = render(StatusCode::OK, &page); | |
| 109 | + | set_cache_headers(&mut res, &etag, immutable); | |
| 110 | + | res | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /// Cached listing of a tree. | |
| 114 | + | pub fn listing(ctx: &Ctx, tree_id: ObjectId) -> anyhow::Result<Arc<Vec<TreeEntry>>> { | |
| 115 | + | ctx.state | |
| 116 | + | .cache | |
| 117 | + | .tree(tree_id, || tree::list(&ctx.git, tree_id)) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | pub fn rows(ctx: &Ctx, rev: &Rev, dir: &[u8], entries: &[TreeEntry]) -> Vec<TreeRow> { | |
| 121 | + | entries | |
| 122 | + | .iter() | |
| 123 | + | .map(|entry| { | |
| 124 | + | let mut path = dir.to_vec(); | |
| 125 | + | if !path.is_empty() { | |
| 126 | + | path.push(b'/'); | |
| 127 | + | } | |
| 128 | + | path.extend_from_slice(&entry.name); | |
| 129 | + | let (action, icon) = match entry.kind { | |
| 130 | + | EntryKind::Tree => ("tree", "folder"), | |
| 131 | + | EntryKind::Symlink => ("blob", "link"), | |
| 132 | + | EntryKind::Submodule => ("", "package"), | |
| 133 | + | EntryKind::Blob | EntryKind::Executable => ("blob", "file"), | |
| 134 | + | }; | |
| 135 | + | TreeRow { | |
| 136 | + | name: entry.name.to_str_lossy().into_owned(), | |
| 137 | + | url: (!action.is_empty()).then(|| ctx.url(action, &rev.name, &path)), | |
| 138 | + | icon, | |
| 139 | + | meta: match entry.kind { | |
| 140 | + | EntryKind::Submodule => format!("@ {}", short(&entry.oid.to_string())), | |
| 141 | + | _ => entry.size.map(human_size).unwrap_or_default(), | |
| 142 | + | }, | |
| 143 | + | } | |
| 144 | + | }) | |
| 145 | + | .collect() | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | /// Names tried for a directory README, in order of preference. | |
| 149 | + | const README_NAMES: &[&str] = &["readme.md", "readme.markdown", "readme", "readme.txt"]; | |
| 150 | + | ||
| 151 | + | fn dir_readme(ctx: &Ctx, rev: &Rev, dir: &[u8], entries: &[TreeEntry]) -> Option<Readme> { | |
| 152 | + | let entry = README_NAMES.iter().find_map(|want| { | |
| 153 | + | entries.iter().find(|e| { | |
| 154 | + | e.kind.is_blob() | |
| 155 | + | && e.kind != EntryKind::Symlink | |
| 156 | + | && e.name.eq_ignore_ascii_case(want.as_bytes()) | |
| 157 | + | }) | |
| 158 | + | })?; | |
| 159 | + | let name = entry.name.to_str_lossy().into_owned(); | |
| 160 | + | readme(ctx, rev, dir, &name, entry.oid) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | /// Render a README blob: Markdown as HTML, anything else as plain text. | |
| 164 | + | pub fn readme(ctx: &Ctx, rev: &Rev, dir: &[u8], name: &str, oid: ObjectId) -> Option<Readme> { | |
| 165 | + | if tree::size(&ctx.git, oid).ok()? > MAX_README { | |
| 166 | + | return None; | |
| 167 | + | } | |
| 168 | + | let links = Links { | |
| 169 | + | repo_url: ctx.repo_url(), | |
| 170 | + | rev: encode_path(&rev.name), | |
| 171 | + | dir: dir.to_vec(), | |
| 172 | + | }; | |
| 173 | + | let is_md = markdown::is_markdown(name); | |
| 174 | + | let key = Key::Markdown { | |
| 175 | + | blob: oid, | |
| 176 | + | context: if is_md { links.key() } else { String::new() }, | |
| 177 | + | }; | |
| 178 | + | let html = ctx | |
| 179 | + | .state | |
| 180 | + | .cache | |
| 181 | + | .html(key, || -> anyhow::Result<String> { | |
| 182 | + | let data = tree::blob(&ctx.git, oid)?; | |
| 183 | + | let text = String::from_utf8_lossy(&data); | |
| 184 | + | Ok(if is_md { | |
| 185 | + | markdown::render(&text, &links) | |
| 186 | + | } else { | |
| 187 | + | markdown::render_plain(&text) | |
| 188 | + | }) | |
| 189 | + | }) | |
| 190 | + | .ok()?; | |
| 191 | + | let mut path = dir.to_vec(); | |
| 192 | + | if !path.is_empty() { | |
| 193 | + | path.push(b'/'); | |
| 194 | + | } | |
| 195 | + | path.extend_from_slice(name.as_bytes()); | |
| 196 | + | Some(Readme { | |
| 197 | + | name: name.to_string(), | |
| 198 | + | url: ctx.url("blob", &rev.name, &path), | |
| 199 | + | html, | |
| 200 | + | }) | |
| 201 | + | } | |
| @@ -0,0 +1,167 @@ | |||
| 1 | + | //! Per-repository metadata, resolved by precedence: | |
| 2 | + | //! `[[repo]]` in ferrenium.toml > repo git config > `description` file > | |
| 3 | + | //! defaults. | |
| 4 | + | ||
| 5 | + | use std::path::Path; | |
| 6 | + | ||
| 7 | + | use gix::bstr::BStr; | |
| 8 | + | use gix::glob::wildmatch; | |
| 9 | + | ||
| 10 | + | use super::scan::Candidate; | |
| 11 | + | use crate::config::{Config, RepoOverride}; | |
| 12 | + | use crate::git::{self, ObjectId, ThreadSafeRepository}; | |
| 13 | + | ||
| 14 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 15 | + | pub struct Readme { | |
| 16 | + | pub path: String, | |
| 17 | + | pub oid: ObjectId, | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | #[derive(Debug, Default, Clone, PartialEq, Eq)] | |
| 21 | + | pub struct RepoMeta { | |
| 22 | + | pub description: Option<String>, | |
| 23 | + | pub owner: Option<String>, | |
| 24 | + | pub section: Option<String>, | |
| 25 | + | /// Branch shown by default (may be unborn in an empty repo). | |
| 26 | + | pub default_branch: Option<String>, | |
| 27 | + | /// Commit the default branch points to; `None` for an empty repo. | |
| 28 | + | pub head: Option<ObjectId>, | |
| 29 | + | pub branches: usize, | |
| 30 | + | pub tags: usize, | |
| 31 | + | /// Unix seconds of the newest commit across branch tips. | |
| 32 | + | pub last_activity: Option<i64>, | |
| 33 | + | pub readme: Option<Readme>, | |
| 34 | + | pub refs_fingerprint: u64, | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | pub struct Inspected { | |
| 38 | + | pub gix: ThreadSafeRepository, | |
| 39 | + | pub meta: RepoMeta, | |
| 40 | + | pub hidden: bool, | |
| 41 | + | pub warnings: Vec<String>, | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// git's placeholder `description`, which means "no description". | |
| 45 | + | const PLACEHOLDER_DESCRIPTION: &str = "Unnamed repository;"; | |
| 46 | + | ||
| 47 | + | pub fn inspect(config: &Config, candidate: &Candidate) -> anyhow::Result<Inspected> { | |
| 48 | + | let gix = git::open(&candidate.git_dir)?; | |
| 49 | + | let repo = gix.to_thread_local(); | |
| 50 | + | let name = candidate.name.as_str(); | |
| 51 | + | let toml = config.repo_overrides.iter().find(|r| r.name == name); | |
| 52 | + | let toml = toml.cloned().unwrap_or_default(); | |
| 53 | + | let cfg = git::config_meta(&repo); | |
| 54 | + | let mut warnings = Vec::new(); | |
| 55 | + | ||
| 56 | + | let hidden = | |
| 57 | + | is_hidden_by_glob(&config.repos.hide, name) || toml.hidden.or(cfg.hidden).unwrap_or(false); | |
| 58 | + | if hidden { | |
| 59 | + | return Ok(Inspected { | |
| 60 | + | gix, | |
| 61 | + | meta: RepoMeta::default(), | |
| 62 | + | hidden, | |
| 63 | + | warnings, | |
| 64 | + | }); | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | let section = toml.section.clone().or(cfg.section.clone()).or_else(|| { | |
| 68 | + | config | |
| 69 | + | .repos | |
| 70 | + | .section_from_path | |
| 71 | + | .then(|| name.split_once('/').map(|(first, _)| first.to_string())) | |
| 72 | + | .flatten() | |
| 73 | + | }); | |
| 74 | + | ||
| 75 | + | let head_branch = git::head_branch(&repo); | |
| 76 | + | let mut default_branch = toml | |
| 77 | + | .default_branch | |
| 78 | + | .clone() | |
| 79 | + | .or(cfg.default_branch.clone()) | |
| 80 | + | .or(head_branch.clone()); | |
| 81 | + | let mut head = default_branch | |
| 82 | + | .as_deref() | |
| 83 | + | .and_then(|b| git::branch_commit(&repo, b)); | |
| 84 | + | if head.is_none() | |
| 85 | + | && let Some(fallback) = head_branch.as_deref() | |
| 86 | + | && default_branch.as_deref() != Some(fallback) | |
| 87 | + | && let Some(commit) = git::branch_commit(&repo, fallback) | |
| 88 | + | { | |
| 89 | + | warnings.push(format!( | |
| 90 | + | "{name}: default branch {:?} does not exist; using {fallback:?}", | |
| 91 | + | default_branch.as_deref().unwrap_or_default() | |
| 92 | + | )); | |
| 93 | + | default_branch = Some(fallback.to_string()); | |
| 94 | + | head = Some(commit); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | let readme_path = toml.readme.clone().or(cfg.readme.clone()); | |
| 98 | + | let readme = head.and_then(|commit| { | |
| 99 | + | let found = git::find_readme(&repo, commit, readme_path.as_deref()); | |
| 100 | + | if found.is_none() | |
| 101 | + | && let Some(path) = &readme_path | |
| 102 | + | { | |
| 103 | + | warnings.push(format!("{name}: readme {path:?} not found")); | |
| 104 | + | } | |
| 105 | + | found.map(|(path, oid)| Readme { path, oid }) | |
| 106 | + | }); | |
| 107 | + | ||
| 108 | + | let refs = git::ref_summary(&repo)?; | |
| 109 | + | let meta = RepoMeta { | |
| 110 | + | description: description(&toml, cfg.description.clone(), &candidate.git_dir), | |
| 111 | + | owner: toml.owner.clone().or(cfg.owner.clone()), | |
| 112 | + | section, | |
| 113 | + | default_branch, | |
| 114 | + | head, | |
| 115 | + | branches: refs.branches, | |
| 116 | + | tags: refs.tags, | |
| 117 | + | last_activity: refs.last_activity, | |
| 118 | + | readme, | |
| 119 | + | refs_fingerprint: refs.fingerprint, | |
| 120 | + | }; | |
| 121 | + | Ok(Inspected { | |
| 122 | + | gix, | |
| 123 | + | meta, | |
| 124 | + | hidden, | |
| 125 | + | warnings, | |
| 126 | + | }) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | fn description(toml: &RepoOverride, from_config: Option<String>, git_dir: &Path) -> Option<String> { | |
| 130 | + | toml.description | |
| 131 | + | .clone() | |
| 132 | + | .or(from_config) | |
| 133 | + | .or_else(|| { | |
| 134 | + | let text = std::fs::read_to_string(git_dir.join("description")).ok()?; | |
| 135 | + | let text = text.trim(); | |
| 136 | + | (!text.is_empty() && !text.starts_with(PLACEHOLDER_DESCRIPTION)) | |
| 137 | + | .then(|| text.to_string()) | |
| 138 | + | }) | |
| 139 | + | .filter(|d| !d.trim().is_empty()) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /// `repos.hide` globs: `*` stays within one path component, `**` crosses. | |
| 143 | + | pub fn is_hidden_by_glob(patterns: &[String], name: &str) -> bool { | |
| 144 | + | patterns.iter().any(|pattern| { | |
| 145 | + | wildmatch( | |
| 146 | + | BStr::new(pattern.as_bytes()), | |
| 147 | + | BStr::new(name.as_bytes()), | |
| 148 | + | wildmatch::Mode::NO_MATCH_SLASH_LITERAL, | |
| 149 | + | ) | |
| 150 | + | }) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | #[cfg(test)] | |
| 154 | + | mod tests { | |
| 155 | + | use super::*; | |
| 156 | + | ||
| 157 | + | #[test] | |
| 158 | + | fn hide_globs() { | |
| 159 | + | let pats = vec!["private/*".to_string(), "*-wip".to_string()]; | |
| 160 | + | assert!(is_hidden_by_glob(&pats, "private/x")); | |
| 161 | + | assert!(!is_hidden_by_glob(&pats, "private/x/y")); | |
| 162 | + | assert!(is_hidden_by_glob(&pats, "thing-wip")); | |
| 163 | + | assert!(!is_hidden_by_glob(&pats, "dir/thing-wip")); | |
| 164 | + | assert!(!is_hidden_by_glob(&pats, "public")); | |
| 165 | + | assert!(is_hidden_by_glob(&["**/secret".into()], "a/b/secret")); | |
| 166 | + | } | |
| 167 | + | } | |
| @@ -0,0 +1,253 @@ | |||
| 1 | + | //! Repository discovery and the in-memory repository index. | |
| 2 | + | //! | |
| 3 | + | //! Readers get a consistent snapshot via [`Indexer::load`] without locking. | |
| 4 | + | //! Writers (full scans and single-repo refreshes) are serialized and swap in | |
| 5 | + | //! a new snapshot copy-on-write. | |
| 6 | + | ||
| 7 | + | pub mod meta; | |
| 8 | + | pub mod scan; | |
| 9 | + | pub mod watch; | |
| 10 | + | ||
| 11 | + | use std::collections::{BTreeMap, BTreeSet}; | |
| 12 | + | use std::path::{Path, PathBuf}; | |
| 13 | + | use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; | |
| 14 | + | use std::sync::{Arc, Mutex}; | |
| 15 | + | use std::time::{Duration, Instant}; | |
| 16 | + | ||
| 17 | + | use arc_swap::ArcSwap; | |
| 18 | + | ||
| 19 | + | use crate::config::Config; | |
| 20 | + | use crate::git::ThreadSafeRepository; | |
| 21 | + | use meta::{Inspected, RepoMeta}; | |
| 22 | + | use scan::Candidate; | |
| 23 | + | ||
| 24 | + | pub struct Repo { | |
| 25 | + | pub name: String, | |
| 26 | + | pub git_dir: PathBuf, | |
| 27 | + | pub meta: RepoMeta, | |
| 28 | + | pub gix: ThreadSafeRepository, | |
| 29 | + | /// Changes whenever this repo's refs or metadata change; part of cache | |
| 30 | + | /// keys for ref-dependent data. | |
| 31 | + | pub generation: u64, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | #[derive(Default, Clone)] | |
| 35 | + | pub struct RepoIndex { | |
| 36 | + | /// Visible repositories by name. | |
| 37 | + | pub repos: BTreeMap<String, Arc<Repo>>, | |
| 38 | + | /// Every discovered git dir (hidden ones included) → name. | |
| 39 | + | pub git_dirs: BTreeMap<PathBuf, String>, | |
| 40 | + | pub hidden: BTreeSet<String>, | |
| 41 | + | /// Non-repository directories walked during discovery. | |
| 42 | + | pub dirs: Vec<PathBuf>, | |
| 43 | + | pub warnings: Vec<String>, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | #[derive(Debug, Default, PartialEq, Eq)] | |
| 47 | + | pub struct ScanStats { | |
| 48 | + | pub total: usize, | |
| 49 | + | pub hidden: usize, | |
| 50 | + | pub added: usize, | |
| 51 | + | pub removed: usize, | |
| 52 | + | pub changed: usize, | |
| 53 | + | pub elapsed: Duration, | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | impl ScanStats { | |
| 57 | + | pub fn any_change(&self) -> bool { | |
| 58 | + | self.added + self.removed + self.changed > 0 | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | #[derive(Debug, PartialEq, Eq)] | |
| 63 | + | pub enum Refresh { | |
| 64 | + | Unchanged, | |
| 65 | + | Updated(String), | |
| 66 | + | Hidden(String), | |
| 67 | + | /// The path is not (or no longer) a known repository: do a full scan. | |
| 68 | + | NeedsRescan, | |
| 69 | + | Failed(String), | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | pub struct Indexer { | |
| 73 | + | current: ArcSwap<RepoIndex>, | |
| 74 | + | generation: AtomicU64, | |
| 75 | + | write: Mutex<()>, | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | impl Default for Indexer { | |
| 79 | + | fn default() -> Self { | |
| 80 | + | Self::new() | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | impl Indexer { | |
| 85 | + | pub fn new() -> Self { | |
| 86 | + | Indexer { | |
| 87 | + | current: ArcSwap::from_pointee(RepoIndex::default()), | |
| 88 | + | generation: AtomicU64::new(0), | |
| 89 | + | write: Mutex::new(()), | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | pub fn load(&self) -> Arc<RepoIndex> { | |
| 94 | + | self.current.load_full() | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// Rediscover everything under the scan path and rebuild the index. | |
| 98 | + | pub fn full_scan(&self, config: &Config) -> ScanStats { | |
| 99 | + | let _guard = self.write.lock().unwrap_or_else(|e| e.into_inner()); | |
| 100 | + | let start = Instant::now(); | |
| 101 | + | let old = self.current.load_full(); | |
| 102 | + | let discovery = scan::discover( | |
| 103 | + | &config.repos.scan_path, | |
| 104 | + | config.repos.max_depth, | |
| 105 | + | config.repos.follow_symlinks, | |
| 106 | + | ); | |
| 107 | + | ||
| 108 | + | let mut new = RepoIndex { | |
| 109 | + | dirs: discovery.dirs, | |
| 110 | + | warnings: discovery.warnings, | |
| 111 | + | ..RepoIndex::default() | |
| 112 | + | }; | |
| 113 | + | let inspected = inspect_all(config, &discovery.candidates); | |
| 114 | + | for (candidate, result) in discovery.candidates.into_iter().zip(inspected) { | |
| 115 | + | new.git_dirs | |
| 116 | + | .insert(candidate.git_dir.clone(), candidate.name.clone()); | |
| 117 | + | match result { | |
| 118 | + | Err(e) => new.warnings.push(format!("{}: {e:#}", candidate.name)), | |
| 119 | + | Ok(inspected) => { | |
| 120 | + | new.warnings.extend(inspected.warnings.iter().cloned()); | |
| 121 | + | if inspected.hidden { | |
| 122 | + | new.hidden.insert(candidate.name); | |
| 123 | + | } else { | |
| 124 | + | let repo = self.make_repo(&old, candidate, inspected); | |
| 125 | + | new.repos.insert(repo.name.clone(), repo); | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | let known: BTreeSet<&String> = new.git_dirs.values().collect(); | |
| 131 | + | for o in &config.repo_overrides { | |
| 132 | + | if !known.contains(&o.name) { | |
| 133 | + | new.warnings.push(format!( | |
| 134 | + | "[[repo]] {:?} does not match any repository", | |
| 135 | + | o.name | |
| 136 | + | )); | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | let mut stats = ScanStats { | |
| 141 | + | total: new.repos.len(), | |
| 142 | + | hidden: new.hidden.len(), | |
| 143 | + | ..ScanStats::default() | |
| 144 | + | }; | |
| 145 | + | for (name, repo) in &new.repos { | |
| 146 | + | match old.repos.get(name) { | |
| 147 | + | None => stats.added += 1, | |
| 148 | + | Some(prev) if !Arc::ptr_eq(prev, repo) => stats.changed += 1, | |
| 149 | + | Some(_) => {} | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | stats.removed = old | |
| 153 | + | .repos | |
| 154 | + | .keys() | |
| 155 | + | .filter(|name| !new.repos.contains_key(*name)) | |
| 156 | + | .count(); | |
| 157 | + | self.current.store(Arc::new(new)); | |
| 158 | + | stats.elapsed = start.elapsed(); | |
| 159 | + | stats | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | /// Re-inspect the single repository at `git_dir` (after a ref change). | |
| 163 | + | pub fn refresh(&self, config: &Config, git_dir: &Path) -> Refresh { | |
| 164 | + | let _guard = self.write.lock().unwrap_or_else(|e| e.into_inner()); | |
| 165 | + | let old = self.current.load_full(); | |
| 166 | + | let Some(name) = old.git_dirs.get(git_dir).cloned() else { | |
| 167 | + | return Refresh::NeedsRescan; | |
| 168 | + | }; | |
| 169 | + | if scan::repo_git_dir(git_dir).as_deref() != Some(git_dir) { | |
| 170 | + | return Refresh::NeedsRescan; | |
| 171 | + | } | |
| 172 | + | let candidate = Candidate { | |
| 173 | + | name: name.clone(), | |
| 174 | + | git_dir: git_dir.to_path_buf(), | |
| 175 | + | }; | |
| 176 | + | let inspected = match meta::inspect(config, &candidate) { | |
| 177 | + | Ok(inspected) => inspected, | |
| 178 | + | // Keep serving the previous state; the next event or poll retries. | |
| 179 | + | Err(e) => return Refresh::Failed(format!("{name}: {e:#}")), | |
| 180 | + | }; | |
| 181 | + | ||
| 182 | + | let mut new = (*old).clone(); | |
| 183 | + | if inspected.hidden { | |
| 184 | + | if new.repos.remove(&name).is_none() { | |
| 185 | + | return Refresh::Unchanged; | |
| 186 | + | } | |
| 187 | + | new.hidden.insert(name.clone()); | |
| 188 | + | self.current.store(Arc::new(new)); | |
| 189 | + | return Refresh::Hidden(name); | |
| 190 | + | } | |
| 191 | + | let repo = self.make_repo(&old, candidate, inspected); | |
| 192 | + | if old | |
| 193 | + | .repos | |
| 194 | + | .get(&name) | |
| 195 | + | .is_some_and(|prev| Arc::ptr_eq(prev, &repo)) | |
| 196 | + | { | |
| 197 | + | return Refresh::Unchanged; | |
| 198 | + | } | |
| 199 | + | new.hidden.remove(&name); | |
| 200 | + | new.repos.insert(name.clone(), repo); | |
| 201 | + | self.current.store(Arc::new(new)); | |
| 202 | + | Refresh::Updated(name) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | /// Reuse the previous entry when nothing changed, so its generation (and | |
| 206 | + | /// everything cached under it) stays valid. | |
| 207 | + | fn make_repo(&self, old: &RepoIndex, candidate: Candidate, inspected: Inspected) -> Arc<Repo> { | |
| 208 | + | if let Some(prev) = old.repos.get(&candidate.name) | |
| 209 | + | && prev.git_dir == candidate.git_dir | |
| 210 | + | && prev.meta == inspected.meta | |
| 211 | + | { | |
| 212 | + | return prev.clone(); | |
| 213 | + | } | |
| 214 | + | Arc::new(Repo { | |
| 215 | + | name: candidate.name, | |
| 216 | + | git_dir: candidate.git_dir, | |
| 217 | + | meta: inspected.meta, | |
| 218 | + | gix: inspected.gix, | |
| 219 | + | generation: self.generation.fetch_add(1, Ordering::Relaxed) + 1, | |
| 220 | + | }) | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | /// Inspect candidates on a few threads; results keep the input order. | |
| 225 | + | fn inspect_all(config: &Config, candidates: &[Candidate]) -> Vec<anyhow::Result<Inspected>> { | |
| 226 | + | let workers = std::thread::available_parallelism() | |
| 227 | + | .map_or(4, |n| n.get()) | |
| 228 | + | .min(candidates.len()) | |
| 229 | + | .max(1); | |
| 230 | + | let next = AtomicUsize::new(0); | |
| 231 | + | let mut results: Vec<(usize, anyhow::Result<Inspected>)> = std::thread::scope(|s| { | |
| 232 | + | let handles: Vec<_> = (0..workers) | |
| 233 | + | .map(|_| { | |
| 234 | + | s.spawn(|| { | |
| 235 | + | let mut out = Vec::new(); | |
| 236 | + | loop { | |
| 237 | + | let i = next.fetch_add(1, Ordering::Relaxed); | |
| 238 | + | let Some(candidate) = candidates.get(i) else { | |
| 239 | + | break out; | |
| 240 | + | }; | |
| 241 | + | out.push((i, meta::inspect(config, candidate))); | |
| 242 | + | } | |
| 243 | + | }) | |
| 244 | + | }) | |
| 245 | + | .collect(); | |
| 246 | + | handles | |
| 247 | + | .into_iter() | |
| 248 | + | .flat_map(|h| h.join().expect("inspect worker panicked")) | |
| 249 | + | .collect() | |
| 250 | + | }); | |
| 251 | + | results.sort_by_key(|(i, _)| *i); | |
| 252 | + | results.into_iter().map(|(_, r)| r).collect() | |
| 253 | + | } | |
| @@ -0,0 +1,186 @@ | |||
| 1 | + | //! Filesystem discovery of repositories under `repos.scan_path`. | |
| 2 | + | ||
| 3 | + | use std::collections::BTreeMap; | |
| 4 | + | use std::path::{Path, PathBuf}; | |
| 5 | + | ||
| 6 | + | /// A repository found on disk. | |
| 7 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 8 | + | pub struct Candidate { | |
| 9 | + | /// Path relative to the scan path, `.git` suffix stripped: `forks/foo`. | |
| 10 | + | pub name: String, | |
| 11 | + | /// The git dir: the bare repo itself, or `<worktree>/.git`. | |
| 12 | + | pub git_dir: PathBuf, | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | #[derive(Debug, Default)] | |
| 16 | + | pub struct Discovery { | |
| 17 | + | pub candidates: Vec<Candidate>, | |
| 18 | + | /// Directories walked that are not repositories; watched for repos | |
| 19 | + | /// appearing or disappearing. | |
| 20 | + | pub dirs: Vec<PathBuf>, | |
| 21 | + | pub warnings: Vec<String>, | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | pub fn discover(scan_path: &Path, max_depth: u32, follow_symlinks: bool) -> Discovery { | |
| 25 | + | let mut out = Discovery::default(); | |
| 26 | + | let mut found: BTreeMap<String, PathBuf> = BTreeMap::new(); | |
| 27 | + | ||
| 28 | + | if repo_git_dir(scan_path).is_some() { | |
| 29 | + | out.warnings.push(format!( | |
| 30 | + | "repos.scan_path {} is itself a repository; point it at the directory containing repositories", | |
| 31 | + | scan_path.display() | |
| 32 | + | )); | |
| 33 | + | return out; | |
| 34 | + | } | |
| 35 | + | walk( | |
| 36 | + | scan_path, | |
| 37 | + | scan_path, | |
| 38 | + | 0, | |
| 39 | + | max_depth, | |
| 40 | + | follow_symlinks, | |
| 41 | + | &mut found, | |
| 42 | + | &mut out, | |
| 43 | + | ); | |
| 44 | + | out.candidates = found | |
| 45 | + | .into_iter() | |
| 46 | + | .map(|(name, git_dir)| Candidate { name, git_dir }) | |
| 47 | + | .collect(); | |
| 48 | + | out | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | fn walk( | |
| 52 | + | root: &Path, | |
| 53 | + | dir: &Path, | |
| 54 | + | depth: u32, | |
| 55 | + | max_depth: u32, | |
| 56 | + | follow_symlinks: bool, | |
| 57 | + | found: &mut BTreeMap<String, PathBuf>, | |
| 58 | + | out: &mut Discovery, | |
| 59 | + | ) { | |
| 60 | + | let entries = match std::fs::read_dir(dir) { | |
| 61 | + | Ok(entries) => entries, | |
| 62 | + | Err(e) => { | |
| 63 | + | out.warnings | |
| 64 | + | .push(format!("cannot read {}: {e}", dir.display())); | |
| 65 | + | return; | |
| 66 | + | } | |
| 67 | + | }; | |
| 68 | + | out.dirs.push(dir.to_path_buf()); | |
| 69 | + | ||
| 70 | + | let mut subdirs: Vec<PathBuf> = entries | |
| 71 | + | .flatten() | |
| 72 | + | .filter(|entry| !entry.file_name().as_encoded_bytes().starts_with(b".")) | |
| 73 | + | .filter(|entry| match entry.file_type() { | |
| 74 | + | Ok(t) if t.is_dir() => true, | |
| 75 | + | Ok(t) if t.is_symlink() && follow_symlinks => entry.path().is_dir(), | |
| 76 | + | _ => false, | |
| 77 | + | }) | |
| 78 | + | .map(|entry| entry.path()) | |
| 79 | + | .collect(); | |
| 80 | + | subdirs.sort(); | |
| 81 | + | ||
| 82 | + | for path in subdirs { | |
| 83 | + | if let Some(git_dir) = repo_git_dir(&path) { | |
| 84 | + | let Some(name) = repo_name(root, &path) else { | |
| 85 | + | out.warnings.push(format!( | |
| 86 | + | "skipping {}: path is not valid UTF-8", | |
| 87 | + | path.display() | |
| 88 | + | )); | |
| 89 | + | continue; | |
| 90 | + | }; | |
| 91 | + | if let Some(existing) = found.get(&name) { | |
| 92 | + | out.warnings.push(format!( | |
| 93 | + | "name collision: {} and {} are both named {name:?}; keeping the first", | |
| 94 | + | existing.display(), | |
| 95 | + | git_dir.display() | |
| 96 | + | )); | |
| 97 | + | continue; | |
| 98 | + | } | |
| 99 | + | found.insert(name, git_dir); | |
| 100 | + | } else if depth + 1 < max_depth { | |
| 101 | + | walk( | |
| 102 | + | root, | |
| 103 | + | &path, | |
| 104 | + | depth + 1, | |
| 105 | + | max_depth, | |
| 106 | + | follow_symlinks, | |
| 107 | + | found, | |
| 108 | + | out, | |
| 109 | + | ); | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | /// The git dir if `path` is a bare repository or a worktree with a `.git` dir. | |
| 115 | + | pub fn repo_git_dir(path: &Path) -> Option<PathBuf> { | |
| 116 | + | if is_bare(path) { | |
| 117 | + | return Some(path.to_path_buf()); | |
| 118 | + | } | |
| 119 | + | let dot_git = path.join(".git"); | |
| 120 | + | is_bare(&dot_git).then_some(dot_git) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | fn is_bare(path: &Path) -> bool { | |
| 124 | + | path.join("HEAD").is_file() && path.join("objects").is_dir() && path.join("refs").is_dir() | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | fn repo_name(root: &Path, path: &Path) -> Option<String> { | |
| 128 | + | let rel = path.strip_prefix(root).ok()?.to_str()?; | |
| 129 | + | let name = rel.strip_suffix(".git").unwrap_or(rel); | |
| 130 | + | (!name.is_empty() && !name.ends_with('/')).then(|| name.to_string()) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | #[cfg(test)] | |
| 134 | + | mod tests { | |
| 135 | + | use super::*; | |
| 136 | + | ||
| 137 | + | fn fake_bare(path: &Path) { | |
| 138 | + | std::fs::create_dir_all(path.join("objects")).unwrap(); | |
| 139 | + | std::fs::create_dir_all(path.join("refs")).unwrap(); | |
| 140 | + | std::fs::write(path.join("HEAD"), "ref: refs/heads/main\n").unwrap(); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | #[test] | |
| 144 | + | fn discovers_bare_and_worktree_repos() { | |
| 145 | + | let dir = tempfile::tempdir().unwrap(); | |
| 146 | + | let root = dir.path(); | |
| 147 | + | fake_bare(&root.join("a.git")); | |
| 148 | + | fake_bare(&root.join("forks/b.git")); | |
| 149 | + | fake_bare(&root.join("work/c/.git")); | |
| 150 | + | fake_bare(&root.join("deep/1/2/3/d.git")); // beyond max_depth 3 | |
| 151 | + | fake_bare(&root.join(".hidden/e.git")); | |
| 152 | + | fake_bare(&root.join("a.git/nested.git")); // inside a repo: not walked | |
| 153 | + | std::fs::create_dir_all(root.join("empty")).unwrap(); | |
| 154 | + | ||
| 155 | + | let d = discover(root, 3, false); | |
| 156 | + | let names: Vec<_> = d.candidates.iter().map(|c| c.name.as_str()).collect(); | |
| 157 | + | assert_eq!(names, ["a", "forks/b", "work/c"]); | |
| 158 | + | assert_eq!(d.candidates[2].git_dir, root.join("work/c/.git")); | |
| 159 | + | assert!(d.dirs.contains(&root.join("forks"))); | |
| 160 | + | assert!(d.dirs.contains(&root.join("empty"))); | |
| 161 | + | assert!(!d.dirs.contains(&root.join("a.git"))); | |
| 162 | + | assert!(d.warnings.is_empty(), "{:?}", d.warnings); | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | #[test] | |
| 166 | + | fn collisions_warn_and_keep_first() { | |
| 167 | + | let dir = tempfile::tempdir().unwrap(); | |
| 168 | + | fake_bare(&dir.path().join("x")); | |
| 169 | + | fake_bare(&dir.path().join("x.git")); | |
| 170 | + | let d = discover(dir.path(), 3, false); | |
| 171 | + | assert_eq!(d.candidates.len(), 1); | |
| 172 | + | assert_eq!(d.candidates[0].git_dir, dir.path().join("x")); | |
| 173 | + | assert!(d.warnings[0].contains("name collision")); | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | #[test] | |
| 177 | + | fn symlinks_followed_only_when_enabled() { | |
| 178 | + | let dir = tempfile::tempdir().unwrap(); | |
| 179 | + | let elsewhere = tempfile::tempdir().unwrap(); | |
| 180 | + | fake_bare(&elsewhere.path().join("r.git")); | |
| 181 | + | std::os::unix::fs::symlink(elsewhere.path().join("r.git"), dir.path().join("r.git")) | |
| 182 | + | .unwrap(); | |
| 183 | + | assert!(discover(dir.path(), 3, false).candidates.is_empty()); | |
| 184 | + | assert_eq!(discover(dir.path(), 3, true).candidates.len(), 1); | |
| 185 | + | } | |
| 186 | + | } | |
| @@ -0,0 +1,266 @@ | |||
| 1 | + | //! Keeps the index fresh: inotify watches (debounced per repository), a | |
| 2 | + | //! periodic full rescan as a fallback, and rescans requested on SIGHUP. | |
| 3 | + | //! | |
| 4 | + | //! Watched paths: | |
| 5 | + | //! - each git dir, non-recursively (`HEAD`, `packed-refs`, `config`, | |
| 6 | + | //! `description`) and its `refs/` recursively; objects are never watched; | |
| 7 | + | //! - every non-repository directory walked during discovery, so repositories | |
| 8 | + | //! appearing or disappearing trigger a rescan. | |
| 9 | + | ||
| 10 | + | use std::collections::{BTreeSet, HashMap}; | |
| 11 | + | use std::path::{Path, PathBuf}; | |
| 12 | + | use std::sync::Arc; | |
| 13 | + | use std::time::Duration; | |
| 14 | + | ||
| 15 | + | use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; | |
| 16 | + | use tokio::sync::mpsc; | |
| 17 | + | use tokio::time::{Instant, Interval, MissedTickBehavior}; | |
| 18 | + | ||
| 19 | + | use super::{Refresh, RepoIndex, ScanStats}; | |
| 20 | + | use crate::state::AppState; | |
| 21 | + | ||
| 22 | + | #[derive(Debug, Clone, PartialEq, Eq, Hash)] | |
| 23 | + | enum Task { | |
| 24 | + | Rescan, | |
| 25 | + | Repo(PathBuf), | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /// Run forever. Call after the initial scan; `seen` holds the warnings that | |
| 29 | + | /// scan already logged. | |
| 30 | + | pub async fn run(state: Arc<AppState>, mut seen: BTreeSet<String>) { | |
| 31 | + | let (tx, mut rx) = mpsc::unbounded_channel::<notify::Result<Event>>(); | |
| 32 | + | let mut fs = FsWatch::new(&state, tx); | |
| 33 | + | fs.sync(&state.index.load()); | |
| 34 | + | ||
| 35 | + | let mut pending: HashMap<Task, Instant> = HashMap::new(); | |
| 36 | + | let mut poll_secs = state.config.load().watch.poll_interval_secs; | |
| 37 | + | let mut poll = poll_timer(poll_secs); | |
| 38 | + | ||
| 39 | + | loop { | |
| 40 | + | let config = state.config.load_full(); | |
| 41 | + | if config.watch.poll_interval_secs != poll_secs { | |
| 42 | + | poll_secs = config.watch.poll_interval_secs; | |
| 43 | + | poll = poll_timer(poll_secs); | |
| 44 | + | } | |
| 45 | + | let debounce = Duration::from_millis(config.watch.debounce_ms); | |
| 46 | + | let next_due = pending.values().min().copied(); | |
| 47 | + | ||
| 48 | + | tokio::select! { | |
| 49 | + | Some(event) = rx.recv() => { | |
| 50 | + | let index = state.index.load(); | |
| 51 | + | for task in classify(event, &index) { | |
| 52 | + | pending.entry(task).or_insert_with(|| Instant::now() + debounce); | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | _ = state.rescan.notified() => { | |
| 56 | + | pending.insert(Task::Rescan, Instant::now()); | |
| 57 | + | } | |
| 58 | + | _ = async { poll.as_mut().unwrap().tick().await }, if poll.is_some() => { | |
| 59 | + | pending.entry(Task::Rescan).or_insert_with(Instant::now); | |
| 60 | + | } | |
| 61 | + | _ = tokio::time::sleep_until(next_due.unwrap_or_else(Instant::now)), if next_due.is_some() => { | |
| 62 | + | let now = Instant::now(); | |
| 63 | + | let due: Vec<Task> = pending | |
| 64 | + | .iter() | |
| 65 | + | .filter(|(_, at)| **at <= now) | |
| 66 | + | .map(|(task, _)| task.clone()) | |
| 67 | + | .collect(); | |
| 68 | + | for task in &due { | |
| 69 | + | pending.remove(task); | |
| 70 | + | } | |
| 71 | + | if due.contains(&Task::Rescan) { | |
| 72 | + | // A full scan covers every per-repo refresh. | |
| 73 | + | pending.retain(|task, _| *task == Task::Rescan); | |
| 74 | + | let st = state.clone(); | |
| 75 | + | let stats = tokio::task::spawn_blocking(move || { | |
| 76 | + | let config = st.config.load_full(); | |
| 77 | + | st.index.full_scan(&config) | |
| 78 | + | }) | |
| 79 | + | .await; | |
| 80 | + | if let Ok(stats) = stats { | |
| 81 | + | let index = state.index.load(); | |
| 82 | + | log_scan(&stats, &index, &mut seen, false); | |
| 83 | + | fs.sync(&index); | |
| 84 | + | } | |
| 85 | + | continue; | |
| 86 | + | } | |
| 87 | + | for task in due { | |
| 88 | + | let Task::Repo(git_dir) = task else { continue }; | |
| 89 | + | let st = state.clone(); | |
| 90 | + | let outcome = tokio::task::spawn_blocking(move || { | |
| 91 | + | let config = st.config.load_full(); | |
| 92 | + | st.index.refresh(&config, &git_dir) | |
| 93 | + | }) | |
| 94 | + | .await; | |
| 95 | + | match outcome { | |
| 96 | + | Ok(Refresh::Updated(name)) => tracing::info!("updated {name}"), | |
| 97 | + | Ok(Refresh::Hidden(name)) => tracing::info!("{name} is now hidden"), | |
| 98 | + | Ok(Refresh::NeedsRescan) => { | |
| 99 | + | pending.insert(Task::Rescan, Instant::now()); | |
| 100 | + | } | |
| 101 | + | Ok(Refresh::Failed(e)) => tracing::warn!("refresh failed: {e}"), | |
| 102 | + | Ok(Refresh::Unchanged) | Err(_) => {} | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | fn poll_timer(secs: u64) -> Option<Interval> { | |
| 111 | + | (secs > 0).then(|| { | |
| 112 | + | let period = Duration::from_secs(secs); | |
| 113 | + | let mut interval = tokio::time::interval_at(Instant::now() + period, period); | |
| 114 | + | interval.set_missed_tick_behavior(MissedTickBehavior::Delay); | |
| 115 | + | interval | |
| 116 | + | }) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /// Map a filesystem event to the work it implies. | |
| 120 | + | fn classify(event: notify::Result<Event>, index: &RepoIndex) -> Vec<Task> { | |
| 121 | + | let event = match event { | |
| 122 | + | Ok(event) => event, | |
| 123 | + | Err(e) => { | |
| 124 | + | tracing::warn!("watch error: {e}; rescanning"); | |
| 125 | + | return vec![Task::Rescan]; | |
| 126 | + | } | |
| 127 | + | }; | |
| 128 | + | if event.need_rescan() { | |
| 129 | + | return vec![Task::Rescan]; | |
| 130 | + | } | |
| 131 | + | if matches!(event.kind, EventKind::Access(_)) { | |
| 132 | + | return Vec::new(); | |
| 133 | + | } | |
| 134 | + | let mut tasks = Vec::new(); | |
| 135 | + | for path in &event.paths { | |
| 136 | + | if path.extension().is_some_and(|ext| ext == "lock") { | |
| 137 | + | continue; | |
| 138 | + | } | |
| 139 | + | let task = if let Some(git_dir) = path.ancestors().find(|a| index.git_dirs.contains_key(*a)) | |
| 140 | + | { | |
| 141 | + | Task::Repo(git_dir.to_path_buf()) | |
| 142 | + | } else if index | |
| 143 | + | .dirs | |
| 144 | + | .iter() | |
| 145 | + | .any(|d| d == path || Some(d.as_path()) == path.parent()) | |
| 146 | + | { | |
| 147 | + | Task::Rescan | |
| 148 | + | } else { | |
| 149 | + | continue; | |
| 150 | + | }; | |
| 151 | + | if !tasks.contains(&task) { | |
| 152 | + | tasks.push(task); | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | tasks | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /// Log a scan: a summary when something changed (always for the initial | |
| 159 | + | /// scan) and each warning once until it goes away. | |
| 160 | + | pub fn log_scan(stats: &ScanStats, index: &RepoIndex, seen: &mut BTreeSet<String>, initial: bool) { | |
| 161 | + | let ms = stats.elapsed.as_secs_f64() * 1000.0; | |
| 162 | + | if initial { | |
| 163 | + | tracing::info!( | |
| 164 | + | "indexed {} repositories ({} hidden) in {ms:.1} ms", | |
| 165 | + | stats.total, | |
| 166 | + | stats.hidden | |
| 167 | + | ); | |
| 168 | + | } else if stats.any_change() { | |
| 169 | + | tracing::info!( | |
| 170 | + | "rescan: {} repositories (+{} −{} ~{}) in {ms:.1} ms", | |
| 171 | + | stats.total, | |
| 172 | + | stats.added, | |
| 173 | + | stats.removed, | |
| 174 | + | stats.changed | |
| 175 | + | ); | |
| 176 | + | } else { | |
| 177 | + | tracing::debug!("rescan: no changes ({ms:.1} ms)"); | |
| 178 | + | } | |
| 179 | + | let current: BTreeSet<String> = index.warnings.iter().cloned().collect(); | |
| 180 | + | for warning in current.difference(seen) { | |
| 181 | + | tracing::warn!("{warning}"); | |
| 182 | + | } | |
| 183 | + | *seen = current; | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | /// The inotify watcher and the set of paths it currently watches. | |
| 187 | + | struct FsWatch { | |
| 188 | + | watcher: Option<RecommendedWatcher>, | |
| 189 | + | watched: HashMap<PathBuf, RecursiveMode>, | |
| 190 | + | warned_limit: bool, | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | impl FsWatch { | |
| 194 | + | fn new(state: &AppState, tx: mpsc::UnboundedSender<notify::Result<Event>>) -> Self { | |
| 195 | + | let watcher = if state.config.load().watch.enabled { | |
| 196 | + | match notify::recommended_watcher(move |event| { | |
| 197 | + | let _ = tx.send(event); | |
| 198 | + | }) { | |
| 199 | + | Ok(w) => Some(w), | |
| 200 | + | Err(e) => { | |
| 201 | + | tracing::warn!("cannot start file watcher ({e}); relying on periodic rescans"); | |
| 202 | + | None | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | } else { | |
| 206 | + | None | |
| 207 | + | }; | |
| 208 | + | FsWatch { | |
| 209 | + | watcher, | |
| 210 | + | watched: HashMap::new(), | |
| 211 | + | warned_limit: false, | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | /// Bring the watched set in line with the index. | |
| 216 | + | fn sync(&mut self, index: &RepoIndex) { | |
| 217 | + | let Some(watcher) = self.watcher.as_mut() else { | |
| 218 | + | return; | |
| 219 | + | }; | |
| 220 | + | let mut desired: HashMap<PathBuf, RecursiveMode> = HashMap::new(); | |
| 221 | + | for dir in &index.dirs { | |
| 222 | + | desired.insert(dir.clone(), RecursiveMode::NonRecursive); | |
| 223 | + | } | |
| 224 | + | for git_dir in index.git_dirs.keys() { | |
| 225 | + | desired.insert(git_dir.clone(), RecursiveMode::NonRecursive); | |
| 226 | + | desired.insert(git_dir.join("refs"), RecursiveMode::Recursive); | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | let stale: Vec<PathBuf> = self | |
| 230 | + | .watched | |
| 231 | + | .iter() | |
| 232 | + | .filter(|(path, mode)| desired.get(*path) != Some(*mode)) | |
| 233 | + | .map(|(path, _)| path.clone()) | |
| 234 | + | .collect(); | |
| 235 | + | for path in stale { | |
| 236 | + | let _ = watcher.unwatch(&path); | |
| 237 | + | self.watched.remove(&path); | |
| 238 | + | } | |
| 239 | + | for (path, mode) in desired { | |
| 240 | + | if self.watched.contains_key(&path) { | |
| 241 | + | continue; | |
| 242 | + | } | |
| 243 | + | match watcher.watch(&path, mode) { | |
| 244 | + | Ok(()) => { | |
| 245 | + | self.watched.insert(path, mode); | |
| 246 | + | } | |
| 247 | + | Err(e) => watch_failed(&mut self.warned_limit, &path, e), | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | fn watch_failed(warned_limit: &mut bool, path: &Path, e: notify::Error) { | |
| 254 | + | // ENOSPC from inotify_add_watch means the per-user watch limit. | |
| 255 | + | let limit = matches!(&e.kind, notify::ErrorKind::MaxFilesWatch) | |
| 256 | + | || matches!(&e.kind, notify::ErrorKind::Io(io) if io.raw_os_error() == Some(28)); | |
| 257 | + | if !limit { | |
| 258 | + | tracing::debug!("cannot watch {}: {e}", path.display()); | |
| 259 | + | } else if !*warned_limit { | |
| 260 | + | *warned_limit = true; | |
| 261 | + | tracing::warn!( | |
| 262 | + | "inotify watch limit reached; raise fs.inotify.max_user_watches \ | |
| 263 | + | (changes are still picked up by periodic rescans)" | |
| 264 | + | ); | |
| 265 | + | } | |
| 266 | + | } | |
| @@ -0,0 +1,12 @@ | |||
| 1 | + | //! Ferrenium: a fast, read-only Git repository browser. | |
| 2 | + | ||
| 3 | + | pub mod cache; | |
| 4 | + | pub mod config; | |
| 5 | + | pub mod examples; | |
| 6 | + | pub mod git; | |
| 7 | + | pub mod handlers; | |
| 8 | + | pub mod index; | |
| 9 | + | pub mod logging; | |
| 10 | + | pub mod render; | |
| 11 | + | pub mod server; | |
| 12 | + | pub mod state; | |
| @@ -0,0 +1,56 @@ | |||
| 1 | + | //! stderr logging. Under systemd (`JOURNAL_STREAM` set) timestamps and colors | |
| 2 | + | //! are dropped because journald adds its own. `RUST_LOG` overrides | |
| 3 | + | //! `log.level`; otherwise the level follows the config across reloads. | |
| 4 | + | ||
| 5 | + | use std::io::IsTerminal; | |
| 6 | + | use std::sync::OnceLock; | |
| 7 | + | ||
| 8 | + | use tracing_subscriber::prelude::*; | |
| 9 | + | use tracing_subscriber::{EnvFilter, Registry, reload}; | |
| 10 | + | ||
| 11 | + | use crate::config::Config; | |
| 12 | + | ||
| 13 | + | static HANDLE: OnceLock<reload::Handle<EnvFilter, Registry>> = OnceLock::new(); | |
| 14 | + | ||
| 15 | + | pub fn init(config: &Config) { | |
| 16 | + | let (filter, handle) = reload::Layer::new(filter_for(config)); | |
| 17 | + | let fmt = tracing_subscriber::fmt::layer() | |
| 18 | + | .with_writer(std::io::stderr) | |
| 19 | + | .with_target(false); | |
| 20 | + | let registry = tracing_subscriber::registry().with(filter); | |
| 21 | + | if std::env::var_os("JOURNAL_STREAM").is_some() { | |
| 22 | + | registry.with(fmt.without_time().with_ansi(false)).init(); | |
| 23 | + | } else { | |
| 24 | + | registry | |
| 25 | + | .with(fmt.with_ansi(std::io::stderr().is_terminal())) | |
| 26 | + | .init(); | |
| 27 | + | } | |
| 28 | + | let _ = HANDLE.set(handle); | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /// Apply `log.level` from a freshly loaded config. | |
| 32 | + | pub fn apply(config: &Config) { | |
| 33 | + | if rust_log().is_some() { | |
| 34 | + | return; | |
| 35 | + | } | |
| 36 | + | if let Some(handle) = HANDLE.get() { | |
| 37 | + | let _ = handle.reload(filter_for(config)); | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | fn rust_log() -> Option<String> { | |
| 42 | + | std::env::var("RUST_LOG") | |
| 43 | + | .ok() | |
| 44 | + | .filter(|v| !v.trim().is_empty()) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | fn filter_for(config: &Config) -> EnvFilter { | |
| 48 | + | if let Some(spec) = rust_log() { | |
| 49 | + | match EnvFilter::try_new(&spec) { | |
| 50 | + | Ok(filter) => return filter, | |
| 51 | + | Err(e) => eprintln!("ignoring invalid RUST_LOG {spec:?}: {e}"), | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | // Validated at config load time. | |
| 55 | + | EnvFilter::try_new(&config.log.level).unwrap_or_else(|_| EnvFilter::new("info")) | |
| 56 | + | } | |
| @@ -0,0 +1,183 @@ | |||
| 1 | + | use std::path::PathBuf; | |
| 2 | + | use std::process::ExitCode; | |
| 3 | + | ||
| 4 | + | use anyhow::Context; | |
| 5 | + | use clap::{Parser, Subcommand, ValueEnum}; | |
| 6 | + | ||
| 7 | + | use ferrenium::config::{self, Config, Listen}; | |
| 8 | + | use ferrenium::index::Indexer; | |
| 9 | + | use ferrenium::render::time; | |
| 10 | + | use ferrenium::state::{AppState, Overrides}; | |
| 11 | + | use ferrenium::{examples, logging, server}; | |
| 12 | + | ||
| 13 | + | #[derive(Parser)] | |
| 14 | + | #[command( | |
| 15 | + | name = "ferrenium", | |
| 16 | + | version, | |
| 17 | + | about = "A fast, read-only Git repository browser" | |
| 18 | + | )] | |
| 19 | + | struct Cli { | |
| 20 | + | #[command(subcommand)] | |
| 21 | + | command: Command, | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | #[derive(Subcommand)] | |
| 25 | + | enum Command { | |
| 26 | + | /// Run the server in the foreground (this is what systemd runs). | |
| 27 | + | Serve { | |
| 28 | + | /// Config file [default: /etc/ferrenium/ferrenium.toml, then | |
| 29 | + | /// $XDG_CONFIG_HOME/ferrenium/ferrenium.toml] | |
| 30 | + | #[arg(long, env = "FERRENIUM_CONFIG")] | |
| 31 | + | config: Option<PathBuf>, | |
| 32 | + | /// Override server.listen (HOST:PORT or unix:/path/to.sock). | |
| 33 | + | #[arg(long)] | |
| 34 | + | listen: Option<Listen>, | |
| 35 | + | }, | |
| 36 | + | /// Validate the config and report what would be served. | |
| 37 | + | Check { | |
| 38 | + | #[arg(long, env = "FERRENIUM_CONFIG")] | |
| 39 | + | config: Option<PathBuf>, | |
| 40 | + | }, | |
| 41 | + | /// Print a ready-to-use template to stdout. | |
| 42 | + | Example { | |
| 43 | + | #[arg(value_enum)] | |
| 44 | + | kind: ExampleKind, | |
| 45 | + | }, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | #[derive(Clone, Copy, ValueEnum)] | |
| 49 | + | enum ExampleKind { | |
| 50 | + | /// ferrenium.toml with every option. | |
| 51 | + | Config, | |
| 52 | + | /// Sandboxed systemd unit. | |
| 53 | + | Systemd, | |
| 54 | + | /// Caddy site block. | |
| 55 | + | Caddy, | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | fn main() -> ExitCode { | |
| 59 | + | let cli = Cli::parse(); | |
| 60 | + | let result = match cli.command { | |
| 61 | + | Command::Serve { config, listen } => serve(config, listen), | |
| 62 | + | Command::Check { config } => check(config), | |
| 63 | + | Command::Example { kind } => { | |
| 64 | + | print!( | |
| 65 | + | "{}", | |
| 66 | + | match kind { | |
| 67 | + | ExampleKind::Config => examples::CONFIG, | |
| 68 | + | ExampleKind::Systemd => examples::SYSTEMD, | |
| 69 | + | ExampleKind::Caddy => examples::CADDY, | |
| 70 | + | } | |
| 71 | + | ); | |
| 72 | + | Ok(()) | |
| 73 | + | } | |
| 74 | + | }; | |
| 75 | + | match result { | |
| 76 | + | Ok(()) => ExitCode::SUCCESS, | |
| 77 | + | Err(e) => { | |
| 78 | + | eprintln!("error: {e:#}"); | |
| 79 | + | ExitCode::FAILURE | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | fn serve(config_path: Option<PathBuf>, listen: Option<Listen>) -> anyhow::Result<()> { | |
| 85 | + | let overrides = Overrides { | |
| 86 | + | config_path: config::resolve_path(config_path.as_deref()), | |
| 87 | + | listen, | |
| 88 | + | }; | |
| 89 | + | let config = overrides.load()?; | |
| 90 | + | logging::init(&config); | |
| 91 | + | match &config.source { | |
| 92 | + | Some(path) => tracing::info!("config: {}", path.display()), | |
| 93 | + | None => tracing::warn!("no config file found; using built-in defaults"), | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | let state = AppState::new(config, overrides); | |
| 97 | + | tokio::runtime::Builder::new_multi_thread() | |
| 98 | + | .enable_all() | |
| 99 | + | .thread_name("ferrenium") | |
| 100 | + | .build() | |
| 101 | + | .context("cannot start async runtime")? | |
| 102 | + | .block_on(server::run(state)) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | fn check(config_path: Option<PathBuf>) -> anyhow::Result<()> { | |
| 106 | + | let path = config::resolve_path(config_path.as_deref()); | |
| 107 | + | let config = Config::load(path.as_deref())?; | |
| 108 | + | let mut warnings = Vec::new(); | |
| 109 | + | ||
| 110 | + | let source = match &config.source { | |
| 111 | + | Some(p) => p.display().to_string(), | |
| 112 | + | None => { | |
| 113 | + | warnings.push("no config file found; built-in defaults apply".to_string()); | |
| 114 | + | "(built-in defaults)".to_string() | |
| 115 | + | } | |
| 116 | + | }; | |
| 117 | + | println!("config {source}"); | |
| 118 | + | println!("listen {}", config.server.listen); | |
| 119 | + | println!("base_path {}", config.server.base_path); | |
| 120 | + | if let Some(url) = &config.server.base_url { | |
| 121 | + | println!("base_url {url}"); | |
| 122 | + | } | |
| 123 | + | println!( | |
| 124 | + | "scan_path {} (max depth {})", | |
| 125 | + | config.repos.scan_path.display(), | |
| 126 | + | config.repos.max_depth | |
| 127 | + | ); | |
| 128 | + | if !config.repos.scan_path.is_dir() { | |
| 129 | + | warnings.push(format!( | |
| 130 | + | "repos.scan_path {} is not a readable directory", | |
| 131 | + | config.repos.scan_path.display() | |
| 132 | + | )); | |
| 133 | + | } | |
| 134 | + | if config.server.base_url.is_none() { | |
| 135 | + | warnings.push("server.base_url is not set; feeds and clone URLs will be relative".into()); | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | let indexer = Indexer::new(); | |
| 139 | + | let stats = indexer.full_scan(&config); | |
| 140 | + | let index = indexer.load(); | |
| 141 | + | println!( | |
| 142 | + | "\nrepositories {} visible, {} hidden (scanned in {:.1} ms)", | |
| 143 | + | stats.total, | |
| 144 | + | stats.hidden, | |
| 145 | + | stats.elapsed.as_secs_f64() * 1000.0 | |
| 146 | + | ); | |
| 147 | + | if !index.repos.is_empty() { | |
| 148 | + | let width = index | |
| 149 | + | .repos | |
| 150 | + | .keys() | |
| 151 | + | .map(|n| n.chars().count()) | |
| 152 | + | .max() | |
| 153 | + | .unwrap_or(0); | |
| 154 | + | let now = time::now(); | |
| 155 | + | for repo in index.repos.values() { | |
| 156 | + | let m = &repo.meta; | |
| 157 | + | println!( | |
| 158 | + | " {:<width$} {:<12} {:>3} br {:>3} tags {:<14} {}", | |
| 159 | + | repo.name, | |
| 160 | + | m.default_branch.as_deref().unwrap_or("-"), | |
| 161 | + | m.branches, | |
| 162 | + | m.tags, | |
| 163 | + | m.last_activity | |
| 164 | + | .map_or_else(|| "empty".into(), |t| time::relative(t, now)), | |
| 165 | + | m.section | |
| 166 | + | .as_deref() | |
| 167 | + | .map(|s| format!("[{s}]")) | |
| 168 | + | .unwrap_or_default(), | |
| 169 | + | ); | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | for name in &index.hidden { | |
| 173 | + | println!(" {name} (hidden)"); | |
| 174 | + | } | |
| 175 | + | warnings.extend(index.warnings.iter().cloned()); | |
| 176 | + | ||
| 177 | + | println!(); | |
| 178 | + | for w in &warnings { | |
| 179 | + | println!("warning: {w}"); | |
| 180 | + | } | |
| 181 | + | println!("ok"); | |
| 182 | + | Ok(()) | |
| 183 | + | } | |
| @@ -0,0 +1,252 @@ | |||
| 1 | + | //! Syntax highlighting into monochrome CSS classes. | |
| 2 | + | //! | |
| 3 | + | //! The whole file is parsed in one pass (so multi-line strings and comments | |
| 4 | + | //! are right), but the output is split into lines whose `<span>`s are | |
| 5 | + | //! balanced: each line can be put in its own table row. | |
| 6 | + | //! | |
| 7 | + | //! Scopes collapse to a handful of classes (see `syntax.css`): | |
| 8 | + | //! `hl-k` keywords/storage, `hl-d` names/numbers/constants, `hl-s` | |
| 9 | + | //! strings/punctuation/operators, `hl-c` comments, `hl-x` invalid. | |
| 10 | + | ||
| 11 | + | use std::sync::OnceLock; | |
| 12 | + | ||
| 13 | + | use syntect::parsing::{ParseState, Scope, ScopeStack, SyntaxReference, SyntaxSet}; | |
| 14 | + | use syntect::util::LinesWithEndings; | |
| 15 | + | ||
| 16 | + | use super::escape_html; | |
| 17 | + | ||
| 18 | + | static SYNTAXES: OnceLock<SyntaxSet> = OnceLock::new(); | |
| 19 | + | ||
| 20 | + | /// The embedded syntax set (bat's ~200 syntaxes). Loading takes a few tens | |
| 21 | + | /// of milliseconds; call once at startup to keep it off the request path. | |
| 22 | + | pub fn syntaxes() -> &'static SyntaxSet { | |
| 23 | + | SYNTAXES.get_or_init(two_face::syntax::extra_newlines) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /// Pick a syntax from the file name, falling back to the first line | |
| 27 | + | /// (shebangs, modelines). `None` means plain text. | |
| 28 | + | pub fn detect(path: &str, first_line: &str) -> Option<&'static SyntaxReference> { | |
| 29 | + | let set = syntaxes(); | |
| 30 | + | let name = path.rsplit('/').next().unwrap_or(path); | |
| 31 | + | let found = set | |
| 32 | + | .find_syntax_by_extension(name) | |
| 33 | + | .or_else(|| { | |
| 34 | + | let (_, ext) = name.rsplit_once('.')?; | |
| 35 | + | set.find_syntax_by_extension(ext) | |
| 36 | + | .or_else(|| set.find_syntax_by_extension(&ext.to_ascii_lowercase())) | |
| 37 | + | }) | |
| 38 | + | .or_else(|| set.find_syntax_by_first_line(first_line)); | |
| 39 | + | found.filter(|s| s.name != "Plain Text") | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// Syntax for a Markdown code fence info string (`rust`, `sh`, `py`, …). | |
| 43 | + | pub fn for_token(token: &str) -> Option<&'static SyntaxReference> { | |
| 44 | + | let token = token.split([' ', ',', '{']).next()?.trim(); | |
| 45 | + | if token.is_empty() { | |
| 46 | + | return None; | |
| 47 | + | } | |
| 48 | + | syntaxes() | |
| 49 | + | .find_syntax_by_token(token) | |
| 50 | + | .filter(|s| s.name != "Plain Text") | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | struct Selectors { | |
| 54 | + | comment: Scope, | |
| 55 | + | invalid: Scope, | |
| 56 | + | string: Scope, | |
| 57 | + | punctuation: Scope, | |
| 58 | + | operator: Scope, | |
| 59 | + | keyword: Scope, | |
| 60 | + | storage: Scope, | |
| 61 | + | display: [Scope; 6], | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | fn selectors() -> &'static Selectors { | |
| 65 | + | static SELECTORS: OnceLock<Selectors> = OnceLock::new(); | |
| 66 | + | SELECTORS.get_or_init(|| { | |
| 67 | + | let s = |name: &str| Scope::new(name).expect("valid scope"); | |
| 68 | + | Selectors { | |
| 69 | + | comment: s("comment"), | |
| 70 | + | invalid: s("invalid"), | |
| 71 | + | string: s("string"), | |
| 72 | + | punctuation: s("punctuation"), | |
| 73 | + | operator: s("keyword.operator"), | |
| 74 | + | keyword: s("keyword"), | |
| 75 | + | storage: s("storage"), | |
| 76 | + | display: [ | |
| 77 | + | s("entity.name"), | |
| 78 | + | s("support.function"), | |
| 79 | + | s("support.type"), | |
| 80 | + | s("support.class"), | |
| 81 | + | s("constant.numeric"), | |
| 82 | + | s("constant.language"), | |
| 83 | + | ], | |
| 84 | + | } | |
| 85 | + | }) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// Class for text under `stack`: comments win outright, otherwise the | |
| 89 | + | /// innermost scope with a class decides. | |
| 90 | + | fn class_for(stack: &[Scope]) -> Option<&'static str> { | |
| 91 | + | let sel = selectors(); | |
| 92 | + | if stack.iter().any(|s| sel.comment.is_prefix_of(*s)) { | |
| 93 | + | return Some("hl-c"); | |
| 94 | + | } | |
| 95 | + | for &scope in stack.iter().rev() { | |
| 96 | + | if sel.invalid.is_prefix_of(scope) { | |
| 97 | + | return Some("hl-x"); | |
| 98 | + | } | |
| 99 | + | if sel.operator.is_prefix_of(scope) | |
| 100 | + | || sel.punctuation.is_prefix_of(scope) | |
| 101 | + | || sel.string.is_prefix_of(scope) | |
| 102 | + | { | |
| 103 | + | return Some("hl-s"); | |
| 104 | + | } | |
| 105 | + | if sel.keyword.is_prefix_of(scope) || sel.storage.is_prefix_of(scope) { | |
| 106 | + | return Some("hl-k"); | |
| 107 | + | } | |
| 108 | + | if sel.display.iter().any(|d| d.is_prefix_of(scope)) { | |
| 109 | + | return Some("hl-d"); | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | None | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// Appends text to a line, opening/closing spans only when the class changes. | |
| 116 | + | struct LineWriter { | |
| 117 | + | out: String, | |
| 118 | + | open: Option<&'static str>, | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | impl LineWriter { | |
| 122 | + | fn push(&mut self, text: &str, class: Option<&'static str>) { | |
| 123 | + | let text = text.trim_end_matches(['\n', '\r']); | |
| 124 | + | if text.is_empty() { | |
| 125 | + | return; | |
| 126 | + | } | |
| 127 | + | if class != self.open { | |
| 128 | + | if self.open.is_some() { | |
| 129 | + | self.out.push_str("</span>"); | |
| 130 | + | } | |
| 131 | + | if let Some(class) = class { | |
| 132 | + | self.out.push_str("<span class=\""); | |
| 133 | + | self.out.push_str(class); | |
| 134 | + | self.out.push_str("\">"); | |
| 135 | + | } | |
| 136 | + | self.open = class; | |
| 137 | + | } | |
| 138 | + | escape_html(&mut self.out, text); | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | fn finish(mut self) -> String { | |
| 142 | + | if self.open.is_some() { | |
| 143 | + | self.out.push_str("</span>"); | |
| 144 | + | } | |
| 145 | + | self.out | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | /// Highlight `text` and return one HTML fragment per line. | |
| 150 | + | /// | |
| 151 | + | /// If the grammar fails part-way, the remaining lines are emitted plain. | |
| 152 | + | pub fn lines(text: &str, syntax: &SyntaxReference) -> Vec<String> { | |
| 153 | + | let set = syntaxes(); | |
| 154 | + | let mut state = ParseState::new(syntax); | |
| 155 | + | let mut stack = ScopeStack::new(); | |
| 156 | + | let mut failed = false; | |
| 157 | + | let mut out = Vec::new(); | |
| 158 | + | for line in LinesWithEndings::from(text) { | |
| 159 | + | let ops = if failed { | |
| 160 | + | None | |
| 161 | + | } else { | |
| 162 | + | state.parse_line(line, set).ok() | |
| 163 | + | }; | |
| 164 | + | let Some(ops) = ops else { | |
| 165 | + | failed = true; | |
| 166 | + | out.push(plain_line(line)); | |
| 167 | + | continue; | |
| 168 | + | }; | |
| 169 | + | let mut writer = LineWriter { | |
| 170 | + | out: String::with_capacity(line.len() * 2), | |
| 171 | + | open: None, | |
| 172 | + | }; | |
| 173 | + | let mut pos = 0; | |
| 174 | + | for (at, op) in ops { | |
| 175 | + | let at = at.min(line.len()); | |
| 176 | + | if at > pos { | |
| 177 | + | writer.push(&line[pos..at], class_for(stack.as_slice())); | |
| 178 | + | pos = at; | |
| 179 | + | } | |
| 180 | + | if stack.apply(&op).is_err() { | |
| 181 | + | failed = true; | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | writer.push(&line[pos..], class_for(stack.as_slice())); | |
| 185 | + | out.push(writer.finish()); | |
| 186 | + | } | |
| 187 | + | out | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /// Escaped lines without highlighting. | |
| 191 | + | pub fn plain_lines(text: &str) -> Vec<String> { | |
| 192 | + | LinesWithEndings::from(text).map(plain_line).collect() | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | fn plain_line(line: &str) -> String { | |
| 196 | + | let mut out = String::with_capacity(line.len()); | |
| 197 | + | escape_html(&mut out, line.trim_end_matches(['\n', '\r'])); | |
| 198 | + | out | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | #[cfg(test)] | |
| 202 | + | mod tests { | |
| 203 | + | use super::*; | |
| 204 | + | ||
| 205 | + | #[test] | |
| 206 | + | fn detects_by_name_extension_and_first_line() { | |
| 207 | + | assert_eq!(detect("src/main.rs", "").unwrap().name, "Rust"); | |
| 208 | + | assert_eq!(detect("Makefile", "").unwrap().name, "Makefile"); | |
| 209 | + | assert_eq!(detect("a/b.PY", "").unwrap().name, "Python"); | |
| 210 | + | assert_eq!( | |
| 211 | + | detect("run", "#!/bin/bash").unwrap().name, | |
| 212 | + | "Bourne Again Shell (bash)" | |
| 213 | + | ); | |
| 214 | + | assert!(detect("notes.txt", "hello").is_none()); | |
| 215 | + | assert_eq!(for_token("rust").unwrap().name, "Rust"); | |
| 216 | + | assert_eq!(for_token("sh").unwrap().name, "Bourne Again Shell (bash)"); | |
| 217 | + | assert!(for_token("").is_none()); | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | #[test] | |
| 221 | + | fn spans_are_balanced_per_line_across_multiline_comments() { | |
| 222 | + | let src = "fn main() {\n /* a\n b */ let x = \"<s>\";\n}\n"; | |
| 223 | + | let lines = lines(src, detect("x.rs", "").unwrap()); | |
| 224 | + | assert_eq!(lines.len(), 4); | |
| 225 | + | for line in &lines { | |
| 226 | + | assert_eq!( | |
| 227 | + | line.matches("<span").count(), | |
| 228 | + | line.matches("</span>").count(), | |
| 229 | + | "{line}" | |
| 230 | + | ); | |
| 231 | + | } | |
| 232 | + | assert!( | |
| 233 | + | lines[0].contains(r#"<span class="hl-k">fn</span>"#), | |
| 234 | + | "{}", | |
| 235 | + | lines[0] | |
| 236 | + | ); | |
| 237 | + | // The comment's second line is still a comment. | |
| 238 | + | assert!( | |
| 239 | + | lines[2].starts_with(r#"<span class="hl-c"> b */"#), | |
| 240 | + | "{}", | |
| 241 | + | lines[2] | |
| 242 | + | ); | |
| 243 | + | // Escaped string contents. | |
| 244 | + | assert!(lines[2].contains("<s>"), "{}", lines[2]); | |
| 245 | + | assert!(!lines.iter().any(|l| l.contains('\n'))); | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | #[test] | |
| 249 | + | fn plain_lines_escape() { | |
| 250 | + | assert_eq!(plain_lines("a<b\r\n\nc"), vec!["a<b", "", "c"]); | |
| 251 | + | } | |
| 252 | + | } | |
| @@ -0,0 +1,314 @@ | |||
| 1 | + | //! Markdown (GFM) to sanitized HTML. | |
| 2 | + | //! | |
| 3 | + | //! comrak renders with raw HTML allowed; ammonia then sanitizes everything, | |
| 4 | + | //! including that raw HTML. Relative links and images (from Markdown or | |
| 5 | + | //! raw HTML alike) are rewritten to point into the repository: links to the | |
| 6 | + | //! blob view, images to the raw endpoint. | |
| 7 | + | ||
| 8 | + | use std::borrow::Cow; | |
| 9 | + | use std::collections::{HashMap, HashSet}; | |
| 10 | + | use std::fmt; | |
| 11 | + | ||
| 12 | + | use comrak::adapters::SyntaxHighlighterAdapter; | |
| 13 | + | use comrak::options::Plugins; | |
| 14 | + | use comrak::{Options, markdown_to_html_with_plugins}; | |
| 15 | + | ||
| 16 | + | use super::{encode_bytes, escape_html, highlight, percent_decode}; | |
| 17 | + | ||
| 18 | + | /// Where relative URLs in a document resolve to. | |
| 19 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 20 | + | pub struct Links { | |
| 21 | + | /// URL of the repository, without a trailing slash (`/git/forks/foo`). | |
| 22 | + | pub repo_url: String, | |
| 23 | + | /// Revision as it appears in URLs (already percent-encoded). | |
| 24 | + | pub rev: String, | |
| 25 | + | /// Directory of the document inside the tree (no slashes at the ends; | |
| 26 | + | /// empty for the root). | |
| 27 | + | pub dir: Vec<u8>, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | impl Links { | |
| 31 | + | /// Cache-key fragment identifying everything the output depends on. | |
| 32 | + | pub fn key(&self) -> String { | |
| 33 | + | format!( | |
| 34 | + | "{}\0{}\0{}", | |
| 35 | + | self.repo_url, | |
| 36 | + | self.rev, | |
| 37 | + | String::from_utf8_lossy(&self.dir) | |
| 38 | + | ) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// Is this a Markdown file (by name)? | |
| 43 | + | pub fn is_markdown(path: &str) -> bool { | |
| 44 | + | let lower = path.to_ascii_lowercase(); | |
| 45 | + | [".md", ".markdown", ".mdown", ".mkd", ".mkdn"] | |
| 46 | + | .iter() | |
| 47 | + | .any(|ext| lower.ends_with(ext)) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | pub fn render(markdown: &str, links: &Links) -> String { | |
| 51 | + | let mut options = Options::default(); | |
| 52 | + | let ext = &mut options.extension; | |
| 53 | + | ext.strikethrough = true; | |
| 54 | + | ext.table = true; | |
| 55 | + | ext.autolink = true; | |
| 56 | + | ext.tasklist = true; | |
| 57 | + | ext.footnotes = true; | |
| 58 | + | ext.alerts = true; | |
| 59 | + | ext.header_id_prefix = Some(String::new()); | |
| 60 | + | ext.front_matter_delimiter = Some("---".into()); | |
| 61 | + | options.render.r#unsafe = true; | |
| 62 | + | options.render.github_pre_lang = false; | |
| 63 | + | ||
| 64 | + | let mut plugins = Plugins::default(); | |
| 65 | + | let highlighter = FenceHighlighter; | |
| 66 | + | plugins.render.codefence_syntax_highlighter = Some(&highlighter); | |
| 67 | + | let html = markdown_to_html_with_plugins(markdown, &options, &plugins); | |
| 68 | + | sanitize(&html, links.clone()) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// Plain text (non-Markdown READMEs) as an escaped `<pre>`. | |
| 72 | + | pub fn render_plain(text: &str) -> String { | |
| 73 | + | let mut out = String::from("<pre class=\"plain\">"); | |
| 74 | + | escape_html(&mut out, text); | |
| 75 | + | out.push_str("</pre>"); | |
| 76 | + | out | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | fn sanitize(html: &str, links: Links) -> String { | |
| 80 | + | let mut builder = ammonia::Builder::default(); | |
| 81 | + | builder | |
| 82 | + | .add_tags(["input"]) | |
| 83 | + | .add_tag_attributes("input", ["checked"]) | |
| 84 | + | .set_tag_attribute_value("input", "type", "checkbox") | |
| 85 | + | .set_tag_attribute_value("input", "disabled", "") | |
| 86 | + | .add_generic_attributes(["id", "align"]) | |
| 87 | + | .add_tag_attributes("a", ["aria-hidden"]) | |
| 88 | + | .add_tag_attributes("img", ["title"]) | |
| 89 | + | .add_tag_attributes("ol", ["start"]) | |
| 90 | + | .add_tag_attributes("section", ["data-footnotes"]) | |
| 91 | + | .add_tags(["section"]) | |
| 92 | + | .link_rel(Some("noopener noreferrer nofollow")) | |
| 93 | + | .allowed_classes(allowed_classes()) | |
| 94 | + | .url_relative(ammonia::UrlRelative::PassThrough) | |
| 95 | + | .attribute_filter(move |element, attribute, value| { | |
| 96 | + | match (element, attribute) { | |
| 97 | + | ("a", "href") => rewrite(&links, value, "blob"), | |
| 98 | + | ("img", "src") => rewrite(&links, value, "raw"), | |
| 99 | + | _ => None, | |
| 100 | + | } | |
| 101 | + | .map_or(Some(Cow::Borrowed(value)), |v| Some(Cow::Owned(v))) | |
| 102 | + | }); | |
| 103 | + | builder.clean(html).to_string() | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | fn allowed_classes() -> HashMap<&'static str, HashSet<&'static str>> { | |
| 107 | + | let set = |items: &[&'static str]| items.iter().copied().collect::<HashSet<_>>(); | |
| 108 | + | let alerts = [ | |
| 109 | + | "markdown-alert", | |
| 110 | + | "markdown-alert-note", | |
| 111 | + | "markdown-alert-tip", | |
| 112 | + | "markdown-alert-important", | |
| 113 | + | "markdown-alert-warning", | |
| 114 | + | "markdown-alert-caution", | |
| 115 | + | ]; | |
| 116 | + | HashMap::from([ | |
| 117 | + | ("span", set(&["hl-k", "hl-d", "hl-s", "hl-c", "hl-x"])), | |
| 118 | + | ("div", set(&alerts)), | |
| 119 | + | ("p", set(&["markdown-alert-title"])), | |
| 120 | + | ("a", set(&["anchor"])), | |
| 121 | + | ("section", set(&["footnotes"])), | |
| 122 | + | ("ul", set(&["contains-task-list"])), | |
| 123 | + | ("li", set(&["task-list-item"])), | |
| 124 | + | ]) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | /// `scheme:` per the URL spec, before any `/`, `?` or `#`. | |
| 128 | + | fn has_scheme(url: &str) -> bool { | |
| 129 | + | let Some(colon) = url.find(':') else { | |
| 130 | + | return false; | |
| 131 | + | }; | |
| 132 | + | let scheme = &url[..colon]; | |
| 133 | + | scheme | |
| 134 | + | .chars() | |
| 135 | + | .next() | |
| 136 | + | .is_some_and(|c| c.is_ascii_alphabetic()) | |
| 137 | + | && scheme | |
| 138 | + | .chars() | |
| 139 | + | .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /// Rewrite a repository-relative URL; `None` leaves the value unchanged. | |
| 143 | + | fn rewrite(links: &Links, url: &str, view: &str) -> Option<String> { | |
| 144 | + | if url.is_empty() || url.starts_with('#') || url.starts_with("//") || has_scheme(url) { | |
| 145 | + | return None; | |
| 146 | + | } | |
| 147 | + | let (rest, fragment) = match url.split_once('#') { | |
| 148 | + | Some((rest, f)) => (rest, Some(f)), | |
| 149 | + | None => (url, None), | |
| 150 | + | }; | |
| 151 | + | let path = rest.split_once('?').map_or(rest, |(p, _)| p); | |
| 152 | + | let path = percent_decode(path); | |
| 153 | + | ||
| 154 | + | let mut parts: Vec<&[u8]> = Vec::new(); | |
| 155 | + | if !path.starts_with(b"/") { | |
| 156 | + | parts.extend(links.dir.split(|b| *b == b'/').filter(|p| !p.is_empty())); | |
| 157 | + | } | |
| 158 | + | for part in path.split(|b| *b == b'/') { | |
| 159 | + | match part { | |
| 160 | + | b"" | b"." => {} | |
| 161 | + | b".." => { | |
| 162 | + | parts.pop(); | |
| 163 | + | } | |
| 164 | + | part => parts.push(part), | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | let joined = parts.join(&b'/'); | |
| 168 | + | let mut out = if joined.is_empty() { | |
| 169 | + | format!("{}/tree/{}", links.repo_url, links.rev) | |
| 170 | + | } else { | |
| 171 | + | format!( | |
| 172 | + | "{}/{view}/{}/{}", | |
| 173 | + | links.repo_url, | |
| 174 | + | links.rev, | |
| 175 | + | encode_bytes(&joined) | |
| 176 | + | ) | |
| 177 | + | }; | |
| 178 | + | if let Some(fragment) = fragment { | |
| 179 | + | out.push('#'); | |
| 180 | + | out.push_str(fragment); | |
| 181 | + | } | |
| 182 | + | Some(out) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | /// Highlights fenced code blocks with the same classes as the blob view. | |
| 186 | + | struct FenceHighlighter; | |
| 187 | + | ||
| 188 | + | impl SyntaxHighlighterAdapter for FenceHighlighter { | |
| 189 | + | fn write_highlighted( | |
| 190 | + | &self, | |
| 191 | + | output: &mut dyn fmt::Write, | |
| 192 | + | lang: Option<&str>, | |
| 193 | + | code: &str, | |
| 194 | + | ) -> fmt::Result { | |
| 195 | + | match lang.and_then(highlight::for_token) { | |
| 196 | + | Some(syntax) => output.write_str(&highlight::lines(code, syntax).join("\n")), | |
| 197 | + | None => { | |
| 198 | + | let mut escaped = String::with_capacity(code.len()); | |
| 199 | + | escape_html(&mut escaped, code.trim_end_matches('\n')); | |
| 200 | + | output.write_str(&escaped) | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | fn write_pre_tag( | |
| 206 | + | &self, | |
| 207 | + | output: &mut dyn fmt::Write, | |
| 208 | + | _attributes: HashMap<&'static str, Cow<'_, str>>, | |
| 209 | + | ) -> fmt::Result { | |
| 210 | + | output.write_str("<pre>") | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | fn write_code_tag( | |
| 214 | + | &self, | |
| 215 | + | output: &mut dyn fmt::Write, | |
| 216 | + | _attributes: HashMap<&'static str, Cow<'_, str>>, | |
| 217 | + | ) -> fmt::Result { | |
| 218 | + | output.write_str("<code>") | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | #[cfg(test)] | |
| 223 | + | mod tests { | |
| 224 | + | use super::*; | |
| 225 | + | ||
| 226 | + | fn links(dir: &str) -> Links { | |
| 227 | + | Links { | |
| 228 | + | repo_url: "/r".into(), | |
| 229 | + | rev: "main".into(), | |
| 230 | + | dir: dir.as_bytes().to_vec(), | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | #[test] | |
| 235 | + | fn strips_dangerous_html() { | |
| 236 | + | let html = render( | |
| 237 | + | "<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>\n\n\ | |
| 238 | + | [x](javascript:alert(1)) <a href=\"data:text/html,hi\">d</a>\n\n\ | |
| 239 | + | <iframe src=\"https://evil\"></iframe><style>body{}</style>\n\n\ | |
| 240 | + | <div style=\"color:red\" onclick=\"x()\">s</div>", | |
| 241 | + | &links(""), | |
| 242 | + | ); | |
| 243 | + | for bad in [ | |
| 244 | + | "<script", | |
| 245 | + | "onerror", | |
| 246 | + | "javascript:", | |
| 247 | + | "data:text", | |
| 248 | + | "<iframe", | |
| 249 | + | "<style", | |
| 250 | + | "onclick", | |
| 251 | + | "style=", | |
| 252 | + | ] { | |
| 253 | + | assert!(!html.contains(bad), "{bad} survived: {html}"); | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | #[test] | |
| 258 | + | fn rewrites_relative_urls() { | |
| 259 | + | let html = render( | |
| 260 | + | "[a](docs/guide.md#install) [b](../up.md) [c](/root.md) [d](https://x.org/y) \ | |
| 261 | + | [e](#frag) [f](./)  <img src=\"pic%20s.svg\"> \ | |
| 262 | + | <a href=\"sub/\">raw</a>", | |
| 263 | + | &links("docs"), | |
| 264 | + | ); | |
| 265 | + | assert!( | |
| 266 | + | html.contains(r#"href="/r/blob/main/docs/docs/guide.md#install""#), | |
| 267 | + | "{html}" | |
| 268 | + | ); | |
| 269 | + | assert!(html.contains(r#"href="/r/blob/main/up.md""#), "{html}"); | |
| 270 | + | assert!(html.contains(r#"href="/r/blob/main/root.md""#), "{html}"); | |
| 271 | + | assert!(html.contains(r#"href="https://x.org/y""#), "{html}"); | |
| 272 | + | assert!(html.contains(r##"href="#frag""##), "{html}"); | |
| 273 | + | assert!(html.contains(r#"href="/r/blob/main/docs""#), "{html}"); | |
| 274 | + | assert!( | |
| 275 | + | html.contains(r#"src="/r/raw/main/docs/img/logo.png""#), | |
| 276 | + | "{html}" | |
| 277 | + | ); | |
| 278 | + | assert!( | |
| 279 | + | html.contains(r#"src="/r/raw/main/docs/pic%20s.svg""#), | |
| 280 | + | "{html}" | |
| 281 | + | ); | |
| 282 | + | assert!(html.contains(r#"href="/r/blob/main/docs/sub""#), "{html}"); | |
| 283 | + | // `..` can't escape the repository root. | |
| 284 | + | assert_eq!( | |
| 285 | + | rewrite(&links(""), "../../x", "blob").unwrap(), | |
| 286 | + | "/r/blob/main/x" | |
| 287 | + | ); | |
| 288 | + | assert_eq!(rewrite(&links(""), "..", "blob").unwrap(), "/r/tree/main"); | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | #[test] | |
| 292 | + | fn gfm_features_survive_sanitizing() { | |
| 293 | + | let html = render( | |
| 294 | + | "# Title\n\n| a | b |\n|---|--:|\n| 1 | 2 |\n\n- [x] done\n- [ ] todo\n\n\ | |
| 295 | + | > [!NOTE]\n> careful\n\nfoot[^1]\n\n[^1]: note\n\n```rust\nfn x() {}\n```\n", | |
| 296 | + | &links(""), | |
| 297 | + | ); | |
| 298 | + | assert!(html.contains("<table>"), "{html}"); | |
| 299 | + | assert!(html.contains(r#"type="checkbox""#), "{html}"); | |
| 300 | + | assert!(html.contains("markdown-alert-note"), "{html}"); | |
| 301 | + | assert!(html.contains(r#"id="title""#), "{html}"); | |
| 302 | + | assert!(html.contains(r#"<span class="hl-k">fn</span>"#), "{html}"); | |
| 303 | + | assert!(html.contains("footnote"), "{html}"); | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | #[test] | |
| 307 | + | fn scheme_detection() { | |
| 308 | + | assert!(has_scheme("https://x")); | |
| 309 | + | assert!(has_scheme("mailto:a@b")); | |
| 310 | + | assert!(!has_scheme("a/b:c")); | |
| 311 | + | assert!(!has_scheme("x.md")); | |
| 312 | + | assert!(!has_scheme(":x")); | |
| 313 | + | } | |
| 314 | + | } | |
| @@ -0,0 +1,115 @@ | |||
| 1 | + | //! Presentation helpers shared by handlers and templates. | |
| 2 | + | ||
| 3 | + | pub mod highlight; | |
| 4 | + | pub mod markdown; | |
| 5 | + | pub mod time; | |
| 6 | + | ||
| 7 | + | use std::fmt::Write; | |
| 8 | + | ||
| 9 | + | /// Percent-encode a slash-separated path for use in a URL, keeping `/`. | |
| 10 | + | pub fn encode_path(path: &str) -> String { | |
| 11 | + | encode_bytes(path.as_bytes()) | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | /// [`encode_path`] for paths that may not be UTF-8 (git tree paths). | |
| 15 | + | pub fn encode_bytes(path: &[u8]) -> String { | |
| 16 | + | let mut out = String::with_capacity(path.len()); | |
| 17 | + | for &b in path { | |
| 18 | + | if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'/') { | |
| 19 | + | out.push(b as char); | |
| 20 | + | } else { | |
| 21 | + | let _ = write!(out, "%{b:02X}"); | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | out | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | /// Decode `%XX` escapes (invalid escapes are kept literally). | |
| 28 | + | pub fn percent_decode(s: &str) -> Vec<u8> { | |
| 29 | + | let bytes = s.as_bytes(); | |
| 30 | + | let mut out = Vec::with_capacity(bytes.len()); | |
| 31 | + | let mut i = 0; | |
| 32 | + | while i < bytes.len() { | |
| 33 | + | let hex = |b: u8| (b as char).to_digit(16); | |
| 34 | + | if bytes[i] == b'%' | |
| 35 | + | && let (Some(&h), Some(&l)) = (bytes.get(i + 1), bytes.get(i + 2)) | |
| 36 | + | && let (Some(h), Some(l)) = (hex(h), hex(l)) | |
| 37 | + | { | |
| 38 | + | out.push((h * 16 + l) as u8); | |
| 39 | + | i += 3; | |
| 40 | + | } else { | |
| 41 | + | out.push(bytes[i]); | |
| 42 | + | i += 1; | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | out | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// Append `text` to `out` with `&<>"'` escaped. | |
| 49 | + | pub fn escape_html(out: &mut String, text: &str) { | |
| 50 | + | for c in text.chars() { | |
| 51 | + | match c { | |
| 52 | + | '&' => out.push_str("&"), | |
| 53 | + | '<' => out.push_str("<"), | |
| 54 | + | '>' => out.push_str(">"), | |
| 55 | + | '"' => out.push_str("""), | |
| 56 | + | '\'' => out.push_str("'"), | |
| 57 | + | c => out.push(c), | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// "12.4 KB" (binary multiples, conventional labels). | |
| 63 | + | pub fn human_size(bytes: u64) -> String { | |
| 64 | + | const UNITS: [&str; 4] = ["KB", "MB", "GB", "TB"]; | |
| 65 | + | if bytes < 1024 { | |
| 66 | + | return format!("{bytes} B"); | |
| 67 | + | } | |
| 68 | + | let mut value = bytes as f64 / 1024.0; | |
| 69 | + | let mut unit = 0; | |
| 70 | + | while value >= 1024.0 && unit < UNITS.len() - 1 { | |
| 71 | + | value /= 1024.0; | |
| 72 | + | unit += 1; | |
| 73 | + | } | |
| 74 | + | if value >= 100.0 { | |
| 75 | + | format!("{value:.0} {}", UNITS[unit]) | |
| 76 | + | } else { | |
| 77 | + | format!("{value:.1} {}", UNITS[unit]) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | #[cfg(test)] | |
| 82 | + | mod tests { | |
| 83 | + | use super::*; | |
| 84 | + | ||
| 85 | + | #[test] | |
| 86 | + | fn percent_decoding() { | |
| 87 | + | assert_eq!(percent_decode("a%20b%2Fc"), b"a b/c"); | |
| 88 | + | assert_eq!(percent_decode("%C3%BC"), "ü".as_bytes()); | |
| 89 | + | assert_eq!(percent_decode("100%"), b"100%"); | |
| 90 | + | assert_eq!(percent_decode("%zz%4"), b"%zz%4"); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | #[test] | |
| 94 | + | fn sizes() { | |
| 95 | + | assert_eq!(human_size(0), "0 B"); | |
| 96 | + | assert_eq!(human_size(1023), "1023 B"); | |
| 97 | + | assert_eq!(human_size(12_698), "12.4 KB"); | |
| 98 | + | assert_eq!(human_size(300 * 1024), "300 KB"); | |
| 99 | + | assert_eq!(human_size(5 * 1024 * 1024), "5.0 MB"); | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | #[test] | |
| 103 | + | fn escaping() { | |
| 104 | + | let mut s = String::new(); | |
| 105 | + | escape_html(&mut s, "<a href=\"x\">&'"); | |
| 106 | + | assert_eq!(s, "<a href="x">&'"); | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | #[test] | |
| 110 | + | fn encodes_reserved_but_keeps_slashes() { | |
| 111 | + | assert_eq!(encode_path("forks/foo.rs"), "forks/foo.rs"); | |
| 112 | + | assert_eq!(encode_path("a b#?%"), "a%20b%23%3F%25"); | |
| 113 | + | assert_eq!(encode_path("ünï"), "%C3%BCn%C3%AF"); | |
| 114 | + | } | |
| 115 | + | } | |
| @@ -0,0 +1,63 @@ | |||
| 1 | + | //! Human-readable times. | |
| 2 | + | ||
| 3 | + | use std::time::{SystemTime, UNIX_EPOCH}; | |
| 4 | + | ||
| 5 | + | pub fn now() -> i64 { | |
| 6 | + | SystemTime::now() | |
| 7 | + | .duration_since(UNIX_EPOCH) | |
| 8 | + | .map_or(0, |d| d.as_secs() as i64) | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | /// "3 hours ago", relative to `now` (both unix seconds). Future times | |
| 12 | + | /// (clock skew) read as "just now". | |
| 13 | + | pub fn relative(then: i64, now: i64) -> String { | |
| 14 | + | const MIN: i64 = 60; | |
| 15 | + | const HOUR: i64 = 60 * MIN; | |
| 16 | + | const DAY: i64 = 24 * HOUR; | |
| 17 | + | const WEEK: i64 = 7 * DAY; | |
| 18 | + | const MONTH: i64 = 30 * DAY; | |
| 19 | + | const YEAR: i64 = 365 * DAY; | |
| 20 | + | ||
| 21 | + | let delta = now - then; | |
| 22 | + | let (n, unit) = match delta { | |
| 23 | + | d if d < MIN => return "just now".into(), | |
| 24 | + | d if d < HOUR => (d / MIN, "minute"), | |
| 25 | + | d if d < DAY => (d / HOUR, "hour"), | |
| 26 | + | d if d < 2 * WEEK => (d / DAY, "day"), | |
| 27 | + | d if d < 2 * MONTH => (d / WEEK, "week"), | |
| 28 | + | d if d < YEAR => (d / MONTH, "month"), | |
| 29 | + | d => (d / YEAR, "year"), | |
| 30 | + | }; | |
| 31 | + | format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" }) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /// RFC 3339 UTC timestamp, for `<time datetime>` and tooltips. | |
| 35 | + | pub fn iso(then: i64) -> String { | |
| 36 | + | jiff::Timestamp::from_second(then) | |
| 37 | + | .map(|t| t.strftime("%Y-%m-%dT%H:%M:%SZ").to_string()) | |
| 38 | + | .unwrap_or_default() | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | #[cfg(test)] | |
| 42 | + | mod tests { | |
| 43 | + | use super::*; | |
| 44 | + | ||
| 45 | + | #[test] | |
| 46 | + | fn relative_times() { | |
| 47 | + | let now = 1_000_000_000; | |
| 48 | + | assert_eq!(relative(now + 5, now), "just now"); | |
| 49 | + | assert_eq!(relative(now - 59, now), "just now"); | |
| 50 | + | assert_eq!(relative(now - 60, now), "1 minute ago"); | |
| 51 | + | assert_eq!(relative(now - 2 * 3600, now), "2 hours ago"); | |
| 52 | + | assert_eq!(relative(now - 86400, now), "1 day ago"); | |
| 53 | + | assert_eq!(relative(now - 13 * 86400, now), "13 days ago"); | |
| 54 | + | assert_eq!(relative(now - 15 * 86400, now), "2 weeks ago"); | |
| 55 | + | assert_eq!(relative(now - 90 * 86400, now), "3 months ago"); | |
| 56 | + | assert_eq!(relative(now - 800 * 86400, now), "2 years ago"); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | #[test] | |
| 60 | + | fn iso_format() { | |
| 61 | + | assert_eq!(iso(0), "1970-01-01T00:00:00Z"); | |
| 62 | + | } | |
| 63 | + | } | |
| @@ -0,0 +1,200 @@ | |||
| 1 | + | //! Embedded static assets served under `/-/static/<hash>/…`. | |
| 2 | + | //! | |
| 3 | + | //! One hash covers the whole asset set, so files can reference each other | |
| 4 | + | //! with relative URLs (`url(../fonts/x.woff2)`) and still be cached forever. | |
| 5 | + | //! In debug builds rust-embed reads from disk; responses are then `no-cache` | |
| 6 | + | //! so CSS/JS edits show up on reload. | |
| 7 | + | ||
| 8 | + | use std::hash::{Hash, Hasher}; | |
| 9 | + | use std::sync::Arc; | |
| 10 | + | ||
| 11 | + | use axum::body::Bytes; | |
| 12 | + | use axum::extract::{Path, State}; | |
| 13 | + | use axum::http::{HeaderValue, StatusCode, header}; | |
| 14 | + | use axum::response::{IntoResponse, Response}; | |
| 15 | + | use rust_embed::RustEmbed; | |
| 16 | + | ||
| 17 | + | use crate::state::AppState; | |
| 18 | + | ||
| 19 | + | #[derive(RustEmbed)] | |
| 20 | + | #[folder = "assets/"] | |
| 21 | + | struct Embedded; | |
| 22 | + | ||
| 23 | + | /// Parts concatenated (in order) into the virtual `css/app.css`. | |
| 24 | + | const CSS_BUNDLE: &[&str] = &[ | |
| 25 | + | "css/fonts.css", | |
| 26 | + | "css/tokens.css", | |
| 27 | + | "css/base.css", | |
| 28 | + | "css/components.css", | |
| 29 | + | "css/repo.css", | |
| 30 | + | "css/syntax.css", | |
| 31 | + | ]; | |
| 32 | + | const CSS_BUNDLE_PATH: &str = "css/app.css"; | |
| 33 | + | ||
| 34 | + | const IMMUTABLE: &str = "public, max-age=31536000, immutable"; | |
| 35 | + | const NO_CACHE: &str = "no-cache"; | |
| 36 | + | ||
| 37 | + | pub struct Assets { | |
| 38 | + | hash: String, | |
| 39 | + | /// Prebuilt bundle (release builds only; debug rebuilds per request). | |
| 40 | + | bundle: Option<Bytes>, | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | impl Assets { | |
| 44 | + | pub fn new() -> Self { | |
| 45 | + | let mut paths: Vec<_> = Embedded::iter().collect(); | |
| 46 | + | paths.sort(); | |
| 47 | + | // SipHash with fixed keys: deterministic for a given binary, which is | |
| 48 | + | // all a cache-busting token needs. | |
| 49 | + | let mut hasher = std::hash::DefaultHasher::new(); | |
| 50 | + | for path in &paths { | |
| 51 | + | path.hash(&mut hasher); | |
| 52 | + | if let Some(file) = Embedded::get(path) { | |
| 53 | + | file.metadata.sha256_hash().hash(&mut hasher); | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | let hash = format!("{:016x}", hasher.finish())[..12].to_string(); | |
| 57 | + | let bundle = (!cfg!(debug_assertions)).then(build_bundle); | |
| 58 | + | Assets { hash, bundle } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | pub fn hash(&self) -> &str { | |
| 62 | + | &self.hash | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /// Bytes for an asset path, including the virtual CSS bundle. | |
| 66 | + | pub fn get(&self, path: &str) -> Option<Bytes> { | |
| 67 | + | if path == CSS_BUNDLE_PATH { | |
| 68 | + | return Some(self.bundle.clone().unwrap_or_else(build_bundle)); | |
| 69 | + | } | |
| 70 | + | let file = Embedded::get(path)?; | |
| 71 | + | Some(match file.data { | |
| 72 | + | std::borrow::Cow::Borrowed(b) => Bytes::from_static(b), | |
| 73 | + | std::borrow::Cow::Owned(v) => Bytes::from(v), | |
| 74 | + | }) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | impl Default for Assets { | |
| 79 | + | fn default() -> Self { | |
| 80 | + | Self::new() | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | fn build_bundle() -> Bytes { | |
| 85 | + | let mut out = Vec::new(); | |
| 86 | + | for part in CSS_BUNDLE { | |
| 87 | + | let file = Embedded::get(part).unwrap_or_else(|| panic!("missing embedded {part}")); | |
| 88 | + | out.extend_from_slice(&file.data); | |
| 89 | + | out.push(b'\n'); | |
| 90 | + | } | |
| 91 | + | Bytes::from(out) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | pub fn content_type(path: &str) -> HeaderValue { | |
| 95 | + | let mime = mime_guess::from_path(path).first_or_octet_stream(); | |
| 96 | + | let value = match (mime.type_(), mime.subtype()) { | |
| 97 | + | (mime_guess::mime::TEXT, _) | (_, mime_guess::mime::JAVASCRIPT) => { | |
| 98 | + | format!("{mime}; charset=utf-8") | |
| 99 | + | } | |
| 100 | + | _ => mime.to_string(), | |
| 101 | + | }; | |
| 102 | + | HeaderValue::from_str(&value).unwrap_or(HeaderValue::from_static("application/octet-stream")) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// `GET /-/static/{hash}/{*path}` | |
| 106 | + | pub async fn serve( | |
| 107 | + | State(state): State<Arc<AppState>>, | |
| 108 | + | Path((hash, path)): Path<(String, String)>, | |
| 109 | + | ) -> Response { | |
| 110 | + | let Some(body) = state.assets.get(&path) else { | |
| 111 | + | return StatusCode::NOT_FOUND.into_response(); | |
| 112 | + | }; | |
| 113 | + | // A stale hash (e.g. a page rendered before an upgrade) still gets the | |
| 114 | + | // current file, but must not be cached under the old URL. | |
| 115 | + | let cache = if !cfg!(debug_assertions) && hash == state.assets.hash() { | |
| 116 | + | IMMUTABLE | |
| 117 | + | } else { | |
| 118 | + | NO_CACHE | |
| 119 | + | }; | |
| 120 | + | ( | |
| 121 | + | [ | |
| 122 | + | (header::CONTENT_TYPE, content_type(&path)), | |
| 123 | + | (header::CACHE_CONTROL, HeaderValue::from_static(cache)), | |
| 124 | + | ], | |
| 125 | + | body, | |
| 126 | + | ) | |
| 127 | + | .into_response() | |
| 128 | + | } | |
| 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 | |
| 132 | + | /// serve; versioned by [`site_css_version`]. | |
| 133 | + | pub async fn site_css(State(state): State<Arc<AppState>>) -> Response { | |
| 134 | + | let config = state.config.load(); | |
| 135 | + | let Some(css) = site_css_body(&config) else { | |
| 136 | + | return StatusCode::NOT_FOUND.into_response(); | |
| 137 | + | }; | |
| 138 | + | ( | |
| 139 | + | [ | |
| 140 | + | ( | |
| 141 | + | header::CONTENT_TYPE, | |
| 142 | + | HeaderValue::from_static("text/css; charset=utf-8"), | |
| 143 | + | ), | |
| 144 | + | (header::CACHE_CONTROL, HeaderValue::from_static(NO_CACHE)), | |
| 145 | + | ], | |
| 146 | + | css, | |
| 147 | + | ) | |
| 148 | + | .into_response() | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | pub fn site_css_body(config: &crate::config::Config) -> Option<String> { | |
| 152 | + | let mut css = String::new(); | |
| 153 | + | if config.ui.code_font != crate::config::DEFAULT_CODE_FONT { | |
| 154 | + | // The font name was validated to contain no quotes or CSS syntax. | |
| 155 | + | css.push_str(&format!( | |
| 156 | + | ":root {{ --font-code: \"{}\", var(--font-mono); }}\n", | |
| 157 | + | config.ui.code_font | |
| 158 | + | )); | |
| 159 | + | } | |
| 160 | + | if let Some(custom) = &config.custom.css { | |
| 161 | + | css.push_str(custom); | |
| 162 | + | } | |
| 163 | + | (!css.is_empty()).then_some(css) | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | /// Short content hash for the `?v=` query of the site stylesheet link. | |
| 167 | + | pub fn site_css_version(config: &crate::config::Config) -> Option<String> { | |
| 168 | + | let body = site_css_body(config)?; | |
| 169 | + | let mut hasher = std::hash::DefaultHasher::new(); | |
| 170 | + | body.hash(&mut hasher); | |
| 171 | + | Some(format!("{:08x}", hasher.finish() as u32)) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | #[cfg(test)] | |
| 175 | + | mod tests { | |
| 176 | + | use super::*; | |
| 177 | + | ||
| 178 | + | #[test] | |
| 179 | + | fn bundle_contains_all_parts_and_hash_is_stable() { | |
| 180 | + | let a = Assets::new(); | |
| 181 | + | let b = Assets::new(); | |
| 182 | + | assert_eq!(a.hash(), b.hash()); | |
| 183 | + | assert_eq!(a.hash().len(), 12); | |
| 184 | + | let bundle = a.get(CSS_BUNDLE_PATH).unwrap(); | |
| 185 | + | let text = std::str::from_utf8(&bundle).unwrap(); | |
| 186 | + | assert!(text.contains("@font-face")); | |
| 187 | + | assert!(text.contains("--text-display")); | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | #[test] | |
| 191 | + | fn content_types() { | |
| 192 | + | assert_eq!(content_type("css/app.css"), "text/css; charset=utf-8"); | |
| 193 | + | assert_eq!( | |
| 194 | + | content_type("js/theme.js"), | |
| 195 | + | "text/javascript; charset=utf-8" | |
| 196 | + | ); | |
| 197 | + | assert_eq!(content_type("fonts/x.woff2"), "font/woff2"); | |
| 198 | + | assert_eq!(content_type("icons/favicon.svg"), "image/svg+xml"); | |
| 199 | + | } | |
| 200 | + | } | |
| @@ -0,0 +1,141 @@ | |||
| 1 | + | //! The catch-all route: map a URL path onto a repository and an action. | |
| 2 | + | //! | |
| 3 | + | //! Repository names may contain `/`, so the longest prefix of the path that | |
| 4 | + | //! names a repository wins; the next segment is the action. Ferrenium's own | |
| 5 | + | //! endpoints live under `/-/`, which never names a repository. | |
| 6 | + | ||
| 7 | + | use std::sync::Arc; | |
| 8 | + | ||
| 9 | + | use axum::extract::{Query, Request, State}; | |
| 10 | + | use axum::http::{StatusCode, header}; | |
| 11 | + | use axum::response::{IntoResponse, Redirect, Response}; | |
| 12 | + | ||
| 13 | + | use crate::handlers::error; | |
| 14 | + | use crate::handlers::repo::{PageQuery, RepoRequest, handle}; | |
| 15 | + | use crate::index::{Repo, RepoIndex}; | |
| 16 | + | use crate::render::{encode_path, percent_decode}; | |
| 17 | + | use crate::state::AppState; | |
| 18 | + | ||
| 19 | + | pub enum Match<'a> { | |
| 20 | + | Repo { | |
| 21 | + | repo: Arc<Repo>, | |
| 22 | + | rest: &'a [Vec<u8>], | |
| 23 | + | }, | |
| 24 | + | /// `/{repo}.git` → `/{repo}`. | |
| 25 | + | DotGit { | |
| 26 | + | name: String, | |
| 27 | + | }, | |
| 28 | + | None, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /// Split a raw URL path into percent-decoded segments, dropping empty ones. | |
| 32 | + | /// `None` for `.`/`..` segments, which are never valid. | |
| 33 | + | pub fn segments(path: &str) -> Option<Vec<Vec<u8>>> { | |
| 34 | + | let segments: Vec<Vec<u8>> = path | |
| 35 | + | .split('/') | |
| 36 | + | .filter(|s| !s.is_empty()) | |
| 37 | + | .map(percent_decode) | |
| 38 | + | .collect(); | |
| 39 | + | let bad = segments | |
| 40 | + | .iter() | |
| 41 | + | .any(|s| s == b"." || s == b".." || s.contains(&b'/') || s.contains(&0)); | |
| 42 | + | (!bad).then_some(segments) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /// Find the repository named by the longest prefix of `segments`. | |
| 46 | + | pub fn find_repo<'a>(index: &RepoIndex, segments: &'a [Vec<u8>]) -> Match<'a> { | |
| 47 | + | if segments.first().is_some_and(|s| s == b"-") { | |
| 48 | + | return Match::None; | |
| 49 | + | } | |
| 50 | + | for n in (1..=segments.len()).rev() { | |
| 51 | + | let parts: Option<Vec<&str>> = segments[..n] | |
| 52 | + | .iter() | |
| 53 | + | .map(|s| std::str::from_utf8(s).ok()) | |
| 54 | + | .collect(); | |
| 55 | + | let Some(parts) = parts else { | |
| 56 | + | continue; | |
| 57 | + | }; | |
| 58 | + | let name = parts.join("/"); | |
| 59 | + | if let Some(repo) = index.repos.get(&name) { | |
| 60 | + | return Match::Repo { | |
| 61 | + | repo: repo.clone(), | |
| 62 | + | rest: &segments[n..], | |
| 63 | + | }; | |
| 64 | + | } | |
| 65 | + | if let Some(stripped) = name.strip_suffix(".git") | |
| 66 | + | && index.repos.contains_key(stripped) | |
| 67 | + | { | |
| 68 | + | // `/{repo}.git/…` is the HTTPS clone endpoint (not served yet). | |
| 69 | + | return if n == segments.len() { | |
| 70 | + | Match::DotGit { | |
| 71 | + | name: stripped.to_string(), | |
| 72 | + | } | |
| 73 | + | } else { | |
| 74 | + | Match::None | |
| 75 | + | }; | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | Match::None | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | pub async fn dispatch(State(state): State<Arc<AppState>>, req: Request) -> Response { | |
| 82 | + | let config = state.config.load_full(); | |
| 83 | + | let index = state.index.load(); | |
| 84 | + | let Some(segments) = segments(req.uri().path()) else { | |
| 85 | + | return error::page(&state, StatusCode::NOT_FOUND, "PAGE NOT FOUND"); | |
| 86 | + | }; | |
| 87 | + | let (repo, rest) = match find_repo(&index, &segments) { | |
| 88 | + | Match::Repo { repo, rest } => (repo, rest.to_vec()), | |
| 89 | + | Match::DotGit { name } => { | |
| 90 | + | let url = format!("{}{}", config.server.base_path, encode_path(&name)); | |
| 91 | + | return Redirect::permanent(&url).into_response(); | |
| 92 | + | } | |
| 93 | + | Match::None => return error::page(&state, StatusCode::NOT_FOUND, "PAGE NOT FOUND"), | |
| 94 | + | }; | |
| 95 | + | let query = Query::<PageQuery>::try_from_uri(req.uri()) | |
| 96 | + | .map(|q| q.0) | |
| 97 | + | .unwrap_or_default(); | |
| 98 | + | let request = RepoRequest { | |
| 99 | + | repo, | |
| 100 | + | segments: rest, | |
| 101 | + | query, | |
| 102 | + | if_none_match: req.headers().get(header::IF_NONE_MATCH).cloned(), | |
| 103 | + | config, | |
| 104 | + | }; | |
| 105 | + | match state.blocking(move |state| handle(state, request)).await { | |
| 106 | + | Ok(response) => response, | |
| 107 | + | Err(e) => { | |
| 108 | + | tracing::error!("request failed: {e:#}"); | |
| 109 | + | error::page(&state, StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL ERROR") | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | #[cfg(test)] | |
| 115 | + | mod tests { | |
| 116 | + | use super::*; | |
| 117 | + | use crate::index::RepoIndex; | |
| 118 | + | ||
| 119 | + | #[test] | |
| 120 | + | fn segment_parsing() { | |
| 121 | + | assert_eq!( | |
| 122 | + | segments("/a//b%2Fc/").map(|s| s.len()), | |
| 123 | + | None, | |
| 124 | + | "encoded slash is rejected" | |
| 125 | + | ); | |
| 126 | + | assert_eq!( | |
| 127 | + | segments("/a//b/%C3%BC/").unwrap(), | |
| 128 | + | vec![b"a".to_vec(), b"b".to_vec(), "ü".as_bytes().to_vec()] | |
| 129 | + | ); | |
| 130 | + | assert!(segments("/a/../b").is_none()); | |
| 131 | + | assert!(segments("/a/%2e%2e/b").is_none()); | |
| 132 | + | assert!(segments("/").unwrap().is_empty()); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | #[test] | |
| 136 | + | fn no_repos_no_match() { | |
| 137 | + | let index = RepoIndex::default(); | |
| 138 | + | let segs = segments("/-/x").unwrap(); | |
| 139 | + | assert!(matches!(find_repo(&index, &segs), Match::None)); | |
| 140 | + | } | |
| 141 | + | } | |
| @@ -0,0 +1,19 @@ | |||
| 1 | + | //! Security headers applied to every response (unless a handler set its own). | |
| 2 | + | ||
| 3 | + | use axum::http::{HeaderName, HeaderValue, header}; | |
| 4 | + | use tower_http::set_header::SetResponseHeaderLayer; | |
| 5 | + | ||
| 6 | + | pub const PAGE_CSP: &str = "default-src 'self'; img-src 'self' https: data:; script-src 'self'; \ | |
| 7 | + | object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"; | |
| 8 | + | ||
| 9 | + | pub fn layers() -> [SetResponseHeaderLayer<HeaderValue>; 4] { | |
| 10 | + | let set = |name: HeaderName, value: &'static str| { | |
| 11 | + | SetResponseHeaderLayer::if_not_present(name, HeaderValue::from_static(value)) | |
| 12 | + | }; | |
| 13 | + | [ | |
| 14 | + | set(header::CONTENT_SECURITY_POLICY, PAGE_CSP), | |
| 15 | + | set(header::X_CONTENT_TYPE_OPTIONS, "nosniff"), | |
| 16 | + | set(header::REFERRER_POLICY, "same-origin"), | |
| 17 | + | set(header::X_FRAME_OPTIONS, "DENY"), | |
| 18 | + | ] | |
| 19 | + | } | |
| @@ -0,0 +1,297 @@ | |||
| 1 | + | //! HTTP server: router, middleware, listeners, signals and shutdown. | |
| 2 | + | ||
| 3 | + | pub mod assets; | |
| 4 | + | pub mod dispatch; | |
| 5 | + | pub mod headers; | |
| 6 | + | ||
| 7 | + | use std::future::{Future, IntoFuture}; | |
| 8 | + | use std::sync::Arc; | |
| 9 | + | use std::time::Duration; | |
| 10 | + | ||
| 11 | + | use anyhow::Context; | |
| 12 | + | use axum::Router; | |
| 13 | + | use axum::extract::{Request, State}; | |
| 14 | + | use axum::http::{HeaderValue, StatusCode, Uri, header}; | |
| 15 | + | use axum::middleware::{self, Next}; | |
| 16 | + | use axum::response::{IntoResponse, Redirect, Response}; | |
| 17 | + | use axum::routing::get; | |
| 18 | + | use sd_notify::NotifyState; | |
| 19 | + | use tokio::signal::unix::{SignalKind, signal}; | |
| 20 | + | use tokio::sync::watch; | |
| 21 | + | use tower::{Layer, ServiceBuilder}; | |
| 22 | + | use tower_http::CompressionLevel; | |
| 23 | + | use tower_http::catch_panic::CatchPanicLayer; | |
| 24 | + | use tower_http::compression::predicate::{NotForContentType, Predicate}; | |
| 25 | + | use tower_http::compression::{CompressionLayer, DefaultPredicate}; | |
| 26 | + | use tower_http::timeout::TimeoutLayer; | |
| 27 | + | use tower_http::trace::{DefaultOnResponse, TraceLayer}; | |
| 28 | + | use tracing::Level; | |
| 29 | + | ||
| 30 | + | use crate::config::Listen; | |
| 31 | + | use crate::state::AppState; | |
| 32 | + | use crate::{handlers, index}; | |
| 33 | + | ||
| 34 | + | /// How long in-flight requests may run after SIGTERM before we exit anyway. | |
| 35 | + | const DRAIN_DEADLINE: Duration = Duration::from_secs(10); | |
| 36 | + | ||
| 37 | + | /// Build the full application (routes + middleware) for the current config. | |
| 38 | + | pub fn app(state: Arc<AppState>) -> Router { | |
| 39 | + | let config = state.config.load(); | |
| 40 | + | ||
| 41 | + | let routes = Router::new() | |
| 42 | + | .route("/", get(handlers::index::index)) | |
| 43 | + | .route("/-/health", get(health)) | |
| 44 | + | .route("/-/site.css", get(assets::site_css)) | |
| 45 | + | .route("/-/static/{hash}/{*path}", get(assets::serve)) | |
| 46 | + | .route("/{*path}", get(dispatch::dispatch)) | |
| 47 | + | .fallback(handlers::error::not_found) | |
| 48 | + | .with_state(state.clone()); | |
| 49 | + | ||
| 50 | + | // The prefix must be stripped *before* routing, so wrap the whole router | |
| 51 | + | // (Router::layer would run after route matching). | |
| 52 | + | let routes = match config.server.base_path.as_str() { | |
| 53 | + | "/" => routes, | |
| 54 | + | base => Router::new().fallback_service( | |
| 55 | + | middleware::from_fn_with_state( | |
| 56 | + | Arc::<str>::from(base.trim_end_matches('/')), | |
| 57 | + | strip_base_path, | |
| 58 | + | ) | |
| 59 | + | .layer(routes), | |
| 60 | + | ), | |
| 61 | + | }; | |
| 62 | + | ||
| 63 | + | let trusted_proxy = config.server.trusted_proxy; | |
| 64 | + | let compression = CompressionLayer::new() | |
| 65 | + | .no_deflate() | |
| 66 | + | .no_zstd() | |
| 67 | + | .quality(CompressionLevel::Precise(4)); | |
| 68 | + | // Always installed (so the body type is fixed); disabling turns off every | |
| 69 | + | // encoding instead. | |
| 70 | + | let compression = if config.server.compression { | |
| 71 | + | compression | |
| 72 | + | } else { | |
| 73 | + | compression.no_gzip().no_br() | |
| 74 | + | } | |
| 75 | + | // woff2 is already compressed. | |
| 76 | + | .compress_when(DefaultPredicate::new().and(NotForContentType::const_new("font/"))); | |
| 77 | + | ||
| 78 | + | let [csp, nosniff, referrer, frame] = headers::layers(); | |
| 79 | + | routes.layer( | |
| 80 | + | ServiceBuilder::new() | |
| 81 | + | .layer( | |
| 82 | + | TraceLayer::new_for_http() | |
| 83 | + | .make_span_with(move |req: &Request| { | |
| 84 | + | let client = trusted_proxy | |
| 85 | + | .then(|| forwarded_for(req)) | |
| 86 | + | .flatten() | |
| 87 | + | .unwrap_or("-"); | |
| 88 | + | tracing::info_span!( | |
| 89 | + | "request", | |
| 90 | + | method = %req.method(), | |
| 91 | + | uri = %req.uri(), | |
| 92 | + | client = %client, | |
| 93 | + | ) | |
| 94 | + | }) | |
| 95 | + | .on_request(()) | |
| 96 | + | .on_response(DefaultOnResponse::new().level(Level::INFO)), | |
| 97 | + | ) | |
| 98 | + | .layer(CatchPanicLayer::new()) | |
| 99 | + | .layer(TimeoutLayer::with_status_code( | |
| 100 | + | StatusCode::SERVICE_UNAVAILABLE, | |
| 101 | + | Duration::from_secs(config.limits.request_timeout_secs), | |
| 102 | + | )) | |
| 103 | + | .layer(compression) | |
| 104 | + | .layer(csp) | |
| 105 | + | .layer(nosniff) | |
| 106 | + | .layer(referrer) | |
| 107 | + | .layer(frame), | |
| 108 | + | ) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /// First address in `X-Forwarded-For` (the original client, as seen by the | |
| 112 | + | /// trusted proxy). | |
| 113 | + | fn forwarded_for(req: &Request) -> Option<&str> { | |
| 114 | + | let value = req.headers().get("x-forwarded-for")?.to_str().ok()?; | |
| 115 | + | value | |
| 116 | + | .split(',') | |
| 117 | + | .next() | |
| 118 | + | .map(str::trim) | |
| 119 | + | .filter(|s| !s.is_empty()) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | /// Serve under `server.base_path`: strip the prefix before routing, redirect | |
| 123 | + | /// the bare prefix to its slash form, and 404 everything outside it. | |
| 124 | + | async fn strip_base_path(State(base): State<Arc<str>>, mut req: Request, next: Next) -> Response { | |
| 125 | + | let path = req.uri().path(); | |
| 126 | + | if path == &*base { | |
| 127 | + | return Redirect::permanent(&format!("{base}/")).into_response(); | |
| 128 | + | } | |
| 129 | + | let Some(rest) = path.strip_prefix(&*base).filter(|r| r.starts_with('/')) else { | |
| 130 | + | return StatusCode::NOT_FOUND.into_response(); | |
| 131 | + | }; | |
| 132 | + | let new_uri = match req.uri().query() { | |
| 133 | + | Some(q) => format!("{rest}?{q}"), | |
| 134 | + | None => rest.to_string(), | |
| 135 | + | }; | |
| 136 | + | match new_uri.parse::<Uri>() { | |
| 137 | + | Ok(uri) => { | |
| 138 | + | *req.uri_mut() = uri; | |
| 139 | + | next.run(req).await | |
| 140 | + | } | |
| 141 | + | Err(_) => StatusCode::BAD_REQUEST.into_response(), | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | async fn health() -> impl IntoResponse { | |
| 146 | + | ( | |
| 147 | + | [(header::CACHE_CONTROL, HeaderValue::from_static("no-store"))], | |
| 148 | + | "ok\n", | |
| 149 | + | ) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | /// Run the server until SIGTERM/SIGINT. SIGHUP reloads the config. | |
| 153 | + | pub async fn run(state: Arc<AppState>) -> anyhow::Result<()> { | |
| 154 | + | let listen = state.config.load().server.listen.clone(); | |
| 155 | + | let (shutdown_tx, shutdown_rx) = watch::channel(false); | |
| 156 | + | tokio::spawn(handle_signals(state.clone(), shutdown_tx)?); | |
| 157 | + | ||
| 158 | + | // The index is complete before we report READY. | |
| 159 | + | let st = state.clone(); | |
| 160 | + | let stats = tokio::task::spawn_blocking(move || { | |
| 161 | + | let config = st.config.load_full(); | |
| 162 | + | st.index.full_scan(&config) | |
| 163 | + | }) | |
| 164 | + | .await | |
| 165 | + | .context("initial scan failed")?; | |
| 166 | + | let mut seen = Default::default(); | |
| 167 | + | index::watch::log_scan(&stats, &state.index.load(), &mut seen, true); | |
| 168 | + | tokio::spawn(index::watch::run(state.clone(), seen)); | |
| 169 | + | // Parse the embedded grammars now rather than on the first blob view. | |
| 170 | + | tokio::task::spawn_blocking(|| { | |
| 171 | + | let start = std::time::Instant::now(); | |
| 172 | + | crate::render::highlight::syntaxes(); | |
| 173 | + | tracing::debug!("syntaxes loaded in {:?}", start.elapsed()); | |
| 174 | + | }); | |
| 175 | + | ||
| 176 | + | let app = app(state.clone()); | |
| 177 | + | ||
| 178 | + | match &listen { | |
| 179 | + | Listen::Tcp(addr) => { | |
| 180 | + | let listener = tokio::net::TcpListener::bind(addr) | |
| 181 | + | .await | |
| 182 | + | .with_context(|| format!("cannot listen on {addr}"))?; | |
| 183 | + | serve(listener, app, &listen, shutdown_rx).await | |
| 184 | + | } | |
| 185 | + | Listen::Unix(path) => { | |
| 186 | + | let listener = bind_unix(path)?; | |
| 187 | + | let result = serve(listener, app, &listen, shutdown_rx).await; | |
| 188 | + | let _ = std::fs::remove_file(path); | |
| 189 | + | result | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | async fn serve<L>( | |
| 195 | + | listener: L, | |
| 196 | + | app: Router, | |
| 197 | + | listen: &Listen, | |
| 198 | + | mut shutdown_rx: watch::Receiver<bool>, | |
| 199 | + | ) -> anyhow::Result<()> | |
| 200 | + | where | |
| 201 | + | L: axum::serve::Listener, | |
| 202 | + | L::Addr: std::fmt::Debug, | |
| 203 | + | { | |
| 204 | + | tracing::info!("listening on {listen}"); | |
| 205 | + | notify(&[NotifyState::Ready, NotifyState::Status("serving")]); | |
| 206 | + | ||
| 207 | + | let mut drain_rx = shutdown_rx.clone(); | |
| 208 | + | let server = axum::serve(listener, app) | |
| 209 | + | .with_graceful_shutdown(async move { | |
| 210 | + | let _ = shutdown_rx.wait_for(|stop| *stop).await; | |
| 211 | + | }) | |
| 212 | + | .into_future(); | |
| 213 | + | ||
| 214 | + | tokio::select! { | |
| 215 | + | result = server => result.context("server error")?, | |
| 216 | + | _ = async { | |
| 217 | + | let _ = drain_rx.wait_for(|stop| *stop).await; | |
| 218 | + | tokio::time::sleep(DRAIN_DEADLINE).await; | |
| 219 | + | } => { | |
| 220 | + | tracing::warn!( | |
| 221 | + | "connections still open after {}s; exiting anyway", | |
| 222 | + | DRAIN_DEADLINE.as_secs() | |
| 223 | + | ); | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | tracing::info!("stopped"); | |
| 227 | + | Ok(()) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | /// Bind a unix socket, replacing a stale socket file from a previous run. | |
| 231 | + | fn bind_unix(path: &std::path::Path) -> anyhow::Result<tokio::net::UnixListener> { | |
| 232 | + | use std::os::unix::fs::{FileTypeExt, PermissionsExt}; | |
| 233 | + | ||
| 234 | + | if let Ok(meta) = std::fs::symlink_metadata(path) { | |
| 235 | + | anyhow::ensure!( | |
| 236 | + | meta.file_type().is_socket(), | |
| 237 | + | "{} exists and is not a socket", | |
| 238 | + | path.display() | |
| 239 | + | ); | |
| 240 | + | std::fs::remove_file(path) | |
| 241 | + | .with_context(|| format!("cannot remove stale socket {}", path.display()))?; | |
| 242 | + | } | |
| 243 | + | let listener = tokio::net::UnixListener::bind(path) | |
| 244 | + | .with_context(|| format!("cannot listen on unix:{}", path.display()))?; | |
| 245 | + | // Same reachability as a loopback TCP port: any local user (i.e. the | |
| 246 | + | // reverse proxy) may connect. All content served is public anyway. | |
| 247 | + | std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o666)) | |
| 248 | + | .with_context(|| format!("cannot set permissions on {}", path.display()))?; | |
| 249 | + | Ok(listener) | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | /// Install signal handlers (synchronously, so a failure aborts startup) and | |
| 253 | + | /// return the task that services them. | |
| 254 | + | fn handle_signals( | |
| 255 | + | state: Arc<AppState>, | |
| 256 | + | shutdown_tx: watch::Sender<bool>, | |
| 257 | + | ) -> anyhow::Result<impl Future<Output = ()> + Send + 'static> { | |
| 258 | + | let mut term = signal(SignalKind::terminate()).context("cannot install SIGTERM handler")?; | |
| 259 | + | let mut int = signal(SignalKind::interrupt()).context("cannot install SIGINT handler")?; | |
| 260 | + | let mut hup = signal(SignalKind::hangup()).context("cannot install SIGHUP handler")?; | |
| 261 | + | ||
| 262 | + | Ok(async move { | |
| 263 | + | loop { | |
| 264 | + | tokio::select! { | |
| 265 | + | _ = term.recv() => { tracing::info!("SIGTERM received; draining"); break; } | |
| 266 | + | _ = int.recv() => { tracing::info!("SIGINT received; draining"); break; } | |
| 267 | + | _ = hup.recv() => reload(&state), | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | notify(&[NotifyState::Stopping]); | |
| 271 | + | let _ = shutdown_tx.send(true); | |
| 272 | + | }) | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | fn reload(state: &AppState) { | |
| 276 | + | tracing::info!("SIGHUP received; reloading configuration"); | |
| 277 | + | let mut msg = vec![NotifyState::Reloading]; | |
| 278 | + | if let Ok(now) = NotifyState::monotonic_usec_now() { | |
| 279 | + | msg.push(now); | |
| 280 | + | } | |
| 281 | + | notify(&msg); | |
| 282 | + | match state.reload() { | |
| 283 | + | Ok(config) => { | |
| 284 | + | crate::logging::apply(&config); | |
| 285 | + | tracing::info!("configuration reloaded"); | |
| 286 | + | state.rescan.notify_one(); | |
| 287 | + | } | |
| 288 | + | Err(e) => tracing::error!("reload failed, keeping current configuration: {e}"), | |
| 289 | + | } | |
| 290 | + | notify(&[NotifyState::Ready]); | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | fn notify(states: &[NotifyState]) { | |
| 294 | + | if let Err(e) = sd_notify::notify(states) { | |
| 295 | + | tracing::debug!("sd_notify failed: {e}"); | |
| 296 | + | } | |
| 297 | + | } | |
| @@ -0,0 +1,109 @@ | |||
| 1 | + | //! Process-wide shared state. | |
| 2 | + | ||
| 3 | + | use std::path::PathBuf; | |
| 4 | + | use std::sync::Arc; | |
| 5 | + | use std::sync::atomic::{AtomicU64, Ordering}; | |
| 6 | + | ||
| 7 | + | use arc_swap::ArcSwap; | |
| 8 | + | use tokio::sync::Semaphore; | |
| 9 | + | ||
| 10 | + | use crate::cache::Cache; | |
| 11 | + | use crate::config::{Config, ConfigError, Listen}; | |
| 12 | + | use crate::index::Indexer; | |
| 13 | + | use crate::server::assets::Assets; | |
| 14 | + | ||
| 15 | + | pub struct AppState { | |
| 16 | + | /// Current config; swapped atomically on reload. | |
| 17 | + | pub config: ArcSwap<Config>, | |
| 18 | + | /// Bumped on every successful reload; part of page ETags. | |
| 19 | + | pub config_generation: AtomicU64, | |
| 20 | + | pub assets: Assets, | |
| 21 | + | pub cache: Cache, | |
| 22 | + | /// Bounds concurrent git work (`limits.git_parallel`). | |
| 23 | + | pub git_permits: Semaphore, | |
| 24 | + | pub index: Indexer, | |
| 25 | + | /// Wakes the index watcher for an immediate full rescan. | |
| 26 | + | pub rescan: tokio::sync::Notify, | |
| 27 | + | /// Command-line overrides re-applied after every reload. | |
| 28 | + | pub overrides: Overrides, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | #[derive(Debug, Clone, Default)] | |
| 32 | + | pub struct Overrides { | |
| 33 | + | pub config_path: Option<PathBuf>, | |
| 34 | + | pub listen: Option<Listen>, | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | impl Overrides { | |
| 38 | + | pub fn load(&self) -> Result<Config, ConfigError> { | |
| 39 | + | let mut config = Config::load(self.config_path.as_deref())?; | |
| 40 | + | if let Some(listen) = &self.listen { | |
| 41 | + | config.server.listen = listen.clone(); | |
| 42 | + | } | |
| 43 | + | Ok(config) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | impl AppState { | |
| 48 | + | pub fn new(config: Config, overrides: Overrides) -> Arc<Self> { | |
| 49 | + | Arc::new(AppState { | |
| 50 | + | cache: Cache::new(config.cache.size_mb), | |
| 51 | + | git_permits: Semaphore::new(config.git_parallel()), | |
| 52 | + | config_generation: AtomicU64::new(0), | |
| 53 | + | config: ArcSwap::from_pointee(config), | |
| 54 | + | assets: Assets::new(), | |
| 55 | + | index: Indexer::new(), | |
| 56 | + | rescan: tokio::sync::Notify::new(), | |
| 57 | + | overrides, | |
| 58 | + | }) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// Re-read the config file and swap it in. On error the running config | |
| 62 | + | /// stays in place. Returns the new config. | |
| 63 | + | pub fn reload(&self) -> Result<Arc<Config>, ConfigError> { | |
| 64 | + | let mut new = self.overrides.load()?; | |
| 65 | + | let old = self.config.load(); | |
| 66 | + | // Settings baked into the listener and middleware stack keep their | |
| 67 | + | // running values so the config always describes the live server. | |
| 68 | + | if new.server.listen != old.server.listen { | |
| 69 | + | tracing::warn!( | |
| 70 | + | "server.listen changed to {}; restart required to take effect", | |
| 71 | + | new.server.listen | |
| 72 | + | ); | |
| 73 | + | new.server.listen = old.server.listen.clone(); | |
| 74 | + | } | |
| 75 | + | if new.server.base_path != old.server.base_path { | |
| 76 | + | tracing::warn!("server.base_path changed; restart required to take effect"); | |
| 77 | + | new.server.base_path = old.server.base_path.clone(); | |
| 78 | + | } | |
| 79 | + | if new.server.compression != old.server.compression | |
| 80 | + | || new.limits.request_timeout_secs != old.limits.request_timeout_secs | |
| 81 | + | { | |
| 82 | + | tracing::warn!( | |
| 83 | + | "server.compression / limits.request_timeout_secs changed; restart required" | |
| 84 | + | ); | |
| 85 | + | new.server.compression = old.server.compression; | |
| 86 | + | new.limits.request_timeout_secs = old.limits.request_timeout_secs; | |
| 87 | + | } | |
| 88 | + | if new.cache.size_mb != old.cache.size_mb || new.git_parallel() != old.git_parallel() { | |
| 89 | + | tracing::warn!("cache.size_mb / limits.git_parallel changed; restart required"); | |
| 90 | + | new.cache.size_mb = old.cache.size_mb; | |
| 91 | + | new.limits.git_parallel = old.limits.git_parallel; | |
| 92 | + | } | |
| 93 | + | let new = Arc::new(new); | |
| 94 | + | self.config.store(new.clone()); | |
| 95 | + | self.config_generation.fetch_add(1, Ordering::Relaxed); | |
| 96 | + | Ok(new) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /// Run blocking git work on the blocking pool, at most | |
| 100 | + | /// `limits.git_parallel` jobs at a time. | |
| 101 | + | pub async fn blocking<T: Send + 'static>( | |
| 102 | + | self: &Arc<Self>, | |
| 103 | + | work: impl FnOnce(&AppState) -> T + Send + 'static, | |
| 104 | + | ) -> anyhow::Result<T> { | |
| 105 | + | let _permit = self.git_permits.acquire().await?; | |
| 106 | + | let state = self.clone(); | |
| 107 | + | Ok(tokio::task::spawn_blocking(move || work(&state)).await?) | |
| 108 | + | } | |
| 109 | + | } | |
| @@ -0,0 +1,41 @@ | |||
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en" data-default-theme="{{ layout.theme }}"{% if layout.theme != "auto" %} data-theme="{{ layout.theme }}"{% endif %}> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="utf-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + | <meta name="color-scheme" content="dark light"> | |
| 7 | + | <title>{{ layout.page_title }}</title> | |
| 8 | + | <link rel="preload" href="{{ layout.static_base }}fonts/space-grotesk-latin-wght.woff2" as="font" type="font/woff2" crossorigin> | |
| 9 | + | <link rel="preload" href="{{ layout.static_base }}fonts/space-mono-latin-400.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | + | <link rel="stylesheet" href="{{ layout.static_base }}css/app.css"> | |
| 11 | + | {%- if let Some(css) = layout.site_css %} | |
| 12 | + | <link rel="stylesheet" href="{{ css }}"> | |
| 13 | + | {%- endif %} | |
| 14 | + | <link rel="icon" href="{{ layout.static_base }}icons/favicon.svg" type="image/svg+xml"> | |
| 15 | + | <script src="{{ layout.static_base }}js/theme.js"></script> | |
| 16 | + | <script src="{{ layout.static_base }}js/app.js" defer></script> | |
| 17 | + | {%- block head %}{% endblock %} | |
| 18 | + | </head> | |
| 19 | + | <body> | |
| 20 | + | <header class="topbar shell"> | |
| 21 | + | <a class="topbar-home label" href="{{ layout.base }}">{{ layout.site_title }}</a> | |
| 22 | + | <div class="segmented theme-switch" role="group" aria-label="Color theme"> | |
| 23 | + | <button type="button" data-theme-choice="auto" aria-pressed="false">Auto</button> | |
| 24 | + | <button type="button" data-theme-choice="light" aria-pressed="false">Light</button> | |
| 25 | + | <button type="button" data-theme-choice="dark" aria-pressed="false">Dark</button> | |
| 26 | + | </div> | |
| 27 | + | </header> | |
| 28 | + | {%- if let Some(header) = layout.header %} | |
| 29 | + | <div class="site-header shell">{{ header|safe }}</div> | |
| 30 | + | {%- endif %} | |
| 31 | + | <main class="shell"> | |
| 32 | + | {% block content %}{% endblock %} | |
| 33 | + | </main> | |
| 34 | + | <footer class="footer shell"> | |
| 35 | + | {%- if let Some(footer) = layout.footer %} | |
| 36 | + | <div class="site-footer">{{ footer|safe }}</div> | |
| 37 | + | {%- endif %} | |
| 38 | + | <span class="label dim">Ferrenium {{ layout.version }}</span> | |
| 39 | + | </footer> | |
| 40 | + | </body> | |
| 41 | + | </html> | |
| @@ -0,0 +1,42 @@ | |||
| 1 | + | {% extends "base.html" %} | |
| 2 | + | {% block content %} | |
| 3 | + | {% include "repo_nav.html" %} | |
| 4 | + | {% include "crumbs.html" %} | |
| 5 | + | <div class="blob-bar"> | |
| 6 | + | <p class="label blob-facts">{% for fact in facts %}{% if !loop.first %} · {% endif %}{{ fact }}{% endfor %}</p> | |
| 7 | + | <div class="blob-actions"> | |
| 8 | + | {%- if let Some(toggle) = toggle %} | |
| 9 | + | <nav class="segmented segmented-sm" aria-label="View"> | |
| 10 | + | <a href="{{ toggle.preview_url }}"{% if !toggle.source %} aria-current="true"{% endif %}>Preview</a> | |
| 11 | + | <a href="{{ toggle.source_url }}"{% if toggle.source %} aria-current="true"{% endif %}>Source</a> | |
| 12 | + | </nav> | |
| 13 | + | {%- endif %} | |
| 14 | + | <a class="btn-ghost" href="{{ raw_url }}">Raw</a> | |
| 15 | + | <a class="btn-ghost" href="{{ download_url }}">Download</a> | |
| 16 | + | </div> | |
| 17 | + | </div> | |
| 18 | + | {%- match body %} | |
| 19 | + | {%- when Body::Code with { lines } %} | |
| 20 | + | <div class="code-wrap"> | |
| 21 | + | <table class="code" data-lines> | |
| 22 | + | <tbody> | |
| 23 | + | {%- for line in lines.iter() %} | |
| 24 | + | <tr id="L{{ loop.index }}"><td class="ln"><a href="#L{{ loop.index }}">{{ loop.index }}</a></td><td class="lc">{{ line|safe }}</td></tr> | |
| 25 | + | {%- endfor %} | |
| 26 | + | </tbody> | |
| 27 | + | </table> | |
| 28 | + | </div> | |
| 29 | + | {%- when Body::Markdown with { html } %} | |
| 30 | + | <article class="markdown blob-markdown">{{ html|safe }}</article> | |
| 31 | + | {%- when Body::Image with { src } %} | |
| 32 | + | <figure class="image-view dot-grid"><img src="{{ src }}" alt="{{ here.as_deref().unwrap_or_default() }}"></figure> | |
| 33 | + | {%- when Body::Symlink with { target, url } %} | |
| 34 | + | <p class="blob-note"><span class="label">Symlink to</span> {% if let Some(url) = url %}<a class="mono" href="{{ url }}">{{ target }}</a>{% else %}<span class="mono">{{ target }}</span>{% endif %}</p> | |
| 35 | + | {%- when Body::Binary %} | |
| 36 | + | <p class="status blob-note">[Binary file]</p> | |
| 37 | + | {%- when Body::TooLarge %} | |
| 38 | + | <p class="status blob-note">[Too large to display] <a href="{{ raw_url }}">View raw</a></p> | |
| 39 | + | {%- when Body::Empty %} | |
| 40 | + | <p class="status blob-note">[Empty file]</p> | |
| 41 | + | {%- endmatch %} | |
| 42 | + | {% endblock %} | |
| @@ -0,0 +1,4 @@ | |||
| 1 | + | <h1 class="crumbs"> | |
| 2 | + | {%- for crumb in crumbs %}<a href="{{ crumb.url }}">{{ crumb.name }}</a><span class="crumb-sep" aria-hidden="true">/</span>{% endfor -%} | |
| 3 | + | {%- if let Some(here) = here %}<span class="crumb-here">{{ here }}</span>{% endif -%} | |
| 4 | + | </h1> | |
| @@ -0,0 +1,8 @@ | |||
| 1 | + | {% extends "base.html" %} | |
| 2 | + | {% block content %} | |
| 3 | + | <section class="error-page"> | |
| 4 | + | <p class="doto display-xl" aria-hidden="true">{{ code }}</p> | |
| 5 | + | <h1 class="status status-error">[ERROR: {{ message }}]</h1> | |
| 6 | + | <a class="btn-ghost" href="{{ layout.base }}">← Index</a> | |
| 7 | + | </section> | |
| 8 | + | {% endblock %} | |
| @@ -0,0 +1,56 @@ | |||
| 1 | + | {% extends "base.html" %} | |
| 2 | + | {% block content %} | |
| 3 | + | <section class="hero"> | |
| 4 | + | <div class="hero-main"> | |
| 5 | + | <h1 class="doto display-lg">{{ layout.site_title }}</h1> | |
| 6 | + | {%- if !description.is_empty() %} | |
| 7 | + | <p class="hero-desc">{{ description }}</p> | |
| 8 | + | {%- endif %} | |
| 9 | + | </div> | |
| 10 | + | <p class="hero-meta label">{{ total }} {% if total == 1 %}Repository{% else %}Repositories{% endif %}</p> | |
| 11 | + | </section> | |
| 12 | + | {%- if total == 0 %} | |
| 13 | + | <section class="empty"> | |
| 14 | + | <p class="empty-title">No repositories</p> | |
| 15 | + | <p class="empty-desc">Push a bare repository under the scan path and it will appear here.</p> | |
| 16 | + | </section> | |
| 17 | + | {%- else %} | |
| 18 | + | <form class="index-tools" method="get" action="{{ layout.base }}" role="search"> | |
| 19 | + | <label class="field"> | |
| 20 | + | <span class="label">Filter</span> | |
| 21 | + | <input type="search" name="q" value="{{ q }}" autocomplete="off" spellcheck="false" placeholder="/" aria-keyshortcuts="/" data-filter> | |
| 22 | + | </label> | |
| 23 | + | {%- if by_activity %} | |
| 24 | + | <input type="hidden" name="sort" value="activity"> | |
| 25 | + | {%- endif %} | |
| 26 | + | <nav class="sort" aria-label="Sort by"> | |
| 27 | + | <a class="label{% if !by_activity %} active{% endif %}" href="{{ sort_name_url }}"{% if !by_activity %} aria-current="true"{% endif %}>Name</a> | |
| 28 | + | <a class="label{% if by_activity %} active{% endif %}" href="{{ sort_activity_url }}"{% if by_activity %} aria-current="true"{% endif %}>Activity</a> | |
| 29 | + | </nav> | |
| 30 | + | </form> | |
| 31 | + | {%- for section in sections %} | |
| 32 | + | <section class="repo-section" data-section> | |
| 33 | + | {%- if let Some(name) = section.name %} | |
| 34 | + | <h2 class="label section-label">[ {{ name }} ]</h2> | |
| 35 | + | {%- endif %} | |
| 36 | + | <ul class="repo-list"> | |
| 37 | + | {%- for repo in section.rows %} | |
| 38 | + | <li class="repo-row" data-search="{{ repo.search }}"> | |
| 39 | + | <a class="repo-link" href="{{ repo.url }}"> | |
| 40 | + | <span class="repo-name">{{ repo.name }}</span> | |
| 41 | + | {%- if let Some(desc) = repo.description %} | |
| 42 | + | <span class="repo-desc">{{ desc }}</span> | |
| 43 | + | {%- endif %} | |
| 44 | + | </a> | |
| 45 | + | <span class="repo-meta label"> | |
| 46 | + | {%- if repo.fresh %}<span class="dot" title="Updated in the last 24 hours"></span>{% endif -%} | |
| 47 | + | {%- if let Some(age) = repo.age %}<time datetime="{{ repo.iso.as_deref().unwrap_or_default() }}" title="{{ repo.iso.as_deref().unwrap_or_default() }}">{{ age }}</time>{% else %}Empty{% endif -%} | |
| 48 | + | </span> | |
| 49 | + | </li> | |
| 50 | + | {%- endfor %} | |
| 51 | + | </ul> | |
| 52 | + | </section> | |
| 53 | + | {%- endfor %} | |
| 54 | + | <p class="status no-matches" data-filter-empty{% if shown > 0 %} hidden{% endif %}>[No matches]</p> | |
| 55 | + | {%- endif %} | |
| 56 | + | {% endblock %} | |
| @@ -0,0 +1,24 @@ | |||
| 1 | + | {% macro tree_list(rows, static_base) %} | |
| 2 | + | <ul class="tree"> | |
| 3 | + | {%- for row in rows %} | |
| 4 | + | <li class="tree-row"> | |
| 5 | + | {%- if let Some(url) = row.url %} | |
| 6 | + | <a class="tree-name" href="{{ url }}"> | |
| 7 | + | {%- else %} | |
| 8 | + | <span class="tree-name"> | |
| 9 | + | {%- endif %} | |
| 10 | + | <svg class="icon icon-{{ row.icon }}" aria-hidden="true"><use href="{{ static_base }}icons/sprite.svg#{{ row.icon }}"></use></svg> | |
| 11 | + | <span>{{ row.name }}</span> | |
| 12 | + | {%- if row.url.is_some() %}</a>{% else %}</span>{% endif %} | |
| 13 | + | <span class="tree-meta">{{ row.meta }}</span> | |
| 14 | + | </li> | |
| 15 | + | {%- endfor %} | |
| 16 | + | </ul> | |
| 17 | + | {% endmacro %} | |
| 18 | + | ||
| 19 | + | {% macro readme(readme) %} | |
| 20 | + | <article class="readme"> | |
| 21 | + | <a class="label readme-name" href="{{ readme.url }}">{{ readme.name }}</a> | |
| 22 | + | <div class="markdown">{{ readme.html|safe }}</div> | |
| 23 | + | </article> | |
| 24 | + | {% endmacro %} | |
| @@ -0,0 +1,16 @@ | |||
| 1 | + | <div class="table-wrap"> | |
| 2 | + | <table class="data-table"> | |
| 3 | + | <thead> | |
| 4 | + | <tr><th class="label">Name</th><th class="label">Last commit</th><th class="label num">Updated</th></tr> | |
| 5 | + | </thead> | |
| 6 | + | <tbody> | |
| 7 | + | {%- for row in rows %} | |
| 8 | + | <tr> | |
| 9 | + | <td class="ref-name"><a href="{{ row.url }}">{{ row.name }}</a>{% if row.is_default %} <span class="chip">Default</span>{% endif %}</td> | |
| 10 | + | <td class="ref-subject">{{ row.subject }}</td> | |
| 11 | + | <td class="num"><time datetime="{{ row.iso }}" title="{{ row.iso }}">{{ row.age }}</time></td> | |
| 12 | + | </tr> | |
| 13 | + | {%- endfor %} | |
| 14 | + | </tbody> | |
| 15 | + | </table> | |
| 16 | + | </div> | |
| @@ -0,0 +1,23 @@ | |||
| 1 | + | {% extends "base.html" %} | |
| 2 | + | {% block content %} | |
| 3 | + | {% include "repo_nav.html" %} | |
| 4 | + | {% include "crumbs.html" %} | |
| 5 | + | <section class="refs-section"> | |
| 6 | + | <h2 class="label section-label">Branches</h2> | |
| 7 | + | {%- if branches.is_empty() %} | |
| 8 | + | <p class="status">[No branches]</p> | |
| 9 | + | {%- else %} | |
| 10 | + | {%- let rows = branches %} | |
| 11 | + | {% include "ref_table.html" %} | |
| 12 | + | {%- endif %} | |
| 13 | + | </section> | |
| 14 | + | <section class="refs-section"> | |
| 15 | + | <h2 class="label section-label">Tags</h2> | |
| 16 | + | {%- if tags.is_empty() %} | |
| 17 | + | <p class="status">[No tags]</p> | |
| 18 | + | {%- else %} | |
| 19 | + | {%- let rows = tags %} | |
| 20 | + | {% include "ref_table.html" %} | |
| 21 | + | {%- endif %} | |
| 22 | + | </section> | |
| 23 | + | {% endblock %} | |
| @@ -0,0 +1,36 @@ | |||
| 1 | + | <div class="repo-bar"> | |
| 2 | + | <nav class="bracket-nav" aria-label="Repository"> | |
| 3 | + | <a class="label{% if nav.active == "summary" %} active{% endif %}" href="{{ nav.url }}"{% if nav.active == "summary" %} aria-current="page"{% endif %}>Summary</a> | |
| 4 | + | <a class="label{% if nav.active == "tree" %} active{% endif %}" href="{{ nav.tree_url }}"{% if nav.active == "tree" %} aria-current="page"{% endif %}>Tree</a> | |
| 5 | + | <a class="label{% if nav.active == "refs" %} active{% endif %}" href="{{ nav.refs_url }}"{% if nav.active == "refs" %} aria-current="page"{% endif %}>Refs</a> | |
| 6 | + | </nav> | |
| 7 | + | {%- if let Some(picker) = nav.picker %} | |
| 8 | + | <details class="ref-picker"> | |
| 9 | + | <summary> | |
| 10 | + | <svg class="icon" aria-hidden="true"><use href="{{ layout.static_base }}icons/sprite.svg#git-branch"></use></svg> | |
| 11 | + | <span class="label dim">{{ picker.kind }}</span> | |
| 12 | + | <span class="ref-current">{{ picker.current }}</span> | |
| 13 | + | <svg class="icon chevron" aria-hidden="true"><use href="{{ layout.static_base }}icons/sprite.svg#chevron-down"></use></svg> | |
| 14 | + | </summary> | |
| 15 | + | <div class="ref-menu"> | |
| 16 | + | {%- if !picker.branches.is_empty() %} | |
| 17 | + | <p class="label ref-group">Branches</p> | |
| 18 | + | <ul> | |
| 19 | + | {%- for item in picker.branches %} | |
| 20 | + | <li><a href="{{ item.url }}"{% if item.active %} class="active" aria-current="true"{% endif %}>{{ item.name }}</a></li> | |
| 21 | + | {%- endfor %} | |
| 22 | + | </ul> | |
| 23 | + | {%- endif %} | |
| 24 | + | {%- if !picker.tags.is_empty() %} | |
| 25 | + | <p class="label ref-group">Tags</p> | |
| 26 | + | <ul> | |
| 27 | + | {%- for item in picker.tags %} | |
| 28 | + | <li><a href="{{ item.url }}"{% if item.active %} class="active" aria-current="true"{% endif %}>{{ item.name }}</a></li> | |
| 29 | + | {%- endfor %} | |
| 30 | + | </ul> | |
| 31 | + | {%- endif %} | |
| 32 | + | <a class="label ref-all" href="{{ nav.refs_url }}">{% if picker.more_tags %}All refs{% else %}Refs{% endif %} →</a> | |
| 33 | + | </div> | |
| 34 | + | </details> | |
| 35 | + | {%- endif %} | |
| 36 | + | </div> | |
| @@ -0,0 +1,66 @@ | |||
| 1 | + | {% extends "base.html" %} | |
| 2 | + | {% import "macros.html" as m %} | |
| 3 | + | {% block content %} | |
| 4 | + | {% include "repo_nav.html" %} | |
| 5 | + | <section class="repo-hero"> | |
| 6 | + | <h1 class="display-md repo-title">{{ name }}</h1> | |
| 7 | + | {%- if let Some(description) = description %} | |
| 8 | + | <p class="repo-about">{{ description }}</p> | |
| 9 | + | {%- endif %} | |
| 10 | + | </section> | |
| 11 | + | <div class="repo-grid"> | |
| 12 | + | <div class="repo-main"> | |
| 13 | + | {%- if let Some(rows) = rows %} | |
| 14 | + | {% call m::tree_list(rows, layout.static_base) %}{% endcall %} | |
| 15 | + | {%- else %} | |
| 16 | + | <section class="empty"> | |
| 17 | + | <p class="empty-title">Empty repository</p> | |
| 18 | + | <p class="empty-desc">Push a branch and it will show up here.</p> | |
| 19 | + | </section> | |
| 20 | + | {%- endif %} | |
| 21 | + | {%- if let Some(readme) = readme %} | |
| 22 | + | {% call m::readme(readme) %}{% endcall %} | |
| 23 | + | {%- endif %} | |
| 24 | + | </div> | |
| 25 | + | <aside class="rail"> | |
| 26 | + | <dl class="stats"> | |
| 27 | + | {%- for stat in stats %} | |
| 28 | + | <div class="stat"> | |
| 29 | + | <dt class="label">{{ stat.label }}</dt> | |
| 30 | + | <dd>{% if let Some(title) = stat.title %}<time datetime="{{ title }}" title="{{ title }}">{{ stat.value }}</time>{% else %}{{ stat.value }}{% endif %}</dd> | |
| 31 | + | </div> | |
| 32 | + | {%- endfor %} | |
| 33 | + | </dl> | |
| 34 | + | {%- if !clone_urls.is_empty() %} | |
| 35 | + | <section class="rail-block" data-clone> | |
| 36 | + | <div class="rail-head"> | |
| 37 | + | <h2 class="label">Clone</h2> | |
| 38 | + | {%- if clone_urls.len() > 1 %} | |
| 39 | + | <div class="segmented segmented-sm clone-switch" role="group" aria-label="Clone protocol"> | |
| 40 | + | {%- for url in clone_urls %} | |
| 41 | + | <button type="button" data-clone-choice="{{ loop.index0 }}" aria-pressed="{% if loop.first %}true{% else %}false{% endif %}">{{ url.label }}</button> | |
| 42 | + | {%- endfor %} | |
| 43 | + | </div> | |
| 44 | + | {%- endif %} | |
| 45 | + | </div> | |
| 46 | + | {%- for url in clone_urls %} | |
| 47 | + | <div class="clone-row" data-clone-url="{{ loop.index0 }}"> | |
| 48 | + | <input class="clone-input" type="text" readonly value="{{ url.url }}" aria-label="{{ url.label }} clone URL" spellcheck="false"> | |
| 49 | + | <button type="button" class="icon-btn" data-copy aria-label="Copy {{ url.label }} clone URL" title="Copy"> | |
| 50 | + | <svg class="icon" aria-hidden="true"><use href="{{ layout.static_base }}icons/sprite.svg#copy"></use></svg> | |
| 51 | + | </button> | |
| 52 | + | </div> | |
| 53 | + | {%- endfor %} | |
| 54 | + | <p class="status copy-status" data-copy-status aria-live="polite"></p> | |
| 55 | + | </section> | |
| 56 | + | {%- endif %} | |
| 57 | + | {%- if let Some(latest) = latest %} | |
| 58 | + | <section class="rail-block"> | |
| 59 | + | <h2 class="label">Latest</h2> | |
| 60 | + | <p class="latest-subject">{{ latest.subject }}</p> | |
| 61 | + | <p class="latest-meta">{{ latest.author }} · <time datetime="{{ latest.iso }}" title="{{ latest.iso }}">{{ latest.age }}</time> · <a class="sha" href="{{ latest.url }}">{{ latest.id }}</a></p> | |
| 62 | + | </section> | |
| 63 | + | {%- endif %} | |
| 64 | + | </aside> | |
| 65 | + | </div> | |
| 66 | + | {% endblock %} | |
| @@ -0,0 +1,11 @@ | |||
| 1 | + | {% extends "base.html" %} | |
| 2 | + | {% import "macros.html" as m %} | |
| 3 | + | {% block content %} | |
| 4 | + | {% include "repo_nav.html" %} | |
| 5 | + | {% include "crumbs.html" %} | |
| 6 | + | {% call m::tree_list(rows, layout.static_base) %}{% endcall %} | |
| 7 | + | <p class="label dim tree-foot">{{ entries }} {% if entries == 1 %}entry{% else %}entries{% endif %} · Tree {{ tree_id }}</p> | |
| 8 | + | {%- if let Some(readme) = readme %} | |
| 9 | + | {% call m::readme(readme) %}{% endcall %} | |
| 10 | + | {%- endif %} | |
| 11 | + | {% endblock %} | |
| @@ -0,0 +1,409 @@ | |||
| 1 | + | //! Browsing pages (summary, tree, blob, raw, refs) against real repos. | |
| 2 | + | ||
| 3 | + | mod common; | |
| 4 | + | ||
| 5 | + | use axum::body::{Body, to_bytes}; | |
| 6 | + | use axum::http::{HeaderMap, Request, StatusCode, header}; | |
| 7 | + | use common::{Rich, bare, rich_repo}; | |
| 8 | + | use ferrenium::config::Config; | |
| 9 | + | use ferrenium::server; | |
| 10 | + | use ferrenium::state::{AppState, Overrides}; | |
| 11 | + | use tower::ServiceExt; | |
| 12 | + | ||
| 13 | + | struct Site { | |
| 14 | + | _root: tempfile::TempDir, | |
| 15 | + | app: axum::Router, | |
| 16 | + | rich: Rich, | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | fn site(extra: &str) -> Site { | |
| 20 | + | let root = tempfile::tempdir().unwrap(); | |
| 21 | + | let rich = rich_repo(root.path(), "rich.git"); | |
| 22 | + | rich_repo(root.path(), "forks/nested.git"); | |
| 23 | + | bare(root.path(), "empty.git", &[]); | |
| 24 | + | let toml = format!( | |
| 25 | + | "[repos]\nscan_path = {:?}\n[watch]\nenabled = false\n\ | |
| 26 | + | [site]\nclone_urls = [\"https://git.example.com/{{name}}\", \"git@example.com:{{name}}\"]\n\ | |
| 27 | + | [ui]\nmax_blob_kb = 2\n{extra}", | |
| 28 | + | root.path().to_str().unwrap() | |
| 29 | + | ); | |
| 30 | + | let mut config = Config::parse(&toml).expect("parse"); | |
| 31 | + | config.finish().expect("valid"); | |
| 32 | + | let state = AppState::new(config, Overrides::default()); | |
| 33 | + | let config = state.config.load_full(); | |
| 34 | + | state.index.full_scan(&config); | |
| 35 | + | Site { | |
| 36 | + | app: server::app(state), | |
| 37 | + | rich, | |
| 38 | + | _root: root, | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | struct Res { | |
| 43 | + | status: StatusCode, | |
| 44 | + | headers: HeaderMap, | |
| 45 | + | body: String, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | impl Res { | |
| 49 | + | fn header(&self, name: header::HeaderName) -> &str { | |
| 50 | + | self.headers | |
| 51 | + | .get(&name) | |
| 52 | + | .map(|v| v.to_str().unwrap()) | |
| 53 | + | .unwrap_or_default() | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | async fn fetch(app: &axum::Router, uri: &str, etag: Option<&str>) -> Res { | |
| 58 | + | let mut req = Request::get(uri); | |
| 59 | + | if let Some(etag) = etag { | |
| 60 | + | req = req.header(header::IF_NONE_MATCH, etag); | |
| 61 | + | } | |
| 62 | + | let res = app | |
| 63 | + | .clone() | |
| 64 | + | .oneshot(req.body(Body::empty()).unwrap()) | |
| 65 | + | .await | |
| 66 | + | .unwrap(); | |
| 67 | + | let status = res.status(); | |
| 68 | + | let headers = res.headers().clone(); | |
| 69 | + | let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); | |
| 70 | + | Res { | |
| 71 | + | status, | |
| 72 | + | headers, | |
| 73 | + | body: String::from_utf8_lossy(&body).into_owned(), | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | async fn ok(app: &axum::Router, uri: &str) -> Res { | |
| 78 | + | let res = fetch(app, uri, None).await; | |
| 79 | + | assert_eq!(res.status, StatusCode::OK, "{uri}: {}", res.body); | |
| 80 | + | assert_csp_clean(&res.body); | |
| 81 | + | res | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// Pages must work under `script-src 'self'` and no inline styles. | |
| 85 | + | fn assert_csp_clean(html: &str) { | |
| 86 | + | assert!(!html.contains("<script>"), "inline script"); | |
| 87 | + | assert!(!html.contains("<style"), "inline style"); | |
| 88 | + | assert!(!html.contains("style=\""), "style attribute"); | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | #[tokio::test] | |
| 92 | + | async fn summary_shows_tree_readme_and_rail() { | |
| 93 | + | let s = site(""); | |
| 94 | + | let res = ok(&s.app, "/rich").await; | |
| 95 | + | let html = &res.body; | |
| 96 | + | assert!( | |
| 97 | + | html.contains(r#"<h1 class="display-md repo-title">rich</h1>"#), | |
| 98 | + | "{html}" | |
| 99 | + | ); | |
| 100 | + | // Root tree: directories first. | |
| 101 | + | let docs = html.find(">docs<").expect("docs row"); | |
| 102 | + | let readme = html.find(">README.md<").expect("README row"); | |
| 103 | + | assert!(docs < readme); | |
| 104 | + | assert!(html.contains(r#"href="/rich/tree/main/src""#)); | |
| 105 | + | assert!(html.contains(r#"href="/rich/blob/main/README.md""#)); | |
| 106 | + | // README: rendered, sanitized, relative links rewritten. | |
| 107 | + | assert!( | |
| 108 | + | html.contains(r#"href="/rich/blob/main/docs/guide.md""#), | |
| 109 | + | "{html}" | |
| 110 | + | ); | |
| 111 | + | assert!(html.contains(r#"src="/rich/raw/main/img/logo.png""#)); | |
| 112 | + | assert!(!html.contains("alert(1)")); | |
| 113 | + | // Rail. | |
| 114 | + | assert!(html.contains("<dd>2</dd>"), "commit count: {html}"); | |
| 115 | + | assert!(html.contains("https://git.example.com/rich")); | |
| 116 | + | assert!(html.contains("git@example.com:rich")); | |
| 117 | + | assert!(html.contains(">HTTPS<") && html.contains(">SSH<")); | |
| 118 | + | assert!(html.contains("add everything")); | |
| 119 | + | assert!(html.contains(&format!(r#"href="/rich/tree/{}""#, s.rich.head))); | |
| 120 | + | // Nav + picker. | |
| 121 | + | assert!(html.contains(r#"class="label active" href="/rich" aria-current="page">Summary"#)); | |
| 122 | + | assert!(html.contains(r#"href="/rich/tree/feature/x""#)); | |
| 123 | + | assert!(html.contains(r#"href="/rich/tree/v1.1""#)); | |
| 124 | + | ||
| 125 | + | // Conditional request. | |
| 126 | + | let etag = res.header(header::ETAG).to_string(); | |
| 127 | + | assert!(etag.starts_with("W/\""), "{etag}"); | |
| 128 | + | assert_eq!(res.header(header::CACHE_CONTROL), "no-cache"); | |
| 129 | + | let again = fetch(&s.app, "/rich", Some(&etag)).await; | |
| 130 | + | assert_eq!(again.status, StatusCode::NOT_MODIFIED); | |
| 131 | + | assert!(again.body.is_empty()); | |
| 132 | + | assert_eq!(again.header(header::ETAG), etag); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | #[tokio::test] | |
| 136 | + | async fn empty_repo_summary() { | |
| 137 | + | let s = site(""); | |
| 138 | + | let res = ok(&s.app, "/empty").await; | |
| 139 | + | assert!(res.body.contains("Empty repository")); | |
| 140 | + | assert!(res.body.contains("git@example.com:empty")); | |
| 141 | + | let tree = fetch(&s.app, "/empty/tree", None).await; | |
| 142 | + | assert_eq!(tree.status, StatusCode::NOT_FOUND); | |
| 143 | + | assert!(tree.body.contains("[ERROR: REPOSITORY IS EMPTY]")); | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | #[tokio::test] | |
| 147 | + | async fn dispatcher_matches_repos_and_revisions() { | |
| 148 | + | let s = site(""); | |
| 149 | + | // Slashy repo name. | |
| 150 | + | ok(&s.app, "/forks/nested").await; | |
| 151 | + | ok(&s.app, "/forks/nested/tree/main/src").await; | |
| 152 | + | // `.git` suffix redirects. | |
| 153 | + | let res = fetch(&s.app, "/forks/nested.git", None).await; | |
| 154 | + | assert_eq!(res.status, StatusCode::PERMANENT_REDIRECT); | |
| 155 | + | assert_eq!(res.header(header::LOCATION), "/forks/nested"); | |
| 156 | + | // Slashy branch, tags, full and short ids. | |
| 157 | + | let res = ok(&s.app, "/rich/tree/feature/x").await; | |
| 158 | + | assert!(res.body.contains("only-on-feature.txt")); | |
| 159 | + | let res = ok(&s.app, "/rich/blob/feature/x/only-on-feature.txt").await; | |
| 160 | + | assert!(res.body.contains("feature")); | |
| 161 | + | let res = ok(&s.app, "/rich/tree/v1.1").await; | |
| 162 | + | assert!(!res.body.contains("data.bin"), "v1.1 is the first commit"); | |
| 163 | + | ok(&s.app, "/rich/blob/v1.0/data.bin").await; | |
| 164 | + | let full = ok(&s.app, &format!("/rich/tree/{}", s.rich.first)).await; | |
| 165 | + | assert_eq!(full.header(header::CACHE_CONTROL), "public, max-age=86400"); | |
| 166 | + | let short = ok(&s.app, &format!("/rich/tree/{}", &s.rich.feature[..8])).await; | |
| 167 | + | assert!(short.body.contains("only-on-feature.txt")); | |
| 168 | + | assert_eq!(short.header(header::CACHE_CONTROL), "no-cache"); | |
| 169 | + | ||
| 170 | + | for (uri, message) in [ | |
| 171 | + | ("/nope", "PAGE NOT FOUND"), | |
| 172 | + | ("/rich/tree/nope", "UNKNOWN REVISION"), | |
| 173 | + | ("/rich/tree/main/nope", "PATH NOT FOUND IN main"), | |
| 174 | + | ("/rich/blob/main/vendor/lib", "PATH IS A SUBMODULE"), | |
| 175 | + | ("/rich/log", "PAGE NOT FOUND"), | |
| 176 | + | ("/rich/tree/main/../x", "PAGE NOT FOUND"), | |
| 177 | + | ("/-/nope", "PAGE NOT FOUND"), | |
| 178 | + | ] { | |
| 179 | + | let res = fetch(&s.app, uri, None).await; | |
| 180 | + | assert_eq!(res.status, StatusCode::NOT_FOUND, "{uri}"); | |
| 181 | + | assert!( | |
| 182 | + | res.body.contains(&format!("[ERROR: {message}]")), | |
| 183 | + | "{uri}: {}", | |
| 184 | + | res.body | |
| 185 | + | ); | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | #[tokio::test] | |
| 190 | + | async fn tree_page() { | |
| 191 | + | let s = site(""); | |
| 192 | + | // Submodule: no link, short commit id instead of a size. | |
| 193 | + | let res = ok(&s.app, "/rich/tree/main/vendor").await; | |
| 194 | + | let row = res.body.split("<li class=\"tree-row\">").nth(2).unwrap(); | |
| 195 | + | assert!(row.contains(r#"<span class="tree-name">"#), "{row}"); | |
| 196 | + | assert!(row.contains(&format!("@ {}", &s.rich.first[..7])), "{row}"); | |
| 197 | + | ||
| 198 | + | let res = ok(&s.app, "/rich/tree/main/docs").await; | |
| 199 | + | let html = &res.body; | |
| 200 | + | assert!( | |
| 201 | + | html.contains(r#"<a href="/rich/tree/main">rich</a>"#), | |
| 202 | + | "{html}" | |
| 203 | + | ); | |
| 204 | + | assert!(html.contains(r#"<span class="crumb-here">docs</span>"#)); | |
| 205 | + | assert!(html.contains(r#"href="/rich/tree/main">"#)); // ".." row | |
| 206 | + | assert!(html.contains("2 entries · Tree ")); | |
| 207 | + | // Plain-text directory README, escaped. | |
| 208 | + | assert!( | |
| 209 | + | html.contains("Plain docs readme <b>not html</b>"), | |
| 210 | + | "{html}" | |
| 211 | + | ); | |
| 212 | + | // Picker keeps the path. | |
| 213 | + | assert!(html.contains(r#"href="/rich/tree/v1.0/docs""#)); | |
| 214 | + | // Unicode, spaces and `#` are percent-encoded. | |
| 215 | + | let res = ok(&s.app, "/rich/tree/main").await; | |
| 216 | + | assert!( | |
| 217 | + | res.body.contains("/rich/tree/main/%C3%BCn%C3%AFcode%20dir"), | |
| 218 | + | "{}", | |
| 219 | + | res.body | |
| 220 | + | ); | |
| 221 | + | let res = ok(&s.app, "/rich/tree/main/%C3%BCn%C3%AFcode%20dir").await; | |
| 222 | + | assert!( | |
| 223 | + | res.body | |
| 224 | + | .contains("/rich/blob/main/%C3%BCn%C3%AFcode%20dir/f%C3%AFle%20%231.txt") | |
| 225 | + | ); | |
| 226 | + | // ETag round trip. | |
| 227 | + | let etag = res.header(header::ETAG).to_string(); | |
| 228 | + | let again = fetch( | |
| 229 | + | &s.app, | |
| 230 | + | "/rich/tree/main/%C3%BCn%C3%AFcode%20dir", | |
| 231 | + | Some(&etag), | |
| 232 | + | ) | |
| 233 | + | .await; | |
| 234 | + | assert_eq!(again.status, StatusCode::NOT_MODIFIED); | |
| 235 | + | ||
| 236 | + | // A file in tree view redirects to the blob view, and vice versa. | |
| 237 | + | let res = fetch(&s.app, "/rich/tree/main/src/main.rs", None).await; | |
| 238 | + | assert_eq!(res.status, StatusCode::TEMPORARY_REDIRECT); | |
| 239 | + | assert_eq!(res.header(header::LOCATION), "/rich/blob/main/src/main.rs"); | |
| 240 | + | let res = fetch(&s.app, "/rich/blob/main/src", None).await; | |
| 241 | + | assert_eq!(res.header(header::LOCATION), "/rich/tree/main/src"); | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | #[tokio::test] | |
| 245 | + | async fn blob_page_variants() { | |
| 246 | + | let s = site(""); | |
| 247 | + | // Highlighted code with line anchors. | |
| 248 | + | let res = ok(&s.app, "/rich/blob/main/src/main.rs").await; | |
| 249 | + | let html = &res.body; | |
| 250 | + | assert!(html.contains("4 lines · 45 B · Rust"), "{html}"); | |
| 251 | + | assert!(html.contains(r##"<tr id="L2"><td class="ln"><a href="#L2">2</a></td>"##)); | |
| 252 | + | assert!(html.contains(r#"<span class="hl-k">fn</span>"#), "{html}"); | |
| 253 | + | assert!(html.contains("<hi>")); | |
| 254 | + | assert!(html.contains(r#"href="/rich/raw/main/src/main.rs""#)); | |
| 255 | + | assert!(html.contains(r#"href="/rich/raw/main/src/main.rs?download""#)); | |
| 256 | + | assert!(html.contains(r#"<span class="crumb-here">main.rs</span>"#)); | |
| 257 | + | ||
| 258 | + | // Markdown: preview by default, source on request. | |
| 259 | + | let res = ok(&s.app, "/rich/blob/main/docs/guide.md").await; | |
| 260 | + | assert!( | |
| 261 | + | res.body | |
| 262 | + | .contains(r#"<article class="markdown blob-markdown">"#) | |
| 263 | + | ); | |
| 264 | + | assert!( | |
| 265 | + | res.body.contains(r#"href="/rich/blob/main/README.md""#), | |
| 266 | + | "{}", | |
| 267 | + | res.body | |
| 268 | + | ); | |
| 269 | + | let res = ok(&s.app, "/rich/blob/main/docs/guide.md?view=source").await; | |
| 270 | + | assert!(res.body.contains(r#"id="L1""#)); | |
| 271 | + | assert!(res.body.contains(r#"aria-current="true">Source"#)); | |
| 272 | + | ||
| 273 | + | let res = ok(&s.app, "/rich/blob/main/img/logo.png").await; | |
| 274 | + | assert!( | |
| 275 | + | res.body | |
| 276 | + | .contains(r#"<img src="/rich/raw/main/img/logo.png""#) | |
| 277 | + | ); | |
| 278 | + | let res = ok(&s.app, "/rich/blob/main/data.bin").await; | |
| 279 | + | assert!(res.body.contains("[Binary file]")); | |
| 280 | + | let res = ok(&s.app, "/rich/blob/main/big.txt").await; | |
| 281 | + | assert!( | |
| 282 | + | res.body.contains("[Too large to display]"), | |
| 283 | + | "max_blob_kb = 2" | |
| 284 | + | ); | |
| 285 | + | let res = ok(&s.app, "/rich/blob/main/link-to-main").await; | |
| 286 | + | assert!( | |
| 287 | + | res.body | |
| 288 | + | .contains(r#"href="/rich/blob/main/src/main.rs">src/main.rs</a>"#) | |
| 289 | + | ); | |
| 290 | + | let res = ok( | |
| 291 | + | &s.app, | |
| 292 | + | "/rich/blob/main/%C3%BCn%C3%AFcode%20dir/f%C3%AFle%20%231.txt", | |
| 293 | + | ) | |
| 294 | + | .await; | |
| 295 | + | assert!(res.body.contains("fïle #1.txt")); | |
| 296 | + | // HTML source is shown as text, never rendered. | |
| 297 | + | let res = ok(&s.app, "/rich/blob/main/page.html").await; | |
| 298 | + | assert!(res.body.contains("alert") && !res.body.contains("<script>alert")); | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | #[tokio::test] | |
| 302 | + | async fn raw_is_sandboxed() { | |
| 303 | + | let s = site(""); | |
| 304 | + | let res = fetch(&s.app, "/rich/raw/main/page.html", None).await; | |
| 305 | + | assert_eq!(res.status, StatusCode::OK); | |
| 306 | + | assert_eq!( | |
| 307 | + | res.header(header::CONTENT_TYPE), | |
| 308 | + | "text/plain; charset=utf-8" | |
| 309 | + | ); | |
| 310 | + | assert_eq!(res.header(header::X_CONTENT_TYPE_OPTIONS), "nosniff"); | |
| 311 | + | assert!( | |
| 312 | + | res.header(header::CONTENT_SECURITY_POLICY) | |
| 313 | + | .contains("sandbox") | |
| 314 | + | ); | |
| 315 | + | assert_eq!(res.body, "<html><script>alert(1)</script></html>\n"); | |
| 316 | + | ||
| 317 | + | let res = fetch(&s.app, "/rich/raw/main/img/logo.png", None).await; | |
| 318 | + | assert_eq!(res.header(header::CONTENT_TYPE), "image/png"); | |
| 319 | + | let res = fetch(&s.app, "/rich/raw/main/data.bin", None).await; | |
| 320 | + | assert_eq!(res.header(header::CONTENT_TYPE), "application/octet-stream"); | |
| 321 | + | ||
| 322 | + | let res = fetch(&s.app, "/rich/raw/main/page.html?download", None).await; | |
| 323 | + | assert_eq!( | |
| 324 | + | res.header(header::CONTENT_DISPOSITION), | |
| 325 | + | "attachment; filename=\"page.html\"; filename*=UTF-8''page.html" | |
| 326 | + | ); | |
| 327 | + | let res = fetch( | |
| 328 | + | &s.app, | |
| 329 | + | "/rich/raw/main/%C3%BCn%C3%AFcode%20dir/f%C3%AFle%20%231.txt?download", | |
| 330 | + | None, | |
| 331 | + | ) | |
| 332 | + | .await; | |
| 333 | + | assert!( | |
| 334 | + | res.header(header::CONTENT_DISPOSITION) | |
| 335 | + | .contains("filename*=UTF-8''f%C3%AFle%20%231.txt") | |
| 336 | + | ); | |
| 337 | + | ||
| 338 | + | // Raw by full id is cacheable; ETag revalidates. | |
| 339 | + | let uri = format!("/rich/raw/{}/src/main.rs", s.rich.head); | |
| 340 | + | let res = fetch(&s.app, &uri, None).await; | |
| 341 | + | assert_eq!(res.header(header::CACHE_CONTROL), "public, max-age=86400"); | |
| 342 | + | let etag = res.header(header::ETAG).to_string(); | |
| 343 | + | assert_eq!( | |
| 344 | + | fetch(&s.app, &uri, Some(&etag)).await.status, | |
| 345 | + | StatusCode::NOT_MODIFIED | |
| 346 | + | ); | |
| 347 | + | ||
| 348 | + | assert_eq!( | |
| 349 | + | fetch(&s.app, "/rich/raw/main/src", None).await.status, | |
| 350 | + | StatusCode::NOT_FOUND | |
| 351 | + | ); | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | #[tokio::test] | |
| 355 | + | async fn refs_page() { | |
| 356 | + | let s = site(""); | |
| 357 | + | let res = ok(&s.app, "/rich/refs").await; | |
| 358 | + | let html = &res.body; | |
| 359 | + | let main = html.find(r#"href="/rich/tree/main""#).expect("main"); | |
| 360 | + | let feature = html | |
| 361 | + | .find(r#"href="/rich/tree/feature/x""#) | |
| 362 | + | .expect("feature"); | |
| 363 | + | assert!(main < feature, "default branch first"); | |
| 364 | + | assert!(html.contains(r#"<span class="chip">Default</span>"#)); | |
| 365 | + | assert!(html.contains("Release 1.1"), "annotated tag subject"); | |
| 366 | + | assert!(html.contains(r#"href="/rich/tree/v1.0""#)); | |
| 367 | + | assert!(html.contains(r#"<span class="crumb-here">Refs</span>"#)); | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | #[tokio::test] | |
| 371 | + | async fn base_path_prefixes_repo_links() { | |
| 372 | + | let s = site("[server]\nbase_path = \"/git/\"\n"); | |
| 373 | + | let res = ok(&s.app, "/git/rich").await; | |
| 374 | + | assert!(res.body.contains(r#"href="/git/rich/tree/main/src""#)); | |
| 375 | + | assert!( | |
| 376 | + | res.body | |
| 377 | + | .contains(r#"href="/git/rich/blob/main/docs/guide.md""#) | |
| 378 | + | ); | |
| 379 | + | let res = fetch(&s.app, "/git/forks/nested.git", None).await; | |
| 380 | + | assert_eq!(res.header(header::LOCATION), "/git/forks/nested"); | |
| 381 | + | ok(&s.app, "/git/rich/blob/main/src/main.rs").await; | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | #[tokio::test] | |
| 385 | + | async fn every_referenced_icon_exists() { | |
| 386 | + | let s = site(""); | |
| 387 | + | let res = ok(&s.app, "/rich").await; | |
| 388 | + | let sprite_url = res | |
| 389 | + | .body | |
| 390 | + | .split('"') | |
| 391 | + | .find(|s| s.contains("icons/sprite.svg#")) | |
| 392 | + | .unwrap() | |
| 393 | + | .split('#') | |
| 394 | + | .next() | |
| 395 | + | .unwrap() | |
| 396 | + | .to_string(); | |
| 397 | + | let sprite = ok(&s.app, &sprite_url).await.body; | |
| 398 | + | for id in res | |
| 399 | + | .body | |
| 400 | + | .split("sprite.svg#") | |
| 401 | + | .skip(1) | |
| 402 | + | .map(|s| s.split('"').next().unwrap()) | |
| 403 | + | { | |
| 404 | + | assert!( | |
| 405 | + | sprite.contains(&format!("id=\"{id}\"")), | |
| 406 | + | "missing icon {id}" | |
| 407 | + | ); | |
| 408 | + | } | |
| 409 | + | } | |
| @@ -0,0 +1,153 @@ | |||
| 1 | + | //! Fixture helpers: build real repositories with the git CLI. | |
| 2 | + | ||
| 3 | + | #![allow(dead_code)] | |
| 4 | + | ||
| 5 | + | use std::path::{Path, PathBuf}; | |
| 6 | + | use std::process::Command; | |
| 7 | + | ||
| 8 | + | /// Run git in `dir` with an isolated environment; panics on failure. | |
| 9 | + | pub fn git(dir: &Path, args: &[&str]) -> String { | |
| 10 | + | let home = std::env::temp_dir(); | |
| 11 | + | let out = Command::new("git") | |
| 12 | + | .args(args) | |
| 13 | + | .current_dir(dir) | |
| 14 | + | .env("GIT_CONFIG_NOSYSTEM", "1") | |
| 15 | + | .env("GIT_CONFIG_GLOBAL", "/dev/null") | |
| 16 | + | .env("HOME", home) | |
| 17 | + | .env("GIT_AUTHOR_NAME", "Ada") | |
| 18 | + | .env("GIT_AUTHOR_EMAIL", "ada@example.com") | |
| 19 | + | .env("GIT_COMMITTER_NAME", "Ada") | |
| 20 | + | .env("GIT_COMMITTER_EMAIL", "ada@example.com") | |
| 21 | + | .env("GIT_TERMINAL_PROMPT", "0") | |
| 22 | + | .output() | |
| 23 | + | .expect("git runs"); | |
| 24 | + | assert!( | |
| 25 | + | out.status.success(), | |
| 26 | + | "git {args:?} failed:\n{}", | |
| 27 | + | String::from_utf8_lossy(&out.stderr) | |
| 28 | + | ); | |
| 29 | + | String::from_utf8_lossy(&out.stdout).trim().to_string() | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// A bare repo at `root/rel` (created with `init --bare`). | |
| 33 | + | pub fn bare(root: &Path, rel: &str, extra: &[&str]) -> PathBuf { | |
| 34 | + | let path = root.join(rel); | |
| 35 | + | std::fs::create_dir_all(&path).unwrap(); | |
| 36 | + | let mut args = vec!["init", "--bare", "-q", "-b", "main"]; | |
| 37 | + | args.extend_from_slice(extra); | |
| 38 | + | git(&path, &args); | |
| 39 | + | path | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// A scratch clone of `bare` used to make commits and push them. | |
| 43 | + | pub struct Work { | |
| 44 | + | pub dir: tempfile::TempDir, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | impl Work { | |
| 48 | + | pub fn clone_of(bare: &Path) -> Work { | |
| 49 | + | let dir = tempfile::tempdir().unwrap(); | |
| 50 | + | git(dir.path(), &["clone", "-q", bare.to_str().unwrap(), "."]); | |
| 51 | + | git(dir.path(), &["checkout", "-q", "-B", "main"]); | |
| 52 | + | Work { dir } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | pub fn path(&self) -> &Path { | |
| 56 | + | self.dir.path() | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// Write `file`, commit it and return the new commit id. | |
| 60 | + | pub fn commit(&self, file: &str, content: &str, message: &str) -> String { | |
| 61 | + | let path = self.path().join(file); | |
| 62 | + | if let Some(parent) = path.parent() { | |
| 63 | + | std::fs::create_dir_all(parent).unwrap(); | |
| 64 | + | } | |
| 65 | + | std::fs::write(&path, content).unwrap(); | |
| 66 | + | git(self.path(), &["add", "-A"]); | |
| 67 | + | git(self.path(), &["commit", "-q", "-m", message]); | |
| 68 | + | git(self.path(), &["rev-parse", "HEAD"]) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | pub fn push(&self, refspec: &str) { | |
| 72 | + | git(self.path(), &["push", "-q", "origin", refspec]); | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// Bare repo with one commit on `main` containing `files`. | |
| 77 | + | pub fn repo_with(root: &Path, rel: &str, files: &[(&str, &str)]) -> (PathBuf, String) { | |
| 78 | + | let bare = bare(root, rel, &[]); | |
| 79 | + | let work = Work::clone_of(&bare); | |
| 80 | + | let mut head = String::new(); | |
| 81 | + | for (name, content) in files { | |
| 82 | + | head = work.commit(name, content, &format!("add {name}")); | |
| 83 | + | } | |
| 84 | + | work.push("main"); | |
| 85 | + | (bare, head) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// Ids of interesting objects in [`rich_repo`]. | |
| 89 | + | pub struct Rich { | |
| 90 | + | pub bare: PathBuf, | |
| 91 | + | pub head: String, | |
| 92 | + | pub first: String, | |
| 93 | + | pub feature: String, | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// A bare repo exercising the browsing edge cases: nested dirs, a README | |
| 97 | + | /// with relative links, a binary and an oversize file, a unicode path, a | |
| 98 | + | /// symlink, a submodule entry, a slashy branch and both kinds of tags. | |
| 99 | + | pub fn rich_repo(root: &Path, rel: &str) -> Rich { | |
| 100 | + | let bare = bare(root, rel, &[]); | |
| 101 | + | let work = Work::clone_of(&bare); | |
| 102 | + | let w = work.path(); | |
| 103 | + | let write = |name: &str, data: &[u8]| { | |
| 104 | + | let path = w.join(name); | |
| 105 | + | std::fs::create_dir_all(path.parent().unwrap()).unwrap(); | |
| 106 | + | std::fs::write(path, data).unwrap(); | |
| 107 | + | }; | |
| 108 | + | write( | |
| 109 | + | "src/main.rs", | |
| 110 | + | b"// entry\nfn main() {\n println!(\"<hi>\");\n}\n", | |
| 111 | + | ); | |
| 112 | + | let first = work.commit( | |
| 113 | + | "README.md", | |
| 114 | + | "# Rich\n\nSee [the guide](docs/guide.md) and .\n\n\ | |
| 115 | + | <script>alert(1)</script>\n", | |
| 116 | + | "first commit", | |
| 117 | + | ); | |
| 118 | + | write("docs/guide.md", b"# Guide\n\n[Back](../README.md)\n"); | |
| 119 | + | write("docs/README", b"Plain docs readme <b>not html</b>\n"); | |
| 120 | + | write("page.html", b"<html><script>alert(1)</script></html>\n"); | |
| 121 | + | write("img/logo.png", b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"); | |
| 122 | + | write("data.bin", b"\0\x01\x02binary\0"); | |
| 123 | + | write("big.txt", &b"0123456789abcdef\n".repeat(200)); // 3400 bytes | |
| 124 | + | write("ünïcode dir/fïle #1.txt", b"unicode\n"); | |
| 125 | + | std::os::unix::fs::symlink("src/main.rs", w.join("link-to-main")).unwrap(); | |
| 126 | + | git(w, &["add", "-A"]); | |
| 127 | + | git( | |
| 128 | + | w, | |
| 129 | + | &[ | |
| 130 | + | "update-index", | |
| 131 | + | "--add", | |
| 132 | + | "--cacheinfo", | |
| 133 | + | &format!("160000,{first},vendor/lib"), | |
| 134 | + | ], | |
| 135 | + | ); | |
| 136 | + | git(w, &["commit", "-q", "-m", "add everything"]); | |
| 137 | + | let head = git(w, &["rev-parse", "HEAD"]); | |
| 138 | + | git(w, &["tag", "v1.0"]); | |
| 139 | + | git( | |
| 140 | + | w, | |
| 141 | + | &["tag", "-a", "v1.1", "-m", "Release 1.1", first.as_str()], | |
| 142 | + | ); | |
| 143 | + | git(w, &["checkout", "-q", "-b", "feature/x"]); | |
| 144 | + | let feature = work.commit("only-on-feature.txt", "feature\n", "feature work"); | |
| 145 | + | git(w, &["checkout", "-q", "main"]); | |
| 146 | + | git(w, &["push", "-q", "origin", "main", "feature/x", "--tags"]); | |
| 147 | + | Rich { | |
| 148 | + | bare, | |
| 149 | + | head, | |
| 150 | + | first, | |
| 151 | + | feature, | |
| 152 | + | } | |
| 153 | + | } | |
| @@ -0,0 +1,215 @@ | |||
| 1 | + | //! Route-level tests driven through `tower::ServiceExt::oneshot`. | |
| 2 | + | ||
| 3 | + | use axum::body::{Body, to_bytes}; | |
| 4 | + | use axum::http::{Request, StatusCode, header}; | |
| 5 | + | use ferrenium::config::Config; | |
| 6 | + | use ferrenium::server; | |
| 7 | + | use ferrenium::state::{AppState, Overrides}; | |
| 8 | + | use tower::ServiceExt; | |
| 9 | + | ||
| 10 | + | fn app_with(toml: &str) -> (axum::Router, std::sync::Arc<AppState>) { | |
| 11 | + | let mut config = Config::parse(toml).expect("parse"); | |
| 12 | + | config.finish().expect("valid"); | |
| 13 | + | let state = AppState::new(config, Overrides::default()); | |
| 14 | + | (server::app(state.clone()), state) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | async fn get(app: &axum::Router, uri: &str) -> (StatusCode, axum::http::HeaderMap, String) { | |
| 18 | + | let res = app | |
| 19 | + | .clone() | |
| 20 | + | .oneshot(Request::get(uri).body(Body::empty()).unwrap()) | |
| 21 | + | .await | |
| 22 | + | .unwrap(); | |
| 23 | + | let status = res.status(); | |
| 24 | + | let headers = res.headers().clone(); | |
| 25 | + | let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); | |
| 26 | + | (status, headers, String::from_utf8_lossy(&body).into_owned()) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | #[tokio::test] | |
| 30 | + | async fn index_renders_styled_empty_page() { | |
| 31 | + | let (app, state) = | |
| 32 | + | app_with("[site]\ntitle = \"krolyxon\"\ndescription = \"Personal <repos>\"\n"); | |
| 33 | + | let (status, headers, body) = get(&app, "/").await; | |
| 34 | + | assert_eq!(status, StatusCode::OK); | |
| 35 | + | assert_eq!(headers[header::CONTENT_TYPE], "text/html; charset=utf-8"); | |
| 36 | + | assert!( | |
| 37 | + | headers[header::CONTENT_SECURITY_POLICY] | |
| 38 | + | .to_str() | |
| 39 | + | .unwrap() | |
| 40 | + | .contains("script-src 'self'") | |
| 41 | + | ); | |
| 42 | + | assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff"); | |
| 43 | + | ||
| 44 | + | let static_base = format!("/-/static/{}/", state.assets.hash()); | |
| 45 | + | assert!( | |
| 46 | + | body.contains(&format!("{static_base}css/app.css")), | |
| 47 | + | "{body}" | |
| 48 | + | ); | |
| 49 | + | assert!(body.contains(&format!("{static_base}js/theme.js"))); | |
| 50 | + | assert!(body.contains(r#"data-default-theme="auto""#)); | |
| 51 | + | assert!(!body.contains("data-theme=\"")); // auto: OS decides | |
| 52 | + | assert!(body.contains("krolyxon")); | |
| 53 | + | // Escaped by askama. | |
| 54 | + | assert!(body.contains("Personal <repos>") || body.contains("Personal <repos>")); | |
| 55 | + | assert!(body.contains("No repositories")); | |
| 56 | + | // No inline scripts or styles (CSP). | |
| 57 | + | assert!(!body.contains("<script>")); | |
| 58 | + | assert!(!body.contains("<style")); | |
| 59 | + | assert!(!body.contains("style=\"")); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | #[tokio::test] | |
| 63 | + | async fn forced_theme_is_rendered_server_side() { | |
| 64 | + | let (app, _) = app_with("[ui]\ntheme = \"light\"\n"); | |
| 65 | + | let (_, _, body) = get(&app, "/").await; | |
| 66 | + | assert!(body.contains(r#"data-default-theme="light" data-theme="light""#)); | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | #[tokio::test] | |
| 70 | + | async fn health() { | |
| 71 | + | let (app, _) = app_with(""); | |
| 72 | + | let (status, headers, body) = get(&app, "/-/health").await; | |
| 73 | + | assert_eq!(status, StatusCode::OK); | |
| 74 | + | assert_eq!(body, "ok\n"); | |
| 75 | + | assert_eq!(headers[header::CACHE_CONTROL], "no-store"); | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | #[tokio::test] | |
| 79 | + | async fn static_assets() { | |
| 80 | + | let (app, state) = app_with(""); | |
| 81 | + | let hash = state.assets.hash().to_string(); | |
| 82 | + | ||
| 83 | + | let (status, headers, body) = get(&app, &format!("/-/static/{hash}/css/app.css")).await; | |
| 84 | + | assert_eq!(status, StatusCode::OK); | |
| 85 | + | assert_eq!(headers[header::CONTENT_TYPE], "text/css; charset=utf-8"); | |
| 86 | + | let expected = if cfg!(debug_assertions) { | |
| 87 | + | "no-cache" | |
| 88 | + | } else { | |
| 89 | + | "public, max-age=31536000, immutable" | |
| 90 | + | }; | |
| 91 | + | assert_eq!(headers[header::CACHE_CONTROL], expected); | |
| 92 | + | assert!(body.contains("--text-display")); | |
| 93 | + | assert!(body.contains("url(../fonts/space-mono-latin-400.woff2)")); | |
| 94 | + | ||
| 95 | + | let (status, headers, _) = get( | |
| 96 | + | &app, | |
| 97 | + | &format!("/-/static/{hash}/fonts/space-mono-latin-400.woff2"), | |
| 98 | + | ) | |
| 99 | + | .await; | |
| 100 | + | assert_eq!(status, StatusCode::OK); | |
| 101 | + | assert_eq!(headers[header::CONTENT_TYPE], "font/woff2"); | |
| 102 | + | ||
| 103 | + | // Stale hash: still served, never cached as immutable. | |
| 104 | + | let (status, headers, _) = get(&app, "/-/static/000000000000/js/theme.js").await; | |
| 105 | + | assert_eq!(status, StatusCode::OK); | |
| 106 | + | assert_eq!(headers[header::CACHE_CONTROL], "no-cache"); | |
| 107 | + | ||
| 108 | + | let (status, _, _) = get(&app, &format!("/-/static/{hash}/nope.css")).await; | |
| 109 | + | assert_eq!(status, StatusCode::NOT_FOUND); | |
| 110 | + | let (status, _, _) = get(&app, &format!("/-/static/{hash}/../Cargo.toml")).await; | |
| 111 | + | assert_eq!(status, StatusCode::NOT_FOUND); | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | #[tokio::test] | |
| 115 | + | async fn every_referenced_asset_exists() { | |
| 116 | + | let (app, _) = app_with(""); | |
| 117 | + | let (_, _, page) = get(&app, "/").await; | |
| 118 | + | let mut urls: Vec<String> = page | |
| 119 | + | .split(['"', '\'']) | |
| 120 | + | .filter(|s| s.starts_with("/-/static/")) | |
| 121 | + | .map(str::to_string) | |
| 122 | + | .collect(); | |
| 123 | + | // Fonts referenced from the stylesheet resolve relative to css/, with | |
| 124 | + | // `..` collapsed the way a browser does before requesting. | |
| 125 | + | let css_url = urls | |
| 126 | + | .iter() | |
| 127 | + | .find(|u| u.ends_with("css/app.css")) | |
| 128 | + | .unwrap() | |
| 129 | + | .clone(); | |
| 130 | + | let (_, _, css) = get(&app, &css_url).await; | |
| 131 | + | let static_root = css_url.trim_end_matches("css/app.css"); | |
| 132 | + | for part in css.split("url(").skip(1) { | |
| 133 | + | let rel = part.split(')').next().unwrap(); | |
| 134 | + | let rel = rel.strip_prefix("../").expect("font urls are ../fonts/…"); | |
| 135 | + | urls.push(format!("{static_root}{rel}")); | |
| 136 | + | } | |
| 137 | + | assert!(urls.len() > 10, "{urls:?}"); | |
| 138 | + | for url in urls { | |
| 139 | + | let (status, _, _) = get(&app, &url).await; | |
| 140 | + | assert_eq!(status, StatusCode::OK, "{url}"); | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | #[tokio::test] | |
| 145 | + | async fn not_found_page() { | |
| 146 | + | let (app, _) = app_with(""); | |
| 147 | + | let (status, headers, body) = get(&app, "/no/such/thing").await; | |
| 148 | + | assert_eq!(status, StatusCode::NOT_FOUND); | |
| 149 | + | assert_eq!(headers[header::CONTENT_TYPE], "text/html; charset=utf-8"); | |
| 150 | + | assert!(body.contains(">404<")); | |
| 151 | + | assert!(body.contains("[ERROR: PAGE NOT FOUND]")); | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | #[tokio::test] | |
| 155 | + | async fn base_path_prefixes_routes_and_links() { | |
| 156 | + | let (app, state) = app_with("[server]\nbase_path = \"/git\"\n"); | |
| 157 | + | let (status, _, body) = get(&app, "/git/").await; | |
| 158 | + | assert_eq!(status, StatusCode::OK); | |
| 159 | + | assert!(body.contains(&format!( | |
| 160 | + | "/git/-/static/{}/css/app.css", | |
| 161 | + | state.assets.hash() | |
| 162 | + | ))); | |
| 163 | + | assert!(body.contains(r#"href="/git/""#)); | |
| 164 | + | ||
| 165 | + | let (status, headers, _) = get(&app, "/git").await; | |
| 166 | + | assert_eq!(status, StatusCode::PERMANENT_REDIRECT); | |
| 167 | + | assert_eq!(headers[header::LOCATION], "/git/"); | |
| 168 | + | ||
| 169 | + | assert_eq!(get(&app, "/git/-/health").await.0, StatusCode::OK); | |
| 170 | + | assert_eq!(get(&app, "/-/health").await.0, StatusCode::NOT_FOUND); | |
| 171 | + | assert_eq!(get(&app, "/gitx/").await.0, StatusCode::NOT_FOUND); | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | #[tokio::test] | |
| 175 | + | async fn site_css_carries_code_font_and_custom_css() { | |
| 176 | + | let (app, _) = app_with(""); | |
| 177 | + | let (_, _, body) = get(&app, "/").await; | |
| 178 | + | assert!(!body.contains("/-/site.css")); | |
| 179 | + | ||
| 180 | + | let (app, _) = app_with("[ui]\ncode_font = \"Berkeley Mono\"\n"); | |
| 181 | + | let (_, _, body) = get(&app, "/").await; | |
| 182 | + | let href = body | |
| 183 | + | .split('"') | |
| 184 | + | .find(|s| s.starts_with("/-/site.css?v=")) | |
| 185 | + | .expect("site.css link"); | |
| 186 | + | let (status, _, css) = get(&app, href).await; | |
| 187 | + | assert_eq!(status, StatusCode::OK); | |
| 188 | + | assert!(css.contains("--font-code: \"Berkeley Mono\"")); | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | #[tokio::test] | |
| 192 | + | async fn compression_skips_fonts() { | |
| 193 | + | let (app, state) = app_with(""); | |
| 194 | + | let hash = state.assets.hash().to_string(); | |
| 195 | + | let req = |uri: String| { | |
| 196 | + | Request::get(uri) | |
| 197 | + | .header(header::ACCEPT_ENCODING, "br, gzip") | |
| 198 | + | .body(Body::empty()) | |
| 199 | + | .unwrap() | |
| 200 | + | }; | |
| 201 | + | let res = app | |
| 202 | + | .clone() | |
| 203 | + | .oneshot(req(format!("/-/static/{hash}/css/app.css"))) | |
| 204 | + | .await | |
| 205 | + | .unwrap(); | |
| 206 | + | assert_eq!(res.headers()[header::CONTENT_ENCODING], "br"); | |
| 207 | + | let res = app | |
| 208 | + | .clone() | |
| 209 | + | .oneshot(req(format!( | |
| 210 | + | "/-/static/{hash}/fonts/space-mono-latin-400.woff2" | |
| 211 | + | ))) | |
| 212 | + | .await | |
| 213 | + | .unwrap(); | |
| 214 | + | assert!(res.headers().get(header::CONTENT_ENCODING).is_none()); | |
| 215 | + | } | |
| @@ -0,0 +1,271 @@ | |||
| 1 | + | //! Discovery, metadata precedence, hiding, the index page and live updates. | |
| 2 | + | ||
| 3 | + | mod common; | |
| 4 | + | ||
| 5 | + | use std::sync::Arc; | |
| 6 | + | use std::time::{Duration, Instant}; | |
| 7 | + | ||
| 8 | + | use axum::body::{Body, to_bytes}; | |
| 9 | + | use axum::http::{Request, StatusCode}; | |
| 10 | + | use common::{Work, bare, git, repo_with}; | |
| 11 | + | use ferrenium::config::Config; | |
| 12 | + | use ferrenium::index::Refresh; | |
| 13 | + | use ferrenium::server; | |
| 14 | + | use ferrenium::state::{AppState, Overrides}; | |
| 15 | + | use tower::ServiceExt; | |
| 16 | + | ||
| 17 | + | /// `repos` holds extra `[repos]` keys; `extra` any further tables. | |
| 18 | + | fn config(scan_path: &std::path::Path, repos: &str, extra: &str) -> Config { | |
| 19 | + | let toml = format!( | |
| 20 | + | "[repos]\nscan_path = {:?}\n{repos}\n[watch]\ndebounce_ms = 100\n{extra}", | |
| 21 | + | scan_path.to_str().unwrap() | |
| 22 | + | ); | |
| 23 | + | let mut config = Config::parse(&toml).expect("parse"); | |
| 24 | + | config.finish().expect("valid"); | |
| 25 | + | config | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | fn state(config: Config) -> Arc<AppState> { | |
| 29 | + | let state = AppState::new(config, Overrides::default()); | |
| 30 | + | let config = state.config.load_full(); | |
| 31 | + | state.index.full_scan(&config); | |
| 32 | + | state | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | async fn page(state: &Arc<AppState>, uri: &str) -> String { | |
| 36 | + | let res = server::app(state.clone()) | |
| 37 | + | .oneshot(Request::get(uri).body(Body::empty()).unwrap()) | |
| 38 | + | .await | |
| 39 | + | .unwrap(); | |
| 40 | + | assert_eq!(res.status(), StatusCode::OK); | |
| 41 | + | String::from_utf8( | |
| 42 | + | to_bytes(res.into_body(), usize::MAX) | |
| 43 | + | .await | |
| 44 | + | .unwrap() | |
| 45 | + | .to_vec(), | |
| 46 | + | ) | |
| 47 | + | .unwrap() | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | #[test] | |
| 51 | + | fn metadata_precedence_and_hiding() { | |
| 52 | + | let root = tempfile::tempdir().unwrap(); | |
| 53 | + | let r = root.path(); | |
| 54 | + | ||
| 55 | + | // description file only | |
| 56 | + | let (a, a_head) = repo_with(r, "alpha.git", &[("README.md", "# alpha\n")]); | |
| 57 | + | std::fs::write(a.join("description"), "From the description file\n").unwrap(); | |
| 58 | + | // git config beats description file; gitweb.* is honored | |
| 59 | + | let (b, _) = repo_with(r, "forks/beta.git", &[("readme.txt", "beta\n")]); | |
| 60 | + | std::fs::write(b.join("description"), "ignored\n").unwrap(); | |
| 61 | + | git(&b, &["config", "gitweb.description", "From gitweb"]); | |
| 62 | + | git(&b, &["config", "gitweb.owner", "bob"]); | |
| 63 | + | // ferrenium.* beats gitweb.*; TOML beats everything | |
| 64 | + | let (c, _) = repo_with(r, "gamma.git", &[("x.rs", "fn main() {}\n")]); | |
| 65 | + | git( | |
| 66 | + | &c, | |
| 67 | + | &["config", "ferrenium.description", "From ferrenium config"], | |
| 68 | + | ); | |
| 69 | + | git(&c, &["config", "gitweb.description", "loses"]); | |
| 70 | + | git(&c, &["config", "ferrenium.section", "tools"]); | |
| 71 | + | // empty repo with git's placeholder description | |
| 72 | + | bare(r, "empty.git", &[]); | |
| 73 | + | // hidden three ways | |
| 74 | + | repo_with(r, "private/secret.git", &[("a", "a")]); | |
| 75 | + | let (h2, _) = repo_with(r, "byconfig.git", &[("a", "a")]); | |
| 76 | + | git(&h2, &["config", "ferrenium.hidden", "true"]); | |
| 77 | + | repo_with(r, "bytoml.git", &[("a", "a")]); | |
| 78 | + | ||
| 79 | + | let state = state(config( | |
| 80 | + | r, | |
| 81 | + | "hide = [\"private/*\"]\nsection_from_path = true", | |
| 82 | + | r#" | |
| 83 | + | [[repo]] | |
| 84 | + | name = "gamma" | |
| 85 | + | description = "From TOML" | |
| 86 | + | [[repo]] | |
| 87 | + | name = "bytoml" | |
| 88 | + | hidden = true | |
| 89 | + | [[repo]] | |
| 90 | + | name = "nonexistent" | |
| 91 | + | "#, | |
| 92 | + | )); | |
| 93 | + | let index = state.index.load(); | |
| 94 | + | ||
| 95 | + | let names: Vec<_> = index.repos.keys().map(String::as_str).collect(); | |
| 96 | + | assert_eq!(names, ["alpha", "empty", "forks/beta", "gamma"]); | |
| 97 | + | let hidden: Vec<_> = index.hidden.iter().map(String::as_str).collect(); | |
| 98 | + | assert_eq!(hidden, ["byconfig", "bytoml", "private/secret"]); | |
| 99 | + | ||
| 100 | + | let alpha = &index.repos["alpha"].meta; | |
| 101 | + | assert_eq!( | |
| 102 | + | alpha.description.as_deref(), | |
| 103 | + | Some("From the description file") | |
| 104 | + | ); | |
| 105 | + | assert_eq!(alpha.default_branch.as_deref(), Some("main")); | |
| 106 | + | assert_eq!(alpha.head.unwrap().to_string(), a_head); | |
| 107 | + | assert_eq!(alpha.branches, 1); | |
| 108 | + | assert_eq!(alpha.readme.as_ref().unwrap().path, "README.md"); | |
| 109 | + | assert!(alpha.last_activity.is_some()); | |
| 110 | + | assert_eq!(alpha.section, None); | |
| 111 | + | ||
| 112 | + | let beta = &index.repos["forks/beta"].meta; | |
| 113 | + | assert_eq!(beta.description.as_deref(), Some("From gitweb")); | |
| 114 | + | assert_eq!(beta.owner.as_deref(), Some("bob")); | |
| 115 | + | assert_eq!(beta.section.as_deref(), Some("forks")); // section_from_path | |
| 116 | + | assert_eq!(beta.readme.as_ref().unwrap().path, "readme.txt"); | |
| 117 | + | ||
| 118 | + | let gamma = &index.repos["gamma"].meta; | |
| 119 | + | assert_eq!(gamma.description.as_deref(), Some("From TOML")); | |
| 120 | + | assert_eq!(gamma.section.as_deref(), Some("tools")); | |
| 121 | + | assert_eq!(gamma.readme, None); | |
| 122 | + | ||
| 123 | + | let empty = &index.repos["empty"].meta; | |
| 124 | + | assert_eq!(empty.description, None); // placeholder ignored | |
| 125 | + | assert_eq!(empty.head, None); | |
| 126 | + | assert_eq!(empty.default_branch.as_deref(), Some("main")); | |
| 127 | + | assert_eq!(empty.last_activity, None); | |
| 128 | + | ||
| 129 | + | assert!( | |
| 130 | + | index.warnings.iter().any(|w| w.contains("\"nonexistent\"")), | |
| 131 | + | "{:?}", | |
| 132 | + | index.warnings | |
| 133 | + | ); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | #[test] | |
| 137 | + | fn reftable_repos_are_skipped_with_a_warning() { | |
| 138 | + | let root = tempfile::tempdir().unwrap(); | |
| 139 | + | repo_with(root.path(), "normal.git", &[("a", "a")]); | |
| 140 | + | bare(root.path(), "rt.git", &["--ref-format=reftable"]); | |
| 141 | + | let state = state(config(root.path(), "", "")); | |
| 142 | + | let index = state.index.load(); | |
| 143 | + | assert_eq!(index.repos.keys().collect::<Vec<_>>(), ["normal"]); | |
| 144 | + | assert!( | |
| 145 | + | index | |
| 146 | + | .warnings | |
| 147 | + | .iter() | |
| 148 | + | .any(|w| w.starts_with("rt:") && w.contains("reftable")), | |
| 149 | + | "{:?}", | |
| 150 | + | index.warnings | |
| 151 | + | ); | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | #[test] | |
| 155 | + | fn unchanged_repos_keep_their_generation() { | |
| 156 | + | let root = tempfile::tempdir().unwrap(); | |
| 157 | + | let (a, _) = repo_with(root.path(), "a.git", &[("f", "1")]); | |
| 158 | + | repo_with(root.path(), "b.git", &[("f", "1")]); | |
| 159 | + | let state = state(config(root.path(), "", "")); | |
| 160 | + | let cfg = state.config.load_full(); | |
| 161 | + | let before = state.index.load(); | |
| 162 | + | ||
| 163 | + | let stats = state.index.full_scan(&cfg); | |
| 164 | + | assert!(!stats.any_change(), "{stats:?}"); | |
| 165 | + | let after = state.index.load(); | |
| 166 | + | assert!(Arc::ptr_eq(&before.repos["a"], &after.repos["a"])); | |
| 167 | + | ||
| 168 | + | let work = Work::clone_of(&a); | |
| 169 | + | work.commit("f", "2", "change"); | |
| 170 | + | work.push("main"); | |
| 171 | + | assert_eq!(state.index.refresh(&cfg, &a), Refresh::Updated("a".into())); | |
| 172 | + | let refreshed = state.index.load(); | |
| 173 | + | assert!(refreshed.repos["a"].generation > after.repos["a"].generation); | |
| 174 | + | assert!(Arc::ptr_eq(&after.repos["b"], &refreshed.repos["b"])); | |
| 175 | + | assert_eq!(state.index.refresh(&cfg, &a), Refresh::Unchanged); | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | #[tokio::test] | |
| 179 | + | async fn index_page_lists_sections_filter_and_sort() { | |
| 180 | + | let root = tempfile::tempdir().unwrap(); | |
| 181 | + | let r = root.path(); | |
| 182 | + | let (a, _) = repo_with(r, "zeta.git", &[("f", "1")]); | |
| 183 | + | git( | |
| 184 | + | &a, | |
| 185 | + | &["config", "ferrenium.description", "Last <b>letter</b>"], | |
| 186 | + | ); | |
| 187 | + | git(&a, &["config", "ferrenium.section", "greek"]); | |
| 188 | + | repo_with(r, "alpha.git", &[("f", "1")]); | |
| 189 | + | repo_with(r, "with space.git", &[("f", "1")]); | |
| 190 | + | bare(r, "empty.git", &[]); | |
| 191 | + | let state = state(config(r, "", "")); | |
| 192 | + | ||
| 193 | + | let html = page(&state, "/").await; | |
| 194 | + | assert!(html.contains("4 Repositories")); | |
| 195 | + | assert!(html.contains(r#"href="/alpha""#)); | |
| 196 | + | assert!(html.contains(r#"href="/with%20space""#)); | |
| 197 | + | assert!(html.contains("Last <b>letter</b>") || html.contains("Last <b>")); | |
| 198 | + | assert!(html.contains("[ greek ]")); | |
| 199 | + | // Unsectioned repos come first, in name order. | |
| 200 | + | let pos = |s: &str| html.find(s).unwrap_or_else(|| panic!("{s} missing")); | |
| 201 | + | assert!(pos("/alpha\"") < pos("/empty\"") && pos("/empty\"") < pos("[ greek ]")); | |
| 202 | + | // Just pushed: fresh dot. Empty repo: no age. | |
| 203 | + | assert!(html.contains(r#"class="dot""#)); | |
| 204 | + | assert!(html.contains(">Empty<")); | |
| 205 | + | ||
| 206 | + | let filtered = page(&state, "/?q=LETTER").await; | |
| 207 | + | assert!(filtered.contains(r#"href="/zeta""#)); | |
| 208 | + | assert!(!filtered.contains(r#"href="/alpha""#)); | |
| 209 | + | assert!(filtered.contains(r#"value="LETTER""#)); | |
| 210 | + | ||
| 211 | + | let none = page(&state, "/?q=nothing-matches").await; | |
| 212 | + | assert!(none.contains("[No matches]")); | |
| 213 | + | assert!(!none.contains("data-filter-empty hidden")); | |
| 214 | + | ||
| 215 | + | let by_activity = page(&state, "/?sort=activity").await; | |
| 216 | + | assert!(!by_activity.contains("[ greek ]")); | |
| 217 | + | assert!(by_activity.contains(r#"name="sort" value="activity""#)); | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | #[tokio::test(flavor = "multi_thread", worker_threads = 2)] | |
| 221 | + | async fn push_updates_the_index_quickly() { | |
| 222 | + | let root = tempfile::tempdir().unwrap(); | |
| 223 | + | let (repo, first) = repo_with(root.path(), "live.git", &[("f", "1")]); | |
| 224 | + | let state = state(config(root.path(), "", "")); | |
| 225 | + | tokio::spawn(ferrenium::index::watch::run( | |
| 226 | + | state.clone(), | |
| 227 | + | Default::default(), | |
| 228 | + | )); | |
| 229 | + | // Let the watcher install its watches. | |
| 230 | + | tokio::time::sleep(Duration::from_millis(200)).await; | |
| 231 | + | ||
| 232 | + | let wait_for = |pred: &dyn Fn(&ferrenium::index::RepoIndex) -> bool| { | |
| 233 | + | let deadline = Instant::now() + Duration::from_secs(5); | |
| 234 | + | loop { | |
| 235 | + | if pred(&state.index.load()) { | |
| 236 | + | return Instant::now(); | |
| 237 | + | } | |
| 238 | + | assert!(Instant::now() < deadline, "index did not update in time"); | |
| 239 | + | std::thread::sleep(Duration::from_millis(20)); | |
| 240 | + | } | |
| 241 | + | }; | |
| 242 | + | ||
| 243 | + | // A push to an existing repo. | |
| 244 | + | let work = Work::clone_of(&repo); | |
| 245 | + | let second = work.commit("f", "2", "second"); | |
| 246 | + | assert_ne!(first, second); | |
| 247 | + | let pushed = Instant::now(); | |
| 248 | + | work.push("main"); | |
| 249 | + | let seen = | |
| 250 | + | wait_for(&|idx| idx.repos["live"].meta.head.map(|h| h.to_string()) == Some(second.clone())); | |
| 251 | + | let latency = seen - pushed; | |
| 252 | + | assert!(latency < Duration::from_secs(1), "took {latency:?}"); | |
| 253 | + | ||
| 254 | + | // A new branch counts too. | |
| 255 | + | git( | |
| 256 | + | work.path(), | |
| 257 | + | &["push", "-q", "origin", "main:refs/heads/feature/x"], | |
| 258 | + | ); | |
| 259 | + | wait_for(&|idx| idx.repos["live"].meta.branches == 2); | |
| 260 | + | ||
| 261 | + | // A new repository appears, a deleted one disappears. | |
| 262 | + | repo_with(root.path(), "nested/new.git", &[("f", "1")]); | |
| 263 | + | wait_for(&|idx| idx.repos.contains_key("nested/new")); | |
| 264 | + | std::fs::remove_dir_all(&repo).unwrap(); | |
| 265 | + | wait_for(&|idx| !idx.repos.contains_key("live")); | |
| 266 | + | ||
| 267 | + | // Hiding via git config takes effect without a restart. | |
| 268 | + | let new_dir = root.path().join("nested/new.git"); | |
| 269 | + | git(&new_dir, &["config", "ferrenium.hidden", "true"]); | |
| 270 | + | wait_for(&|idx| idx.hidden.contains("nested/new")); | |
| 271 | + | } | |