fabrica

hanna/fabrica

feat(web): repository browsing — tree, blob, branches, tags, commits

3befe49 · hanna committed on 2026-07-25

Add the repo views over the git read layer. Variable-length repo paths
resolve through a single /{owner}/{*rest} catch-all that splits on the
GitLab /-/ separator; access is checked once via auth::access, and no
access to a private repo renders 404 (never 403) so existence never
leaks. libgit2 reads run on the blocking pool, opened per operation.

- User page and group listings; repo home renders the root tree plus a
  sanitized (comrak + ammonia) README.
- Tree browsing with breadcrumbs; blob view with line-number anchors and
  syntax highlighting (honouring the .gitattributes language override);
  raw endpoint served as text/plain (nosniff) or the real image type.
- Branches (ahead/behind), tags, and a commit list with Verified/
  Unverified signature badges verified against all registered keys.

Repo sub-header with tabs and an HTTPS clone URL. Store gains
collaborator_permission and all_keys. Verified end-to-end against a live
server with a pushed repo; 17 handler tests incl. the private-repo-404
invariant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
11 files changed · +1327 −2UnifiedSplit
Cargo.lock +225 −0
@@ -82,6 +82,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
8282 checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
8383
8484 [[package]]
85+name = "ammonia"
86+version = "4.1.4"
87+source = "registry+https://github.com/rust-lang/crates.io-index"
88+checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
89+dependencies = [
90+ "cssparser",
91+ "html5ever",
92+ "maplit",
93+ "url",
94+]
95+
96+[[package]]
8597 name = "anstream"
8698 version = "1.0.0"
8799 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -465,6 +477,15 @@ dependencies = [
465477 ]
466478
467479 [[package]]
480+name = "caseless"
481+version = "0.2.2"
482+source = "registry+https://github.com/rust-lang/crates.io-index"
483+checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8"
484+dependencies = [
485+ "unicode-normalization",
486+]
487+
488+[[package]]
468489 name = "cast5"
469490 version = "0.11.1"
470491 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -621,6 +642,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
621642 checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
622643
623644 [[package]]
645+name = "comrak"
646+version = "0.54.0"
647+source = "registry+https://github.com/rust-lang/crates.io-index"
648+checksum = "0d5910408554659ed848ff469e67ec83b30f179e72cec286cfdae64d1616f466"
649+dependencies = [
650+ "caseless",
651+ "entities",
652+ "finl_unicode",
653+ "jetscii",
654+ "phf",
655+ "phf_codegen",
656+ "rustc-hash",
657+ "smallvec",
658+ "typed-arena",
659+]
660+
661+[[package]]
624662 name = "concurrent-queue"
625663 version = "2.5.0"
626664 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -813,6 +851,17 @@ dependencies = [
813851 ]
814852
815853 [[package]]
854+name = "cssparser"
855+version = "0.37.0"
856+source = "registry+https://github.com/rust-lang/crates.io-index"
857+checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
858+dependencies = [
859+ "dtoa-short",
860+ "itoa",
861+ "smallvec",
862+]
863+
864+[[package]]
816865 name = "ctr"
817866 version = "0.9.2"
818867 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1059,6 +1108,21 @@ dependencies = [
10591108 ]
10601109
10611110 [[package]]
1111+name = "dtoa"
1112+version = "1.0.11"
1113+source = "registry+https://github.com/rust-lang/crates.io-index"
1114+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
1115+
1116+[[package]]
1117+name = "dtoa-short"
1118+version = "0.3.5"
1119+source = "registry+https://github.com/rust-lang/crates.io-index"
1120+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
1121+dependencies = [
1122+ "dtoa",
1123+]
1124+
1125+[[package]]
10621126 name = "eax"
10631127 version = "0.5.0"
10641128 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1167,6 +1231,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
11671231 checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
11681232
11691233 [[package]]
1234+name = "entities"
1235+version = "1.0.1"
1236+source = "registry+https://github.com/rust-lang/crates.io-index"
1237+checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
1238+
1239+[[package]]
11701240 name = "equivalent"
11711241 version = "1.0.2"
11721242 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1261,6 +1331,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
12611331 checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
12621332
12631333 [[package]]
1334+name = "finl_unicode"
1335+version = "1.4.0"
1336+source = "registry+https://github.com/rust-lang/crates.io-index"
1337+checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
1338+
1339+[[package]]
12641340 name = "flate2"
12651341 version = "1.1.9"
12661342 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1598,6 +1674,16 @@ dependencies = [
15981674 ]
15991675
16001676 [[package]]
1677+name = "html5ever"
1678+version = "0.39.0"
1679+source = "registry+https://github.com/rust-lang/crates.io-index"
1680+checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
1681+dependencies = [
1682+ "log",
1683+ "markup5ever",
1684+]
1685+
1686+[[package]]
16011687 name = "http"
16021688 version = "1.4.2"
16031689 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1870,6 +1956,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
18701956 checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
18711957
18721958 [[package]]
1959+name = "jetscii"
1960+version = "0.5.3"
1961+source = "registry+https://github.com/rust-lang/crates.io-index"
1962+checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e"
1963+
1964+[[package]]
18731965 name = "jobserver"
18741966 version = "0.1.35"
18751967 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2040,6 +2132,23 @@ dependencies = [
20402132 ]
20412133
20422134 [[package]]
2135+name = "maplit"
2136+version = "1.0.2"
2137+source = "registry+https://github.com/rust-lang/crates.io-index"
2138+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
2139+
2140+[[package]]
2141+name = "markup5ever"
2142+version = "0.39.0"
2143+source = "registry+https://github.com/rust-lang/crates.io-index"
2144+checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
2145+dependencies = [
2146+ "log",
2147+ "tendril",
2148+ "web_atoms",
2149+]
2150+
2151+[[package]]
20432152 name = "matchers"
20442153 version = "0.2.0"
20452154 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2183,6 +2292,12 @@ dependencies = [
21832292 ]
21842293
21852294 [[package]]
2295+name = "new_debug_unreachable"
2296+version = "1.0.6"
2297+source = "registry+https://github.com/rust-lang/crates.io-index"
2298+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
2299+
2300+[[package]]
21862301 name = "nom"
21872302 version = "8.0.0"
21882303 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2533,6 +2648,45 @@ dependencies = [
25332648 ]
25342649
25352650 [[package]]
2651+name = "phf"
2652+version = "0.13.1"
2653+source = "registry+https://github.com/rust-lang/crates.io-index"
2654+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
2655+dependencies = [
2656+ "phf_shared",
2657+ "serde",
2658+]
2659+
2660+[[package]]
2661+name = "phf_codegen"
2662+version = "0.13.1"
2663+source = "registry+https://github.com/rust-lang/crates.io-index"
2664+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
2665+dependencies = [
2666+ "phf_generator",
2667+ "phf_shared",
2668+]
2669+
2670+[[package]]
2671+name = "phf_generator"
2672+version = "0.13.1"
2673+source = "registry+https://github.com/rust-lang/crates.io-index"
2674+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
2675+dependencies = [
2676+ "fastrand",
2677+ "phf_shared",
2678+]
2679+
2680+[[package]]
2681+name = "phf_shared"
2682+version = "0.13.1"
2683+source = "registry+https://github.com/rust-lang/crates.io-index"
2684+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
2685+dependencies = [
2686+ "siphasher",
2687+]
2688+
2689+[[package]]
25362690 name = "pin-project-lite"
25372691 version = "0.2.17"
25382692 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2608,6 +2762,12 @@ dependencies = [
26082762 ]
26092763
26102764 [[package]]
2765+name = "precomputed-hash"
2766+version = "0.1.1"
2767+source = "registry+https://github.com/rust-lang/crates.io-index"
2768+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
2769+
2770+[[package]]
26112771 name = "primeorder"
26122772 version = "0.13.6"
26132773 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2888,6 +3048,12 @@ dependencies = [
28883048 ]
28893049
28903050 [[package]]
3051+name = "rustc-hash"
3052+version = "2.1.3"
3053+source = "registry+https://github.com/rust-lang/crates.io-index"
3054+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
3055+
3056+[[package]]
28913057 name = "rustc_version"
28923058 version = "0.4.1"
28933059 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3197,6 +3363,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
31973363 checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
31983364
31993365 [[package]]
3366+name = "siphasher"
3367+version = "1.0.3"
3368+source = "registry+https://github.com/rust-lang/crates.io-index"
3369+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
3370+
3371+[[package]]
32003372 name = "slab"
32013373 version = "0.4.12"
32023374 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3497,6 +3669,30 @@ dependencies = [
34973669 ]
34983670
34993671 [[package]]
3672+name = "string_cache"
3673+version = "0.9.0"
3674+source = "registry+https://github.com/rust-lang/crates.io-index"
3675+checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
3676+dependencies = [
3677+ "new_debug_unreachable",
3678+ "parking_lot",
3679+ "phf_shared",
3680+ "precomputed-hash",
3681+]
3682+
3683+[[package]]
3684+name = "string_cache_codegen"
3685+version = "0.6.1"
3686+source = "registry+https://github.com/rust-lang/crates.io-index"
3687+checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
3688+dependencies = [
3689+ "phf_generator",
3690+ "phf_shared",
3691+ "proc-macro2",
3692+ "quote",
3693+]
3694+
3695+[[package]]
35003696 name = "stringprep"
35013697 version = "0.1.5"
35023698 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3584,6 +3780,15 @@ dependencies = [
35843780 ]
35853781
35863782 [[package]]
3783+name = "tendril"
3784+version = "0.5.1"
3785+source = "registry+https://github.com/rust-lang/crates.io-index"
3786+checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
3787+dependencies = [
3788+ "new_debug_unreachable",
3789+]
3790+
3791+[[package]]
35873792 name = "thiserror"
35883793 version = "1.0.69"
35893794 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3954,6 +4159,12 @@ dependencies = [
39544159 ]
39554160
39564161 [[package]]
4162+name = "typed-arena"
4163+version = "2.0.2"
4164+source = "registry+https://github.com/rust-lang/crates.io-index"
4165+checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
4166+
4167+[[package]]
39574168 name = "typenum"
39584169 version = "1.20.1"
39594170 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4166,9 +4377,11 @@ dependencies = [
41664377 name = "web"
41674378 version = "0.1.0"
41684379 dependencies = [
4380+ "ammonia",
41694381 "auth",
41704382 "axum",
41714383 "axum-extra",
4384+ "comrak",
41724385 "config",
41734386 "git",
41744387 "highlight",
@@ -4200,6 +4413,18 @@ dependencies = [
42004413 ]
42014414
42024415 [[package]]
4416+name = "web_atoms"
4417+version = "0.2.5"
4418+source = "registry+https://github.com/rust-lang/crates.io-index"
4419+checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297"
4420+dependencies = [
4421+ "phf",
4422+ "phf_codegen",
4423+ "string_cache",
4424+ "string_cache_codegen",
4425+]
4426+
4427+[[package]]
42034428 name = "whoami"
42044429 version = "2.1.2"
42054430 source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.toml +3 −0
@@ -141,6 +141,9 @@ tower-http = { version = "0.6", features = [
141141 rust-embed = "8"
142142 mime_guess = "2"
143143 time = "0.3"
144+# Markdown rendering (GFM) + HTML sanitization for READMEs and comments.
145+comrak = { version = "0.54", default-features = false }
146+ammonia = "4"
144147 tracing = "0.1"
145148 tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] }
146149
assets/base.css +87 −0
@@ -358,6 +358,93 @@ pre.code {
358358 color: var(--fb-fg-muted);
359359 }
360360
361+/* ---- Repo views ---- */
362+.repo-head {
363+ border-bottom: 1px solid var(--fb-border);
364+ padding-bottom: 0.75rem;
365+ margin-bottom: 1rem;
366+}
367+.repo-slug {
368+ font-size: 1.4rem;
369+ font-weight: 400;
370+}
371+.repo-slug a {
372+ color: var(--fb-accent);
373+}
374+.repo-tabs {
375+ margin: 0.5rem 0;
376+}
377+.ref-row,
378+.clone-row {
379+ display: flex;
380+ gap: 0.5rem;
381+ align-items: center;
382+ margin-top: 0.5rem;
383+}
384+.clone-row input {
385+ max-width: 30rem;
386+ font-family: var(--fb-font-mono);
387+ font-size: 0.85em;
388+}
389+.breadcrumb {
390+ margin-bottom: 0.75rem;
391+ font-family: var(--fb-font-mono);
392+}
393+
394+table.tree td {
395+ padding: 0.3rem 0.6rem;
396+ border-bottom: 1px solid var(--fb-border);
397+}
398+.tree-icon {
399+ width: 1.5rem;
400+ text-align: center;
401+}
402+
403+.blob-header {
404+ display: flex;
405+ justify-content: space-between;
406+ align-items: center;
407+ padding: 0.5rem 0.75rem;
408+ background: var(--fb-bg-raised);
409+ border: 1px solid var(--fb-border);
410+ border-bottom: none;
411+ border-radius: var(--fb-radius) var(--fb-radius) 0 0;
412+}
413+.blob-header .btn {
414+ padding: 0.2rem 0.5rem;
415+}
416+
417+table.blob {
418+ width: 100%;
419+ border-collapse: collapse;
420+ border: 1px solid var(--fb-border);
421+ border-radius: 0 0 var(--fb-radius) var(--fb-radius);
422+ font-family: var(--fb-font-mono);
423+ font-size: 0.85rem;
424+ background: var(--fb-bg-inset);
425+ overflow-x: auto;
426+}
427+table.blob td.lineno {
428+ width: 1%;
429+ min-width: 3rem;
430+ text-align: right;
431+ padding: 0 0.75rem;
432+ color: var(--fb-fg-muted);
433+ user-select: none;
434+ vertical-align: top;
435+}
436+table.blob td.lineno a {
437+ color: inherit;
438+}
439+table.blob td.code-line {
440+ padding: 0 0.75rem;
441+ white-space: pre;
442+ tab-size: 4;
443+}
444+table.blob tr:target {
445+ background: var(--fb-diff-add-bg);
446+}
447+
361448 /* ---- Footer ---- */
362449 .footer {
363450 max-width: 76rem;
crates/store/src/keys.rs +17 −0
@@ -100,6 +100,23 @@ impl Store {
100100 .map_err(Into::into)
101101 }
102102
103+ /// Every registered key on the instance, for signature verification (the
104+ /// signer may be any registered user).
105+ ///
106+ /// # Errors
107+ ///
108+ /// Returns [`StoreError::Query`] on a database failure.
109+ pub async fn all_keys(&self) -> Result<Vec<Key>, StoreError> {
110+ let sql = format!("SELECT {KEY_COLUMNS} FROM keys");
111+ let rows = sqlx::query(AssertSqlSafe(sql))
112+ .fetch_all(&self.pool)
113+ .await?;
114+ rows.iter()
115+ .map(map_key)
116+ .collect::<Result<_, _>>()
117+ .map_err(Into::into)
118+ }
119+
103120 /// Fetch a user's key by its 1-based ordinal, or `None`.
104121 ///
105122 /// # Errors
crates/store/src/repos.rs +21 −0
@@ -198,6 +198,27 @@ impl Store {
198198 Ok(deleted > 0)
199199 }
200200
201+ /// The viewer's collaborator permission on a repo (`read`|`write`|`admin`), or
202+ /// `None` if they are not an explicit collaborator. Feeds [`auth::access`].
203+ ///
204+ /// # Errors
205+ ///
206+ /// Returns [`StoreError::Query`] on a database failure.
207+ pub async fn collaborator_permission(
208+ &self,
209+ repo_id: &str,
210+ user_id: &str,
211+ ) -> Result<Option<String>, StoreError> {
212+ let row = sqlx::query(
213+ "SELECT permission FROM repo_collaborators WHERE repo_id = $1 AND user_id = $2",
214+ )
215+ .bind(repo_id)
216+ .bind(user_id)
217+ .fetch_optional(&self.pool)
218+ .await?;
219+ Ok(row.as_ref().map(|r| r.try_get("permission")).transpose()?)
220+ }
221+
201222 /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`].
202223 fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> {
203224 Ok(Repo {
crates/web/Cargo.toml +2 −0
@@ -24,6 +24,8 @@ tower = { workspace = true }
2424 tower-http = { workspace = true }
2525 rust-embed = { workspace = true }
2626 mime_guess = { workspace = true }
27+comrak = { workspace = true }
28+ammonia = { workspace = true }
2729 time = { workspace = true }
2830 serde = { workspace = true }
2931 serde_json = { workspace = true }
crates/web/src/lib.rs +5 −0
@@ -13,7 +13,9 @@
1313 mod assets;
1414 mod error;
1515 mod layout;
16+mod markdown;
1617 mod pages;
18+mod repo;
1719 #[path = "serve.rs"]
1820 mod serve_assets;
1921 mod session;
@@ -114,6 +116,9 @@ pub fn build_router(state: AppState) -> Router {
114116 .route("/theme.css", get(serve_assets::theme_default))
115117 .route("/theme/{file}", get(serve_assets::theme_named))
116118 .route("/assets/{*path}", get(serve_assets::assets))
119+ // Repository browsing. Variable-length paths split on `/-/` in the handler.
120+ .route("/{owner}", get(repo::user_page))
121+ .route("/{owner}/{*rest}", get(repo::dispatch))
117122 .layer(
118123 ServiceBuilder::new()
119124 .layer(SetRequestIdLayer::new(
crates/web/src/markdown.rs +50 −0
@@ -0,0 +1,50 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Markdown rendering for READMEs.
6+//!
7+//! `comrak` with the GFM extensions renders the source; `ammonia` then sanitizes
8+//! the result, stripping raw HTML and any scripting so a README can never inject
9+//! markup into the page. Relative-link rewriting and fenced-code highlighting are
10+//! deferred to the polish phase.
11+
12+use maud::{Markup, PreEscaped};
13+
14+/// Render `source` markdown to sanitized HTML, ready to splice into a page.
15+#[must_use]
16+pub fn render(source: &str) -> Markup {
17+ let mut options = comrak::Options::default();
18+ options.extension.table = true;
19+ options.extension.strikethrough = true;
20+ options.extension.tasklist = true;
21+ options.extension.autolink = true;
22+ options.extension.footnotes = true;
23+
24+ let unsafe_html = comrak::markdown_to_html(source, &options);
25+ let safe = ammonia::clean(&unsafe_html);
26+ PreEscaped(safe)
27+}
28+
29+#[cfg(test)]
30+mod tests {
31+ use super::*;
32+
33+ #[test]
34+ fn renders_gfm_and_strips_scripts() {
35+ let html =
36+ render("# Title\n\n- [x] done\n\n<script>alert(1)</script>\n\n`code`").into_string();
37+ assert!(html.contains("<h1>"));
38+ assert!(html.contains("<code>code</code>"));
39+ assert!(
40+ !html.contains("<script"),
41+ "scripts must be stripped: {html}"
42+ );
43+ }
44+
45+ #[test]
46+ fn renders_tables() {
47+ let html = render("| a | b |\n| - | - |\n| 1 | 2 |").into_string();
48+ assert!(html.contains("<table>"));
49+ }
50+}
crates/web/src/pages.rs +1 −1
@@ -26,7 +26,7 @@ use crate::session::{
2626
2727 /// Assemble the page chrome, ensuring the CSRF cookie and resolving the active
2828 /// theme from the visitor's cookie (falling back to the instance default).
29-fn build_chrome(
29+pub(crate) fn build_chrome(
3030 state: &AppState,
3131 jar: CookieJar,
3232 user: Option<User>,
crates/web/src/repo.rs +885 −0
@@ -0,0 +1,885 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Repository browsing: the user page, repo home (README/tree), tree and blob
6+//! views, raw content, branches, tags, and the commit list.
7+//!
8+//! Group nesting makes repo paths variable-length, so `/{owner}/{*rest}` is a
9+//! single catch-all that splits `rest` on the GitLab `/-/` separator: everything
10+//! before is the repo (or group) path, everything after is the view. Access is
11+//! resolved once through [`auth::access`]; no access to a private repo renders
12+//! `404`, never `403`, so existence never leaks.
13+
14+use std::path::Path as FsPath;
15+
16+use axum::extract::{Path, State};
17+use axum::http::{Uri, header};
18+use axum::response::{IntoResponse, Response};
19+use axum_extra::extract::cookie::CookieJar;
20+use maud::{Markup, html};
21+use model::{Repo, User};
22+
23+use crate::AppState;
24+use crate::error::{AppError, AppResult};
25+use crate::layout::page;
26+use crate::markdown;
27+use crate::pages::build_chrome;
28+use crate::session::MaybeUser;
29+
30+/// The candidate README filenames, in lookup order.
31+const README_NAMES: &[&str] = &["README.md", "README", "readme.md", "README.txt"];
32+
33+/// Which repo tab is active, for highlighting.
34+#[derive(Clone, Copy, PartialEq, Eq)]
35+enum Tab {
36+ Code,
37+ Branches,
38+ Tags,
39+ Search,
40+}
41+
42+/// A resolved repository plus the viewer's access to it.
43+struct RepoCtx {
44+ owner: User,
45+ repo: Repo,
46+ /// The viewer's access level. Reserved for gating mutation UI (settings, edit)
47+ /// in later phases; every browse view here requires only `Read`.
48+ #[allow(dead_code)]
49+ access: auth::Access,
50+}
51+
52+/// Run a blocking libgit2 read for a repo on the blocking pool, opening the repo
53+/// per operation (§3.7 threading).
54+async fn git_read<T, F>(state: &AppState, repo_id: &str, f: F) -> AppResult<T>
55+where
56+ F: FnOnce(&git::Repo) -> Result<T, git::GitError> + Send + 'static,
57+ T: Send + 'static,
58+{
59+ let repo_dir = state.config.storage.repo_dir.clone();
60+ let repo_id = repo_id.to_string();
61+ tokio::task::spawn_blocking(move || {
62+ let path = git::repo_path(&repo_dir, &repo_id)?;
63+ let repo = git::Repo::open_path(&path)?;
64+ f(&repo)
65+ })
66+ .await
67+ .map_err(AppError::internal)?
68+ .map_err(AppError::from)
69+}
70+
71+/// Resolve `(owner, path)` to a repo and check access, or `Ok(None)` if the owner
72+/// or repo does not exist (so the caller can try a group listing).
73+///
74+/// Denied access to an existing private repo returns `Err(NotFound)` — identical
75+/// to a missing repo, so private existence never leaks.
76+async fn resolve(
77+ state: &AppState,
78+ owner_name: &str,
79+ repo_path: &str,
80+ viewer: Option<&User>,
81+) -> AppResult<Option<RepoCtx>> {
82+ let Some(owner) = state.store.user_by_username(owner_name).await? else {
83+ return Ok(None);
84+ };
85+ let Some(repo) = state.store.repo_by_owner_path(&owner.id, repo_path).await? else {
86+ return Ok(None);
87+ };
88+
89+ let viewer_id = viewer.map(|u| auth::Viewer {
90+ id: u.id.clone(),
91+ is_admin: u.is_admin,
92+ });
93+ let collaborator = match viewer {
94+ Some(u) => state
95+ .store
96+ .collaborator_permission(&repo.id, &u.id)
97+ .await?
98+ .and_then(|p| auth::Permission::parse(&p)),
99+ None => None,
100+ };
101+ let access = auth::access(
102+ viewer_id.as_ref(),
103+ &repo,
104+ collaborator,
105+ state.config.instance.allow_anonymous,
106+ );
107+ if access == auth::Access::None {
108+ return Err(AppError::NotFound);
109+ }
110+ Ok(Some(RepoCtx {
111+ owner,
112+ repo,
113+ access,
114+ }))
115+}
116+
117+/// `GET /{owner}` — the user's page: their groups and repositories the viewer can
118+/// see.
119+pub async fn user_page(
120+ State(state): State<AppState>,
121+ MaybeUser(viewer): MaybeUser,
122+ jar: CookieJar,
123+ uri: Uri,
124+ Path(owner_name): Path<String>,
125+) -> AppResult<Response> {
126+ let Some(owner) = state.store.user_by_username(&owner_name).await? else {
127+ return Err(AppError::NotFound);
128+ };
129+ let repos = visible_repos(&state, &owner, viewer.as_ref()).await?;
130+ let groups = state.store.groups_by_owner(&owner.id).await?;
131+
132+ let (jar, chrome) = build_chrome(&state, jar, viewer.clone(), uri.path().to_string());
133+ let body = html! {
134+ h1 { (owner.username) }
135+ @if !groups.is_empty() {
136+ section {
137+ h2 { "Groups" }
138+ ul class="repo-list" {
139+ @for group in &groups {
140+ li { a href={ "/" (owner.username) "/" (group.path) } { (group.path) } }
141+ }
142+ }
143+ }
144+ }
145+ section {
146+ h2 { "Repositories" }
147+ (repo_list(&owner.username, &repos))
148+ }
149+ };
150+ Ok((jar, page(&chrome, &owner.username, body)).into_response())
151+}
152+
153+/// `GET /{owner}/{*rest}` — dispatch a repo path and view.
154+pub async fn dispatch(
155+ State(state): State<AppState>,
156+ MaybeUser(viewer): MaybeUser,
157+ jar: CookieJar,
158+ uri: Uri,
159+ Path((owner_name, rest)): Path<(String, String)>,
160+) -> AppResult<Response> {
161+ if let Some((repo_path, view)) = rest.split_once("/-/") {
162+ let ctx = resolve(&state, &owner_name, repo_path, viewer.as_ref())
163+ .await?
164+ .ok_or(AppError::NotFound)?;
165+ dispatch_view(&state, viewer, jar, &uri, ctx, view).await
166+ } else {
167+ match resolve(&state, &owner_name, &rest, viewer.as_ref()).await? {
168+ Some(ctx) => repo_home(&state, viewer, jar, &uri, ctx).await,
169+ None => group_listing(&state, viewer, jar, &uri, &owner_name, &rest).await,
170+ }
171+ }
172+}
173+
174+/// Dispatch the portion after `/-/`.
175+async fn dispatch_view(
176+ state: &AppState,
177+ viewer: Option<User>,
178+ jar: CookieJar,
179+ uri: &Uri,
180+ ctx: RepoCtx,
181+ view: &str,
182+) -> AppResult<Response> {
183+ let (kind, rest) = view.split_once('/').unwrap_or((view, ""));
184+ match kind {
185+ "tree" => tree_view(state, viewer, jar, uri, ctx, rest).await,
186+ "blob" => blob_view(state, viewer, jar, uri, ctx, rest, false).await,
187+ "raw" => raw_view(state, ctx, rest).await,
188+ "commits" => commits_view(state, viewer, jar, uri, ctx, rest).await,
189+ "branches" => branches_view(state, viewer, jar, uri, ctx).await,
190+ "tags" => tags_view(state, viewer, jar, uri, ctx).await,
191+ // `commit` (single-commit diff) lands with the commit-view phase; the
192+ // reserved `runs`/`search`/`settings` slots return 404 until built.
193+ _ => Err(AppError::NotFound),
194+ }
195+}
196+
197+/// Split `"{ref}/{path..}"` into the ref (first segment) and the remaining path.
198+/// Branch names containing slashes resolve to their first segment only — a known
199+/// MVP limitation.
200+fn split_ref_path(rest: &str) -> (String, String) {
201+ match rest.split_once('/') {
202+ Some((r, p)) => (r.to_string(), p.to_string()),
203+ None => (rest.to_string(), String::new()),
204+ }
205+}
206+
207+/// The reference to browse, defaulting to the repo's default branch when none is
208+/// given in the URL.
209+fn ref_or_default(repo: &Repo, given: &str) -> String {
210+ if given.is_empty() {
211+ repo.default_branch.clone()
212+ } else {
213+ given.to_string()
214+ }
215+}
216+
217+// ---- Repo home ----
218+
219+async fn repo_home(
220+ state: &AppState,
221+ viewer: Option<User>,
222+ jar: CookieJar,
223+ uri: &Uri,
224+ ctx: RepoCtx,
225+) -> AppResult<Response> {
226+ let rev_name = ctx.repo.default_branch.clone();
227+ // Resolve the default branch; an unborn HEAD means an empty repo.
228+ let resolved = git_read(state, &ctx.repo.id, {
229+ let rev_name = rev_name.clone();
230+ move |repo| repo.resolve_ref(&rev_name)
231+ })
232+ .await;
233+
234+ let body = match resolved {
235+ Err(_) => empty_repo_body(state, &ctx),
236+ Ok(rev) => {
237+ let entries = git_read(state, &ctx.repo.id, {
238+ let rev = rev.clone();
239+ move |repo| repo.tree_entries(&rev, FsPath::new(""))
240+ })
241+ .await?;
242+ let readme = load_readme(state, &ctx.repo.id, &rev, &entries).await;
243+ html! {
244+ (repo_header(state, &ctx, Tab::Code, &rev_name))
245+ (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, "", &entries))
246+ @if let Some(rendered) = readme {
247+ div class="card" style="margin-top:1rem" { (rendered) }
248+ }
249+ }
250+ }
251+ };
252+
253+ let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
254+ let title = format!("{}/{}", ctx.owner.username, ctx.repo.path);
255+ Ok((jar, page(&chrome, &title, body)).into_response())
256+}
257+
258+/// Find and render the first matching README in the root tree.
259+async fn load_readme(
260+ state: &AppState,
261+ repo_id: &str,
262+ rev: &git::Resolved,
263+ entries: &[git::TreeEntry],
264+) -> Option<Markup> {
265+ let name = README_NAMES.iter().find(|candidate| {
266+ entries
267+ .iter()
268+ .any(|e| e.kind == git::EntryKind::File && e.name.eq_ignore_ascii_case(candidate))
269+ })?;
270+ let name = (*name).to_string();
271+ let blob = git_read(state, repo_id, {
272+ let rev = rev.clone();
273+ let name = name.clone();
274+ move |repo| repo.blob(&rev, FsPath::new(&name))
275+ })
276+ .await
277+ .ok()?;
278+ if blob.is_binary {
279+ return None;
280+ }
281+ let text = String::from_utf8_lossy(&blob.content);
282+ Some(markdown::render(&text))
283+}
284+
285+/// The "empty repository" landing with clone instructions.
286+fn empty_repo_body(state: &AppState, ctx: &RepoCtx) -> Markup {
287+ html! {
288+ (repo_header(state, ctx, Tab::Code, &ctx.repo.default_branch))
289+ div class="card" {
290+ h2 { "This repository is empty" }
291+ p class="muted" { "Push a commit to get started:" }
292+ pre class="code" {
293+ (clone_https(state, ctx)) "\n"
294+ }
295+ }
296+ }
297+}
298+
299+// ---- Tree view ----
300+
301+async fn tree_view(
302+ state: &AppState,
303+ viewer: Option<User>,
304+ jar: CookieJar,
305+ uri: &Uri,
306+ ctx: RepoCtx,
307+ rest: &str,
308+) -> AppResult<Response> {
309+ let (rev_name, path) = split_ref_path(rest);
310+ let rev_name = ref_or_default(&ctx.repo, &rev_name);
311+ let rev = git_read(state, &ctx.repo.id, {
312+ let rev_name = rev_name.clone();
313+ move |repo| repo.resolve_ref(&rev_name)
314+ })
315+ .await?;
316+ let entries = git_read(state, &ctx.repo.id, {
317+ let rev = rev.clone();
318+ let path = path.clone();
319+ move |repo| repo.tree_entries(&rev, FsPath::new(&path))
320+ })
321+ .await?;
322+
323+ let body = html! {
324+ (repo_header(state, &ctx, Tab::Code, &rev_name))
325+ (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))
326+ (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, &path, &entries))
327+ };
328+ let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
329+ let title = format!("{}/{}: {path}", ctx.owner.username, ctx.repo.path);
330+ Ok((jar, page(&chrome, &title, body)).into_response())
331+}
332+
333+// ---- Blob view ----
334+
335+async fn blob_view(
336+ state: &AppState,
337+ viewer: Option<User>,
338+ jar: CookieJar,
339+ uri: &Uri,
340+ ctx: RepoCtx,
341+ rest: &str,
342+ _raw: bool,
343+) -> AppResult<Response> {
344+ let (rev_name, path) = split_ref_path(rest);
345+ let rev_name = ref_or_default(&ctx.repo, &rev_name);
346+ if path.is_empty() {
347+ return Err(AppError::NotFound);
348+ }
349+ let rev = git_read(state, &ctx.repo.id, {
350+ let rev_name = rev_name.clone();
351+ move |repo| repo.resolve_ref(&rev_name)
352+ })
353+ .await?;
354+
355+ // Read the blob and resolve any attribute language override in one pass.
356+ let (blob, attr_lang) = git_read(state, &ctx.repo.id, {
357+ let rev = rev.clone();
358+ let path = path.clone();
359+ move |repo| {
360+ let blob = repo.blob(&rev, FsPath::new(&path))?;
361+ let attrs = repo.attributes_set(&rev.oid).ok();
362+ let lang = attrs.and_then(|set| set.attributes(&path).language().map(str::to_string));
363+ Ok((blob, lang))
364+ }
365+ })
366+ .await?;
367+
368+ let content_body = render_blob(&ctx, &rev_name, &path, &blob, attr_lang.as_deref());
369+ let body = html! {
370+ (repo_header(state, &ctx, Tab::Code, &rev_name))
371+ (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))
372+ (content_body)
373+ };
374+ let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
375+ let title = format!("{}/{}: {path}", ctx.owner.username, ctx.repo.path);
376+ Ok((jar, page(&chrome, &title, body)).into_response())
377+}
378+
379+/// Render a blob: highlighted code with line anchors, an inline image, or a
380+/// binary notice.
381+fn render_blob(
382+ ctx: &RepoCtx,
383+ rev_name: &str,
384+ path: &str,
385+ blob: &git::Blob,
386+ attr_lang: Option<&str>,
387+) -> Markup {
388+ let raw_url = format!(
389+ "/{}/{}/-/raw/{rev_name}/{path}",
390+ ctx.owner.username, ctx.repo.path
391+ );
392+ let filename = path.rsplit('/').next().unwrap_or(path);
393+
394+ html! {
395+ div class="blob-header" {
396+ span class="muted" { (blob.size) " bytes" }
397+ span { a class="btn" href=(raw_url) { "Raw" }
398+ button class="btn" type="button" data-clipboard=(raw_url) { "Copy path" } }
399+ }
400+ @if is_image(filename) {
401+ p { img src=(raw_url) alt=(filename) style="max-width:100%"; }
402+ } @else if blob.is_binary {
403+ div class="card" {
404+ p class="muted" { "Binary file not shown." }
405+ p { a class="btn" href=(raw_url) { "Download" } }
406+ }
407+ } @else {
408+ (highlighted_code(FsPath::new(path), &blob.content, attr_lang))
409+ }
410+ }
411+}
412+
413+/// Render highlighted code as a line-numbered table with `#L{n}` anchors.
414+fn highlighted_code(path: &FsPath, content: &[u8], attr_lang: Option<&str>) -> Markup {
415+ let highlighted = highlight::highlight(path, content, attr_lang);
416+ html! {
417+ table class="blob" {
418+ tbody {
419+ @for (i, line) in highlighted.lines.iter().enumerate() {
420+ @let n = i + 1;
421+ tr id={ "L" (n) } {
422+ td class="lineno" { a href={ "#L" (n) } { (n) } }
423+ td class="code-line" {
424+ @for span in &line.spans {
425+ @match span.class {
426+ Some(class) => { span class=(class) { (span.text) } }
427+ None => { (span.text) }
428+ }
429+ }
430+ }
431+ }
432+ }
433+ }
434+ }
435+ }
436+}
437+
438+// ---- Raw ----
439+
440+async fn raw_view(state: &AppState, ctx: RepoCtx, rest: &str) -> AppResult<Response> {
441+ let (rev_name, path) = split_ref_path(rest);
442+ let rev_name = ref_or_default(&ctx.repo, &rev_name);
443+ if path.is_empty() {
444+ return Err(AppError::NotFound);
445+ }
446+ let rev = git_read(state, &ctx.repo.id, {
447+ let rev_name = rev_name.clone();
448+ move |repo| repo.resolve_ref(&rev_name)
449+ })
450+ .await?;
451+ let blob = git_read(state, &ctx.repo.id, {
452+ let rev = rev.clone();
453+ let path = path.clone();
454+ move |repo| repo.blob(&rev, FsPath::new(&path))
455+ })
456+ .await?;
457+
458+ let filename = path.rsplit('/').next().unwrap_or(&path);
459+ // Serve images with their real type; everything else as text/plain to prevent
460+ // a stored-HTML blob from being interpreted as markup. `nosniff` reinforces it.
461+ let content_type = if is_image(filename) {
462+ mime_guess::from_path(filename)
463+ .first_raw()
464+ .unwrap_or("application/octet-stream")
465+ } else if blob.is_binary {
466+ "application/octet-stream"
467+ } else {
468+ "text/plain; charset=utf-8"
469+ };
470+ Ok((
471+ [
472+ (header::CONTENT_TYPE, content_type),
473+ (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
474+ (header::CONTENT_DISPOSITION, "inline"),
475+ ],
476+ blob.content,
477+ )
478+ .into_response())
479+}
480+
481+// ---- Branches ----
482+
483+async fn branches_view(
484+ state: &AppState,
485+ viewer: Option<User>,
486+ jar: CookieJar,
487+ uri: &Uri,
488+ ctx: RepoCtx,
489+) -> AppResult<Response> {
490+ let branches = git_read(state, &ctx.repo.id, git::Repo::branches).await?;
491+ let body = html! {
492+ (repo_header(state, &ctx, Tab::Branches, &ctx.repo.default_branch))
493+ h2 { "Branches" }
494+ table class="list" {
495+ thead { tr { th { "Branch" } th { "Ahead" } th { "Behind" } th { "Updated" } } }
496+ tbody {
497+ @for b in &branches {
498+ tr {
499+ td {
500+ a href={ "/" (ctx.owner.username) "/" (ctx.repo.path) "/-/tree/" (b.name) } { (b.name) }
501+ @if b.is_default { " " span class="badge" { "default" } }
502+ }
503+ td { @if b.ahead > 0 { span style="color:var(--fb-success)" { (b.ahead) } } @else { "0" } }
504+ td { (b.behind) }
505+ td class="muted" { (b.tip.author.name) " · " (fmt_date(b.tip.author.time_ms)) }
506+ }
507+ }
508+ }
509+ }
510+ };
511+ let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
512+ let title = format!("{}/{}: branches", ctx.owner.username, ctx.repo.path);
513+ Ok((jar, page(&chrome, &title, body)).into_response())
514+}
515+
516+// ---- Tags ----
517+
518+async fn tags_view(
519+ state: &AppState,
520+ viewer: Option<User>,
521+ jar: CookieJar,
522+ uri: &Uri,
523+ ctx: RepoCtx,
524+) -> AppResult<Response> {
525+ let tags = git_read(state, &ctx.repo.id, git::Repo::tags).await?;
526+ let body = html! {
527+ (repo_header(state, &ctx, Tab::Tags, &ctx.repo.default_branch))
528+ h2 { "Tags" }
529+ table class="list" {
530+ thead { tr { th { "Tag" } th { "Commit" } th { "Kind" } } }
531+ tbody {
532+ @for t in &tags {
533+ tr {
534+ td { a href={ "/" (ctx.owner.username) "/" (ctx.repo.path) "/-/tree/" (t.name) } { (t.name) } }
535+ td class="mono" { (t.oid.short()) }
536+ td { (if t.annotated { "annotated" } else { "lightweight" }) }
537+ }
538+ }
539+ }
540+ }
541+ };
542+ let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
543+ let title = format!("{}/{}: tags", ctx.owner.username, ctx.repo.path);
544+ Ok((jar, page(&chrome, &title, body)).into_response())
545+}
546+
547+// ---- Commit list ----
548+
549+async fn commits_view(
550+ state: &AppState,
551+ viewer: Option<User>,
552+ jar: CookieJar,
553+ uri: &Uri,
554+ ctx: RepoCtx,
555+ rest: &str,
556+) -> AppResult<Response> {
557+ let rev_name = ref_or_default(&ctx.repo, rest);
558+ let rev = git_read(state, &ctx.repo.id, {
559+ let rev_name = rev_name.clone();
560+ move |repo| repo.resolve_ref(&rev_name)
561+ })
562+ .await?;
563+ let commits = git_read(state, &ctx.repo.id, {
564+ let rev = rev.clone();
565+ move |repo| repo.commits(&rev, None, git::Page::first(50))
566+ })
567+ .await?;
568+
569+ // Verify signatures against all registered keys (bounded to this page's rows).
570+ let keys = signing_keys(state.store.all_keys().await?);
571+ let states = git_read(state, &ctx.repo.id, {
572+ let oids: Vec<git::Oid> = commits.iter().map(|c| c.oid.clone()).collect();
573+ move |repo| {
574+ Ok(oids
575+ .iter()
576+ .map(|o| repo.signature_state(o, &keys))
577+ .collect::<Vec<_>>())
578+ }
579+ })
580+ .await?;
581+
582+ let body = html! {
583+ (repo_header(state, &ctx, Tab::Code, &rev_name))
584+ h2 { "Commits" }
585+ ul class="repo-list" {
586+ @for (commit, sig) in commits.iter().zip(states.iter()) {
587+ li {
588+ div class="name" {
589+ a href={ "/" (ctx.owner.username) "/" (ctx.repo.path) "/-/commit/" (commit.oid) } {
590+ (commit.summary)
591+ }
592+ " " (signature_badge(sig))
593+ }
594+ p class="muted" {
595+ span class="mono" { (commit.oid.short()) }
596+ " · " (commit.author.name) " · " (fmt_date(commit.author.time_ms))
597+ }
598+ }
599+ }
600+ }
601+ };
602+ let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
603+ let title = format!("{}/{}: commits", ctx.owner.username, ctx.repo.path);
604+ Ok((jar, page(&chrome, &title, body)).into_response())
605+}
606+
607+// ---- Group listing ----
608+
609+async fn group_listing(
610+ state: &AppState,
611+ viewer: Option<User>,
612+ jar: CookieJar,
613+ uri: &Uri,
614+ owner_name: &str,
615+ group_path: &str,
616+) -> AppResult<Response> {
617+ let Some(owner) = state.store.user_by_username(owner_name).await? else {
618+ return Err(AppError::NotFound);
619+ };
620+ let Some(group) = state
621+ .store
622+ .group_by_owner_path(&owner.id, group_path)
623+ .await?
624+ else {
625+ return Err(AppError::NotFound);
626+ };
627+
628+ let all_groups = state.store.groups_by_owner(&owner.id).await?;
629+ let prefix = format!("{group_path}/");
630+ let subgroups: Vec<_> = all_groups
631+ .iter()
632+ .filter(|g| g.path.starts_with(&prefix) && !g.path[prefix.len()..].contains('/'))
633+ .collect();
634+ let repos: Vec<Repo> = visible_repos(state, &owner, viewer.as_ref())
635+ .await?
636+ .into_iter()
637+ .filter(|r| r.path.starts_with(&prefix) && !r.path[prefix.len()..].contains('/'))
638+ .collect();
639+
640+ let body = html! {
641+ h1 { (owner.username) "/" (group.path) }
642+ @if !subgroups.is_empty() {
643+ section { h2 { "Subgroups" }
644+ ul class="repo-list" {
645+ @for g in &subgroups { li { a href={ "/" (owner.username) "/" (g.path) } { (g.path) } } }
646+ }
647+ }
648+ }
649+ section { h2 { "Repositories" } (repo_list(&owner.username, &repos)) }
650+ };
651+ let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
652+ let title = format!("{}/{}", owner.username, group.path);
653+ Ok((jar, page(&chrome, &title, body)).into_response())
654+}
655+
656+// ---- Shared components ----
657+
658+/// The repos of `owner` the viewer may see: all of them for the owner/admin, else
659+/// the public ones.
660+async fn visible_repos(
661+ state: &AppState,
662+ owner: &User,
663+ viewer: Option<&User>,
664+) -> AppResult<Vec<Repo>> {
665+ let repos = state.store.repos_by_owner(&owner.id).await?;
666+ let is_owner_or_admin = viewer.is_some_and(|v| v.id == owner.id || v.is_admin);
667+ if is_owner_or_admin {
668+ return Ok(repos);
669+ }
670+ // Non-owners: public repos plus any where they are a collaborator.
671+ let mut out = Vec::new();
672+ for repo in repos {
673+ let allowed = !repo.is_private && state.config.instance.allow_anonymous;
674+ let collab = match viewer {
675+ Some(v) => state
676+ .store
677+ .collaborator_permission(&repo.id, &v.id)
678+ .await?
679+ .is_some(),
680+ None => false,
681+ };
682+ if allowed || collab {
683+ out.push(repo);
684+ }
685+ }
686+ Ok(out)
687+}
688+
689+/// Render a list of repositories as links.
690+fn repo_list(owner: &str, repos: &[Repo]) -> Markup {
691+ html! {
692+ @if repos.is_empty() {
693+ p class="muted" { "No repositories." }
694+ } @else {
695+ ul class="repo-list" {
696+ @for repo in repos {
697+ li {
698+ div class="name" {
699+ a href={ "/" (owner) "/" (repo.path) } { (repo.path) }
700+ @if repo.is_private { " " span class="badge badge-private" { "private" } }
701+ }
702+ @if let Some(desc) = &repo.description { p class="muted" { (desc) } }
703+ }
704+ }
705+ }
706+ }
707+ }
708+}
709+
710+/// The repo sub-header: slug, tabs, and clone URLs.
711+fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Markup {
712+ let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
713+ let tab = |t: Tab, label: &str, href: String| {
714+ html! {
715+ a href=(href) aria-current=[(active == t).then_some("page")] { (label) }
716+ }
717+ };
718+ html! {
719+ div class="repo-head" {
720+ h1 class="repo-slug" {
721+ a href={ "/" (ctx.owner.username) } { (ctx.owner.username) } "/"
722+ a href=(base) { (ctx.repo.name) }
723+ @if ctx.repo.is_private { " " span class="badge badge-private" { "private" } }
724+ }
725+ @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } }
726+ nav class="tabs repo-tabs" {
727+ (tab(Tab::Code, "Code", base.clone()))
728+ (tab(Tab::Branches, "Branches", format!("{base}/-/branches")))
729+ (tab(Tab::Tags, "Tags", format!("{base}/-/tags")))
730+ (tab(Tab::Search, "Search", format!("{base}/-/search")))
731+ }
732+ div class="ref-row" {
733+ // Current ref, linking to the branch picker.
734+ a class="badge" href=(format!("{base}/-/branches")) { (rev) }
735+ a href=(format!("{base}/-/commits/{rev}")) { "Commits" }
736+ }
737+ div class="clone-row" {
738+ input type="text" readonly value=(clone_https(state, ctx))
739+ aria-label="HTTPS clone URL";
740+ button class="btn" type="button" data-clipboard=(clone_https(state, ctx)) { "Copy" }
741+ }
742+ }
743+ }
744+}
745+
746+/// The HTTPS clone URL.
747+fn clone_https(state: &AppState, ctx: &RepoCtx) -> String {
748+ format!(
749+ "{}/{}/{}.git",
750+ state.config.instance.url.trim_end_matches('/'),
751+ ctx.owner.username,
752+ ctx.repo.path
753+ )
754+}
755+
756+/// A path breadcrumb for tree/blob views.
757+fn breadcrumb(owner: &str, repo_path: &str, rev: &str, path: &str) -> Markup {
758+ let root = format!("/{owner}/{repo_path}/-/tree/{rev}");
759+ // Precompute each crumb's (label, cumulative href) before rendering.
760+ let mut acc = String::new();
761+ let crumbs: Vec<(String, String)> = path
762+ .split('/')
763+ .filter(|s| !s.is_empty())
764+ .map(|segment| {
765+ if acc.is_empty() {
766+ acc.push_str(segment);
767+ } else {
768+ acc.push('/');
769+ acc.push_str(segment);
770+ }
771+ (segment.to_string(), format!("{root}/{acc}"))
772+ })
773+ .collect();
774+ html! {
775+ nav class="breadcrumb" {
776+ a href=(root) { (repo_path) }
777+ @for (label, href) in &crumbs {
778+ " / " a href=(href) { (label) }
779+ }
780+ }
781+ }
782+}
783+
784+/// A tree listing table.
785+fn tree_table(
786+ owner: &str,
787+ repo_path: &str,
788+ rev: &str,
789+ dir: &str,
790+ entries: &[git::TreeEntry],
791+) -> Markup {
792+ let href = |name: &str, kind: git::EntryKind| {
793+ let sub = if dir.is_empty() {
794+ name.to_string()
795+ } else {
796+ format!("{dir}/{name}")
797+ };
798+ let view = match kind {
799+ git::EntryKind::Directory => "tree",
800+ _ => "blob",
801+ };
802+ format!("/{owner}/{repo_path}/-/{view}/{rev}/{sub}")
803+ };
804+ html! {
805+ table class="list tree" {
806+ tbody {
807+ @for entry in entries {
808+ tr {
809+ td class="tree-icon" { (entry_icon(entry.kind)) }
810+ td { a href=(href(&entry.name, entry.kind)) { (entry.name) } }
811+ }
812+ }
813+ }
814+ }
815+ }
816+}
817+
818+/// An icon glyph for a tree entry kind.
819+fn entry_icon(kind: git::EntryKind) -> &'static str {
820+ match kind {
821+ git::EntryKind::Directory => "📁",
822+ git::EntryKind::Submodule => "📦",
823+ git::EntryKind::Symlink => "🔗",
824+ git::EntryKind::File => "📄",
825+ }
826+}
827+
828+/// A signature badge (green Verified / amber Unverified) with a descriptive title.
829+fn signature_badge(state: &git::SignatureState) -> Markup {
830+ match state {
831+ git::SignatureState::Verified { fingerprint, .. } => html! {
832+ span class="badge badge-verified" title={ "Verified: " (fingerprint) } { "Verified" }
833+ },
834+ git::SignatureState::Unsigned => html! {
835+ span class="badge badge-unverified" title="This commit is not signed" { "Unverified" }
836+ },
837+ git::SignatureState::UnknownKey { fingerprint, .. } => html! {
838+ span class="badge badge-unverified"
839+ title={ "Signed by an unrecognized key " (fingerprint) } { "Unverified" }
840+ },
841+ git::SignatureState::Invalid { reason, .. } => html! {
842+ span class="badge badge-unverified"
843+ title={ "Signature could not be verified: " (reason) } { "Unverified" }
844+ },
845+ }
846+}
847+
848+/// Map registered keys to the verification input type.
849+fn signing_keys(keys: Vec<model::Key>) -> Vec<git::SigningKey> {
850+ keys.into_iter()
851+ .map(|k| git::SigningKey {
852+ user_id: k.user_id,
853+ key_id: k.id,
854+ kind: k.kind,
855+ fingerprint: k.fingerprint,
856+ public_key: k.public_key,
857+ })
858+ .collect()
859+}
860+
861+/// Whether a filename is a browser-renderable image.
862+fn is_image(filename: &str) -> bool {
863+ matches!(
864+ filename
865+ .rsplit('.')
866+ .next()
867+ .map(str::to_ascii_lowercase)
868+ .as_deref(),
869+ Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "ico" | "bmp")
870+ )
871+}
872+
873+/// Format an epoch-millisecond timestamp as a `YYYY-MM-DD` date.
874+fn fmt_date(ms: i64) -> String {
875+ let secs = ms.div_euclid(1000);
876+ match time::OffsetDateTime::from_unix_timestamp(secs) {
877+ Ok(dt) => format!(
878+ "{:04}-{:02}-{:02}",
879+ dt.year(),
880+ u8::from(dt.month()),
881+ dt.day()
882+ ),
883+ Err(_) => "—".to_string(),
884+ }
885+}
crates/web/src/tests.rs +31 −1
@@ -14,7 +14,7 @@ use axum::Router;
1414 use axum::body::Body;
1515 use axum::http::{Request, StatusCode, header};
1616 use config::Config;
17-use store::{NewUser, Store};
17+use store::{NewRepo, NewUser, Store};
1818 use tower::ServiceExt as _;
1919
2020 use crate::AppState;
@@ -236,3 +236,33 @@ async fn unknown_theme_is_rejected() {
236236 let res = app.oneshot(get("/settings/theme?name=nope")).await.unwrap();
237237 assert_eq!(res.status(), StatusCode::BAD_REQUEST);
238238 }
239+
240+#[tokio::test]
241+async fn private_repo_is_404_for_anonymous() {
242+ let (state, app) = app().await;
243+ let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
244+ state
245+ .store
246+ .create_repo(NewRepo {
247+ owner_id: ada.id,
248+ group_id: None,
249+ name: "secret".to_string(),
250+ path: "secret".to_string(),
251+ description: None,
252+ is_private: true,
253+ default_branch: "main".to_string(),
254+ })
255+ .await
256+ .unwrap();
257+
258+ // Access is denied before any git read, so existence never leaks: 404, not 403.
259+ let res = app.oneshot(get("/ada/secret")).await.unwrap();
260+ assert_eq!(res.status(), StatusCode::NOT_FOUND);
261+}
262+
263+#[tokio::test]
264+async fn unknown_owner_is_404() {
265+ let (_state, app) = app().await;
266+ let res = app.oneshot(get("/nobody")).await.unwrap();
267+ assert_eq!(res.status(), StatusCode::NOT_FOUND);
268+}