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"
82checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"82checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
8383
84[[package]]84[[package]]
85name = "ammonia"
86version = "4.1.4"
87source = "registry+https://github.com/rust-lang/crates.io-index"
88checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
89dependencies = [
90 "cssparser",
91 "html5ever",
92 "maplit",
93 "url",
94]
95
96[[package]]
85name = "anstream"97name = "anstream"
86version = "1.0.0"98version = "1.0.0"
87source = "registry+https://github.com/rust-lang/crates.io-index"99source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -465,6 +477,15 @@ dependencies = [
465]477]
466478
467[[package]]479[[package]]
480name = "caseless"
481version = "0.2.2"
482source = "registry+https://github.com/rust-lang/crates.io-index"
483checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8"
484dependencies = [
485 "unicode-normalization",
486]
487
488[[package]]
468name = "cast5"489name = "cast5"
469version = "0.11.1"490version = "0.11.1"
470source = "registry+https://github.com/rust-lang/crates.io-index"491source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -621,6 +642,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
621checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"642checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
622643
623[[package]]644[[package]]
645name = "comrak"
646version = "0.54.0"
647source = "registry+https://github.com/rust-lang/crates.io-index"
648checksum = "0d5910408554659ed848ff469e67ec83b30f179e72cec286cfdae64d1616f466"
649dependencies = [
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]]
624name = "concurrent-queue"662name = "concurrent-queue"
625version = "2.5.0"663version = "2.5.0"
626source = "registry+https://github.com/rust-lang/crates.io-index"664source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -813,6 +851,17 @@ dependencies = [
813]851]
814852
815[[package]]853[[package]]
854name = "cssparser"
855version = "0.37.0"
856source = "registry+https://github.com/rust-lang/crates.io-index"
857checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
858dependencies = [
859 "dtoa-short",
860 "itoa",
861 "smallvec",
862]
863
864[[package]]
816name = "ctr"865name = "ctr"
817version = "0.9.2"866version = "0.9.2"
818source = "registry+https://github.com/rust-lang/crates.io-index"867source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1059,6 +1108,21 @@ dependencies = [
1059]1108]
10601109
1061[[package]]1110[[package]]
1111name = "dtoa"
1112version = "1.0.11"
1113source = "registry+https://github.com/rust-lang/crates.io-index"
1114checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
1115
1116[[package]]
1117name = "dtoa-short"
1118version = "0.3.5"
1119source = "registry+https://github.com/rust-lang/crates.io-index"
1120checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
1121dependencies = [
1122 "dtoa",
1123]
1124
1125[[package]]
1062name = "eax"1126name = "eax"
1063version = "0.5.0"1127version = "0.5.0"
1064source = "registry+https://github.com/rust-lang/crates.io-index"1128source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1167,6 +1231,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1167checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"1231checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
11681232
1169[[package]]1233[[package]]
1234name = "entities"
1235version = "1.0.1"
1236source = "registry+https://github.com/rust-lang/crates.io-index"
1237checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
1238
1239[[package]]
1170name = "equivalent"1240name = "equivalent"
1171version = "1.0.2"1241version = "1.0.2"
1172source = "registry+https://github.com/rust-lang/crates.io-index"1242source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1261,6 +1331,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1261checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"1331checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
12621332
1263[[package]]1333[[package]]
1334name = "finl_unicode"
1335version = "1.4.0"
1336source = "registry+https://github.com/rust-lang/crates.io-index"
1337checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
1338
1339[[package]]
1264name = "flate2"1340name = "flate2"
1265version = "1.1.9"1341version = "1.1.9"
1266source = "registry+https://github.com/rust-lang/crates.io-index"1342source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1598,6 +1674,16 @@ dependencies = [
1598]1674]
15991675
1600[[package]]1676[[package]]
1677name = "html5ever"
1678version = "0.39.0"
1679source = "registry+https://github.com/rust-lang/crates.io-index"
1680checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
1681dependencies = [
1682 "log",
1683 "markup5ever",
1684]
1685
1686[[package]]
1601name = "http"1687name = "http"
1602version = "1.4.2"1688version = "1.4.2"
1603source = "registry+https://github.com/rust-lang/crates.io-index"1689source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1870,6 +1956,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1870checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"1956checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
18711957
1872[[package]]1958[[package]]
1959name = "jetscii"
1960version = "0.5.3"
1961source = "registry+https://github.com/rust-lang/crates.io-index"
1962checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e"
1963
1964[[package]]
1873name = "jobserver"1965name = "jobserver"
1874version = "0.1.35"1966version = "0.1.35"
1875source = "registry+https://github.com/rust-lang/crates.io-index"1967source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2040,6 +2132,23 @@ dependencies = [
2040]2132]
20412133
2042[[package]]2134[[package]]
2135name = "maplit"
2136version = "1.0.2"
2137source = "registry+https://github.com/rust-lang/crates.io-index"
2138checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
2139
2140[[package]]
2141name = "markup5ever"
2142version = "0.39.0"
2143source = "registry+https://github.com/rust-lang/crates.io-index"
2144checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
2145dependencies = [
2146 "log",
2147 "tendril",
2148 "web_atoms",
2149]
2150
2151[[package]]
2043name = "matchers"2152name = "matchers"
2044version = "0.2.0"2153version = "0.2.0"
2045source = "registry+https://github.com/rust-lang/crates.io-index"2154source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2183,6 +2292,12 @@ dependencies = [
2183]2292]
21842293
2185[[package]]2294[[package]]
2295name = "new_debug_unreachable"
2296version = "1.0.6"
2297source = "registry+https://github.com/rust-lang/crates.io-index"
2298checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
2299
2300[[package]]
2186name = "nom"2301name = "nom"
2187version = "8.0.0"2302version = "8.0.0"
2188source = "registry+https://github.com/rust-lang/crates.io-index"2303source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2533,6 +2648,45 @@ dependencies = [
2533]2648]
25342649
2535[[package]]2650[[package]]
2651name = "phf"
2652version = "0.13.1"
2653source = "registry+https://github.com/rust-lang/crates.io-index"
2654checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
2655dependencies = [
2656 "phf_shared",
2657 "serde",
2658]
2659
2660[[package]]
2661name = "phf_codegen"
2662version = "0.13.1"
2663source = "registry+https://github.com/rust-lang/crates.io-index"
2664checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
2665dependencies = [
2666 "phf_generator",
2667 "phf_shared",
2668]
2669
2670[[package]]
2671name = "phf_generator"
2672version = "0.13.1"
2673source = "registry+https://github.com/rust-lang/crates.io-index"
2674checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
2675dependencies = [
2676 "fastrand",
2677 "phf_shared",
2678]
2679
2680[[package]]
2681name = "phf_shared"
2682version = "0.13.1"
2683source = "registry+https://github.com/rust-lang/crates.io-index"
2684checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
2685dependencies = [
2686 "siphasher",
2687]
2688
2689[[package]]
2536name = "pin-project-lite"2690name = "pin-project-lite"
2537version = "0.2.17"2691version = "0.2.17"
2538source = "registry+https://github.com/rust-lang/crates.io-index"2692source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2608,6 +2762,12 @@ dependencies = [
2608]2762]
26092763
2610[[package]]2764[[package]]
2765name = "precomputed-hash"
2766version = "0.1.1"
2767source = "registry+https://github.com/rust-lang/crates.io-index"
2768checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
2769
2770[[package]]
2611name = "primeorder"2771name = "primeorder"
2612version = "0.13.6"2772version = "0.13.6"
2613source = "registry+https://github.com/rust-lang/crates.io-index"2773source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2888,6 +3048,12 @@ dependencies = [
2888]3048]
28893049
2890[[package]]3050[[package]]
3051name = "rustc-hash"
3052version = "2.1.3"
3053source = "registry+https://github.com/rust-lang/crates.io-index"
3054checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
3055
3056[[package]]
2891name = "rustc_version"3057name = "rustc_version"
2892version = "0.4.1"3058version = "0.4.1"
2893source = "registry+https://github.com/rust-lang/crates.io-index"3059source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3197,6 +3363,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3197checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"3363checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
31983364
3199[[package]]3365[[package]]
3366name = "siphasher"
3367version = "1.0.3"
3368source = "registry+https://github.com/rust-lang/crates.io-index"
3369checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
3370
3371[[package]]
3200name = "slab"3372name = "slab"
3201version = "0.4.12"3373version = "0.4.12"
3202source = "registry+https://github.com/rust-lang/crates.io-index"3374source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3497,6 +3669,30 @@ dependencies = [
3497]3669]
34983670
3499[[package]]3671[[package]]
3672name = "string_cache"
3673version = "0.9.0"
3674source = "registry+https://github.com/rust-lang/crates.io-index"
3675checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
3676dependencies = [
3677 "new_debug_unreachable",
3678 "parking_lot",
3679 "phf_shared",
3680 "precomputed-hash",
3681]
3682
3683[[package]]
3684name = "string_cache_codegen"
3685version = "0.6.1"
3686source = "registry+https://github.com/rust-lang/crates.io-index"
3687checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
3688dependencies = [
3689 "phf_generator",
3690 "phf_shared",
3691 "proc-macro2",
3692 "quote",
3693]
3694
3695[[package]]
3500name = "stringprep"3696name = "stringprep"
3501version = "0.1.5"3697version = "0.1.5"
3502source = "registry+https://github.com/rust-lang/crates.io-index"3698source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3584,6 +3780,15 @@ dependencies = [
3584]3780]
35853781
3586[[package]]3782[[package]]
3783name = "tendril"
3784version = "0.5.1"
3785source = "registry+https://github.com/rust-lang/crates.io-index"
3786checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
3787dependencies = [
3788 "new_debug_unreachable",
3789]
3790
3791[[package]]
3587name = "thiserror"3792name = "thiserror"
3588version = "1.0.69"3793version = "1.0.69"
3589source = "registry+https://github.com/rust-lang/crates.io-index"3794source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3954,6 +4159,12 @@ dependencies = [
3954]4159]
39554160
3956[[package]]4161[[package]]
4162name = "typed-arena"
4163version = "2.0.2"
4164source = "registry+https://github.com/rust-lang/crates.io-index"
4165checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
4166
4167[[package]]
3957name = "typenum"4168name = "typenum"
3958version = "1.20.1"4169version = "1.20.1"
3959source = "registry+https://github.com/rust-lang/crates.io-index"4170source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4166,9 +4377,11 @@ dependencies = [
4166name = "web"4377name = "web"
4167version = "0.1.0"4378version = "0.1.0"
4168dependencies = [4379dependencies = [
4380 "ammonia",
4169 "auth",4381 "auth",
4170 "axum",4382 "axum",
4171 "axum-extra",4383 "axum-extra",
4384 "comrak",
4172 "config",4385 "config",
4173 "git",4386 "git",
4174 "highlight",4387 "highlight",
@@ -4200,6 +4413,18 @@ dependencies = [
4200]4413]
42014414
4202[[package]]4415[[package]]
4416name = "web_atoms"
4417version = "0.2.5"
4418source = "registry+https://github.com/rust-lang/crates.io-index"
4419checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297"
4420dependencies = [
4421 "phf",
4422 "phf_codegen",
4423 "string_cache",
4424 "string_cache_codegen",
4425]
4426
4427[[package]]
4203name = "whoami"4428name = "whoami"
4204version = "2.1.2"4429version = "2.1.2"
4205source = "registry+https://github.com/rust-lang/crates.io-index"4430source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.toml +3 −0
@@ -141,6 +141,9 @@ tower-http = { version = "0.6", features = [
141rust-embed = "8"141rust-embed = "8"
142mime_guess = "2"142mime_guess = "2"
143time = "0.3"143time = "0.3"
144# Markdown rendering (GFM) + HTML sanitization for READMEs and comments.
145comrak = { version = "0.54", default-features = false }
146ammonia = "4"
144tracing = "0.1"147tracing = "0.1"
145tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] }148tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] }
146149
assets/base.css +87 −0
@@ -358,6 +358,93 @@ pre.code {
358 color: var(--fb-fg-muted);358 color: var(--fb-fg-muted);
359}359}
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
394table.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
417table.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}
427table.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}
436table.blob td.lineno a {
437 color: inherit;
438}
439table.blob td.code-line {
440 padding: 0 0.75rem;
441 white-space: pre;
442 tab-size: 4;
443}
444table.blob tr:target {
445 background: var(--fb-diff-add-bg);
446}
447
361/* ---- Footer ---- */448/* ---- Footer ---- */
362.footer {449.footer {
363 max-width: 76rem;450 max-width: 76rem;
crates/store/src/keys.rs +17 −0
@@ -100,6 +100,23 @@ impl Store {
100 .map_err(Into::into)100 .map_err(Into::into)
101 }101 }
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
103 /// Fetch a user's key by its 1-based ordinal, or `None`.120 /// Fetch a user's key by its 1-based ordinal, or `None`.
104 ///121 ///
105 /// # Errors122 /// # Errors
crates/store/src/repos.rs +21 −0
@@ -198,6 +198,27 @@ impl Store {
198 Ok(deleted > 0)198 Ok(deleted > 0)
199 }199 }
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
201 /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`].222 /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`].
202 fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> {223 fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> {
203 Ok(Repo {224 Ok(Repo {
crates/web/Cargo.toml +2 −0
@@ -24,6 +24,8 @@ tower = { workspace = true }
24tower-http = { workspace = true }24tower-http = { workspace = true }
25rust-embed = { workspace = true }25rust-embed = { workspace = true }
26mime_guess = { workspace = true }26mime_guess = { workspace = true }
27comrak = { workspace = true }
28ammonia = { workspace = true }
27time = { workspace = true }29time = { workspace = true }
28serde = { workspace = true }30serde = { workspace = true }
29serde_json = { workspace = true }31serde_json = { workspace = true }
crates/web/src/lib.rs +5 −0
@@ -13,7 +13,9 @@
13mod assets;13mod assets;
14mod error;14mod error;
15mod layout;15mod layout;
16mod markdown;
16mod pages;17mod pages;
18mod repo;
17#[path = "serve.rs"]19#[path = "serve.rs"]
18mod serve_assets;20mod serve_assets;
19mod session;21mod session;
@@ -114,6 +116,9 @@ pub fn build_router(state: AppState) -> Router {
114 .route("/theme.css", get(serve_assets::theme_default))116 .route("/theme.css", get(serve_assets::theme_default))
115 .route("/theme/{file}", get(serve_assets::theme_named))117 .route("/theme/{file}", get(serve_assets::theme_named))
116 .route("/assets/{*path}", get(serve_assets::assets))118 .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))
117 .layer(122 .layer(
118 ServiceBuilder::new()123 ServiceBuilder::new()
119 .layer(SetRequestIdLayer::new(124 .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
12use maud::{Markup, PreEscaped};
13
14/// Render `source` markdown to sanitized HTML, ready to splice into a page.
15#[must_use]
16pub 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)]
30mod 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
27/// Assemble the page chrome, ensuring the CSRF cookie and resolving the active27/// Assemble the page chrome, ensuring the CSRF cookie and resolving the active
28/// theme from the visitor's cookie (falling back to the instance default).28/// theme from the visitor's cookie (falling back to the instance default).
29fn build_chrome(29pub(crate) fn build_chrome(
30 state: &AppState,30 state: &AppState,
31 jar: CookieJar,31 jar: CookieJar,
32 user: Option<User>,32 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
14use std::path::Path as FsPath;
15
16use axum::extract::{Path, State};
17use axum::http::{Uri, header};
18use axum::response::{IntoResponse, Response};
19use axum_extra::extract::cookie::CookieJar;
20use maud::{Markup, html};
21use model::{Repo, User};
22
23use crate::AppState;
24use crate::error::{AppError, AppResult};
25use crate::layout::page;
26use crate::markdown;
27use crate::pages::build_chrome;
28use crate::session::MaybeUser;
29
30/// The candidate README filenames, in lookup order.
31const 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)]
35enum Tab {
36 Code,
37 Branches,
38 Tags,
39 Search,
40}
41
42/// A resolved repository plus the viewer's access to it.
43struct 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).
54async fn git_read<T, F>(state: &AppState, repo_id: &str, f: F) -> AppResult<T>
55where
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.
76async 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.
119pub 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.
154pub 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 `/-/`.
175async 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.
200fn 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.
209fn 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
219async 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.
259async 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.
286fn 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
301async 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
335async 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.
381fn 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.
414fn 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
440async 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
483async 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
518async 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
549async 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
609async 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.
660async 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.
690fn 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.
711fn 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.
747fn 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.
757fn 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.
785fn 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.
819fn 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.
829fn 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.
849fn 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.
862fn 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.
874fn 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;
14use axum::body::Body;14use axum::body::Body;
15use axum::http::{Request, StatusCode, header};15use axum::http::{Request, StatusCode, header};
16use config::Config;16use config::Config;
17use store::{NewUser, Store};17use store::{NewRepo, NewUser, Store};
18use tower::ServiceExt as _;18use tower::ServiceExt as _;
1919
20use crate::AppState;20use crate::AppState;
@@ -236,3 +236,33 @@ async fn unknown_theme_is_rejected() {
236 let res = app.oneshot(get("/settings/theme?name=nope")).await.unwrap();236 let res = app.oneshot(get("/settings/theme?name=nope")).await.unwrap();
237 assert_eq!(res.status(), StatusCode::BAD_REQUEST);237 assert_eq!(res.status(), StatusCode::BAD_REQUEST);
238}238}
239
240#[tokio::test]
241async 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]
264async 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}