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 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | 82 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" |
| 83 | 83 | ||
| 84 | [[package]] | 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 | name = "anstream" | 97 | name = "anstream" |
| 86 | version = "1.0.0" | 98 | version = "1.0.0" |
| 87 | source = "registry+https://github.com/rust-lang/crates.io-index" | 99 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -465,6 +477,15 @@ dependencies = [ | |||
| 465 | ] | 477 | ] |
| 466 | 478 | ||
| 467 | [[package]] | 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 | name = "cast5" | 489 | name = "cast5" |
| 469 | version = "0.11.1" | 490 | version = "0.11.1" |
| 470 | source = "registry+https://github.com/rust-lang/crates.io-index" | 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 | checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" | 642 | checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" |
| 622 | 643 | ||
| 623 | [[package]] | 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 | name = "concurrent-queue" | 662 | name = "concurrent-queue" |
| 625 | version = "2.5.0" | 663 | version = "2.5.0" |
| 626 | source = "registry+https://github.com/rust-lang/crates.io-index" | 664 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -813,6 +851,17 @@ dependencies = [ | |||
| 813 | ] | 851 | ] |
| 814 | 852 | ||
| 815 | [[package]] | 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 | name = "ctr" | 865 | name = "ctr" |
| 817 | version = "0.9.2" | 866 | version = "0.9.2" |
| 818 | source = "registry+https://github.com/rust-lang/crates.io-index" | 867 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1059,6 +1108,21 @@ dependencies = [ | |||
| 1059 | ] | 1108 | ] |
| 1060 | 1109 | ||
| 1061 | [[package]] | 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 | name = "eax" | 1126 | name = "eax" |
| 1063 | version = "0.5.0" | 1127 | version = "0.5.0" |
| 1064 | source = "registry+https://github.com/rust-lang/crates.io-index" | 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 | checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" | 1231 | checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" |
| 1168 | 1232 | ||
| 1169 | [[package]] | 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 | name = "equivalent" | 1240 | name = "equivalent" |
| 1171 | version = "1.0.2" | 1241 | version = "1.0.2" |
| 1172 | source = "registry+https://github.com/rust-lang/crates.io-index" | 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 | checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" | 1331 | checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" |
| 1262 | 1332 | ||
| 1263 | [[package]] | 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 | name = "flate2" | 1340 | name = "flate2" |
| 1265 | version = "1.1.9" | 1341 | version = "1.1.9" |
| 1266 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1342 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1598,6 +1674,16 @@ dependencies = [ | |||
| 1598 | ] | 1674 | ] |
| 1599 | 1675 | ||
| 1600 | [[package]] | 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 | name = "http" | 1687 | name = "http" |
| 1602 | version = "1.4.2" | 1688 | version = "1.4.2" |
| 1603 | source = "registry+https://github.com/rust-lang/crates.io-index" | 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 | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" | 1956 | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" |
| 1871 | 1957 | ||
| 1872 | [[package]] | 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 | name = "jobserver" | 1965 | name = "jobserver" |
| 1874 | version = "0.1.35" | 1966 | version = "0.1.35" |
| 1875 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1967 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2040,6 +2132,23 @@ dependencies = [ | |||
| 2040 | ] | 2132 | ] |
| 2041 | 2133 | ||
| 2042 | [[package]] | 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 | name = "matchers" | 2152 | name = "matchers" |
| 2044 | version = "0.2.0" | 2153 | version = "0.2.0" |
| 2045 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2154 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2183,6 +2292,12 @@ dependencies = [ | |||
| 2183 | ] | 2292 | ] |
| 2184 | 2293 | ||
| 2185 | [[package]] | 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 | name = "nom" | 2301 | name = "nom" |
| 2187 | version = "8.0.0" | 2302 | version = "8.0.0" |
| 2188 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2303 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2533,6 +2648,45 @@ dependencies = [ | |||
| 2533 | ] | 2648 | ] |
| 2534 | 2649 | ||
| 2535 | [[package]] | 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 | name = "pin-project-lite" | 2690 | name = "pin-project-lite" |
| 2537 | version = "0.2.17" | 2691 | version = "0.2.17" |
| 2538 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2692 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2608,6 +2762,12 @@ dependencies = [ | |||
| 2608 | ] | 2762 | ] |
| 2609 | 2763 | ||
| 2610 | [[package]] | 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 | name = "primeorder" | 2771 | name = "primeorder" |
| 2612 | version = "0.13.6" | 2772 | version = "0.13.6" |
| 2613 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2773 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2888,6 +3048,12 @@ dependencies = [ | |||
| 2888 | ] | 3048 | ] |
| 2889 | 3049 | ||
| 2890 | [[package]] | 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 | name = "rustc_version" | 3057 | name = "rustc_version" |
| 2892 | version = "0.4.1" | 3058 | version = "0.4.1" |
| 2893 | source = "registry+https://github.com/rust-lang/crates.io-index" | 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 | checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" | 3363 | checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" |
| 3198 | 3364 | ||
| 3199 | [[package]] | 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 | name = "slab" | 3372 | name = "slab" |
| 3201 | version = "0.4.12" | 3373 | version = "0.4.12" |
| 3202 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3374 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -3497,6 +3669,30 @@ dependencies = [ | |||
| 3497 | ] | 3669 | ] |
| 3498 | 3670 | ||
| 3499 | [[package]] | 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 | name = "stringprep" | 3696 | name = "stringprep" |
| 3501 | version = "0.1.5" | 3697 | version = "0.1.5" |
| 3502 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3698 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -3584,6 +3780,15 @@ dependencies = [ | |||
| 3584 | ] | 3780 | ] |
| 3585 | 3781 | ||
| 3586 | [[package]] | 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 | name = "thiserror" | 3792 | name = "thiserror" |
| 3588 | version = "1.0.69" | 3793 | version = "1.0.69" |
| 3589 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3794 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -3954,6 +4159,12 @@ dependencies = [ | |||
| 3954 | ] | 4159 | ] |
| 3955 | 4160 | ||
| 3956 | [[package]] | 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 | name = "typenum" | 4168 | name = "typenum" |
| 3958 | version = "1.20.1" | 4169 | version = "1.20.1" |
| 3959 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4170 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -4166,9 +4377,11 @@ dependencies = [ | |||
| 4166 | name = "web" | 4377 | name = "web" |
| 4167 | version = "0.1.0" | 4378 | version = "0.1.0" |
| 4168 | dependencies = [ | 4379 | dependencies = [ |
| 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 | ] |
| 4201 | 4414 | ||
| 4202 | [[package]] | 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 | name = "whoami" | 4428 | name = "whoami" |
| 4204 | version = "2.1.2" | 4429 | version = "2.1.2" |
| 4205 | source = "registry+https://github.com/rust-lang/crates.io-index" | 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 | rust-embed = "8" | 141 | rust-embed = "8" |
| 142 | mime_guess = "2" | 142 | mime_guess = "2" |
| 143 | time = "0.3" | 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 | tracing = "0.1" | 147 | tracing = "0.1" |
| 145 | tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } | 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 | color: var(--fb-fg-muted); | 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 | /* ---- 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 | } |
| 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 | /// 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 | /// # Errors | 122 | /// # Errors |
crates/store/src/repos.rs +21 −0
| @@ -198,6 +198,27 @@ impl Store { | |||
| 198 | Ok(deleted > 0) | 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 | /// 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 } | |||
| 24 | tower-http = { workspace = true } | 24 | tower-http = { workspace = true } |
| 25 | rust-embed = { workspace = true } | 25 | rust-embed = { workspace = true } |
| 26 | mime_guess = { workspace = true } | 26 | mime_guess = { workspace = true } |
| 27 | comrak = { workspace = true } | ||
| 28 | ammonia = { workspace = true } | ||
| 27 | time = { workspace = true } | 29 | time = { workspace = true } |
| 28 | serde = { workspace = true } | 30 | serde = { workspace = true } |
| 29 | serde_json = { workspace = true } | 31 | serde_json = { workspace = true } |
crates/web/src/lib.rs +5 −0
| @@ -13,7 +13,9 @@ | |||
| 13 | mod assets; | 13 | mod assets; |
| 14 | mod error; | 14 | mod error; |
| 15 | mod layout; | 15 | mod layout; |
| 16 | mod markdown; | ||
| 16 | mod pages; | 17 | mod pages; |
| 18 | mod repo; | ||
| 17 | #[path = "serve.rs"] | 19 | #[path = "serve.rs"] |
| 18 | mod serve_assets; | 20 | mod serve_assets; |
| 19 | mod session; | 21 | mod 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 | |||
| 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 | /// Assemble the page chrome, ensuring the CSRF cookie and resolving the active | 27 | /// 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). |
| 29 | fn build_chrome( | 29 | pub(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 | |||
| 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 | use axum::body::Body; | 14 | use axum::body::Body; |
| 15 | use axum::http::{Request, StatusCode, header}; | 15 | use axum::http::{Request, StatusCode, header}; |
| 16 | use config::Config; | 16 | use config::Config; |
| 17 | use store::{NewUser, Store}; | 17 | use store::{NewRepo, NewUser, Store}; |
| 18 | use tower::ServiceExt as _; | 18 | use tower::ServiceExt as _; |
| 19 | 19 | ||
| 20 | use crate::AppState; | 20 | use 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] | ||
| 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 | } | ||