Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +225 −0
| @@ -82,6 +82,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 82 | 82 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | |
| 83 | 83 | ||
| 84 | 84 | [[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]] | |
| 85 | 97 | name = "anstream" | |
| 86 | 98 | version = "1.0.0" | |
| 87 | 99 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -465,6 +477,15 @@ dependencies = [ | |||
| 465 | 477 | ] | |
| 466 | 478 | ||
| 467 | 479 | [[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]] | |
| 468 | 489 | name = "cast5" | |
| 469 | 490 | version = "0.11.1" | |
| 470 | 491 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -621,6 +642,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 621 | 642 | checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" | |
| 622 | 643 | ||
| 623 | 644 | [[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]] | |
| 624 | 662 | name = "concurrent-queue" | |
| 625 | 663 | version = "2.5.0" | |
| 626 | 664 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -813,6 +851,17 @@ dependencies = [ | |||
| 813 | 851 | ] | |
| 814 | 852 | ||
| 815 | 853 | [[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]] | |
| 816 | 865 | name = "ctr" | |
| 817 | 866 | version = "0.9.2" | |
| 818 | 867 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1059,6 +1108,21 @@ dependencies = [ | |||
| 1059 | 1108 | ] | |
| 1060 | 1109 | ||
| 1061 | 1110 | [[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]] | |
| 1062 | 1126 | name = "eax" | |
| 1063 | 1127 | version = "0.5.0" | |
| 1064 | 1128 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1167,6 +1231,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1167 | 1231 | checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" | |
| 1168 | 1232 | ||
| 1169 | 1233 | [[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]] | |
| 1170 | 1240 | name = "equivalent" | |
| 1171 | 1241 | version = "1.0.2" | |
| 1172 | 1242 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1261,6 +1331,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1261 | 1331 | checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" | |
| 1262 | 1332 | ||
| 1263 | 1333 | [[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]] | |
| 1264 | 1340 | name = "flate2" | |
| 1265 | 1341 | version = "1.1.9" | |
| 1266 | 1342 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1598,6 +1674,16 @@ dependencies = [ | |||
| 1598 | 1674 | ] | |
| 1599 | 1675 | ||
| 1600 | 1676 | [[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]] | |
| 1601 | 1687 | name = "http" | |
| 1602 | 1688 | version = "1.4.2" | |
| 1603 | 1689 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1870,6 +1956,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1870 | 1956 | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" | |
| 1871 | 1957 | ||
| 1872 | 1958 | [[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]] | |
| 1873 | 1965 | name = "jobserver" | |
| 1874 | 1966 | version = "0.1.35" | |
| 1875 | 1967 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2040,6 +2132,23 @@ dependencies = [ | |||
| 2040 | 2132 | ] | |
| 2041 | 2133 | ||
| 2042 | 2134 | [[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]] | |
| 2043 | 2152 | name = "matchers" | |
| 2044 | 2153 | version = "0.2.0" | |
| 2045 | 2154 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2183,6 +2292,12 @@ dependencies = [ | |||
| 2183 | 2292 | ] | |
| 2184 | 2293 | ||
| 2185 | 2294 | [[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]] | |
| 2186 | 2301 | name = "nom" | |
| 2187 | 2302 | version = "8.0.0" | |
| 2188 | 2303 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2533,6 +2648,45 @@ dependencies = [ | |||
| 2533 | 2648 | ] | |
| 2534 | 2649 | ||
| 2535 | 2650 | [[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]] | |
| 2536 | 2690 | name = "pin-project-lite" | |
| 2537 | 2691 | version = "0.2.17" | |
| 2538 | 2692 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2608,6 +2762,12 @@ dependencies = [ | |||
| 2608 | 2762 | ] | |
| 2609 | 2763 | ||
| 2610 | 2764 | [[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]] | |
| 2611 | 2771 | name = "primeorder" | |
| 2612 | 2772 | version = "0.13.6" | |
| 2613 | 2773 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2888,6 +3048,12 @@ dependencies = [ | |||
| 2888 | 3048 | ] | |
| 2889 | 3049 | ||
| 2890 | 3050 | [[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]] | |
| 2891 | 3057 | name = "rustc_version" | |
| 2892 | 3058 | version = "0.4.1" | |
| 2893 | 3059 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3197,6 +3363,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3197 | 3363 | checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" | |
| 3198 | 3364 | ||
| 3199 | 3365 | [[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]] | |
| 3200 | 3372 | name = "slab" | |
| 3201 | 3373 | version = "0.4.12" | |
| 3202 | 3374 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3497,6 +3669,30 @@ dependencies = [ | |||
| 3497 | 3669 | ] | |
| 3498 | 3670 | ||
| 3499 | 3671 | [[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]] | |
| 3500 | 3696 | name = "stringprep" | |
| 3501 | 3697 | version = "0.1.5" | |
| 3502 | 3698 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3584,6 +3780,15 @@ dependencies = [ | |||
| 3584 | 3780 | ] | |
| 3585 | 3781 | ||
| 3586 | 3782 | [[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]] | |
| 3587 | 3792 | name = "thiserror" | |
| 3588 | 3793 | version = "1.0.69" | |
| 3589 | 3794 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3954,6 +4159,12 @@ dependencies = [ | |||
| 3954 | 4159 | ] | |
| 3955 | 4160 | ||
| 3956 | 4161 | [[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]] | |
| 3957 | 4168 | name = "typenum" | |
| 3958 | 4169 | version = "1.20.1" | |
| 3959 | 4170 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -4166,9 +4377,11 @@ dependencies = [ | |||
| 4166 | 4377 | name = "web" | |
| 4167 | 4378 | version = "0.1.0" | |
| 4168 | 4379 | dependencies = [ | |
| 4380 | + | "ammonia", | |
| 4169 | 4381 | "auth", | |
| 4170 | 4382 | "axum", | |
| 4171 | 4383 | "axum-extra", | |
| 4384 | + | "comrak", | |
| 4172 | 4385 | "config", | |
| 4173 | 4386 | "git", | |
| 4174 | 4387 | "highlight", | |
| @@ -4200,6 +4413,18 @@ dependencies = [ | |||
| 4200 | 4413 | ] | |
| 4201 | 4414 | ||
| 4202 | 4415 | [[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]] | |
| 4203 | 4428 | name = "whoami" | |
| 4204 | 4429 | version = "2.1.2" | |
| 4205 | 4430 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
Cargo.toml +3 −0
| @@ -141,6 +141,9 @@ tower-http = { version = "0.6", features = [ | |||
| 141 | 141 | rust-embed = "8" | |
| 142 | 142 | mime_guess = "2" | |
| 143 | 143 | time = "0.3" | |
| 144 | + | # Markdown rendering (GFM) + HTML sanitization for READMEs and comments. | |
| 145 | + | comrak = { version = "0.54", default-features = false } | |
| 146 | + | ammonia = "4" | |
| 144 | 147 | tracing = "0.1" | |
| 145 | 148 | tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } | |
| 146 | 149 | ||
assets/base.css +87 −0
| @@ -358,6 +358,93 @@ pre.code { | |||
| 358 | 358 | color: var(--fb-fg-muted); | |
| 359 | 359 | } | |
| 360 | 360 | ||
| 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 | + | ||
| 361 | 448 | /* ---- Footer ---- */ | |
| 362 | 449 | .footer { | |
| 363 | 450 | max-width: 76rem; | |
crates/store/src/keys.rs +17 −0
| @@ -100,6 +100,23 @@ impl Store { | |||
| 100 | 100 | .map_err(Into::into) | |
| 101 | 101 | } | |
| 102 | 102 | ||
| 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 | 120 | /// Fetch a user's key by its 1-based ordinal, or `None`. | |
| 104 | 121 | /// | |
| 105 | 122 | /// # Errors | |
crates/store/src/repos.rs +21 −0
| @@ -198,6 +198,27 @@ impl Store { | |||
| 198 | 198 | Ok(deleted > 0) | |
| 199 | 199 | } | |
| 200 | 200 | ||
| 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 | 222 | /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`]. | |
| 202 | 223 | fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> { | |
| 203 | 224 | Ok(Repo { | |
crates/web/Cargo.toml +2 −0
| @@ -24,6 +24,8 @@ tower = { workspace = true } | |||
| 24 | 24 | tower-http = { workspace = true } | |
| 25 | 25 | rust-embed = { workspace = true } | |
| 26 | 26 | mime_guess = { workspace = true } | |
| 27 | + | comrak = { workspace = true } | |
| 28 | + | ammonia = { workspace = true } | |
| 27 | 29 | time = { workspace = true } | |
| 28 | 30 | serde = { workspace = true } | |
| 29 | 31 | serde_json = { workspace = true } | |
crates/web/src/lib.rs +5 −0
| @@ -13,7 +13,9 @@ | |||
| 13 | 13 | mod assets; | |
| 14 | 14 | mod error; | |
| 15 | 15 | mod layout; | |
| 16 | + | mod markdown; | |
| 16 | 17 | mod pages; | |
| 18 | + | mod repo; | |
| 17 | 19 | #[path = "serve.rs"] | |
| 18 | 20 | mod serve_assets; | |
| 19 | 21 | mod session; | |
| @@ -114,6 +116,9 @@ pub fn build_router(state: AppState) -> Router { | |||
| 114 | 116 | .route("/theme.css", get(serve_assets::theme_default)) | |
| 115 | 117 | .route("/theme/{file}", get(serve_assets::theme_named)) | |
| 116 | 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 | 122 | .layer( | |
| 118 | 123 | ServiceBuilder::new() | |
| 119 | 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 | + | ||
| 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::{ | |||
| 26 | 26 | ||
| 27 | 27 | /// Assemble the page chrome, ensuring the CSRF cookie and resolving the active | |
| 28 | 28 | /// theme from the visitor's cookie (falling back to the instance default). | |
| 29 | - | fn build_chrome( | |
| 29 | + | pub(crate) fn build_chrome( | |
| 30 | 30 | state: &AppState, | |
| 31 | 31 | jar: CookieJar, | |
| 32 | 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 | + | ||
| 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; | |||
| 14 | 14 | use axum::body::Body; | |
| 15 | 15 | use axum::http::{Request, StatusCode, header}; | |
| 16 | 16 | use config::Config; | |
| 17 | - | use store::{NewUser, Store}; | |
| 17 | + | use store::{NewRepo, NewUser, Store}; | |
| 18 | 18 | use tower::ServiceExt as _; | |
| 19 | 19 | ||
| 20 | 20 | use crate::AppState; | |
| @@ -236,3 +236,33 @@ async fn unknown_theme_is_rejected() { | |||
| 236 | 236 | let res = app.oneshot(get("/settings/theme?name=nope")).await.unwrap(); | |
| 237 | 237 | assert_eq!(res.status(), StatusCode::BAD_REQUEST); | |
| 238 | 238 | } | |
| 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 | + | } | |