tirbofish/kitgit · diff
feat: ssh key verification, enhanced admin panel and opengraph implementations.
Type: SSH
SSH Key Fingerprint:
Verified
SgQHY4vUORbJC3ZtdixCl62ek/4QGh/iG2FRSyjfGPc
@@ -10,12 +10,33 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.6", + "generic-array 0.14.9", +] + +[[package]] +name = "aead" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ "crypto-common 0.2.2", - "inout", + "inout 0.2.2", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", ] [[package]] @@ -24,7 +45,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" dependencies = [ - "cipher", + "cipher 0.5.2", "cpubits", "cpufeatures 0.3.0", "zeroize", @@ -32,20 +53,43 @@ dependencies = [ [[package]] name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", + "subtle", +] + +[[package]] +name = "aes-gcm" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", + "aead 0.6.1", + "aes 0.9.2", + "cipher 0.5.2", + "ctr 0.10.1", + "ghash 0.6.0", "subtle", "zeroize", ] [[package]] +name = "aes-kw" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c" +dependencies = [ + "aes 0.8.4", +] + +[[package]] name = "aho-corasick" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -127,14 +171,27 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2 0.10.6", + "cpufeatures 0.2.17", + "password-hash 0.5.0", + "zeroize", +] + +[[package]] +name = "argon2" version = "0.6.0-rc.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7af50940b73bf4e16c15c448a2b121c63f2d68e3e54b6a8731673cb4aa0cdff5" dependencies = [ "base64ct", - "blake2", + "blake2 0.11.0-rc.6", "cpufeatures 0.3.0", - "password-hash", + "password-hash 0.6.1", ] [[package]] @@ -176,7 +233,7 @@ dependencies = [ "memchr", "serde", "serde_derive", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -381,7 +438,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0" dependencies = [ - "blowfish", + "blowfish 0.10.0", "pbkdf2", "sha2 0.11.0", ] @@ -411,6 +468,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] +name = "bitfield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d7e60934ceec538daadb9d8432424ed043a904d8e0243f3c6446bce549a46ac" + +[[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -421,6 +484,15 @@ dependencies = [ [[package]] name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "blake2" version = "0.11.0-rc.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690" @@ -449,6 +521,15 @@ dependencies = [ [[package]] name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array 0.14.9", +] + +[[package]] +name = "block-padding" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" @@ -458,12 +539,22 @@ dependencies = [ [[package]] name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher 0.4.4", +] + +[[package]] +name = "blowfish" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" dependencies = [ "byteorder", - "cipher", + "cipher 0.5.2", ] [[package]] @@ -476,6 +567,25 @@ dependencies = [ ] [[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "buffer-redux" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "431a9cc8d7efa49bc326729264537f5e60affce816c66edf434350778c9f4f54" +dependencies = [ + "memchr", +] + +[[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -494,12 +604,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] +name = "camellia" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3264e2574e9ef2b53ce6f536dea83a69ac0bc600b762d1523ff83fe07230ce30" +dependencies = [ + "byteorder", + "cipher 0.4.4", +] + +[[package]] +name = "cast5" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b07d673db1ccf000e90f54b819db9e75a8348d6eb056e9b8ab53231b7a9911" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] name = "cbc" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" dependencies = [ - "cipher", + "cipher 0.5.2", ] [[package]] @@ -515,6 +644,15 @@ dependencies = [ ] [[package]] +name = "cfb-mode" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -533,7 +671,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cipher", + "cipher 0.5.2", "cpufeatures 0.3.0", "rand_core 0.10.1", "zeroize", @@ -555,13 +693,23 @@ dependencies = [ [[package]] name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.6", + "inout 0.1.4", +] + +[[package]] +name = "cipher" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "block-buffer 0.12.1", "crypto-common 0.2.2", - "inout", + "inout 0.2.2", "zeroize", ] @@ -606,6 +754,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher 0.4.4", + "dbl", + "digest 0.10.7", +] + +[[package]] name = "cmov" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -721,6 +880,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] +name = "crc24" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd121741cf3eb82c08dd3023eb55bf2665e5f60ec20f89760cf836ae4562e6a0" + +[[package]] name = "crc32fast" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -780,6 +945,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array 0.14.9", + "rand_core 0.6.4", "typenum", ] @@ -806,11 +972,20 @@ dependencies = [ [[package]] name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "ctr" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher", + "cipher 0.5.2", ] [[package]] @@ -869,12 +1044,36 @@ dependencies = [ [[package]] name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", ] [[package]] @@ -892,11 +1091,22 @@ dependencies = [ [[package]] name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.119", ] @@ -922,6 +1132,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array 0.14.9", +] + +[[package]] name = "delegate" version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -964,12 +1183,73 @@ dependencies = [ ] [[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] name = "des" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" dependencies = [ - "cipher", + "cipher 0.5.2", ] [[package]] @@ -1023,12 +1303,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] +name = "dsa" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689" +dependencies = [ + "digest 0.10.7", + "num-bigint-dig", + "num-traits", + "pkcs8 0.10.2", + "rfc6979 0.4.0", + "sha2 0.10.9", + "signature 2.2.0", + "zeroize", +] + +[[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] +name = "eax" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" +dependencies = [ + "aead 0.5.2", + "cipher 0.4.4", + "cmac", + "ctr 0.9.2", + "subtle", +] + +[[package]] name = "ecdsa" version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1108,6 +1417,17 @@ dependencies = [ ] [[package]] +name = "ed448-goldilocks" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87b5fa9e9e3dd5fe1369f380acd3dcdfa766dbd0a1cd5b048fb40e38a6a78e79" +dependencies = [ + "fiat-crypto 0.1.20", + "hex", + "subtle", +] + +[[package]] name = "either" version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1250,6 +1570,12 @@ dependencies = [ [[package]] name = "fiat-crypto" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e825f6987101665dea6ec934c09ec6d721de7bc1bf92248e1d5810c8cd636b77" + +[[package]] +name = "fiat-crypto" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" @@ -1458,11 +1784,21 @@ dependencies = [ [[package]] name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval 0.6.2", +] + +[[package]] +name = "ghash" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" dependencies = [ - "polyval", + "polyval 0.7.3", ] [[package]] @@ -1857,6 +2193,15 @@ dependencies = [ ] [[package]] +name = "idea" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075557004419d7f2031b8bb7f44bb43e55a83ca7b63076a8fb8fe75753836477" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1908,11 +2253,20 @@ dependencies = [ [[package]] name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array 0.14.9", +] + +[[package]] +name = "inout" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ - "block-padding", + "block-padding 0.4.2", "hybrid-array", ] @@ -1941,6 +2295,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] +name = "iter-read" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071ed4cc1afd86650602c7b11aa2e1ce30762a1c27193201cb5cee9c6ebb1294" + +[[package]] name = "itertools" version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1977,6 +2337,29 @@ dependencies = [ ] [[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "once_cell", + "sha2 0.10.9", + "signature 2.2.0", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] name = "keccak" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2012,6 +2395,7 @@ dependencies = [ "clap", "dashmap", "data-encoding", + "flate2", "futures", "git2", "hex", @@ -2019,6 +2403,7 @@ dependencies = [ "http-body-util", "mime_guess", "openidconnect", + "pgp", "pulldown-cmark", "qrcode", "rand 0.10.2", @@ -2029,6 +2414,7 @@ dependencies = [ "sha1 0.10.7", "sha2 0.10.9", "sqlx", + "ssh-key", "syntect", "thiserror 2.0.19", "time", @@ -2214,6 +2600,12 @@ dependencies = [ ] [[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] name = "miniz_oxide" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2289,6 +2681,16 @@ dependencies = [ ] [[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2319,6 +2721,7 @@ dependencies = [ "num-iter", "num-traits", "rand 0.8.7", + "serde", "smallvec", "zeroize", ] @@ -2359,6 +2762,28 @@ dependencies = [ ] [[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] name = "oauth2" version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2379,6 +2804,18 @@ dependencies = [ ] [[package]] +name = "ocb3" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" +dependencies = [ + "aead 0.5.2", + "cipher 0.4.4", + "ctr 0.9.2", + "subtle", +] + +[[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2391,6 +2828,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] name = "openidconnect" version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2501,6 +2944,20 @@ dependencies = [ [[package]] name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct 0.2.0", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "rand_core 0.6.4", + "sha2 0.10.9", +] + +[[package]] +name = "p521" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449" @@ -2564,6 +3021,17 @@ dependencies = [ [[package]] name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "password-hash" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" @@ -2606,6 +3074,73 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] +name = "pgp" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1877a97fd422433220ad272eb008ec55691944b1200e9eb204e3cb2cb69d34e9" +dependencies = [ + "aes 0.8.4", + "aes-gcm 0.10.3", + "aes-kw", + "argon2 0.5.3", + "base64 0.22.1", + "bitfield", + "block-padding 0.3.3", + "blowfish 0.9.1", + "bstr", + "buffer-redux", + "byteorder", + "camellia", + "cast5", + "cfb-mode", + "chrono", + "cipher 0.4.4", + "const-oid 0.9.6", + "crc24", + "curve25519-dalek 4.1.3", + "derive_builder", + "derive_more", + "des 0.8.1", + "digest 0.10.7", + "dsa", + "eax", + "ecdsa 0.16.9", + "ed25519-dalek 2.2.0", + "elliptic-curve 0.13.8", + "flate2", + "generic-array 0.14.9", + "hex", + "hkdf 0.12.4", + "idea", + "iter-read", + "k256", + "log", + "md-5", + "nom", + "num-bigint-dig", + "num-traits", + "num_enum", + "ocb3", + "p256 0.13.2", + "p384 0.13.1", + "p521 0.13.3", + "rand 0.8.7", + "ripemd", + "rsa 0.9.10", + "sha1 0.10.7", + "sha1-checked", + "sha2 0.10.9", + "sha3 0.10.9", + "signature 2.2.0", + "smallvec", + "thiserror 1.0.69", + "twofish", + "x25519-dalek", + "x448", + "zeroize", +] + +[[package]] name = "phc" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2648,8 +3183,8 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" dependencies = [ - "aes", - "aes-gcm", + "aes 0.9.2", + "aes-gcm 0.11.0", "cbc", "der 0.8.1", "pbkdf2", @@ -2700,19 +3235,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" dependencies = [ "cpufeatures 0.3.0", - "universal-hash", + "universal-hash 0.6.1", "zeroize", ] [[package]] name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ "cpubits", "cpufeatures 0.3.0", - "universal-hash", + "universal-hash 0.6.1", ] [[package]] @@ -2776,6 +3323,15 @@ dependencies = [ ] [[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] name = "proc-macro2" version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2929,6 +3485,12 @@ dependencies = [ [[package]] name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" + +[[package]] +name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" @@ -3081,6 +3643,15 @@ dependencies = [ ] [[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest 0.10.7", +] + +[[package]] name = "rsa" version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3125,15 +3696,15 @@ version = "0.62.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8b67b5a0d8068c89dcbe9d95df986af7a851d1f3c604525274c37468e60464f" dependencies = [ - "aes", + "aes 0.9.2", "bitflags", - "block-padding", + "block-padding 0.4.2", "byteorder", "bytes", "cbc", - "cipher", + "cipher 0.5.2", "crypto-bigint 0.7.5", - "ctr", + "ctr 0.10.1", "curve25519-dalek 5.0.0", "data-encoding", "delegate", @@ -3147,12 +3718,12 @@ dependencies = [ "futures", "generic-array 1.4.4", "getrandom 0.4.3", - "ghash", + "ghash 0.6.0", "hex-literal", "hmac 0.13.0", - "inout", + "inout 0.2.2", "internal-russh-num-bigint", - "keccak", + "keccak 0.2.0", "log", "md5", "ml-kem", @@ -3160,13 +3731,13 @@ dependencies = [ "num-bigint", "p256 0.14.0", "p384 0.14.0", - "p521", + "p521 0.14.0", "pageant", "pbkdf2", "pkcs1 0.8.0-rc.4", "pkcs5", "pkcs8 0.11.0", - "polyval", + "polyval 0.7.3", "rand 0.10.2", "rand_core 0.10.1", "ring", @@ -3187,7 +3758,7 @@ dependencies = [ "thiserror 2.0.19", "tokio", "typenum", - "universal-hash", + "universal-hash 0.6.1", "zeroize", ] @@ -3284,7 +3855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" dependencies = [ "cfg-if", - "cipher", + "cipher 0.5.2", ] [[package]] @@ -3492,7 +4063,7 @@ version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.119", @@ -3531,6 +4102,17 @@ dependencies = [ ] [[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest 0.10.7", + "sha1 0.10.7", + "zeroize", +] + +[[package]] name = "sha2" version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3554,12 +4136,22 @@ dependencies = [ [[package]] name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak 0.1.6", +] + +[[package]] +name = "sha3" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" dependencies = [ "digest 0.11.3", - "keccak", + "keccak 0.2.0", ] [[package]] @@ -3569,7 +4161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" dependencies = [ "digest 0.11.3", - "keccak", + "keccak 0.2.0", "sponge-cursor", ] @@ -3888,13 +4480,13 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597" dependencies = [ - "aead", - "aes", - "aes-gcm", + "aead 0.6.1", + "aes 0.9.2", + "aes-gcm 0.11.0", "chacha20", - "cipher", + "cipher 0.5.2", "ctutils", - "des", + "des 0.9.0", "poly1305", "ssh-encoding", "zeroize", @@ -3921,7 +4513,7 @@ version = "0.7.0-rc.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9a32fae177b74a22aa9c5b01bf7e68b33545be32d9e381e248058d2adc15ce3" dependencies = [ - "argon2", + "argon2 0.6.0-rc.8", "bcrypt-pbkdf", "ctutils", "ed25519-dalek 3.0.0", @@ -3929,7 +4521,7 @@ dependencies = [ "hmac 0.13.0", "p256 0.14.0", "p384 0.14.0", - "p521", + "p521 0.14.0", "rand_core 0.10.1", "rsa 0.10.0-rc.18", "sec1 0.8.1", @@ -4204,8 +4796,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -4218,6 +4810,15 @@ dependencies = [ ] [[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] name = "toml_edit" version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4226,9 +4827,30 @@ dependencies = [ "indexmap 2.14.0", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", ] [[package]] @@ -4363,6 +4985,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] +name = "twofish" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a78e83a30223c757c3947cd144a31014ff04298d8719ae10d03c31c0448c8013" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4402,6 +5033,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.6", + "subtle", +] + +[[package]] name = "universal-hash" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4889,6 +5536,15 @@ dependencies = [ ] [[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] name = "wnaf" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4906,6 +5562,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x448" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cd07d4fae29e07089dbcacf7077cd52dce7760125ca9a4dd5a35ca603ffebb" +dependencies = [ + "ed448-goldilocks", + "hex", + "rand_core 0.5.1", +] + +[[package]] name = "yoke" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4974,6 +5653,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" @@ -17,6 +17,7 @@ bytes = "1" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4", features = ["derive", "env"] } data-encoding = "2" +flate2 = "1" futures = "0.3" git2 = "0.20" hex = "0.4" @@ -29,8 +30,10 @@ openidconnect = "4" pulldown-cmark = { version = "0.13", default-features = false, features = ["html", "simd"] } rand = "0.10" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "cookies", "gzip"] } +pgp = "0.14" russh = { version = "0.62", default-features = false, features = ["flate2", "rsa", "ring"] } serde = { version = "1", features = ["derive"] } +ssh-key = { version = "=0.7.0-rc.11", default-features = false, features = ["std", "ed25519", "rsa", "p256", "p384", "p521"] } serde_json = "1" sha2 = "0.10" sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "uuid", "migrate"] } @@ -0,0 +1,4 @@ +INSERT INTO site_settings (key, value) VALUES + ('signups_enabled', 'true'), + ('signup_disabled_message', 'Unfortunately, the admin has disabled new signups. Sorry!') +ON CONFLICT (key) DO NOTHING; @@ -0,0 +1,6 @@ +-- SSH keys may be used for git authentication, commit signing, or both. +ALTER TABLE ssh_keys + ADD COLUMN IF NOT EXISTS key_usage TEXT NOT NULL DEFAULT 'authentication' + CHECK (key_usage IN ('authentication', 'signing', 'both')); + +CREATE INDEX IF NOT EXISTS ssh_keys_key_usage_idx ON ssh_keys (key_usage); @@ -0,0 +1,21 @@ +-- Suspended users cannot log in or use Git (HTTP/SSH). +ALTER TABLE users ADD COLUMN IF NOT EXISTS is_suspended BOOLEAN NOT NULL DEFAULT FALSE; +CREATE INDEX IF NOT EXISTS users_is_suspended_idx ON users(is_suspended) WHERE is_suspended = TRUE; + +-- Site-wide announcement / maintenance banner (separate from MOTD). +INSERT INTO site_settings (key, value) VALUES ('announcement', '') +ON CONFLICT (key) DO NOTHING; + +-- Single-use invite codes for signup when public signups are disabled. +CREATE TABLE IF NOT EXISTS invite_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code TEXT NOT NULL UNIQUE, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + used_by UUID REFERENCES users(id) ON DELETE SET NULL, + used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS invite_codes_unused_idx + ON invite_codes(created_at DESC) + WHERE used_at IS NULL AND revoked_at IS NULL; @@ -255,6 +255,10 @@ pub async fn finish_login(auth: &AuthState, code: &str, state: &str) -> Result<( ) .await?; + if user.is_suspended { + anyhow::bail!("account suspended"); + } + // First user to ever log in becomes site admin. if queries::site_admin_count(&auth.pool).await? == 0 { user = queries::set_site_admin(&auth.pool, user.id, true).await?; @@ -826,6 +830,9 @@ async fn user_from_authentik_me(auth: &AuthState, me: &AuthentikMe) -> Result<Us } async fn create_kitgit_session(auth: &AuthState, user: &User) -> Result<(User, String)> { + if user.is_suspended { + anyhow::bail!("account suspended"); + } let token = new_session_token(); queries::create_session(&auth.pool, user.id, &hash_token(&token), 14).await?; Ok((user.clone(), token)) @@ -887,6 +894,9 @@ pub async fn login_with_password( password: &str, ) -> Result<LoginOutcome> { let user = verify_password(auth, username, password).await?; + if user.is_suspended { + anyhow::bail!("account suspended"); + } if queries::mfa_is_enabled(&auth.pool, user.id).await? { let pending = new_session_token(); queries::delete_mfa_pending_for_user(&auth.pool, user.id).await?; @@ -940,6 +950,9 @@ pub async fn complete_mfa_login( let user = queries::get_user_by_id(&auth.pool, user_id) .await? .ok_or_else(|| anyhow!("user not found"))?; + if user.is_suspended { + anyhow::bail!("account suspended"); + } create_kitgit_session(auth, &user).await } @@ -14,6 +14,7 @@ pub struct User { pub avatar_path: Option<String>, pub avatar_url: Option<String>, pub is_site_admin: bool, + pub is_suspended: bool, pub show_email: bool, pub vigilant_mode: bool, pub created_at: DateTime<Utc>, @@ -115,9 +116,29 @@ pub struct SshKey { pub name: String, pub public_key: String, pub fingerprint: String, + /// `authentication`, `signing`, or `both`. + pub key_usage: String, pub created_at: DateTime<Utc>, } +impl SshKey { + pub fn allows_authentication(&self) -> bool { + self.key_usage == "authentication" || self.key_usage == "both" + } + + pub fn allows_signing(&self) -> bool { + self.key_usage == "signing" || self.key_usage == "both" + } + + pub fn usage_label(&self) -> &'static str { + match self.key_usage.as_str() { + "signing" => "Signing", + "both" => "Authentication & Signing", + _ => "Authentication", + } + } +} + #[derive(Debug, Clone, FromRow)] pub struct Collaborator { pub repo_id: Uuid, @@ -127,6 +127,450 @@ pub async fn set_setting(pool: &PgPool, key: &str, value: &str) -> Result<()> { Ok(()) } +pub const DEFAULT_SIGNUP_DISABLED_MESSAGE: &str = + "Unfortunately, the admin has disabled new signups. Sorry!"; + +pub async fn signups_enabled(pool: &PgPool) -> bool { + let v = get_setting(pool, "signups_enabled") + .await + .unwrap_or_default(); + if v.is_empty() { + return true; + } + matches!(v.as_str(), "true" | "1" | "yes" | "on") +} + +pub async fn signup_disabled_message(pool: &PgPool) -> String { + let v = get_setting(pool, "signup_disabled_message") + .await + .unwrap_or_default(); + let trimmed = v.trim(); + if trimmed.is_empty() { + DEFAULT_SIGNUP_DISABLED_MESSAGE.to_string() + } else { + trimmed.to_string() + } +} + +pub async fn site_announcement(pool: &PgPool) -> String { + get_setting(pool, "announcement") + .await + .unwrap_or_default() + .trim() + .to_string() +} + +pub async fn set_user_suspended(pool: &PgPool, id: Uuid, suspended: bool) -> Result<User> { + let user = sqlx::query_as::<_, User>( + "UPDATE users SET is_suspended = $2, updated_at = now() WHERE id = $1 RETURNING *", + ) + .bind(id) + .bind(suspended) + .fetch_one(pool) + .await?; + if suspended { + sqlx::query("DELETE FROM sessions WHERE user_id = $1") + .bind(id) + .execute(pool) + .await?; + let _ = delete_mfa_pending_for_user(pool, id).await; + } + Ok(user) +} + +pub async fn search_users( + pool: &PgPool, + query: Option<&str>, + limit: i64, + offset: i64, +) -> Result<(Vec<User>, i64)> { + let q = query.map(|s| s.trim()).filter(|s| !s.is_empty()); + let (users, total): (Vec<User>, i64) = if let Some(term) = q { + let like = format!("%{}%", term.replace('%', "\\%").replace('_', "\\_")); + let users = sqlx::query_as::<_, User>( + r#" + SELECT * FROM users + WHERE username ILIKE $1 ESCAPE '\' + OR email ILIKE $1 ESCAPE '\' + OR display_name ILIKE $1 ESCAPE '\' + ORDER BY username + LIMIT $2 OFFSET $3 + "#, + ) + .bind(&like) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + let (total,): (i64,) = sqlx::query_as( + r#" + SELECT COUNT(*) FROM users + WHERE username ILIKE $1 ESCAPE '\' + OR email ILIKE $1 ESCAPE '\' + OR display_name ILIKE $1 ESCAPE '\' + "#, + ) + .bind(&like) + .fetch_one(pool) + .await?; + (users, total) + } else { + let users = sqlx::query_as::<_, User>( + "SELECT * FROM users ORDER BY username LIMIT $1 OFFSET $2", + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + let (total,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") + .fetch_one(pool) + .await?; + (users, total) + }; + Ok((users, total)) +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct InviteCode { + pub id: Uuid, + pub code: String, + pub created_by: Option<Uuid>, + pub created_at: DateTime<Utc>, + pub used_by: Option<Uuid>, + pub used_at: Option<DateTime<Utc>>, + pub revoked_at: Option<DateTime<Utc>>, +} + +pub fn generate_invite_code() -> String { + use rand::RngExt; + const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + let mut rng = rand::rng(); + let mut out = String::with_capacity(10); + for _ in 0..10 { + let mut b = [0u8; 1]; + rng.fill(&mut b); + out.push(ALPHABET[(b[0] as usize) % ALPHABET.len()] as char); + } + out +} + +pub async fn create_invite(pool: &PgPool, created_by: Uuid) -> Result<InviteCode> { + let code = generate_invite_code(); + Ok(sqlx::query_as::<_, InviteCode>( + r#" + INSERT INTO invite_codes (code, created_by) + VALUES ($1, $2) + RETURNING * + "#, + ) + .bind(code) + .bind(created_by) + .fetch_one(pool) + .await?) +} + +pub async fn list_active_invites(pool: &PgPool, limit: i64) -> Result<Vec<InviteCode>> { + Ok(sqlx::query_as::<_, InviteCode>( + r#" + SELECT * FROM invite_codes + WHERE used_at IS NULL AND revoked_at IS NULL + ORDER BY created_at DESC + LIMIT $1 + "#, + ) + .bind(limit) + .fetch_all(pool) + .await?) +} + +pub async fn revoke_invite(pool: &PgPool, id: Uuid) -> Result<()> { + sqlx::query( + "UPDATE invite_codes SET revoked_at = now() WHERE id = $1 AND used_at IS NULL AND revoked_at IS NULL", + ) + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + +/// Returns the invite if it is unused and not revoked. +pub async fn get_valid_invite(pool: &PgPool, code: &str) -> Result<Option<InviteCode>> { + let code = code.trim().to_uppercase(); + if code.is_empty() { + return Ok(None); + } + Ok(sqlx::query_as::<_, InviteCode>( + r#" + SELECT * FROM invite_codes + WHERE upper(code) = $1 AND used_at IS NULL AND revoked_at IS NULL + "#, + ) + .bind(code) + .fetch_optional(pool) + .await?) +} + +pub async fn consume_invite(pool: &PgPool, invite_id: Uuid, user_id: Uuid) -> Result<bool> { + let res = sqlx::query( + r#" + UPDATE invite_codes + SET used_by = $2, used_at = now() + WHERE id = $1 AND used_at IS NULL AND revoked_at IS NULL + "#, + ) + .bind(invite_id) + .bind(user_id) + .execute(pool) + .await?; + Ok(res.rows_affected() > 0) +} + +#[derive(Debug, Clone)] +pub struct AdminStats { + pub user_count: i64, + pub repo_count: i64, + pub public_repo_count: i64, + pub recent_signups: i64, + pub active_invites: i64, + pub disk_bytes: u64, +} + +pub async fn admin_stats(pool: &PgPool) -> Result<AdminStats> { + let (user_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") + .fetch_one(pool) + .await?; + let (repo_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM repositories") + .fetch_one(pool) + .await?; + let (public_repo_count,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM repositories WHERE visibility = 'public'") + .fetch_one(pool) + .await?; + let (recent_signups,): (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM users WHERE created_at > now() - interval '7 days'", + ) + .fetch_one(pool) + .await?; + let (active_invites,): (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM invite_codes WHERE used_at IS NULL AND revoked_at IS NULL", + ) + .fetch_one(pool) + .await?; + Ok(AdminStats { + user_count, + repo_count, + public_repo_count, + recent_signups, + active_invites, + disk_bytes: 0, + }) +} + +pub async fn list_repos_admin( + pool: &PgPool, + query: Option<&str>, + limit: i64, + offset: i64, +) -> Result<(Vec<(Repository, String)>, i64)> { + #[derive(sqlx::FromRow)] + struct Row { + id: Uuid, + owner_id: Uuid, + name: String, + description: String, + visibility: String, + default_branch: String, + archived: bool, + issues_enabled: bool, + pulls_enabled: bool, + releases_enabled: bool, + allow_merge: bool, + allow_squash: bool, + allow_rebase: bool, + default_merge_style: String, + protect_default_branch: bool, + protect_block_force_push: bool, + fork_of_id: Option<Uuid>, + stars_count: i32, + watches_count: i32, + forks_count: i32, + created_at: DateTime<Utc>, + updated_at: DateTime<Utc>, + owner_username: String, + } + + let q = query.map(|s| s.trim()).filter(|s| !s.is_empty()); + let (rows, total): (Vec<Row>, i64) = if let Some(term) = q { + let like = format!("%{}%", term.replace('%', "\\%").replace('_', "\\_")); + let rows = sqlx::query_as( + r#" + SELECT r.*, u.username AS owner_username + FROM repositories r + JOIN users u ON u.id = r.owner_id + WHERE r.name ILIKE $1 ESCAPE '\' + OR r.description ILIKE $1 ESCAPE '\' + OR u.username ILIKE $1 ESCAPE '\' + ORDER BY r.updated_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(&like) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + let (total,): (i64,) = sqlx::query_as( + r#" + SELECT COUNT(*) + FROM repositories r + JOIN users u ON u.id = r.owner_id + WHERE r.name ILIKE $1 ESCAPE '\' + OR r.description ILIKE $1 ESCAPE '\' + OR u.username ILIKE $1 ESCAPE '\' + "#, + ) + .bind(&like) + .fetch_one(pool) + .await?; + (rows, total) + } else { + let rows = sqlx::query_as( + r#" + SELECT r.*, u.username AS owner_username + FROM repositories r + JOIN users u ON u.id = r.owner_id + ORDER BY r.updated_at DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + let (total,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM repositories") + .fetch_one(pool) + .await?; + (rows, total) + }; + + let repos = rows + .into_iter() + .map(|r| { + ( + Repository { + id: r.id, + owner_id: r.owner_id, + name: r.name, + description: r.description, + visibility: r.visibility, + default_branch: r.default_branch, + archived: r.archived, + issues_enabled: r.issues_enabled, + pulls_enabled: r.pulls_enabled, + releases_enabled: r.releases_enabled, + allow_merge: r.allow_merge, + allow_squash: r.allow_squash, + allow_rebase: r.allow_rebase, + default_merge_style: r.default_merge_style, + protect_default_branch: r.protect_default_branch, + protect_block_force_push: r.protect_block_force_push, + fork_of_id: r.fork_of_id, + stars_count: r.stars_count, + watches_count: r.watches_count, + forks_count: r.forks_count, + created_at: r.created_at, + updated_at: r.updated_at, + }, + r.owner_username, + ) + }) + .collect(); + Ok((repos, total)) +} + +pub async fn set_repo_visibility( + pool: &PgPool, + id: Uuid, + visibility: &str, +) -> Result<Repository> { + Ok(sqlx::query_as::<_, Repository>( + "UPDATE repositories SET visibility = $2, updated_at = now() WHERE id = $1 RETURNING *", + ) + .bind(id) + .bind(visibility) + .fetch_one(pool) + .await?) +} + +pub async fn get_repo_by_id(pool: &PgPool, id: Uuid) -> Result<Option<(Repository, String)>> { + #[derive(sqlx::FromRow)] + struct Row { + id: Uuid, + owner_id: Uuid, + name: String, + description: String, + visibility: String, + default_branch: String, + archived: bool, + issues_enabled: bool, + pulls_enabled: bool, + releases_enabled: bool, + allow_merge: bool, + allow_squash: bool, + allow_rebase: bool, + default_merge_style: String, + protect_default_branch: bool, + protect_block_force_push: bool, + fork_of_id: Option<Uuid>, + stars_count: i32, + watches_count: i32, + forks_count: i32, + created_at: DateTime<Utc>, + updated_at: DateTime<Utc>, + owner_username: String, + } + let row: Option<Row> = sqlx::query_as( + r#" + SELECT r.*, u.username AS owner_username + FROM repositories r + JOIN users u ON u.id = r.owner_id + WHERE r.id = $1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| { + ( + Repository { + id: r.id, + owner_id: r.owner_id, + name: r.name, + description: r.description, + visibility: r.visibility, + default_branch: r.default_branch, + archived: r.archived, + issues_enabled: r.issues_enabled, + pulls_enabled: r.pulls_enabled, + releases_enabled: r.releases_enabled, + allow_merge: r.allow_merge, + allow_squash: r.allow_squash, + allow_rebase: r.allow_rebase, + default_merge_style: r.default_merge_style, + protect_default_branch: r.protect_default_branch, + protect_block_force_push: r.protect_block_force_push, + fork_of_id: r.fork_of_id, + stars_count: r.stars_count, + watches_count: r.watches_count, + forks_count: r.forks_count, + created_at: r.created_at, + updated_at: r.updated_at, + }, + r.owner_username, + ) + })) +} + pub async fn create_session(pool: &PgPool, user_id: Uuid, token_hash: &str, days: i64) -> Result<()> { let expires = Utc::now() + chrono::Duration::days(days); @@ -146,7 +590,9 @@ pub async fn user_from_session(pool: &PgPool, token_hash: &str) -> Result<Option r#" SELECT u.* FROM users u JOIN sessions s ON s.user_id = u.id - WHERE s.token_hash = $1 AND s.expires_at > now() + WHERE s.token_hash = $1 + AND s.expires_at > now() + AND u.is_suspended = FALSE "#, ) .bind(token_hash) @@ -238,15 +684,6 @@ pub async fn get_repo(pool: &PgPool, owner: &str, name: &str) -> Result<Option<( Ok(repo.map(|r| (r, owner_user))) } -pub async fn get_repo_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Repository>> { - Ok( - sqlx::query_as::<_, Repository>("SELECT * FROM repositories WHERE id = $1") - .bind(id) - .fetch_optional(pool) - .await?, - ) -} - pub async fn list_user_repos(pool: &PgPool, owner_id: Uuid, viewer: Option<Uuid>) -> Result<Vec<Repository>> { Ok(sqlx::query_as::<_, Repository>( r#" @@ -521,17 +958,27 @@ pub async fn list_collaborators(pool: &PgPool, repo_id: Uuid) -> Result<Vec<(Col Ok(out) } +pub fn normalize_ssh_key_usage(usage: &str) -> &'static str { + match usage.trim().to_ascii_lowercase().as_str() { + "signing" => "signing", + "both" => "both", + _ => "authentication", + } +} + pub async fn add_ssh_key( pool: &PgPool, user_id: Uuid, name: &str, public_key: &str, fingerprint: &str, + key_usage: &str, ) -> Result<SshKey> { + let key_usage = normalize_ssh_key_usage(key_usage); Ok(sqlx::query_as::<_, SshKey>( r#" - INSERT INTO ssh_keys (user_id, name, public_key, fingerprint) - VALUES ($1, $2, $3, $4) + INSERT INTO ssh_keys (user_id, name, public_key, fingerprint, key_usage) + VALUES ($1, $2, $3, $4, $5) RETURNING * "#, ) @@ -539,6 +986,7 @@ pub async fn add_ssh_key( .bind(name) .bind(public_key) .bind(fingerprint) + .bind(key_usage) .fetch_one(pool) .await?) } @@ -561,6 +1009,23 @@ pub async fn delete_ssh_key(pool: &PgPool, user_id: Uuid, id: Uuid) -> Result<() Ok(()) } +pub async fn update_ssh_key_usage( + pool: &PgPool, + user_id: Uuid, + id: Uuid, + key_usage: &str, +) -> Result<()> { + let key_usage = normalize_ssh_key_usage(key_usage); + sqlx::query("UPDATE ssh_keys SET key_usage = $3 WHERE id = $1 AND user_id = $2") + .bind(id) + .bind(user_id) + .bind(key_usage) + .execute(pool) + .await?; + Ok(()) +} + +/// Git-over-SSH authentication: only authentication / both keys. pub async fn user_by_ssh_fingerprint(pool: &PgPool, fingerprint: &str) -> Result<Option<User>> { // OpenSSH prints SHA256 fingerprints without `=` padding; tolerate either form. let normalized = fingerprint.trim_end_matches('='); @@ -569,6 +1034,25 @@ pub async fn user_by_ssh_fingerprint(pool: &PgPool, fingerprint: &str) -> Result SELECT u.* FROM users u JOIN ssh_keys k ON k.user_id = u.id WHERE rtrim(k.fingerprint, '=') = $1 + AND k.key_usage IN ('authentication', 'both') + "#, + ) + .bind(normalized) + .fetch_optional(pool) + .await?) +} + +/// Commit signing: only signing / both keys. +pub async fn signing_ssh_key_by_fingerprint( + pool: &PgPool, + fingerprint: &str, +) -> Result<Option<SshKey>> { + let normalized = fingerprint.trim_end_matches('='); + Ok(sqlx::query_as::<_, SshKey>( + r#" + SELECT * FROM ssh_keys + WHERE rtrim(fingerprint, '=') = $1 + AND key_usage IN ('signing', 'both') "#, ) .bind(normalized) @@ -576,6 +1060,31 @@ pub async fn user_by_ssh_fingerprint(pool: &PgPool, fingerprint: &str) -> Result .await?) } +pub async fn user_owns_email(pool: &PgPool, user_id: Uuid, email: &str) -> Result<bool> { + let email = email.trim().to_ascii_lowercase(); + let row: Option<(bool,)> = sqlx::query_as( + r#" + SELECT true FROM users + WHERE id = $1 AND lower(email) = $2 + UNION + SELECT true FROM user_emails + WHERE user_id = $1 AND lower(email) = $2 + LIMIT 1 + "#, + ) + .bind(user_id) + .bind(&email) + .fetch_optional(pool) + .await?; + Ok(row.is_some()) +} + +pub async fn list_all_gpg_keys(pool: &PgPool) -> Result<Vec<GpgKey>> { + Ok(sqlx::query_as::<_, GpgKey>("SELECT * FROM gpg_keys ORDER BY created_at DESC") + .fetch_all(pool) + .await?) +} + pub async fn record_activity( pool: &PgPool, actor_id: Option<Uuid>, @@ -635,14 +1144,10 @@ async fn hydrate_activity( let (repo, owner_name) = match e.repo_id { Some(id) => { let r = get_repo_by_id(pool, id).await?; - let oname = if let Some(ref repo) = r { - get_user_by_id(pool, repo.owner_id) - .await? - .map(|u| u.username) - } else { - None - }; - (r, oname) + match r { + Some((repo, owner_username)) => (Some(repo), Some(owner_username)), + None => (None, None), + } } None => (None, None), }; @@ -1782,6 +2287,7 @@ pub async fn export_user_data(pool: &PgPool, user_id: Uuid) -> Result<serde_json "ssh_keys": keys.iter().map(|k| serde_json::json!({ "name": k.name, "fingerprint": k.fingerprint, + "key_usage": k.key_usage, "created_at": k.created_at, })).collect::<Vec<_>>(), "repositories": repos.iter().map(|r| serde_json::json!({ @@ -272,7 +272,9 @@ async fn basic_auth_user(pool: &PgPool, headers: &HeaderMap) -> Result<Option<Us // For HTTP git with Authentik, prefer session cookie. Basic auth accepts username // with any password matching an existing user (dev-friendly). Production should // use deploy tokens later; for now OIDC session or SSH is preferred. - Ok(queries::get_user_by_username(pool, username).await?) + Ok(queries::get_user_by_username(pool, username) + .await? + .filter(|u| !u.is_suspended)) } fn strip_git_suffix(name: &str) -> &str { @@ -3,5 +3,7 @@ pub mod languages; pub mod lfs; pub mod repo; pub mod ssh; +pub mod verify; pub use repo::*; +pub use verify::{extract_commit_signature, verify_commit_signature}; @@ -153,6 +153,10 @@ impl Handler for SshHandler { ) -> Result<Auth, Self::Error> { let fp = fingerprint_public_key(public_key); if let Some(user) = queries::user_by_ssh_fingerprint(&self.state.pool, &fp).await? { + if user.is_suspended { + tracing::info!("ssh rejected suspended user {}", user.username); + return Ok(auth_reject()); + } self.user_id = Some(user.id); self.username = Some(user.username); return Ok(Auth::Accept); @@ -0,0 +1,215 @@ +//! Commit signature verification (SSH + GPG). + +use crate::db::queries; +use anyhow::Result; +use chrono::{DateTime, Datelike, Local, Timelike, Utc}; +use git2::{Oid, Repository as G2Repo}; +use pgp::composed::{Deserializable, SignedPublicKey, StandaloneSignature}; +use pgp::types::PublicKeyTrait; +use sha2::{Digest, Sha256}; +use sqlx::PgPool; +use ssh_key::{HashAlg, PublicKey, SshSig}; + +const GIT_SSH_NAMESPACE: &str = "git"; + +#[derive(Debug, Clone)] +pub struct CommitVerification { + /// `"ssh"` or `"gpg"`. + pub kind: String, + /// Fingerprint shown in the badge popover (no `SHA256:` prefix for SSH). + pub fingerprint: String, + /// Human-readable "Verified on …" timestamp from the commit. + pub verified_at: String, +} + +impl CommitVerification { + pub fn fingerprint_label(&self) -> &'static str { + match self.kind.as_str() { + "gpg" => "GPG Key Fingerprint", + _ => "SSH Key Fingerprint", + } + } +} + +/// Sync extract only — keep `git2::Repository` off async boundaries (`!Send`). +pub fn extract_commit_signature(repo: &G2Repo, commit_id: &str) -> Option<(String, Vec<u8>)> { + let oid = Oid::from_str(commit_id).ok()?; + let (sig_buf, data_buf) = repo.extract_signature(&oid, None).ok()?; + let sig = std::str::from_utf8(&sig_buf).ok()?.trim().to_string(); + let payload = data_buf.to_vec(); + if sig.contains("BEGIN SSH SIGNATURE") || sig.contains("BEGIN PGP SIGNATURE") { + Some((sig, payload)) + } else { + None + } +} + +/// Verify a previously extracted signature against registered signing keys. +pub async fn verify_commit_signature( + pool: &PgPool, + sig: &str, + payload: &[u8], + author_email: &str, + commit_time: i64, +) -> Option<CommitVerification> { + let verified_at = format_verified_at(commit_time); + if sig.contains("BEGIN SSH SIGNATURE") { + return verify_ssh(pool, sig, payload, author_email, verified_at).await; + } + if sig.contains("BEGIN PGP SIGNATURE") { + return verify_gpg(pool, sig, payload, author_email, verified_at).await; + } + None +} + +/// Extract + verify a commit signature. Does all git2 work before any `.await`. +pub async fn verify_commit( + pool: &PgPool, + repo: &G2Repo, + commit_id: &str, + author_email: &str, + commit_time: i64, +) -> Option<CommitVerification> { + let (sig, payload) = extract_commit_signature(repo, commit_id)?; + verify_commit_signature(pool, &sig, &payload, author_email, commit_time).await +} + +async fn verify_ssh( + pool: &PgPool, + sig_pem: &str, + payload: &[u8], + author_email: &str, + verified_at: String, +) -> Option<CommitVerification> { + let sshsig = SshSig::from_pem(sig_pem.as_bytes()).ok()?; + let embedded = PublicKey::new(sshsig.public_key().clone(), ""); + let fp = embedded.fingerprint(HashAlg::Sha256).to_string(); + + let key = queries::signing_ssh_key_by_fingerprint(pool, &fp) + .await + .ok() + .flatten()?; + + if !email_matches_user(pool, key.user_id, author_email).await { + return None; + } + + let stored: PublicKey = key.public_key.trim().parse().ok()?; + stored.verify(GIT_SSH_NAMESPACE, payload, &sshsig).ok()?; + + Some(CommitVerification { + kind: "ssh".into(), + fingerprint: display_ssh_fingerprint(&fp), + verified_at, + }) +} + +async fn verify_gpg( + pool: &PgPool, + sig_armor: &str, + payload: &[u8], + author_email: &str, + verified_at: String, +) -> Option<CommitVerification> { + let (signature, _) = StandaloneSignature::from_string(sig_armor).ok()?; + let keys = queries::list_all_gpg_keys(pool).await.ok()?; + + for key in keys { + if !email_matches_user(pool, key.user_id, author_email).await { + continue; + } + let Ok((pubkey, _)) = SignedPublicKey::from_string(&key.public_key) else { + continue; + }; + let mut ok = signature.verify(&pubkey, payload).is_ok(); + if !ok { + for sub in &pubkey.public_subkeys { + if signature.verify(sub, payload).is_ok() { + ok = true; + break; + } + } + } + if ok { + return Some(CommitVerification { + kind: "gpg".into(), + fingerprint: display_gpg_fingerprint(&key.fingerprint), + verified_at, + }); + } + } + None +} + +async fn email_matches_user(pool: &PgPool, user_id: uuid::Uuid, email: &str) -> bool { + let email = email.trim(); + if email.is_empty() { + return false; + } + queries::user_owns_email(pool, user_id, email) + .await + .unwrap_or(false) +} + +/// Compute a real OpenPGP fingerprint (uppercase hex) from armored public key. +pub fn gpg_fingerprint_from_armor(armor: &str) -> Result<String> { + let (pubkey, _) = SignedPublicKey::from_string(armor)?; + Ok(hex::encode_upper(pubkey.fingerprint().as_bytes())) +} + +/// Legacy fallback used when OpenPGP parse fails — keep old keys loadable. +pub fn gpg_fingerprint_fallback(armor: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(armor.as_bytes()); + hex::encode(hasher.finalize()) +} + +fn display_ssh_fingerprint(fp: &str) -> String { + fp.strip_prefix("SHA256:") + .unwrap_or(fp) + .trim_end_matches('=') + .to_string() +} + +fn display_gpg_fingerprint(fp: &str) -> String { + let clean: String = fp.chars().filter(|c| c.is_ascii_hexdigit()).collect(); + if clean.len() == 40 { + clean + .as_bytes() + .chunks(4) + .map(|c| std::str::from_utf8(c).unwrap_or("")) + .collect::<Vec<_>>() + .join(" ") + .to_uppercase() + } else { + fp.to_string() + } +} + +fn format_verified_at(secs: i64) -> String { + let dt: DateTime<Utc> = DateTime::from_timestamp(secs, 0).unwrap_or_else(Utc::now); + let local = dt.with_timezone(&Local); + let (is_pm, hour12) = local.hour12(); + let ampm = if is_pm { "PM" } else { "AM" }; + let month = match local.month() { + 1 => "Jan", + 2 => "Feb", + 3 => "Mar", + 4 => "Apr", + 5 => "May", + 6 => "Jun", + 7 => "Jul", + 8 => "Aug", + 9 => "Sep", + 10 => "Oct", + 11 => "Nov", + _ => "Dec", + }; + format!( + "{month} {}, {}, {}:{:02} {ampm}", + local.day(), + local.year(), + hour12, + local.minute() + ) +} @@ -5,6 +5,7 @@ mod git; mod highlight; mod markdown; mod mfa; +mod og; mod state; mod web; @@ -147,9 +147,18 @@ pub fn sanitize_user_error(msg: &str) -> String { if lower.contains("email") && lower.contains("required") { return msg.to_string(); } + if lower.contains("account suspended") || lower.contains("suspended") { + return "this account has been suspended".into(); + } + if lower.contains("invite") { + return msg.to_string(); + } if lower.contains("signup unavailable") || lower.contains("api token") { return "signup is temporarily unavailable".into(); } + if lower.contains("signups disabled") || lower.contains("disabled new signups") { + return msg.to_string(); + } if lower.contains("authentik") || lower.contains("oidc") { return "authentication failed".into(); } @@ -0,0 +1,362 @@ +//! Open Graph / social card helpers derived from repository settings. + +use crate::db::models::Repository; +use flate2::write::ZlibEncoder; +use flate2::Compression; +use std::io::Write; + +pub const SITE_NAME: &str = "kitgit"; +pub const SITE_TAGLINE: &str = "self-hosted git. templates, not a product page."; + +const OG_W: u32 = 1200; +const OG_H: u32 = 630; + +/// View-model fields for meta tags in templates. +#[derive(Debug, Clone)] +pub struct SocialMeta { + pub title: String, + pub description: String, + pub image_url: String, + pub url: String, + pub site_name: String, +} + +fn truncate_chars(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + let mut out = String::new(); + for (i, c) in s.chars().enumerate() { + if i + 1 >= max { + out.push('\u{2026}'); + break; + } + out.push(c); + } + out +} + +fn fallback_description(repo: &Repository) -> String { + let desc = repo.description.trim(); + if desc.is_empty() { + format!("Git repository on {SITE_NAME}.") + } else { + truncate_chars(desc, 200) + } +} + +/// Page title / og:title / twitter:title - aim for ~50-60 SERP chars. +pub fn repo_social_title(owner: &str, repo: &Repository) -> String { + let base = format!("{}/{}", owner, repo.name); + let desc = repo.description.trim(); + if desc.is_empty() { + format!("{base} - Git repository on {SITE_NAME}") + } else { + truncate_chars(&format!("{base} - {desc}"), 60) + } +} + +pub fn repo_social_description(repo: &Repository) -> String { + fallback_description(repo) +} + +pub fn absolute_url(public_url: &str, path: &str) -> String { + let base = public_url.trim_end_matches('/'); + let path = if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + }; + format!("{base}{path}") +} + +pub fn repo_social_meta(public_url: &str, owner: &str, repo: &Repository) -> SocialMeta { + let path = format!("/{}/{}", owner, repo.name); + SocialMeta { + title: repo_social_title(owner, repo), + description: repo_social_description(repo), + image_url: absolute_url(public_url, &format!("{path}/og.png")), + url: absolute_url(public_url, &path), + site_name: SITE_NAME.to_string(), + } +} + +pub fn site_social_meta(public_url: &str, path: &str, title: &str, description: &str) -> SocialMeta { + SocialMeta { + title: title.to_string(), + description: description.to_string(), + image_url: absolute_url(public_url, "/og.png"), + url: absolute_url(public_url, path), + site_name: SITE_NAME.to_string(), + } +} + +// PNG card (1200x630) + +fn glyph(c: char) -> [u8; 7] { + const FONT: [[u8; 7]; 95] = [ + [0, 0, 0, 0, 0, 0, 0], + [0x04, 0x04, 0x04, 0x04, 0x00, 0x04, 0x00], + [0x0A, 0x0A, 0x00, 0x00, 0x00, 0x00, 0x00], + [0x0A, 0x1F, 0x0A, 0x1F, 0x0A, 0x00, 0x00], + [0x04, 0x0F, 0x14, 0x0E, 0x05, 0x1E, 0x04], + [0x18, 0x19, 0x02, 0x04, 0x08, 0x13, 0x03], + [0x08, 0x14, 0x08, 0x15, 0x12, 0x0D, 0x00], + [0x04, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00], + [0x02, 0x04, 0x04, 0x04, 0x04, 0x02, 0x00], + [0x08, 0x04, 0x04, 0x04, 0x04, 0x08, 0x00], + [0x00, 0x0A, 0x04, 0x1F, 0x04, 0x0A, 0x00], + [0x00, 0x04, 0x04, 0x1F, 0x04, 0x04, 0x00], + [0x00, 0x00, 0x00, 0x00, 0x04, 0x04, 0x08], + [0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00], + [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00], + [0x01, 0x02, 0x04, 0x08, 0x10, 0x00, 0x00], + [0x0E, 0x11, 0x13, 0x15, 0x19, 0x0E, 0x00], + [0x04, 0x0C, 0x04, 0x04, 0x04, 0x0E, 0x00], + [0x0E, 0x11, 0x01, 0x06, 0x08, 0x1F, 0x00], + [0x1F, 0x02, 0x04, 0x02, 0x11, 0x0E, 0x00], + [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x00], + [0x1F, 0x10, 0x1E, 0x01, 0x11, 0x0E, 0x00], + [0x06, 0x08, 0x1E, 0x11, 0x11, 0x0E, 0x00], + [0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x00], + [0x0E, 0x11, 0x0E, 0x11, 0x11, 0x0E, 0x00], + [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x0C, 0x00], + [0x00, 0x04, 0x00, 0x00, 0x04, 0x00, 0x00], + [0x00, 0x04, 0x00, 0x00, 0x04, 0x04, 0x08], + [0x02, 0x04, 0x08, 0x10, 0x08, 0x04, 0x02], + [0x00, 0x00, 0x1F, 0x00, 0x1F, 0x00, 0x00], + [0x08, 0x04, 0x02, 0x01, 0x02, 0x04, 0x08], + [0x0E, 0x11, 0x01, 0x02, 0x00, 0x02, 0x00], + [0x0E, 0x11, 0x17, 0x15, 0x17, 0x10, 0x0E], + [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x00], + [0x1E, 0x11, 0x1E, 0x11, 0x11, 0x1E, 0x00], + [0x0E, 0x11, 0x10, 0x10, 0x11, 0x0E, 0x00], + [0x1E, 0x11, 0x11, 0x11, 0x11, 0x1E, 0x00], + [0x1F, 0x10, 0x1E, 0x10, 0x10, 0x1F, 0x00], + [0x1F, 0x10, 0x1E, 0x10, 0x10, 0x10, 0x00], + [0x0E, 0x11, 0x10, 0x13, 0x11, 0x0F, 0x00], + [0x11, 0x11, 0x1F, 0x11, 0x11, 0x11, 0x00], + [0x0E, 0x04, 0x04, 0x04, 0x04, 0x0E, 0x00], + [0x01, 0x01, 0x01, 0x01, 0x11, 0x0E, 0x00], + [0x11, 0x12, 0x1C, 0x12, 0x11, 0x11, 0x00], + [0x10, 0x10, 0x10, 0x10, 0x10, 0x1F, 0x00], + [0x11, 0x1B, 0x15, 0x11, 0x11, 0x11, 0x00], + [0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x00], + [0x0E, 0x11, 0x11, 0x11, 0x11, 0x0E, 0x00], + [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x00], + [0x0E, 0x11, 0x11, 0x15, 0x12, 0x0D, 0x00], + [0x1E, 0x11, 0x11, 0x1E, 0x12, 0x11, 0x00], + [0x0F, 0x10, 0x0E, 0x01, 0x01, 0x1E, 0x00], + [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x00], + [0x11, 0x11, 0x11, 0x11, 0x11, 0x0E, 0x00], + [0x11, 0x11, 0x11, 0x11, 0x0A, 0x04, 0x00], + [0x11, 0x11, 0x11, 0x15, 0x1B, 0x11, 0x00], + [0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x00], + [0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x00], + [0x1F, 0x01, 0x02, 0x04, 0x08, 0x1F, 0x00], + [0x0E, 0x08, 0x08, 0x08, 0x08, 0x0E, 0x00], + [0x10, 0x08, 0x04, 0x02, 0x01, 0x00, 0x00], + [0x0E, 0x02, 0x02, 0x02, 0x02, 0x0E, 0x00], + [0x04, 0x0A, 0x11, 0x00, 0x00, 0x00, 0x00], + [0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0x00], + [0x08, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00], + [0x00, 0x00, 0x0E, 0x01, 0x0F, 0x11, 0x0F], + [0x10, 0x10, 0x1E, 0x11, 0x11, 0x1E, 0x00], + [0x00, 0x00, 0x0E, 0x10, 0x10, 0x11, 0x0E], + [0x01, 0x01, 0x0F, 0x11, 0x11, 0x0F, 0x00], + [0x00, 0x00, 0x0E, 0x11, 0x1F, 0x10, 0x0E], + [0x06, 0x08, 0x1C, 0x08, 0x08, 0x08, 0x00], + [0x00, 0x0F, 0x11, 0x0F, 0x01, 0x0E, 0x00], + [0x10, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x00], + [0x04, 0x00, 0x0C, 0x04, 0x04, 0x0E, 0x00], + [0x02, 0x00, 0x06, 0x02, 0x02, 0x12, 0x0C], + [0x10, 0x10, 0x12, 0x1C, 0x12, 0x11, 0x00], + [0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E, 0x00], + [0x00, 0x00, 0x1A, 0x15, 0x15, 0x11, 0x00], + [0x00, 0x00, 0x1E, 0x11, 0x11, 0x11, 0x00], + [0x00, 0x00, 0x0E, 0x11, 0x11, 0x11, 0x0E], + [0x00, 0x00, 0x1E, 0x11, 0x1E, 0x10, 0x10], + [0x00, 0x00, 0x0F, 0x11, 0x0F, 0x01, 0x01], + [0x00, 0x00, 0x16, 0x19, 0x10, 0x10, 0x00], + [0x00, 0x00, 0x0F, 0x10, 0x0E, 0x01, 0x1E], + [0x08, 0x08, 0x1C, 0x08, 0x08, 0x06, 0x00], + [0x00, 0x00, 0x11, 0x11, 0x11, 0x13, 0x0D], + [0x00, 0x00, 0x11, 0x11, 0x11, 0x0A, 0x04], + [0x00, 0x00, 0x11, 0x11, 0x15, 0x15, 0x0A], + [0x00, 0x00, 0x11, 0x0A, 0x04, 0x0A, 0x11], + [0x00, 0x00, 0x11, 0x11, 0x0F, 0x01, 0x0E], + [0x00, 0x00, 0x1F, 0x02, 0x04, 0x08, 0x1F], + [0x02, 0x04, 0x04, 0x08, 0x04, 0x04, 0x02], + [0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x00], + [0x08, 0x04, 0x04, 0x02, 0x04, 0x04, 0x08], + [0x00, 0x00, 0x08, 0x15, 0x02, 0x00, 0x00], + ]; + let idx = if c.is_ascii() && (0x20..=0x7E).contains(&(c as u8)) { + (c as u8 - 0x20) as usize + } else { + (b'?' - 0x20) as usize + }; + FONT[idx] +} + +struct Canvas { + w: u32, + h: u32, + px: Vec<u8>, +} + +impl Canvas { + fn new(w: u32, h: u32, fill: [u8; 3]) -> Self { + let mut px = vec![0u8; (w * h * 3) as usize]; + for chunk in px.chunks_exact_mut(3) { + chunk.copy_from_slice(&fill); + } + Self { w, h, px } + } + + fn set(&mut self, x: i32, y: i32, color: [u8; 3]) { + if x < 0 || y < 0 || x as u32 >= self.w || y as u32 >= self.h { + return; + } + let i = ((y as u32 * self.w + x as u32) * 3) as usize; + self.px[i..i + 3].copy_from_slice(&color); + } + + fn fill_rect(&mut self, x: i32, y: i32, w: i32, h: i32, color: [u8; 3]) { + for yy in y..y + h { + for xx in x..x + w { + self.set(xx, yy, color); + } + } + } + + fn text(&mut self, x: i32, y: i32, s: &str, scale: i32, color: [u8; 3]) { + let mut cx = x; + for ch in s.chars() { + let g = glyph(ch); + for (row, bits) in g.iter().enumerate() { + for col in 0..5 { + if bits & (1 << (4 - col)) != 0 { + self.fill_rect( + cx + col * scale, + y + row as i32 * scale, + scale, + scale, + color, + ); + } + } + } + cx += 6 * scale; + } + } + + fn text_clipped(&mut self, x: i32, y: i32, s: &str, scale: i32, color: [u8; 3], max_w: i32) { + let char_w = 6 * scale; + let max_chars = (max_w / char_w).max(1) as usize; + let clipped = if s.chars().count() > max_chars { + truncate_chars(s, max_chars.saturating_sub(1)) + } else { + s.to_string() + }; + self.text(x, y, &clipped, scale, color); + } + + fn to_png(&self) -> Vec<u8> { + let mut raw = Vec::with_capacity(((self.w * 3 + 1) * self.h) as usize); + for y in 0..self.h { + raw.push(0); + let start = (y * self.w * 3) as usize; + let end = start + (self.w * 3) as usize; + raw.extend_from_slice(&self.px[start..end]); + } + let mut enc = ZlibEncoder::new(Vec::new(), Compression::fast()); + enc.write_all(&raw).expect("zlib write"); + let compressed = enc.finish().expect("zlib finish"); + + let mut out = Vec::with_capacity(compressed.len() + 128); + out.extend_from_slice(&[137, 80, 78, 71, 13, 10, 26, 10]); + write_chunk(&mut out, b"IHDR", &{ + let mut d = Vec::with_capacity(13); + d.extend_from_slice(&self.w.to_be_bytes()); + d.extend_from_slice(&self.h.to_be_bytes()); + d.extend_from_slice(&[8, 2, 0, 0, 0]); + d + }); + write_chunk(&mut out, b"IDAT", &compressed); + write_chunk(&mut out, b"IEND", &[]); + out + } +} + +fn write_chunk(out: &mut Vec<u8>, ty: &[u8; 4], data: &[u8]) { + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(ty); + out.extend_from_slice(data); + let mut crc = 0xFFFF_FFFFu32; + crc = crc32_update(crc, ty); + crc = crc32_update(crc, data); + out.extend_from_slice(&(!crc).to_be_bytes()); +} + +fn crc32_update(mut crc: u32, data: &[u8]) -> u32 { + for &b in data { + crc ^= u32::from(b); + for _ in 0..8 { + let mask = (!(crc & 1)).wrapping_add(1); + crc = (crc >> 1) ^ (0xEDB8_8320 & mask); + } + } + crc +} + +/// Render a social card from repository settings (name + description). +pub fn render_repo_card(owner: &str, repo: &Repository) -> Vec<u8> { + let ink = [0x0a, 0x0a, 0x0a]; + let muted = [0x6b, 0x6b, 0x6b]; + let paper = [0xff, 0xff, 0xff]; + let line = [0xe5, 0xe5, 0xe5]; + + let mut c = Canvas::new(OG_W, OG_H, paper); + c.fill_rect(0, 0, OG_W as i32, 4, ink); + c.fill_rect(0, OG_H as i32 - 56, OG_W as i32, 56, ink); + + c.text(64, 48, SITE_NAME, 4, muted); + + let full = format!("{}/{}", owner, repo.name); + c.text_clipped(64, 160, &full, 8, ink, OG_W as i32 - 128); + + let desc = repo.description.trim(); + if !desc.is_empty() { + c.text_clipped(64, 280, desc, 4, muted, OG_W as i32 - 128); + let char_w = 6 * 4; + let max_chars = ((OG_W as i32 - 128) / char_w).max(1) as usize; + if desc.chars().count() > max_chars { + let rest: String = desc.chars().skip(max_chars).collect(); + if !rest.is_empty() { + c.text_clipped(64, 320, rest.trim_start(), 4, muted, OG_W as i32 - 128); + } + } + } else { + c.text(64, 280, "Git repository", 4, muted); + } + + c.fill_rect(64, 400, 120, 2, line); + c.text(64, OG_H as i32 - 40, SITE_TAGLINE, 3, [0xa3, 0xa3, 0xa3]); + + c.to_png() +} + +/// Default site-wide OG image (no repo context). +pub fn render_site_card() -> Vec<u8> { + let ink = [0x0a, 0x0a, 0x0a]; + let muted = [0x6b, 0x6b, 0x6b]; + let paper = [0xff, 0xff, 0xff]; + + let mut c = Canvas::new(OG_W, OG_H, paper); + c.fill_rect(0, 0, OG_W as i32, 4, ink); + c.fill_rect(0, OG_H as i32 - 56, OG_W as i32, 56, ink); + c.text(64, 200, SITE_NAME, 12, ink); + c.text(64, 340, SITE_TAGLINE, 4, muted); + c.to_png() +} @@ -54,6 +54,7 @@ pub fn app_router(state: AppState) -> Router { let static_dir = state.config.static_dir.clone(); Router::new() .route("/", get(routes::home)) + .route("/og.png", get(routes::site_og_image)) .route("/explore", get(routes::explore)) .route( "/auth/login", @@ -72,7 +73,21 @@ pub fn app_router(state: AppState) -> Router { .route("/auth/logout", get(routes::auth_logout)) .route("/admin", get(routes::admin_panel)) .route("/admin/users", post(routes::admin_set_user)) + .route("/admin/users/suspend", post(routes::admin_set_suspended)) .route("/admin/motd", post(routes::admin_save_motd)) + .route("/admin/announcement", post(routes::admin_save_announcement)) + .route("/admin/signups", post(routes::admin_save_signups)) + .route("/admin/invites", post(routes::admin_create_invite)) + .route( + "/admin/invites/{id}/revoke", + post(routes::admin_revoke_invite), + ) + .route( + "/admin/repos/{id}/visibility", + post(routes::admin_repo_visibility), + ) + .route("/admin/repos/{id}/delete", post(routes::admin_repo_delete)) + .route("/site-banner.json", get(routes::site_banner_json)) .route("/new", get(routes::new_repo_form).post(routes::new_repo)) .route( "/settings/profile", @@ -121,6 +136,7 @@ pub fn app_router(state: AppState) -> Router { get(routes::keys_settings).post(routes::keys_add), ) .route("/settings/keys/{id}/delete", post(routes::keys_delete)) + .route("/settings/keys/{id}/usage", post(routes::keys_update_usage)) .route("/settings/gpg", post(routes_extra::gpg_add)) .route("/settings/gpg/{id}/delete", post(routes_extra::gpg_delete)) .route("/avatars/{user_id}", get(routes::avatar)) @@ -142,6 +158,7 @@ pub fn app_router(state: AppState) -> Router { get(git::lfs::lfs_download).put(git::lfs::lfs_upload), ) .route("/{owner}/{repo}", get(routes::repo_home)) + .route("/{owner}/{repo}/og.png", get(routes::repo_og_image)) .route("/{owner}/{repo}/tree/{*rest}", get(routes::repo_tree)) .route("/{owner}/{repo}/blob/{*rest}", get(routes::repo_blob)) .route("/{owner}/{repo}/raw/{*rest}", get(routes_extra::repo_raw)) @@ -8,6 +8,7 @@ use crate::git; use crate::git::ssh::fingerprint_ssh_pubkey; use crate::highlight::highlight; use crate::markdown::{MarkdownRepoBase, parent_dir, render_markdown, render_markdown_in_repo}; +use crate::og; use crate::state::AppState; use crate::web::templates::*; use axum::extract::{Form, Multipart, Path, Query, State}; @@ -599,11 +600,23 @@ fn build_diff_file(idx: usize, path: String, lines: &[String]) -> DiffFileView { } } -fn latest_commit_view(grepo: &git2::Repository, branch: &str) -> Option<CommitView> { - git::list_commits(grepo, branch, 1) +fn prepare_latest_commit( + grepo: &git2::Repository, + branch: &str, +) -> Option<(git::CommitInfo, Option<(String, Vec<u8>)>)> { + let c = git::list_commits(grepo, branch, 1) .ok() - .and_then(|mut v| v.drain(..).next()) - .map(commit_view) + .and_then(|mut v| v.drain(..).next())?; + let extracted = git::extract_commit_signature(grepo, &c.id); + Some((c, extracted)) +} + +async fn latest_commit_view( + state: &AppState, + prepared: Option<(git::CommitInfo, Option<(String, Vec<u8>)>)>, +) -> Option<CommitView> { + let (c, extracted) = prepared?; + Some(commit_view(state, c, extracted).await) } fn clone_urls(state: &AppState, owner: &str, repo: &str) -> (String, String) { @@ -830,6 +843,12 @@ pub async fn home( motd, my_repos: Vec::new(), activities: Vec::new(), + social: og::site_social_meta( + &state.config.public_url, + "/", + "kitgit - self-hosted git forge", + og::SITE_TAGLINE, + ), }); }; @@ -843,6 +862,12 @@ pub async fn home( motd, my_repos, activities, + social: og::site_social_meta( + &state.config.public_url, + "/", + "kitgit - self-hosted git forge", + og::SITE_TAGLINE, + ), }) } @@ -872,10 +897,23 @@ pub async fn explore( .into_iter() .map(|(repo, owner)| ExploreRepo { owner, repo }) .collect(); + let social = if query.trim().is_empty() { + og::site_social_meta( + &state.config.public_url, + "/explore", + "explore repositories - kitgit", + "Browse public repositories on kitgit.", + ) + } else { + let title = format!("search '{query}' - kitgit"); + let desc = format!("Public repositories matching '{query}' on kitgit."); + og::site_social_meta(&state.config.public_url, "/explore", &title, &desc) + }; Ok(ExploreTemplate { viewer, repos, query, + social, }) } @@ -984,16 +1022,38 @@ pub async fn auth_oidc_start() -> AppResult<Response> { Ok(redirect("/auth/login")) } +#[derive(Deserialize)] +pub struct SignupQuery { + pub invite: Option<String>, +} + pub async fn auth_signup_page( State(state): State<AppState>, headers: HeaderMap, + Query(q): Query<SignupQuery>, ) -> AppResult<impl IntoResponse> { if current_user(&state.auth, &headers).await?.is_some() { return Ok(redirect("/").into_response()); } + let signups_enabled = queries::signups_enabled(&state.pool).await; + let signup_disabled_message = queries::signup_disabled_message(&state.pool).await; + let mut invite = q.invite.unwrap_or_default().trim().to_string(); + let mut error = None; + if !signups_enabled && !invite.is_empty() { + if queries::get_valid_invite(&state.pool, &invite) + .await? + .is_none() + { + error = Some("invalid or already used invite code".into()); + invite.clear(); + } + } Ok(SignupTemplate { viewer: None, - error: None, + error, + signups_enabled, + signup_disabled_message, + invite, } .into_response()) } @@ -1004,12 +1064,34 @@ pub struct SignupForm { pub email: String, pub password: String, pub display_name: Option<String>, + pub invite: Option<String>, } pub async fn auth_signup_submit( State(state): State<AppState>, Form(form): Form<SignupForm>, ) -> AppResult<Response> { + let signups_enabled = queries::signups_enabled(&state.pool).await; + let signup_disabled_message = queries::signup_disabled_message(&state.pool).await; + let invite_code = form.invite.clone().unwrap_or_default(); + let invite_row = if !signups_enabled { + match queries::get_valid_invite(&state.pool, &invite_code).await? { + Some(inv) => Some(inv), + None => { + return Ok(SignupTemplate { + viewer: None, + error: Some("a valid invite code is required".into()), + signups_enabled: false, + signup_disabled_message, + invite: invite_code, + } + .into_response()); + } + } + } else { + None + }; + match auth::signup_with_password( &state.auth, &form.username, @@ -1019,22 +1101,41 @@ pub async fn auth_signup_submit( ) .await { - Ok(LoginOutcome::Complete { token, .. }) => Ok(redirect_with_cookies( - "/", - vec![ - session_cookie_header(&token, 14 * 24 * 3600), - clear_mfa_pending_cookie(), - ], - )), - Ok(LoginOutcome::MfaRequired { pending_token }) => Ok(redirect_with_cookie( - "/auth/mfa", - mfa_pending_cookie_header(&pending_token, 10 * 60), - )), + Ok(outcome) => { + if let Some(inv) = invite_row { + if let LoginOutcome::Complete { user, .. } = &outcome { + let _ = queries::consume_invite(&state.pool, inv.id, user.id).await; + } else if let Some(u) = queries::get_user_by_username( + &state.pool, + &form.username.trim().to_ascii_lowercase(), + ) + .await? + { + let _ = queries::consume_invite(&state.pool, inv.id, u.id).await; + } + } + match outcome { + LoginOutcome::Complete { token, .. } => Ok(redirect_with_cookies( + "/", + vec![ + session_cookie_header(&token, 14 * 24 * 3600), + clear_mfa_pending_cookie(), + ], + )), + LoginOutcome::MfaRequired { pending_token } => Ok(redirect_with_cookie( + "/auth/mfa", + mfa_pending_cookie_header(&pending_token, 10 * 60), + )), + } + } Err(e) => { tracing::warn!("signup failed: {e:#}"); Ok(SignupTemplate { viewer: None, error: Some(crate::mfa::sanitize_user_error(&e.to_string())), + signups_enabled, + signup_disabled_message, + invite: invite_code, } .into_response()) } @@ -1080,17 +1181,147 @@ async fn require_site_admin(auth: &AuthState, headers: &HeaderMap) -> AppResult< Ok(user) } +fn format_bytes(n: i64) -> String { + const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; + let mut v = n.max(0) as f64; + let mut i = 0usize; + while v >= 1024.0 && i < UNITS.len() - 1 { + v /= 1024.0; + i += 1; + } + if i == 0 { + format!("{} {}", n, UNITS[i]) + } else { + format!("{v:.1} {}", UNITS[i]) + } +} + +fn dir_size(path: &std::path::Path) -> u64 { + let mut total = 0u64; + let Ok(entries) = std::fs::read_dir(path) else { + return 0; + }; + for entry in entries.flatten() { + let Ok(meta) = entry.metadata() else { + continue; + }; + if meta.is_file() { + total += meta.len(); + } else if meta.is_dir() { + total += dir_size(&entry.path()); + } + } + total +} + +#[derive(Deserialize)] +pub struct AdminQuery { + pub q: Option<String>, + pub page: Option<i64>, + pub repo_q: Option<String>, + pub repo_page: Option<i64>, + pub flash: Option<String>, +} + +const ADMIN_PAGE_SIZE: i64 = 20; + pub async fn admin_panel( State(state): State<AppState>, headers: HeaderMap, + Query(q): Query<AdminQuery>, ) -> AppResult<impl IntoResponse> { let viewer = require_site_admin(&state.auth, &headers).await?; - let users = queries::list_users(&state.pool).await?; - let motd = queries::get_setting(&state.pool, "motd").await.unwrap_or_default(); + let user_query = q.q.unwrap_or_default(); + let user_page = q.page.unwrap_or(1).max(1); + let repo_query = q.repo_q.unwrap_or_default(); + let repo_page = q.repo_page.unwrap_or(1).max(1); + + let (users, user_total) = queries::search_users( + &state.pool, + if user_query.is_empty() { + None + } else { + Some(user_query.as_str()) + }, + ADMIN_PAGE_SIZE, + (user_page - 1) * ADMIN_PAGE_SIZE, + ) + .await?; + let user_pages = ((user_total + ADMIN_PAGE_SIZE - 1) / ADMIN_PAGE_SIZE).max(1); + + let (repo_rows, repo_total) = queries::list_repos_admin( + &state.pool, + if repo_query.is_empty() { + None + } else { + Some(repo_query.as_str()) + }, + ADMIN_PAGE_SIZE, + (repo_page - 1) * ADMIN_PAGE_SIZE, + ) + .await?; + let repo_pages = ((repo_total + ADMIN_PAGE_SIZE - 1) / ADMIN_PAGE_SIZE).max(1); + let repos = repo_rows + .into_iter() + .map(|(r, owner)| AdminRepoView { + id: r.id, + owner, + name: r.name, + visibility: r.visibility, + archived: r.archived, + updated_at: r.updated_at, + }) + .collect(); + + let motd = queries::get_setting(&state.pool, "motd") + .await + .unwrap_or_default(); + let announcement = queries::site_announcement(&state.pool).await; + let signups_enabled = queries::signups_enabled(&state.pool).await; + let signup_disabled_message = queries::signup_disabled_message(&state.pool).await; + let invites = queries::list_active_invites(&state.pool, 50) + .await? + .into_iter() + .map(|i| AdminInviteView { + id: i.id, + code: i.code, + created_at: i.created_at, + }) + .collect(); + + let mut stats = queries::admin_stats(&state.pool).await?; + let repos_dir = state.config.repos_dir(); + stats.disk_bytes = match tokio::task::spawn_blocking(move || dir_size(&repos_dir)).await { + Ok(n) => n, + Err(_) => 0, + }; + Ok(AdminTemplate { viewer: Some(viewer), users, + user_query, + user_page, + user_pages, + user_total, motd, + announcement, + signups_enabled, + signup_disabled_message, + invites, + repos, + repo_query, + repo_page, + repo_pages, + repo_total, + stats: AdminStatsView { + user_count: stats.user_count, + repo_count: stats.repo_count, + public_repo_count: stats.public_repo_count, + recent_signups: stats.recent_signups, + active_invites: stats.active_invites, + disk_label: format_bytes(stats.disk_bytes as i64), + }, + flash: q.flash.filter(|s| !s.is_empty()), }) } @@ -1122,6 +1353,35 @@ pub async fn admin_set_user( } #[derive(Deserialize)] +pub struct AdminSuspendForm { + pub user_id: Uuid, + pub is_suspended: Option<String>, +} + +pub async fn admin_set_suspended( + State(state): State<AppState>, + headers: HeaderMap, + Form(form): Form<AdminSuspendForm>, +) -> AppResult<Response> { + let viewer = require_site_admin(&state.auth, &headers).await?; + let suspend = checkbox(&form.is_suspended); + if form.user_id == viewer.id && suspend { + return Err(AppError::bad("cannot suspend yourself")); + } + let target = queries::get_user_by_id(&state.pool, form.user_id) + .await? + .ok_or_else(|| AppError::bad("user not found"))?; + if target.is_site_admin && suspend { + let count = queries::site_admin_count(&state.pool).await?; + if count <= 1 { + return Err(AppError::bad("cannot suspend the last site admin")); + } + } + queries::set_user_suspended(&state.pool, form.user_id, suspend).await?; + Ok(redirect_see_other("/admin")) +} + +#[derive(Deserialize)] pub struct AdminMotdForm { pub motd: Option<String>, } @@ -1136,6 +1396,135 @@ pub async fn admin_save_motd( Ok(redirect_see_other("/admin")) } +#[derive(Deserialize)] +pub struct AdminAnnouncementForm { + pub announcement: Option<String>, +} + +pub async fn admin_save_announcement( + State(state): State<AppState>, + headers: HeaderMap, + Form(form): Form<AdminAnnouncementForm>, +) -> AppResult<Response> { + let _viewer = require_site_admin(&state.auth, &headers).await?; + queries::set_setting( + &state.pool, + "announcement", + form.announcement.unwrap_or_default().trim(), + ) + .await?; + Ok(redirect_see_other("/admin")) +} + +#[derive(Deserialize)] +pub struct AdminSignupsForm { + pub signups_enabled: Option<String>, + pub signup_disabled_message: Option<String>, +} + +pub async fn admin_save_signups( + State(state): State<AppState>, + headers: HeaderMap, + Form(form): Form<AdminSignupsForm>, +) -> AppResult<Response> { + let _viewer = require_site_admin(&state.auth, &headers).await?; + let enabled = checkbox(&form.signups_enabled); + queries::set_setting( + &state.pool, + "signups_enabled", + if enabled { "true" } else { "false" }, + ) + .await?; + let raw = form.signup_disabled_message.unwrap_or_default(); + let trimmed = raw.trim(); + let message = if trimmed.is_empty() { + queries::DEFAULT_SIGNUP_DISABLED_MESSAGE + } else { + trimmed + }; + queries::set_setting(&state.pool, "signup_disabled_message", message).await?; + Ok(redirect_see_other("/admin")) +} + +pub async fn admin_create_invite( + State(state): State<AppState>, + headers: HeaderMap, +) -> AppResult<Response> { + let viewer = require_site_admin(&state.auth, &headers).await?; + let inv = queries::create_invite(&state.pool, viewer.id).await?; + let flash_raw = format!("invite created: {}", inv.code); + let flash = urlencoding::encode(&flash_raw); + Ok(redirect_see_other(&format!("/admin?flash={flash}"))) +} + +pub async fn admin_revoke_invite( + State(state): State<AppState>, + headers: HeaderMap, + Path(id): Path<Uuid>, +) -> AppResult<Response> { + let _viewer = require_site_admin(&state.auth, &headers).await?; + queries::revoke_invite(&state.pool, id).await?; + Ok(redirect_see_other("/admin")) +} + +#[derive(Deserialize)] +pub struct AdminVisibilityForm { + pub visibility: String, +} + +pub async fn admin_repo_visibility( + State(state): State<AppState>, + headers: HeaderMap, + Path(id): Path<Uuid>, + Form(form): Form<AdminVisibilityForm>, +) -> AppResult<Response> { + let _viewer = require_site_admin(&state.auth, &headers).await?; + let visibility = match form.visibility.as_str() { + "public" => "public", + _ => "private", + }; + queries::set_repo_visibility(&state.pool, id, visibility).await?; + Ok(redirect_see_other("/admin")) +} + +#[derive(Deserialize)] +pub struct AdminDeleteRepoForm { + pub confirm: Option<String>, +} + +pub async fn admin_repo_delete( + State(state): State<AppState>, + headers: HeaderMap, + Path(id): Path<Uuid>, + Form(form): Form<AdminDeleteRepoForm>, +) -> AppResult<Response> { + let viewer = require_site_admin(&state.auth, &headers).await?; + let (repo, owner) = queries::get_repo_by_id(&state.pool, id) + .await? + .ok_or_else(|| AppError::bad("repository not found"))?; + let confirm = form.confirm.unwrap_or_default(); + if confirm != repo.name && confirm != format!("{owner}/{}", repo.name) { + return Err(AppError::bad("type the repository name to confirm delete")); + } + queries::delete_repo(&state.pool, repo.id).await?; + let _ = git::remove_bare(&state.config.repos_dir(), &owner, &repo.name); + queries::record_activity( + &state.pool, + Some(viewer.id), + None, + "repo.delete", + "deleted repository", + serde_json::json!({ "owner": owner, "name": repo.name, "admin": true }), + ) + .await?; + Ok(redirect_see_other("/admin")) +} + +pub async fn site_banner_json(State(state): State<AppState>) -> impl IntoResponse { + let message = queries::site_announcement(&state.pool).await; + axum::Json(serde_json::json!({ "message": message })) +} + // ── new repo ───────────────────────────────────────────────────────────────── pub async fn new_repo_form( @@ -1363,6 +1752,7 @@ pub async fn keys_settings( pub struct AddKeyForm { pub name: String, pub public_key: String, + pub key_usage: Option<String>, } pub async fn keys_add( @@ -1373,6 +1763,7 @@ pub async fn keys_add( let user = require_login(&state.auth, &headers).await?; let name = form.name.trim(); let public_key = form.public_key.trim(); + let key_usage = form.key_usage.as_deref().unwrap_or("authentication"); if name.is_empty() || public_key.is_empty() { let keys = queries::list_ssh_keys(&state.pool, user.id).await?; let gpg_keys = queries::list_gpg_keys(&state.pool, user.id).await?; @@ -1385,12 +1776,28 @@ pub async fn keys_add( .into_response()); } let fp = fingerprint_ssh_pubkey(public_key).map_err(|e| AppError::bad(e.to_string()))?; - queries::add_ssh_key(&state.pool, user.id, name, public_key, &fp) + queries::add_ssh_key(&state.pool, user.id, name, public_key, &fp, key_usage) .await .map_err(|e| AppError::bad(format!("could not add key: {e}")))?; Ok(redirect_see_other("/settings/keys")) } +#[derive(Deserialize)] +pub struct KeyUsageForm { + pub key_usage: String, +} + +pub async fn keys_update_usage( + State(state): State<AppState>, + headers: HeaderMap, + Path(id): Path<Uuid>, + Form(form): Form<KeyUsageForm>, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + queries::update_ssh_key_usage(&state.pool, user.id, id, &form.key_usage).await?; + Ok(redirect_see_other("/settings/keys")) +} + pub async fn keys_delete( State(state): State<AppState>, headers: HeaderMap, @@ -1546,20 +1953,18 @@ pub async fn repo_home( } } - let latest_commit = grepo - .as_ref() - .and_then(|g| latest_commit_view(g, ¤t_branch)); + let latest_commit = match grepo.as_ref() { + Some(g) => { + let prepared = prepare_latest_commit(g, ¤t_branch); + latest_commit_view(&state, prepared).await + } + None => None, + }; let languages = language_stat_views(languages); let forked_from = if let Some(fid) = repository.fork_of_id { match queries::get_repo_by_id(&state.pool, fid).await? { - Some(parent) => { - let parent_owner = queries::get_user_by_id(&state.pool, parent.owner_id) - .await? - .map(|u| u.username) - .unwrap_or_else(|| "unknown".into()); - Some((parent_owner, parent.name)) - } + Some((parent, parent_owner)) => Some((parent_owner, parent.name)), None => None, } } else { @@ -1574,6 +1979,7 @@ pub async fn repo_home( (false, false) }; + let social = og::repo_social_meta(&state.config.public_url, &owner, &repository); Ok(RepoHomeTemplate { viewer, owner: owner_user, @@ -1592,9 +1998,36 @@ pub async fn repo_home( forked_from, starred, watching, + social, }) } +pub async fn repo_og_image( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, +) -> AppResult<Response> { + let (repository, owner_user, _viewer, _access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let png = og::render_repo_card(&owner_user.username, &repository); + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "image/png") + .header(axum::http::header::CACHE_CONTROL, "public, max-age=3600") + .body(Body::from(png)) + .unwrap()) +} + +pub async fn site_og_image() -> Response { + let png = og::render_site_card(); + Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "image/png") + .header(axum::http::header::CACHE_CONTROL, "public, max-age=86400") + .body(Body::from(png)) + .unwrap() +} + pub async fn repo_tree( State(state): State<AppState>, headers: HeaderMap, @@ -1628,7 +2061,8 @@ pub async fn repo_tree( _ => None, }; let branches = git::list_branches(&grepo).unwrap_or_default(); - let latest_commit = latest_commit_view(&grepo, &branch); + let prepared = prepare_latest_commit(&grepo, &branch); + let latest_commit = latest_commit_view(&state, prepared).await; let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); Ok(RepoTreeTemplate { viewer, @@ -1720,11 +2154,15 @@ pub async fn repo_commits( let branch = q .branch .unwrap_or_else(|| repository.default_branch.clone()); - let commits = git::list_commits(&grepo, &branch, 100) - .unwrap_or_default() + let raw_commits = git::list_commits(&grepo, &branch, 100).unwrap_or_default(); + let prepared: Vec<_> = raw_commits .into_iter() - .map(commit_view) + .map(|c| { + let extracted = git::extract_commit_signature(&grepo, &c.id); + (c, extracted) + }) .collect(); + let commits = commit_views(&state, prepared).await; let branches = git::list_branches(&grepo).unwrap_or_default(); let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); Ok(RepoCommitsTemplate { @@ -1750,6 +2188,7 @@ pub async fn repo_commit( let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo) .map_err(|_| AppError::not_found())?; let commit = git::get_commit(&grepo, &id).map_err(|_| AppError::not_found())?; + let extracted = git::extract_commit_signature(&grepo, &commit.id); let diff = git::commit_diff(&grepo, &id).unwrap_or_default(); let diff_html = render_diff_html(&diff); let message_html = linkify_commit_message( @@ -1765,7 +2204,7 @@ pub async fn repo_commit( owner: owner_user, repo: repository, access, - commit: commit_view(commit), + commit: commit_view(&state, commit, extracted).await, message_html, diff_html, clone_http: clone_urls(&state, &owner, &repo).0, @@ -1783,6 +2222,7 @@ pub async fn repo_diff( let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo) .map_err(|_| AppError::not_found())?; let commit = git::get_commit(&grepo, &id).map_err(|_| AppError::not_found())?; + let extracted = git::extract_commit_signature(&grepo, &commit.id); let diff = git::commit_diff(&grepo, &id).unwrap_or_default(); let diff_html = render_diff_html(&diff); let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); @@ -1791,7 +2231,7 @@ pub async fn repo_diff( owner: owner_user, repo: repository, access, - commit: commit_view(commit), + commit: commit_view(&state, commit, extracted).await, diff_html, clone_http, clone_ssh, @@ -2046,7 +2486,38 @@ pub async fn repo_branches( }) } -fn commit_view(c: git::CommitInfo) -> CommitView { +async fn commit_view( + state: &AppState, + c: git::CommitInfo, + extracted: Option<(String, Vec<u8>)>, +) -> CommitView { + let verification = match extracted { + Some((sig, payload)) => { + git::verify_commit_signature( + &state.pool, + &sig, + &payload, + &c.email, + c.time, + ) + .await + } + None => None, + }; + let (verified, verify_kind, verify_fingerprint, verify_fingerprint_label, verified_at) = + match verification { + Some(v) => { + let label = v.fingerprint_label().to_string(); + ( + true, + v.kind.clone(), + v.fingerprint, + label, + v.verified_at, + ) + } + None => (false, String::new(), String::new(), String::new(), String::new()), + }; CommitView { id: c.id, short_id: c.short_id, @@ -2054,7 +2525,23 @@ fn commit_view(c: git::CommitInfo) -> CommitView { author: c.author, email: c.email, time: c.time, + verified, + verify_kind, + verify_fingerprint, + verify_fingerprint_label, + verified_at, + } +} + +async fn commit_views( + state: &AppState, + commits: Vec<(git::CommitInfo, Option<(String, Vec<u8>)>)>, +) -> Vec<CommitView> { + let mut out = Vec::with_capacity(commits.len()); + for (c, extracted) in commits { + out.push(commit_view(state, c, extracted).await); } + out } // ── issues ─────────────────────────────────────────────────────────────────── @@ -2462,11 +2949,15 @@ pub async fn pull_view( let mut commits = Vec::new(); let mut diff_files = Vec::new(); if let Ok(g) = git::open_bare(&state.config.repos_dir(), &owner, &repo) { - commits = git::commits_between(&g, &pull.source_branch, &pull.target_branch, 100) + let prepared: Vec<_> = git::commits_between(&g, &pull.source_branch, &pull.target_branch, 100) .unwrap_or_default() .into_iter() - .map(commit_view) + .map(|c| { + let extracted = git::extract_commit_signature(&g, &c.id); + (c, extracted) + }) .collect(); + commits = commit_views(&state, prepared).await; let diff = git::branch_diff(&g, &pull.source_branch, &pull.target_branch).unwrap_or_default(); diff_files = parse_diff_files(&diff); @@ -2639,20 +3130,6 @@ pub async fn pull_close( // ── releases ───────────────────────────────────────────────────────────────── -fn format_bytes(n: i64) -> String { - const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; - let mut v = n.max(0) as f64; - let mut i = 0usize; - while v >= 1024.0 && i < UNITS.len() - 1 { - v /= 1024.0; - i += 1; - } - if i == 0 { - format!("{} {}", n, UNITS[i]) - } else { - format!("{v:.1} {}", UNITS[i]) - } -} fn asset_views(assets: Vec<crate::db::models::ReleaseAsset>) -> Vec<ReleaseAssetView> { assets @@ -784,10 +784,8 @@ pub async fn gpg_add( if !key.contains("BEGIN PGP PUBLIC KEY") { return Err(AppError::bad("expected a PGP public key block")); } - let mut hasher = sha2::Sha256::new(); - use sha2::Digest; - hasher.update(key.as_bytes()); - let fp = hex::encode(hasher.finalize()); + let fp = crate::git::verify::gpg_fingerprint_from_armor(key) + .unwrap_or_else(|_| crate::git::verify::gpg_fingerprint_fallback(key)); queries::add_gpg_key(&state.pool, user.id, form.name.trim(), key, &fp).await?; Ok(redirect_see_other("/settings/keys")) } @@ -1,3 +1,4 @@ +use crate::og::SocialMeta; use crate::db::models::{ Access, BranchRule, CommitDay, GpgKey, Issue, PullRequest, Release, Repository, SshKey, User, UserEmail, @@ -64,6 +65,11 @@ pub struct CommitView { pub author: String, pub email: String, pub time: i64, + pub verified: bool, + pub verify_kind: String, + pub verify_fingerprint: String, + pub verify_fingerprint_label: String, + pub verified_at: String, } pub struct CollaboratorView { @@ -112,6 +118,7 @@ pub struct HomeTemplate { pub motd: String, pub my_repos: Vec<Repository>, pub activities: Vec<ActivityRow>, + pub social: SocialMeta, } pub struct ExploreRepo { @@ -125,6 +132,7 @@ pub struct ExploreTemplate { pub viewer: Option<User>, pub repos: Vec<ExploreRepo>, pub query: String, + pub social: SocialMeta, } #[derive(Template, WebTemplate)] @@ -139,6 +147,9 @@ pub struct LoginTemplate { pub struct SignupTemplate { pub viewer: Option<User>, pub error: Option<String>, + pub signups_enabled: bool, + pub signup_disabled_message: String, + pub invite: String, } #[derive(Template, WebTemplate)] @@ -251,6 +262,7 @@ pub struct RepoHomeTemplate { pub starred: bool, pub watching: bool, pub forked_from: Option<(String, String)>, + pub social: SocialMeta, } #[derive(Template, WebTemplate)] @@ -520,5 +532,44 @@ pub struct RepoSettingsTemplate { pub struct AdminTemplate { pub viewer: Option<User>, pub users: Vec<User>, + pub user_query: String, + pub user_page: i64, + pub user_pages: i64, + pub user_total: i64, pub motd: String, + pub announcement: String, + pub signups_enabled: bool, + pub signup_disabled_message: String, + pub invites: Vec<AdminInviteView>, + pub repos: Vec<AdminRepoView>, + pub repo_query: String, + pub repo_page: i64, + pub repo_pages: i64, + pub repo_total: i64, + pub stats: AdminStatsView, + pub flash: Option<String>, +} + +pub struct AdminInviteView { + pub id: Uuid, + pub code: String, + pub created_at: DateTime<Utc>, +} + +pub struct AdminRepoView { + pub id: Uuid, + pub owner: String, + pub name: String, + pub visibility: String, + pub archived: bool, + pub updated_at: DateTime<Utc>, +} + +pub struct AdminStatsView { + pub user_count: i64, + pub repo_count: i64, + pub public_repo_count: i64, + pub recent_signups: i64, + pub active_invites: i64, + pub disk_label: String, } @@ -302,6 +302,64 @@ a:hover { font-size: 0.875rem; } +.kg-flash--banner { + margin: 0 0 var(--kg-space-3); + white-space: pre-wrap; +} + +.kg-site-banner { + position: sticky; + top: 0; + z-index: 50; + padding: 0.65rem 1.25rem; + border-bottom: 1px solid var(--kg-line-strong); + background: var(--kg-bg); + color: var(--kg-fg); + font-size: 0.875rem; + white-space: pre-wrap; + text-align: center; +} + +.kg-admin-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr)); + gap: 0.75rem 1rem; + margin: 1.5rem 0 0; + max-width: 48rem; +} + +.kg-admin-stats div { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.65rem 0; + border-bottom: 1px solid var(--kg-line); +} + +.kg-admin-stats strong { + font-size: 1.15rem; + letter-spacing: var(--kg-tracking-tight); +} + +.kg-admin-stats span { + color: var(--kg-muted); + font-size: 0.75rem; +} + +.kg-admin-actions { + display: inline-flex; + flex-wrap: wrap; + gap: 0.35rem; + justify-content: flex-end; +} + +.kg-admin-pager { + display: flex; + gap: 1rem; + align-items: center; + margin-top: 1rem; +} + .kg-form { display: flex; flex-direction: column; @@ -1399,6 +1457,86 @@ html.kg-dark .kg-codeview .punctuation { color: #b0b0b0; } color: var(--kg-muted); } +.kg-badge--verified { + color: #1a7f37; + border-color: #1a7f37; + background: color-mix(in srgb, #1a7f37 12%, transparent); + text-transform: none; + letter-spacing: 0.02em; + font-weight: 600; + cursor: pointer; +} + +.kg-verify { + position: relative; + display: inline-block; + vertical-align: middle; +} + +.kg-verify > summary { + list-style: none; +} + +.kg-verify > summary::-webkit-details-marker { + display: none; +} + +.kg-verify__panel { + position: absolute; + left: 0; + top: calc(100% + 0.35rem); + z-index: 30; + min-width: 16rem; + max-width: min(28rem, 90vw); + padding: var(--kg-space-3); + background: var(--kg-bg); + border: 1px solid var(--kg-line-strong); + box-shadow: 0 8px 24px color-mix(in srgb, var(--kg-fg) 12%, transparent); +} + +.kg-verify__fp { + margin: 0 0 0.35rem; + font-size: 0.8125rem; + line-height: 1.4; + word-break: break-all; +} + +.kg-verify__fp code { + font-size: 0.75rem; +} + +.kg-key-row { + align-items: flex-start; +} + +.kg-key-row__actions { + display: inline-flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.kg-inline-form select { + font-family: var(--kg-font); + font-size: 0.75rem; + border: 1px solid var(--kg-line); + background: var(--kg-bg-raised); + color: var(--kg-fg); + padding: 0.25em 0.4em; +} + +.kg-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .kg-clone { display: block; padding: var(--kg-space-2) var(--kg-space-3); @@ -5,7 +5,32 @@ <span class="kg-kicker">admin</span> <h1 class="kg-display">site settings</h1> - <form class="kg-form" method="post" action="/admin/motd" style="margin:1.5rem 0 2.5rem;max-width:36rem"> + {% if let Some(msg) = flash %} + <div class="kg-flash">{{ msg }}</div> + {% endif %} + + <div class="kg-admin-stats"> + <div><strong>{{ stats.user_count }}</strong><span>users</span></div> + <div><strong>{{ stats.repo_count }}</strong><span>repos</span></div> + <div><strong>{{ stats.public_repo_count }}</strong><span>public</span></div> + <div><strong>{{ stats.recent_signups }}</strong><span>signups (7d)</span></div> + <div><strong>{{ stats.active_invites }}</strong><span>invites</span></div> + <div><strong>{{ stats.disk_label }}</strong><span>disk</span></div> + </div> + + <form class="kg-form" method="post" action="/admin/announcement" style="margin:2rem 0;max-width:36rem"> + <h2 class="kg-title" style="margin:0">announcement</h2> + <p class="kg-muted">Sticky site-wide banner (maintenance, incidents). Leave empty to hide.</p> + <label> + banner message + <textarea name="announcement" rows="2" placeholder="Shown at the top of every page">{{ announcement }}</textarea> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">save announcement</button> + </div> + </form> + + <form class="kg-form" method="post" action="/admin/motd" style="margin:0 0 2.5rem;max-width:36rem"> <label> message of the day <textarea name="motd" rows="3" placeholder="Shown on the frontpage">{{ motd }}</textarea> @@ -15,30 +40,163 @@ </div> </form> - <h2 class="kg-title">site admins</h2> - <p class="kg-muted">Pick who can open this panel. The first successful login becomes admin automatically if none exist.</p> + <form class="kg-form" method="post" action="/admin/signups" style="margin:0 0 1.5rem;max-width:36rem"> + <h2 class="kg-title" style="margin:0">signups</h2> + <p class="kg-muted">When disabled, new accounts need a single-use invite code.</p> + <label class="kg-check"> + <input type="checkbox" name="signups_enabled" value="on"{% if signups_enabled %} checked{% endif %} /> + allow open signups + </label> + <label> + disabled banner message + <textarea name="signup_disabled_message" rows="3" placeholder="Shown on the signup page when signups are off">{{ signup_disabled_message }}</textarea> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">save signup settings</button> + </div> + </form> - <ul class="kg-list" style="margin-top:1.5rem"> + <div style="margin:0 0 2.5rem;max-width:40rem"> + <h2 class="kg-title">invites</h2> + <p class="kg-muted">Create single-use codes for invite-only signups.</p> + <form method="post" action="/admin/invites" style="margin:0.75rem 0 1rem"> + <button class="kg-btn" type="submit">create invite</button> + </form> + <ul class="kg-list"> + {% for inv in invites %} + <li> + <div> + <code>{{ inv.code }}</code> + <span class="kg-meta"> · {{ inv.created_at }}</span> + </div> + <form method="post" action="/admin/invites/{{ inv.id }}/revoke" style="display:inline"> + <button class="kg-btn kg-btn--ghost" type="submit">revoke</button> + </form> + </li> + {% else %} + <li><span class="kg-muted">no active invites</span></li> + {% endfor %} + </ul> + </div> + + <h2 class="kg-title">users</h2> + <p class="kg-muted">Search, suspend accounts, and manage site admins.</p> + <form method="get" action="/admin" class="kg-form" style="margin:1rem 0;max-width:28rem;flex-direction:row;align-items:end;gap:0.75rem"> + <label style="flex:1"> + search + <input type="search" name="q" value="{{ user_query }}" placeholder="username, email, name" /> + </label> + {% if !repo_query.is_empty() %}<input type="hidden" name="repo_q" value="{{ repo_query }}" />{% endif %} + {% if repo_page > 1 %}<input type="hidden" name="repo_page" value="{{ repo_page }}" />{% endif %} + <button class="kg-btn" type="submit">search</button> + </form> + <p class="kg-meta">{{ user_total }} user{% if user_total != 1 %}s{% endif %}</p> + + <ul class="kg-list" style="margin-top:1rem"> {% for u in users %} <li> <div> <a href="/{{ u.username }}">{{ u.username }}</a> - <span class="kg-meta">{{ u.email }}{% if u.is_site_admin %} · admin{% endif %}</span> + <span class="kg-meta"> + {{ u.email }} + {% if u.is_site_admin %} · admin{% endif %} + {% if u.is_suspended %} · suspended{% endif %} + </span> </div> - <form method="post" action="/admin/users" style="display:inline"> - <input type="hidden" name="user_id" value="{{ u.id }}" /> - {% if u.is_site_admin %} - <input type="hidden" name="is_site_admin" value="false" /> - <button class="kg-btn kg-btn--ghost" type="submit">revoke admin</button> + <div class="kg-admin-actions"> + <form method="post" action="/admin/users/suspend" style="display:inline"> + <input type="hidden" name="user_id" value="{{ u.id }}" /> + {% if u.is_suspended %} + <input type="hidden" name="is_suspended" value="false" /> + <button class="kg-btn kg-btn--ghost" type="submit">unsuspend</button> + {% else %} + <input type="hidden" name="is_suspended" value="true" /> + <button class="kg-btn kg-btn--ghost" type="submit">suspend</button> + {% endif %} + </form> + <form method="post" action="/admin/users" style="display:inline"> + <input type="hidden" name="user_id" value="{{ u.id }}" /> + {% if u.is_site_admin %} + <input type="hidden" name="is_site_admin" value="false" /> + <button class="kg-btn kg-btn--ghost" type="submit">revoke admin</button> + {% else %} + <input type="hidden" name="is_site_admin" value="true" /> + <button class="kg-btn" type="submit">make admin</button> + {% endif %} + </form> + </div> + </li> + {% else %} + <li><span class="kg-muted">no users match.</span></li> + {% endfor %} + </ul> + {% if user_pages > 1 %} + <p class="kg-meta kg-admin-pager"> + {% if user_page > 1 %} + <a href="/admin?q={{ user_query }}&page={{ user_page - 1 }}&repo_q={{ repo_query }}&repo_page={{ repo_page }}">← prev</a> + {% endif %} + page {{ user_page }} / {{ user_pages }} + {% if user_page < user_pages %} + <a href="/admin?q={{ user_query }}&page={{ user_page + 1 }}&repo_q={{ repo_query }}&repo_page={{ repo_page }}">next →</a> + {% endif %} + </p> + {% endif %} + + <h2 class="kg-title" style="margin-top:2.5rem">repositories</h2> + <p class="kg-muted">Force private or delete repositories across the instance.</p> + <form method="get" action="/admin" class="kg-form" style="margin:1rem 0;max-width:28rem;flex-direction:row;align-items:end;gap:0.75rem"> + <label style="flex:1"> + search repos + <input type="search" name="repo_q" value="{{ repo_query }}" placeholder="owner, name, description" /> + </label> + {% if !user_query.is_empty() %}<input type="hidden" name="q" value="{{ user_query }}" />{% endif %} + {% if user_page > 1 %}<input type="hidden" name="page" value="{{ user_page }}" />{% endif %} + <button class="kg-btn" type="submit">search</button> + </form> + <p class="kg-meta">{{ repo_total }} repositor{% if repo_total != 1 %}ies{% else %}y{% endif %}</p> + + <ul class="kg-list" style="margin-top:1rem"> + {% for r in repos %} + <li> + <div> + <a href="/{{ r.owner }}/{{ r.name }}">{{ r.owner }}/{{ r.name }}</a> + <span class="kg-meta"> + {{ r.visibility }}{% if r.archived %} · archived{% endif %} + · {{ r.updated_at }} + </span> + </div> + <div class="kg-admin-actions"> + {% if r.visibility == "public" %} + <form method="post" action="/admin/repos/{{ r.id }}/visibility" style="display:inline"> + <input type="hidden" name="visibility" value="private" /> + <button class="kg-btn kg-btn--ghost" type="submit">force private</button> + </form> {% else %} - <input type="hidden" name="is_site_admin" value="true" /> - <button class="kg-btn" type="submit">make admin</button> + <form method="post" action="/admin/repos/{{ r.id }}/visibility" style="display:inline"> + <input type="hidden" name="visibility" value="public" /> + <button class="kg-btn kg-btn--ghost" type="submit">make public</button> + </form> {% endif %} - </form> + <form method="post" action="/admin/repos/{{ r.id }}/delete" style="display:inline" onsubmit="return confirm('Delete {{ r.owner }}/{{ r.name }} permanently?');"> + <input type="hidden" name="confirm" value="{{ r.name }}" /> + <button class="kg-btn kg-btn--ghost" type="submit">delete</button> + </form> + </div> </li> {% else %} - <li><span class="kg-muted">no users yet — create an account or log in first.</span></li> + <li><span class="kg-muted">no repositories match.</span></li> {% endfor %} </ul> + {% if repo_pages > 1 %} + <p class="kg-meta kg-admin-pager"> + {% if repo_page > 1 %} + <a href="/admin?q={{ user_query }}&page={{ user_page }}&repo_q={{ repo_query }}&repo_page={{ repo_page - 1 }}">← prev</a> + {% endif %} + page {{ repo_page }} / {{ repo_pages }} + {% if repo_page < repo_pages %} + <a href="/admin?q={{ user_query }}&page={{ user_page }}&repo_q={{ repo_query }}&repo_page={{ repo_page + 1 }}">next →</a> + {% endif %} + </p> + {% endif %} </section> {% endblock %} @@ -1,6 +1,10 @@ {% extends "layout.html" %} -{% block title %}explore — kitgit{% endblock %} +{% block title %}{{ social.title }}{% endblock %} + +{% block meta %} +{% include "partials/social_meta.html" %} +{% endblock %} {% block content %} <section class="kg-section"> @@ -1,6 +1,10 @@ {% extends "layout.html" %} -{% block title %}kitgit{% endblock %} +{% block title %}{{ social.title }}{% endblock %} + +{% block meta %} +{% include "partials/social_meta.html" %} +{% endblock %} {% block content %} {% if let Some(u) = viewer %} @@ -17,17 +17,29 @@ {% endif %} <span class="kg-kicker" style="margin-top:1.5rem">SSH keys</span> + <p class="kg-muted">Authentication keys let you push/pull over SSH. Signing keys verify signed commits.</p> {% if !keys.is_empty() %} <ul class="kg-list"> {% for key in keys %} - <li> + <li class="kg-key-row"> <span> <strong>{{ key.name }}</strong> <span class="kg-meta">{{ key.fingerprint }}</span> + <span class="kg-badge">{{ key.usage_label() }}</span> + </span> + <span class="kg-key-row__actions"> + <form method="post" action="/settings/keys/{{ key.id }}/usage" class="kg-inline-form"> + <label class="kg-sr-only" for="usage-{{ key.id }}">usage</label> + <select id="usage-{{ key.id }}" name="key_usage" onchange="this.form.submit()"> + <option value="authentication"{% if key.key_usage == "authentication" %} selected{% endif %}>Authentication</option> + <option value="signing"{% if key.key_usage == "signing" %} selected{% endif %}>Signing</option> + <option value="both"{% if key.key_usage == "both" %} selected{% endif %}>Authentication & Signing</option> + </select> + </form> + <form method="post" action="/settings/keys/{{ key.id }}/delete"> + <button class="kg-btn kg-btn--danger-ghost" type="submit">delete</button> + </form> </span> - <form method="post" action="/settings/keys/{{ key.id }}/delete"> - <button class="kg-btn kg-btn--danger-ghost" type="submit">delete</button> - </form> </li> {% endfor %} </ul> @@ -42,6 +54,14 @@ <input type="text" name="name" required /> </label> <label> + usage + <select name="key_usage"> + <option value="authentication" selected>Authentication</option> + <option value="signing">Signing</option> + <option value="both">Authentication & Signing</option> + </select> + </label> + <label> public key <textarea name="public_key" rows="4" required placeholder="ssh-ed25519 AAAA…"></textarea> </label> @@ -4,6 +4,7 @@ <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>{% block title %}kitgit{% endblock %}</title> + {% block meta %}{% endblock %} <link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" /> @@ -11,6 +12,7 @@ {% block head %}{% endblock %} </head> <body> + <div id="kg-site-banner" class="kg-site-banner" hidden role="status"></div> <div class="kg-shell"> <header class="kg-bar"> <a class="kg-mark" href="/"> @@ -40,6 +42,18 @@ <footer class="kg-footer">kitgit</footer> </div> <script> + (async () => { + try { + const r = await fetch('/site-banner.json', { credentials: 'same-origin' }); + if (!r.ok) return; + const j = await r.json(); + if (j && j.message) { + const el = document.getElementById('kg-site-banner'); + el.textContent = j.message; + el.hidden = false; + } + } catch (_) {} + })(); document.addEventListener('click', (e) => { const btn = e.target.closest('[data-copy]'); if (!btn) return; @@ -12,6 +12,18 @@ {{ c.short_id }} </a> <a class="kg-branchbar__msg" href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}" title="View commit">{{ c.message }}</a> + {% if c.verified %} + <details class="kg-verify"> + <summary class="kg-badge kg-badge--verified">Verified</summary> + <div class="kg-verify__panel" role="dialog" aria-label="Commit signature"> + <p class="kg-verify__fp"> + <strong>{{ c.verify_fingerprint_label }}:</strong> + <code>{{ c.verify_fingerprint }}</code> + </p> + <p class="kg-meta">Verified on {{ c.verified_at }}</p> + </div> + </details> + {% endif %} </div> {% endif %} </div> @@ -0,0 +1,13 @@ + <meta name="description" content="{{ social.description }}" /> + <meta property="og:type" content="website" /> + <meta property="og:site_name" content="{{ social.site_name }}" /> + <meta property="og:title" content="{{ social.title }}" /> + <meta property="og:description" content="{{ social.description }}" /> + <meta property="og:url" content="{{ social.url }}" /> + <meta property="og:image" content="{{ social.image_url }}" /> + <meta property="og:image:width" content="1200" /> + <meta property="og:image:height" content="630" /> + <meta name="twitter:card" content="summary_large_image" /> + <meta name="twitter:title" content="{{ social.title }}" /> + <meta name="twitter:description" content="{{ social.description }}" /> + <meta name="twitter:image" content="{{ social.image_url }}" /> @@ -0,0 +1,12 @@ +{% if let Some(v) = verification %} +<details class="kg-verify"> + <summary class="kg-badge kg-badge--verified">Verified</summary> + <div class="kg-verify__panel" role="dialog" aria-label="Commit signature"> + <p class="kg-verify__fp"> + <strong>{{ v.fingerprint_label() }}:</strong> + <code>{{ v.fingerprint }}</code> + </p> + <p class="kg-meta">Verified on {{ v.verified_at }}</p> + </div> +</details> +{% endif %} @@ -98,6 +98,18 @@ <span> <a href="/{{ owner.username }}/{{ repo.name }}/commits" title="View commits"><code>{{ c.short_id }}</code></a> <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}">{{ c.message }}</a> + {% if c.verified %} + <details class="kg-verify"> + <summary class="kg-badge kg-badge--verified">Verified</summary> + <div class="kg-verify__panel" role="dialog" aria-label="Commit signature"> + <p class="kg-verify__fp"> + <strong>{{ c.verify_fingerprint_label }}:</strong> + <code>{{ c.verify_fingerprint }}</code> + </p> + <p class="kg-meta">Verified on {{ c.verified_at }}</p> + </div> + </details> + {% endif %} </span> <span class="kg-meta">{{ c.author }}</span> </li> @@ -13,7 +13,21 @@ {% include "partials/repo_tabs.html" %} <p><code>{{ commit.id }}</code></p> - <p class="kg-title">{{ message_html|safe }}</p> + <p class="kg-title"> + {{ message_html|safe }} + {% if commit.verified %} + <details class="kg-verify"> + <summary class="kg-badge kg-badge--verified">Verified</summary> + <div class="kg-verify__panel" role="dialog" aria-label="Commit signature"> + <p class="kg-verify__fp"> + <strong>{{ commit.verify_fingerprint_label }}:</strong> + <code>{{ commit.verify_fingerprint }}</code> + </p> + <p class="kg-meta">Verified on {{ commit.verified_at }}</p> + </div> + </details> + {% endif %} + </p> <p class="kg-meta">{{ commit.author }} <{{ commit.email }}> · {{ commit.time }}</p> <p><a href="/{{ owner.username }}/{{ repo.name }}/diff/{{ commit.id }}">view full diff</a></p> @@ -31,6 +31,18 @@ <code>{{ c.short_id }}</code> </a> <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}">{{ c.message }}</a> + {% if c.verified %} + <details class="kg-verify"> + <summary class="kg-badge kg-badge--verified">Verified</summary> + <div class="kg-verify__panel" role="dialog" aria-label="Commit signature"> + <p class="kg-verify__fp"> + <strong>{{ c.verify_fingerprint_label }}:</strong> + <code>{{ c.verify_fingerprint }}</code> + </p> + <p class="kg-meta">Verified on {{ c.verified_at }}</p> + </div> + </details> + {% endif %} <span class="kg-muted"> — {{ c.author }}</span> </span> <span class="kg-meta">{{ c.time }}</span> @@ -1,6 +1,10 @@ {% extends "layout.html" %} -{% block title %}{{ owner.username }}/{{ repo.name }} — kitgit{% endblock %} +{% block title %}{{ social.title }}{% endblock %} + +{% block meta %} +{% include "partials/social_meta.html" %} +{% endblock %} {% block head %} {% if readme_html.is_some() %} @@ -4,6 +4,9 @@ {% block content %} <section class="kg-section kg-auth"> + {% if !signups_enabled %} + <div class="kg-flash kg-flash--banner">{{ signup_disabled_message }}</div> + {% endif %} <p class="kg-landing__brand"> <span class="kg-mark__glyph" aria-hidden="true">狐</span> <span class="kg-mark__name">kitgit</span> @@ -12,7 +15,14 @@ {% if let Some(err) = error %} <div class="kg-flash">{{ err }}</div> {% endif %} + {% if signups_enabled || !invite.is_empty() %} <form class="kg-form" method="post" action="/auth/signup" autocomplete="on"> + {% if !signups_enabled %} + <label> + invite code + <input type="text" name="invite" required value="{{ invite }}" autocomplete="off" spellcheck="false" /> + </label> + {% endif %} <label> username <input type="text" name="username" required autofocus autocomplete="username" pattern="[A-Za-z0-9._\-]+" minlength="2" /> @@ -33,6 +43,17 @@ <button class="kg-btn" type="submit">create account</button> </div> </form> + {% else %} + <form class="kg-form" method="get" action="/auth/signup" style="margin-top:1rem"> + <label> + invite code + <input type="text" name="invite" required autocomplete="off" spellcheck="false" placeholder="Enter an invite code" /> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">continue</button> + </div> + </form> + {% endif %} <p class="kg-meta" style="margin-top:1.25rem"> Already have an account? <a href="/auth/login">Log in</a> </p>