tirbofish/kitgit · diff
initial commit
Type: SSH
SSH Key Fingerprint:
Verified
SgQHY4vUORbJC3ZtdixCl62ek/4QGh/iG2FRSyjfGPc
@@ -0,0 +1,11 @@ +/target +**/*.rs.bk +.env +deploy/.env +deploy/.env.prod +deploy/.bootstrap-password +data/ +*.pem +.DS_Store +.deploy-staging/ +kitgit-deploy.tar @@ -0,0 +1,4496 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "askama" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 2.0.119", +] + +[[package]] +name = "askama_parser" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow", +] + +[[package]] +name = "askama_web" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1acadd534892f9ef8c3809b47997e3cd857fad735edceff77a88be1c8236920" +dependencies = [ + "askama", + "askama_web_derive", + "axum-core", + "bytes", + "http", +] + +[[package]] +name = "askama_web_derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34921de3d57974069bad483fdfe0ec65d88c4ff892edd1ab4d8b03be0dda1b9b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "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", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.9", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "internal-russh-forked-ssh-key" +version = "0.6.9+upstream-0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5af01d366561582e9ea5f841837cc1d8e37e7142a32f33a43801e81863cba5" +dependencies = [ + "argon2", + "bcrypt-pbkdf", + "ecdsa", + "ed25519-dalek", + "hex", + "hmac", + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kitgit" +version = "0.1.0" +dependencies = [ + "anyhow", + "askama", + "askama_web", + "async-trait", + "axum", + "axum-extra", + "base64 0.22.1", + "bytes", + "chrono", + "clap", + "dashmap", + "data-encoding", + "futures", + "git2", + "hex", + "hmac", + "http-body-util", + "mime_guess", + "openidconnect", + "pulldown-cmark", + "qrcode", + "rand 0.8.7", + "reqwest", + "russh", + "serde", + "serde_json", + "sha1", + "sha2", + "sqlx", + "syntect", + "thiserror 2.0.19", + "time", + "tokio", + "tokio-util", + "toml", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libgit2-sys" +version = "0.18.7+1.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23c7391e4b9f4ffab1a624223cc1d7385ff9a678f490768add717de7ea2f4d89" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c04141a07bb0c0bc461cb657808764de571702a59bc5c726c400ac9a7625e3ab" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.8.7", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.7", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +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" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http", + "itertools", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.7", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + +[[package]] +name = "pageant" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c6f0e349ea8dea1b50aa17c082777d30df133d89898c7568a615354772d3731" +dependencies = [ + "bytes", + "delegate", + "futures", + "log", + "rand 0.8.7", + "thiserror 1.0.69", + "tokio", + "windows", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[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 = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes", + "cbc", + "der", + "pbkdf2", + "scrypt", + "sha2", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "pkcs5", + "rand_core 0.6.4", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[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", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.19", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.19", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.1", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "cookie", + "cookie_store", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.9", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "russh" +version = "0.50.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d8075561703e70dab7b095b2c13597cde37f5be94af0849fa4e51c315020d0" +dependencies = [ + "aes", + "aes-gcm", + "bitflags", + "block-padding", + "byteorder", + "bytes", + "cbc", + "chacha20 0.9.1", + "ctr", + "curve25519-dalek", + "data-encoding", + "delegate", + "der", + "digest", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "enum_dispatch", + "flate2", + "futures", + "generic-array", + "getrandom 0.2.17", + "hex-literal", + "hmac", + "home", + "inout", + "internal-russh-forked-ssh-key", + "log", + "md5", + "num-bigint", + "once_cell", + "p256", + "p384", + "p521", + "pageant", + "pbkdf2", + "pkcs1", + "pkcs5", + "pkcs8", + "poly1305", + "rand 0.8.7", + "rand_core 0.6.4", + "rsa", + "russh-cryptovec", + "russh-util", + "sec1", + "sha1", + "sha2", + "signature", + "spki", + "ssh-encoding", + "subtle", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "typenum", + "zeroize", +] + +[[package]] +name = "russh-cryptovec" +version = "0.50.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fcb7c127135848b47715b5bcb13d8a27ccd86ce1de1c15eab5982df91fe279a" +dependencies = [ + "libc", + "log", + "ssh-encoding", + "winapi", +] + +[[package]] +name = "russh-util" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c698d702527b51a82e64de98d506e9c1e83a063035d41df4f2354499ec090b79" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap 2.14.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.19", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.7", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.19", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.7", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.19", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.19", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20 0.9.1", + "cipher", + "ctr", + "poly1305", + "ssh-encoding", + "subtle", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "bytes", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "regex-syntax", + "serde", + "serde_derive", + "thiserror 2.0.19", + "walkdir", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" @@ -0,0 +1,55 @@ +[package] +name = "kitgit" +version = "0.1.0" +edition = "2021" +description = "self-hosted git. templates, not a product page." +license = "MIT" + +[dependencies] +anyhow = "1" +askama = { version = "0.14", features = ["serde_json"] } +askama_web = { version = "0.14", features = ["axum-0.8"] } +async-trait = "0.1" +axum = { version = "0.8", features = ["multipart", "macros"] } +axum-extra = { version = "0.10", features = ["cookie", "typed-header"] } +base64 = "0.22" +bytes = "1" +chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4", features = ["derive", "env"] } +data-encoding = "2" +futures = "0.3" +git2 = "0.20" +hex = "0.4" +hmac = "0.12" +qrcode = { version = "0.14", default-features = false, features = ["svg"] } +sha1 = "0.10" +http-body-util = "0.1" +mime_guess = "2" +openidconnect = "4" +pulldown-cmark = { version = "0.13", default-features = false, features = ["html", "simd"] } +rand = "0.8" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "cookies", "gzip"] } +russh = "0.50" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "uuid", "migrate"] } +syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "html", "regex-fancy"] } +thiserror = "2" +time = "0.3" +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["io"] } +toml = "0.8" +tower = "0.5" +tower-http = { version = "0.6", features = ["fs", "trace", "limit"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +url = "2" +urlencoding = "2" +uuid = { version = "1", features = ["serde", "v4"] } +dashmap = "6" + +[profile.release] +lto = true +codegen-units = 1 +strip = true @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1 + +FROM rust:1-bookworm AS builder +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config libssl-dev cmake git \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY Cargo.toml Cargo.lock* ./ +COPY src ./src +COPY templates ./templates +COPY static ./static +COPY migrations ./migrations +RUN cargo build --release + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates git git-lfs \ + && git lfs install --system \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=builder /app/target/release/kitgit /usr/local/bin/kitgit +COPY static /app/static +COPY migrations /app/migrations +ENV KITGIT_STATIC_DIR=/app/static +ENV KITGIT_DATA_DIR=/data +EXPOSE 8080 2222 +VOLUME ["/data"] +ENTRYPOINT ["kitgit"] @@ -0,0 +1,90 @@ +# kitgit + +Self-hosted git. Templates, not a product page. + +Authentication is **Authentik OIDC only** — there is no local/dev password bypass. + +## Docker Compose + +```bash +cd deploy +cp .env.example .env +# edit secrets if you want +docker compose up -d --build +``` + +| Service | URL | +|---------|-----| +| kitgit | http://localhost:8080 | +| kitgit SSH | localhost:2222 | +| Authentik | http://localhost:9000 | + +### Authentik bootstrap + +Compose sets: + +- user: `akadmin` +- password: value of `AUTHENTIK_BOOTSTRAP_PASSWORD` (default `kitgit-admin-change-me`) + +A blueprint at `deploy/authentik/blueprints/kitgit-oidc.yaml` creates the **kitgit** OAuth2 application (client id `kitgit`, redirect `http://localhost:8080/auth/callback`). + +After Authentik is up, apply/confirm the blueprint if needed: + +```bash +docker compose exec authentik-worker ak apply_blueprint /blueprints/custom/kitgit-oidc.yaml +``` + +Then open http://localhost:8080/auth/login → Authentik → kitgit. + +### Site admins + +- The **first** successful OIDC login becomes a site admin automatically. +- Site admins open **/admin** and can grant/revoke admin for any user. + +## Environment + +| Variable | Purpose | +|----------|---------| +| `KITGIT_DATABASE_URL` | Postgres | +| `KITGIT_DATA_DIR` | Repos, avatars, SSH host key, releases | +| `KITGIT_HTTP_BIND` / `KITGIT_SSH_BIND` | Listen addresses | +| `KITGIT_PUBLIC_URL` | Public base URL for clone links | +| `KITGIT_SESSION_SECRET` | Session cookie secret | +| `KITGIT_OIDC_ISSUER` | Public issuer (browser), e.g. `http://localhost:9000/application/o/kitgit/` | +| `KITGIT_OIDC_DISCOVERY_URL` | Discovery URL (defaults to issuer) | +| `KITGIT_OIDC_CLIENT_ID` / `SECRET` / `REDIRECT_URL` | OIDC client | + +## Build + +```bash +cargo build --release +``` + +## Backup + +- Postgres: `pg_dump` the `kitgit` database. +- Data dir: bare repos, avatars, releases, SSH host key. + +## DigitalOcean (production) + +Minimal single-Droplet deploy under the **personal** project: + +| Resource | Value | +|----------|-------| +| Droplet | `kitgit` · `s-2vcpu-4gb` · `syd1` · Docker 1-Click | +| Sites | https://git.tirbo.fish · https://auth.tirbo.fish | +| Git SSH | `git.tirbo.fish:2222` | +| Cost | ~$24/mo (droplet only; no managed DB/LB/registry) | + +On the droplet, the stack lives in `/opt/kitgit` and is started with: + +```bash +cd /opt/kitgit/deploy +docker compose up -d --build +``` + +Prod files: `deploy/docker-compose.prod.yml`, `deploy/Caddyfile`, `deploy/.env.prod.example`, `deploy/authentik/blueprints-prod/`. + +## Brand + +See `brand/index.html`. Mark glyph is **狐**. @@ -0,0 +1,15 @@ +# kitgit + +a minimalistic git frontend build in Rust and pure HTML templates, allowing for blazing fast (how cliche) loading times and high performance. + +## deployment + +```bash +cd deploy +cp .env.example .env +# edit secrets if you want +docker compose up -d --build +``` + +find kitgit at [localhost:8080](http://localhost:8080). + @@ -0,0 +1,349 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>kitgit — brand</title> + <meta + name="description" + content="kitgit — self-hosted git. ink on paper." + /> + <link rel="preconnect" href="https://fonts.googleapis.com" /> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> + <!-- Web fallback when a Nerd Font is not installed locally --> + <link + href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap" + rel="stylesheet" + /> + <link rel="stylesheet" href="./theme.css" /> + <style> + .hero { + padding: var(--kg-space-6) 0 var(--kg-space-5); + min-height: calc(100vh - 4.5rem); + display: flex; + flex-direction: column; + justify-content: center; + } + + .hero p { + max-width: 28rem; + margin: var(--kg-space-4) 0 var(--kg-space-5); + color: var(--kg-muted); + font-size: 1rem; + } + + .section { + padding: var(--kg-space-5) 0; + } + + .section + .section { + border-top: 1px solid var(--kg-line); + } + + .stack { + display: flex; + flex-direction: column; + gap: var(--kg-space-3); + } + + .type-row { + display: grid; + gap: var(--kg-space-2); + } + + .type-row small { + color: var(--kg-faint); + font-size: 0.6875rem; + letter-spacing: 0.1em; + text-transform: uppercase; + } + + .toggle { + font: inherit; + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--kg-muted); + background: none; + border: 0; + cursor: pointer; + padding: 0; + } + + .toggle:hover { + color: var(--kg-fg); + } + + .sample-list { + list-style: none; + margin: 0; + padding: 0; + border: 1px solid var(--kg-line); + } + + .sample-list li { + display: flex; + justify-content: space-between; + gap: var(--kg-space-3); + padding: var(--kg-space-2) var(--kg-space-3); + border-bottom: 1px solid var(--kg-line); + font-size: 0.875rem; + } + + .sample-list li:last-child { + border-bottom: 0; + } + + .sample-list a { + color: var(--kg-fg); + text-decoration: none; + } + + .sample-list a:hover { + text-decoration: underline; + text-underline-offset: 0.2em; + } + + .voice { + display: grid; + gap: var(--kg-space-4); + } + + @media (min-width: 640px) { + .voice { + grid-template-columns: 1fr 1fr; + } + } + + .voice dt { + font-size: 0.75rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--kg-muted); + margin-bottom: var(--kg-space-1); + } + + .voice dd { + margin: 0; + } + </style> + </head> + <body> + <div class="kg-shell"> + <header class="kg-bar"> + <a class="kg-mark" href="#"> + <span class="kg-mark__glyph" aria-hidden="true">狐</span> + <span class="kg-mark__name">kitgit</span> + </a> + <nav aria-label="Brand sections"> + <a href="#mark">mark</a> + <a href="#type">type</a> + <a href="#ink">ink</a> + <a href="#voice">voice</a> + </nav> + <button + type="button" + class="toggle" + id="theme-toggle" + aria-pressed="false" + > + invert + </button> + </header> + + <main> + <!-- first viewport: brand only --> + <section class="hero" aria-label="Brand"> + <a class="kg-mark kg-mark--hero kg-caret" href="#"> + <span class="kg-mark__glyph" aria-hidden="true">狐</span> + <span class="kg-mark__name">kitgit</span> + </a> + <p> + Self-hosted git. Templates, not a product page. Ink on paper. + </p> + <div class="kg-actions"> + <a class="kg-btn" href="#mark">read the system</a> + <a class="kg-btn kg-btn--ghost" href="#sample">see a template</a> + </div> + </section> + + <section class="section" id="mark"> + <span class="kg-kicker">01 — mark</span> + <h2 class="kg-display">Wordmark, not a logo lockup.</h2> + <p class="kg-muted" style="margin: 1rem 0 1.5rem; max-width: 36rem"> + Always lowercase. Always monospace. The mark is a solid ink square — + no SVG, no lockup. UI chrome elsewhere uses Nerd Font glyphs + (<code>nf-md-git</code>, branch, commit, etc.). + </p> + <div class="stack"> + <a class="kg-mark kg-mark--hero" href="#"> + <span class="kg-mark__glyph" aria-hidden="true">狐</span> + <span class="kg-mark__name">kitgit</span> + </a> + <a class="kg-mark" href="#"> + <span class="kg-mark__glyph" aria-hidden="true">狐</span> + <span class="kg-mark__name">kitgit</span> + </a> + <span class="kg-mark kg-faint"> + <span class="kg-mark__name">kitgit</span> + </span> + </div> + </section> + + <section class="section" id="type"> + <span class="kg-kicker">02 — type</span> + <h2 class="kg-display">One face. Nerd Font first.</h2> + <p class="kg-muted" style="margin: 1rem 0 1.5rem; max-width: 36rem"> + Primary stack: + <strong style="color: var(--kg-fg); font-weight: 500" + >JetBrainsMono Nerd Font</strong + > + → JetBrains Mono → FiraCode Nerd Font → system mono. No second + family. Hierarchy is weight and size only. + </p> + <div class="stack"> + <div class="type-row"> + <small>display / 700</small> + <p class="kg-display">clone. push. host.</p> + </div> + <div class="type-row"> + <small>title / 700</small> + <p class="kg-title">thribhu / kitgit</p> + </div> + <div class="type-row"> + <small>body / 400</small> + <p> + A bare forge for people who want git over http and ssh — and + almost nothing else. + </p> + </div> + <div class="type-row"> + <small>muted / 400</small> + <p class="kg-muted">updated 2h ago · 3 commits · main</p> + </div> + </div> + </section> + + <section class="section" id="ink"> + <span class="kg-kicker">03 — ink</span> + <h2 class="kg-display">Black. White. Stop.</h2> + <p class="kg-muted" style="margin: 1rem 0 1.5rem; max-width: 36rem"> + No accent hue. Interaction is invert, underline, or opacity. Borders + are either faint paper lines or full ink. + </p> + <div class="kg-swatches" aria-label="Color tokens"> + <div class="kg-swatch" style="background: #ffffff; color: #0a0a0a"> + <span>bg #fff</span> + </div> + <div class="kg-swatch" style="background: #f7f7f7; color: #0a0a0a"> + <span>raised #f7</span> + </div> + <div class="kg-swatch" style="background: #0a0a0a; color: #ffffff"> + <span>fg #0a</span> + </div> + <div class="kg-swatch" style="background: #6b6b6b; color: #ffffff"> + <span>muted #6b</span> + </div> + <div class="kg-swatch" style="background: #e5e5e5; color: #0a0a0a"> + <span>line #e5</span> + </div> + <div class="kg-swatch" style="background: #0a0a0a; color: #ffffff"> + <span>invert</span> + </div> + </div> + <pre + class="kg-code" + style="margin-top: 1.5rem" + aria-label="Install example" + ><span class="cm"># serve a bare repo</span> +$ kitgit init ~/repos/app.git +$ kitgit serve --listen 0.0.0.0:8080 +<span class="ok">listening on :8080</span></pre> + </section> + + <section class="section" id="voice"> + <span class="kg-kicker">04 — voice</span> + <h2 class="kg-display">Short. Literal. Quiet.</h2> + <dl class="voice" style="margin-top: 1.5rem"> + <div> + <dt>Name</dt> + <dd><strong>kitgit</strong> — kit + git. A kit you host.</dd> + </div> + <div> + <dt>Tagline</dt> + <dd>Self-hosted git. Templates, not a product page.</dd> + </div> + <div> + <dt>Say</dt> + <dd> + “repos”, “bare”, “clone url”, “owner”, “push”. Prefer verbs over + marketing nouns. + </dd> + </div> + <div> + <dt>Don’t say</dt> + <dd> + “forge platform”, “developer experience”, “seamless”, “powerful”, + “next-gen”. + </dd> + </div> + <div> + <dt>UI copy</dt> + <dd> + Sentence case. No exclamation marks. Empty states are one line: + <em class="kg-muted">no repos yet.</em> + </dd> + </div> + <div> + <dt>Iconography</dt> + <dd> + Nerd Font codepoints only. No SVG icon packs. Prefer text labels + when a glyph would be decorative. + </dd> + </div> + </dl> + </section> + + <section class="section" id="sample"> + <span class="kg-kicker">05 — template sample</span> + <h2 class="kg-display">How a page should feel.</h2> + <p class="kg-muted" style="margin: 1rem 0 1.5rem; max-width: 36rem"> + One job. One title. Lists over cards. Metadata stays muted. + </p> + <p class="kg-title" style="margin-bottom: 0.75rem">repos</p> + <ul class="sample-list"> + <li> + <a href="#">thribhu/kitgit</a> + <span class="kg-muted">main · 12c</span> + </li> + <li> + <a href="#">thribhu/notes</a> + <span class="kg-muted">trunk · 4c</span> + </li> + <li> + <a href="#">ops/backup</a> + <span class="kg-muted">master · 1c</span> + </li> + </ul> + <p class="kg-faint" style="margin-top: 1rem; font-size: 0.75rem"> + clone · http://git.example/thribhu/kitgit.git + </p> + </section> + </main> + + <footer class="kg-footer"> + kitgit · ink on paper · brand v0.1 + </footer> + </div> + + <script> + const root = document.documentElement; + const btn = document.getElementById("theme-toggle"); + btn.addEventListener("click", () => { + const on = root.classList.toggle("kg-dark"); + btn.setAttribute("aria-pressed", String(on)); + btn.textContent = on ? "paper" : "invert"; + }); + </script> + </body> +</html> @@ -0,0 +1,371 @@ +/* kitgit — brand theme + ink on paper. no chrome. */ + +:root { + /* surfaces */ + --kg-bg: #ffffff; + --kg-bg-raised: #f7f7f7; + --kg-fg: #0a0a0a; + --kg-muted: #6b6b6b; + --kg-faint: #a3a3a3; + --kg-line: #e5e5e5; + --kg-line-strong: #0a0a0a; + + /* interactive */ + --kg-link: #0a0a0a; + --kg-link-hover: #0a0a0a; + --kg-focus: #0a0a0a; + --kg-danger: #0a0a0a; + + /* invert block (nav, code, CTA) */ + --kg-invert-bg: #0a0a0a; + --kg-invert-fg: #ffffff; + --kg-invert-muted: #a3a3a3; + + /* type */ + --kg-font: "JetBrainsMono Nerd Font", "JetBrainsMono NF", "JetBrains Mono", + "FiraCode Nerd Font", "Fira Code", "SauceCodePro Nerd Font", + ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace; + --kg-font-size: 14px; + --kg-line-height: 1.55; + --kg-tracking-tight: -0.02em; + --kg-tracking-brand: 0.08em; + + /* rhythm */ + --kg-space-1: 0.25rem; + --kg-space-2: 0.5rem; + --kg-space-3: 1rem; + --kg-space-4: 1.5rem; + --kg-space-5: 2.5rem; + --kg-space-6: 4rem; + --kg-max: 75rem; + --kg-radius: 0; + + color-scheme: light; +} + +/* optional dark invert — add class="kg-dark" on <html> */ +html.kg-dark { + --kg-bg: #0a0a0a; + --kg-bg-raised: #141414; + --kg-fg: #f5f5f5; + --kg-muted: #8a8a8a; + --kg-faint: #5c5c5c; + --kg-line: #262626; + --kg-line-strong: #f5f5f5; + --kg-link: #f5f5f5; + --kg-invert-bg: #f5f5f5; + --kg-invert-fg: #0a0a0a; + --kg-invert-muted: #525252; + color-scheme: dark; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + font-size: var(--kg-font-size); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +body { + margin: 0; + min-height: 100vh; + font-family: var(--kg-font); + font-size: 1rem; + line-height: var(--kg-line-height); + color: var(--kg-fg); + background: var(--kg-bg); +} + +/* —— wordmark —— */ +.kg-mark { + display: inline-flex; + align-items: baseline; + gap: 0.55em; + font-family: var(--kg-font); + font-weight: 700; + font-size: 1.125rem; + letter-spacing: var(--kg-tracking-brand); + text-transform: lowercase; + text-decoration: none; + color: var(--kg-fg); + line-height: 1; +} + +.kg-mark__glyph { + display: inline-block; + flex-shrink: 0; + align-self: center; + font-weight: 700; + letter-spacing: 0; + font-size: 1.15em; + line-height: 1; + speak: never; +} + +.kg-mark__name { + letter-spacing: var(--kg-tracking-brand); +} + +.kg-mark--hero { + font-size: clamp(2.5rem, 8vw, 4.5rem); + letter-spacing: 0.06em; +} + +.kg-mark--hero .kg-mark__glyph { + font-size: 1.05em; +} + +/* —— type scale —— */ +.kg-display { + font-family: var(--kg-font); + font-weight: 700; + font-size: clamp(1.75rem, 4vw, 2.25rem); + line-height: 1.2; + letter-spacing: var(--kg-tracking-tight); + margin: 0; +} + +.kg-title { + font-weight: 700; + font-size: 1.125rem; + letter-spacing: var(--kg-tracking-tight); + margin: 0; +} + +.kg-body { + margin: 0; + color: var(--kg-fg); +} + +.kg-muted { + color: var(--kg-muted); +} + +.kg-faint { + color: var(--kg-faint); +} + +.kg-mono { + font-family: var(--kg-font); +} + +.kg-kicker { + display: block; + font-size: 0.75rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--kg-muted); + margin: 0 0 var(--kg-space-3); +} + +/* —— links & controls —— */ +a.kg-link { + color: var(--kg-link); + text-decoration: underline; + text-underline-offset: 0.2em; + text-decoration-thickness: 1px; +} + +a.kg-link:hover { + text-decoration-thickness: 2px; +} + +.kg-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5em; + padding: 0.65em 1.1em; + font-family: var(--kg-font); + font-size: 0.875rem; + font-weight: 500; + letter-spacing: 0.02em; + line-height: 1; + border: 1px solid var(--kg-line-strong); + border-radius: var(--kg-radius); + background: var(--kg-invert-bg); + color: var(--kg-invert-fg); + text-decoration: none; + cursor: pointer; + transition: opacity 120ms ease; +} + +.kg-btn:hover { + opacity: 0.85; +} + +.kg-btn:focus-visible { + outline: 2px solid var(--kg-focus); + outline-offset: 3px; +} + +.kg-btn--ghost { + background: transparent; + color: var(--kg-fg); +} + +.kg-btn--ghost:hover { + opacity: 1; + background: var(--kg-bg-raised); +} + +.kg-actions { + display: flex; + flex-wrap: wrap; + gap: var(--kg-space-2); +} + +/* —— structure —— */ +.kg-shell { + width: min(100% - 2rem, var(--kg-max)); + margin-inline: auto; +} + +.kg-rule { + border: 0; + border-top: 1px solid var(--kg-line); + margin: var(--kg-space-5) 0; +} + +.kg-rule--strong { + border-top-color: var(--kg-line-strong); +} + +.kg-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--kg-space-3); + padding: var(--kg-space-3) 0; + border-bottom: 1px solid var(--kg-line); +} + +.kg-bar nav { + display: flex; + gap: var(--kg-space-3); +} + +.kg-bar nav a { + color: var(--kg-muted); + text-decoration: none; + font-size: 0.8125rem; + letter-spacing: 0.04em; +} + +.kg-bar nav a:hover, +.kg-bar nav a[aria-current="page"] { + color: var(--kg-fg); +} + +.kg-footer { + padding: var(--kg-space-4) 0 var(--kg-space-6); + color: var(--kg-faint); + font-size: 0.75rem; + letter-spacing: 0.06em; +} + +/* code / terminal strip */ +.kg-code { + display: block; + padding: var(--kg-space-3) var(--kg-space-4); + background: var(--kg-invert-bg); + color: var(--kg-invert-fg); + font-family: var(--kg-font); + font-size: 0.8125rem; + line-height: 1.6; + overflow-x: auto; + border-radius: var(--kg-radius); +} + +.kg-code .cm { + color: var(--kg-invert-muted); +} + +.kg-code .ok { + color: var(--kg-invert-fg); +} + +/* token swatches (brand preview only) */ +.kg-swatches { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr)); + gap: var(--kg-space-2); +} + +.kg-swatch { + aspect-ratio: 1; + border: 1px solid var(--kg-line); + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: var(--kg-space-2); + font-size: 0.6875rem; + letter-spacing: 0.04em; +} + +.kg-swatch span { + background: color-mix(in srgb, var(--kg-bg) 85%, transparent); + padding: 0.1em 0.25em; +} + +/* motion — presence, not noise */ +@keyframes kg-ink { + from { + opacity: 0; + transform: translateY(0.4rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes kg-caret { + 0%, + 49% { + opacity: 1; + } + 50%, + 100% { + opacity: 0; + } +} + +.kg-in { + animation: kg-ink 480ms ease both; +} + +.kg-in-2 { + animation: kg-ink 480ms ease 80ms both; +} + +.kg-in-3 { + animation: kg-ink 480ms ease 160ms both; +} + +.kg-caret::after { + content: "█"; + margin-left: 0.15em; + animation: kg-caret 1.1s step-end infinite; + font-weight: 400; +} + +@media (prefers-reduced-motion: reduce) { + .kg-in, + .kg-in-2, + .kg-in-3 { + animation: none; + } + + .kg-caret::after { + animation: none; + opacity: 1; + } +} @@ -0,0 +1,18 @@ +# Authentik +AUTHENTIK_SECRET_KEY=changeme-authentik-secret-key-32chars +AUTHENTIK_BOOTSTRAP_PASSWORD=kitgit-admin-change-me +AUTHENTIK_BOOTSTRAP_EMAIL=admin@kitgit.local + +# kitgit +KITGIT_PUBLIC_URL=http://localhost:8080 +KITGIT_SESSION_SECRET=changeme-kitgit-session-secret-32b +KITGIT_OIDC_ISSUER=http://localhost:9000/application/o/kitgit/ +KITGIT_OIDC_DISCOVERY_URL=http://authentik-server:9000/application/o/kitgit/ +KITGIT_OIDC_CLIENT_ID=kitgit +KITGIT_OIDC_CLIENT_SECRET=changeme-oidc-client-secret +KITGIT_OIDC_REDIRECT_URL=http://localhost:8080/auth/callback + +# Authentik API (signup). Must match blueprint token key. +KITGIT_AUTHENTIK_URL=http://authentik-server:9000 +KITGIT_AUTHENTIK_API_TOKEN=changeme-kitgit-authentik-api-token +AUTHENTIK_TOKEN=changeme-kitgit-authentik-api-token @@ -0,0 +1,18 @@ +# Copy to .env.prod and fill in secrets before deploy. +POSTGRES_PASSWORD= +AUTHENTIK_SECRET_KEY= +AUTHENTIK_BOOTSTRAP_PASSWORD= +AUTHENTIK_BOOTSTRAP_EMAIL=admin@tirbo.fish + +KITGIT_PUBLIC_URL=https://git.tirbo.fish +KITGIT_SESSION_SECRET= +KITGIT_OIDC_ISSUER=https://auth.tirbo.fish/application/o/kitgit/ +KITGIT_OIDC_DISCOVERY_URL=http://authentik-server:9000/application/o/kitgit/ +KITGIT_OIDC_CLIENT_ID=kitgit +KITGIT_OIDC_CLIENT_SECRET= +KITGIT_OIDC_REDIRECT_URL=https://git.tirbo.fish/auth/callback + +KITGIT_AUTHENTIK_URL=http://authentik-server:9000 +KITGIT_AUTHENTIK_API_TOKEN= +AUTHENTIK_TOKEN= +KITGIT_AUTHENTIK_AUTH_FLOW=default-authentication-flow @@ -0,0 +1,9 @@ +git.tirbo.fish { + encode gzip + reverse_proxy kitgit:8080 +} + +auth.tirbo.fish { + encode gzip + reverse_proxy authentik-server:9000 +} @@ -0,0 +1,70 @@ +# kitgit OIDC application for Authentik (production: tirbo.fish) +# Replace PLACEHOLDER_* values to match deploy/.env.prod before first apply. +version: 1 +metadata: + name: kitgit-oidc +entries: + - model: authentik_providers_oauth2.oauth2provider + id: kitgit-provider + state: present + identifiers: + name: kitgit + attrs: + name: kitgit + client_type: confidential + client_id: kitgit + client_secret: PLACEHOLDER_OIDC_CLIENT_SECRET + redirect_uris: + - matching_mode: strict + url: https://git.tirbo.fish/auth/callback + include_claims_in_id_token: true + sub_mode: user_username + issuer_mode: per_provider + access_code_validity: minutes=10 + access_token_validity: hours=1 + refresh_token_validity: days=30 + authentication_flow: !Find [authentik_flows.flow, [slug, default-authentication-flow]] + authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] + invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] + signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] + property_mappings: + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]] + + - model: authentik_core.application + id: kitgit-app + state: present + identifiers: + slug: kitgit + attrs: + name: kitgit + slug: kitgit + provider: !KeyOf kitgit-provider + policy_engine_mode: any + meta_launch_url: https://git.tirbo.fish/ + + - model: authentik_core.token + id: kitgit-api-token + state: present + identifiers: + identifier: kitgit-api + attrs: + identifier: kitgit-api + intent: api + expiring: false + description: kitgit service API token for user registration + user: !Find [authentik_core.user, [username, akadmin]] + key: PLACEHOLDER_AUTHENTIK_API_TOKEN + + - model: authentik_stages_identification.identificationstage + id: default-authentication-identification + state: present + identifiers: + name: default-authentication-identification + attrs: + name: default-authentication-identification + user_fields: + - username + - email + captcha_stage: null @@ -0,0 +1,71 @@ +# kitgit OIDC application for Authentik +version: 1 +metadata: + name: kitgit-oidc +entries: + - model: authentik_providers_oauth2.oauth2provider + id: kitgit-provider + state: present + identifiers: + name: kitgit + attrs: + name: kitgit + client_type: confidential + client_id: kitgit + client_secret: changeme-oidc-client-secret + redirect_uris: + - matching_mode: strict + url: http://localhost:8080/auth/callback + include_claims_in_id_token: true + sub_mode: user_username + issuer_mode: per_provider + access_code_validity: minutes=10 + access_token_validity: hours=1 + refresh_token_validity: days=30 + authentication_flow: !Find [authentik_flows.flow, [slug, default-authentication-flow]] + authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] + invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] + signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] + property_mappings: + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]] + + - model: authentik_core.application + id: kitgit-app + state: present + identifiers: + slug: kitgit + attrs: + name: kitgit + slug: kitgit + provider: !KeyOf kitgit-provider + policy_engine_mode: any + meta_launch_url: http://localhost:8080/ + + # API token for kitgit signup (create users via /api/v3/core/users/) + - model: authentik_core.token + id: kitgit-api-token + state: present + identifiers: + identifier: kitgit-api + attrs: + identifier: kitgit-api + intent: api + expiring: false + description: kitgit service API token for user registration + user: !Find [authentik_core.user, [username, akadmin]] + key: changeme-kitgit-authentik-api-token + + # Ensure kitgit login works without captcha (kitgit UI has no captcha widget). + - model: authentik_stages_identification.identificationstage + id: default-authentication-identification + state: present + identifiers: + name: default-authentication-identification + attrs: + name: default-authentication-identification + user_fields: + - username + - email + captcha_stage: null @@ -0,0 +1,26 @@ +#!/bin/sh +# Ensure kitgit API token exists in Authentik (idempotent). +# Usage: docker compose exec -T authentik-server ak shell < ensure-api-token.py +# Or: python manage.py shell < this file via ak shell +from authentik.core.models import Token, TokenIntents, User + +KEY = "changeme-kitgit-authentik-api-token" +IDENT = "kitgit-api" + +user = User.objects.filter(username="akadmin").first() +if user is None: + raise SystemExit("akadmin user not found yet") + +tok = Token.objects.filter(identifier=IDENT).first() +if tok is None: + tok = Token(identifier=IDENT, user=user, intent=TokenIntents.INTENT_API, expiring=False) + tok.key = KEY + tok.save() + print(f"created token {IDENT}") +else: + tok.key = KEY + tok.expiring = False + tok.user = user + tok.intent = TokenIntents.INTENT_API + tok.save() + print(f"updated token {IDENT}") @@ -0,0 +1,131 @@ +services: + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + - kitgit + - authentik-server + + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: kitgit + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: postgres + volumes: + - pgdata:/var/lib/postgresql/data + - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U kitgit"] + interval: 5s + timeout: 5s + retries: 10 + # not published — internal only + + redis: + image: redis:7-alpine + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + authentik-server: + image: ghcr.io/goauthentik/server:2024.10.4 + command: server + restart: unless-stopped + environment: + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} + AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD} + AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL} + AUTHENTIK_REDIS__HOST: redis + AUTHENTIK_POSTGRESQL__HOST: postgres + AUTHENTIK_POSTGRESQL__USER: kitgit + AUTHENTIK_POSTGRESQL__NAME: authentik + AUTHENTIK_POSTGRESQL__PASSWORD: ${POSTGRES_PASSWORD} + AUTHENTIK_HOST: https://auth.tirbo.fish + AUTHENTIK_HOST_BROWSER: https://auth.tirbo.fish + volumes: + - authentik-media:/media + - authentik-templates:/templates + - ./authentik/blueprints-prod:/blueprints/custom:ro + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + authentik-worker: + image: ghcr.io/goauthentik/server:2024.10.4 + command: worker + restart: unless-stopped + environment: + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} + AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD} + AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL} + AUTHENTIK_REDIS__HOST: redis + AUTHENTIK_POSTGRESQL__HOST: postgres + AUTHENTIK_POSTGRESQL__USER: kitgit + AUTHENTIK_POSTGRESQL__NAME: authentik + AUTHENTIK_POSTGRESQL__PASSWORD: ${POSTGRES_PASSWORD} + AUTHENTIK_HOST: https://auth.tirbo.fish + AUTHENTIK_HOST_BROWSER: https://auth.tirbo.fish + user: root + volumes: + - authentik-media:/media + - authentik-templates:/templates + - ./authentik/blueprints-prod:/blueprints/custom:ro + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + kitgit: + build: + context: .. + dockerfile: Dockerfile + restart: unless-stopped + environment: + KITGIT_DATABASE_URL: postgres://kitgit:${POSTGRES_PASSWORD}@postgres:5432/kitgit + KITGIT_DATA_DIR: /data + KITGIT_HTTP_BIND: 0.0.0.0:8080 + KITGIT_SSH_BIND: 0.0.0.0:2222 + KITGIT_PUBLIC_URL: ${KITGIT_PUBLIC_URL} + KITGIT_SESSION_SECRET: ${KITGIT_SESSION_SECRET} + KITGIT_OIDC_ISSUER: ${KITGIT_OIDC_ISSUER} + KITGIT_OIDC_DISCOVERY_URL: ${KITGIT_OIDC_DISCOVERY_URL} + KITGIT_OIDC_CLIENT_ID: ${KITGIT_OIDC_CLIENT_ID} + KITGIT_OIDC_CLIENT_SECRET: ${KITGIT_OIDC_CLIENT_SECRET} + KITGIT_OIDC_REDIRECT_URL: ${KITGIT_OIDC_REDIRECT_URL} + KITGIT_AUTHENTIK_URL: ${KITGIT_AUTHENTIK_URL} + KITGIT_AUTHENTIK_API_TOKEN: ${KITGIT_AUTHENTIK_API_TOKEN} + AUTHENTIK_TOKEN: ${AUTHENTIK_TOKEN} + KITGIT_AUTHENTIK_AUTH_FLOW: ${KITGIT_AUTHENTIK_AUTH_FLOW:-default-authentication-flow} + RUST_LOG: info,kitgit=info + volumes: + - kitgit-data:/data + ports: + - "2222:2222" + depends_on: + postgres: + condition: service_healthy + authentik-server: + condition: service_started + +volumes: + pgdata: + authentik-media: + authentik-templates: + kitgit-data: + caddy-data: + caddy-config: @@ -0,0 +1,112 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: kitgit + POSTGRES_PASSWORD: kitgit + POSTGRES_DB: postgres + volumes: + - pgdata:/var/lib/postgresql/data + - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U kitgit"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + authentik-server: + image: ghcr.io/goauthentik/server:2024.10.4 + command: server + environment: + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:-changeme-authentik-secret-key-32chars} + AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD:-kitgit-admin-change-me} + AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL:-admin@kitgit.local} + AUTHENTIK_REDIS__HOST: redis + AUTHENTIK_POSTGRESQL__HOST: postgres + AUTHENTIK_POSTGRESQL__USER: kitgit + AUTHENTIK_POSTGRESQL__NAME: authentik + AUTHENTIK_POSTGRESQL__PASSWORD: kitgit + volumes: + - authentik-media:/media + - authentik-templates:/templates + - ./authentik/blueprints:/blueprints/custom:ro + ports: + - "9000:9000" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + authentik-worker: + image: ghcr.io/goauthentik/server:2024.10.4 + command: worker + environment: + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:-changeme-authentik-secret-key-32chars} + AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD:-kitgit-admin-change-me} + AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL:-admin@kitgit.local} + AUTHENTIK_REDIS__HOST: redis + AUTHENTIK_POSTGRESQL__HOST: postgres + AUTHENTIK_POSTGRESQL__USER: kitgit + AUTHENTIK_POSTGRESQL__NAME: authentik + AUTHENTIK_POSTGRESQL__PASSWORD: kitgit + user: root + volumes: + - authentik-media:/media + - authentik-templates:/templates + - ./authentik/blueprints:/blueprints/custom:ro + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + kitgit: + build: + context: .. + dockerfile: Dockerfile + environment: + KITGIT_DATABASE_URL: postgres://kitgit:kitgit@postgres:5432/kitgit + KITGIT_DATA_DIR: /data + KITGIT_HTTP_BIND: 0.0.0.0:8080 + KITGIT_SSH_BIND: 0.0.0.0:2222 + KITGIT_PUBLIC_URL: ${KITGIT_PUBLIC_URL:-http://localhost:8080} + KITGIT_SESSION_SECRET: ${KITGIT_SESSION_SECRET:-changeme-kitgit-session-secret-32b} + # Public issuer / browser-facing authorize host + KITGIT_OIDC_ISSUER: ${KITGIT_OIDC_ISSUER:-http://localhost:9000/application/o/kitgit/} + # Internal discovery (Docker DNS) — authorize URL rewritten to KITGIT_OIDC_ISSUER host + KITGIT_OIDC_DISCOVERY_URL: ${KITGIT_OIDC_DISCOVERY_URL:-http://authentik-server:9000/application/o/kitgit/} + KITGIT_OIDC_CLIENT_ID: ${KITGIT_OIDC_CLIENT_ID:-kitgit} + KITGIT_OIDC_CLIENT_SECRET: ${KITGIT_OIDC_CLIENT_SECRET:-changeme-oidc-client-secret} + KITGIT_OIDC_REDIRECT_URL: ${KITGIT_OIDC_REDIRECT_URL:-http://localhost:8080/auth/callback} + KITGIT_AUTHENTIK_URL: ${KITGIT_AUTHENTIK_URL:-http://authentik-server:9000} + KITGIT_AUTHENTIK_API_TOKEN: ${KITGIT_AUTHENTIK_API_TOKEN:-changeme-kitgit-authentik-api-token} + AUTHENTIK_TOKEN: ${AUTHENTIK_TOKEN:-changeme-kitgit-authentik-api-token} + KITGIT_AUTHENTIK_AUTH_FLOW: ${KITGIT_AUTHENTIK_AUTH_FLOW:-default-authentication-flow} + RUST_LOG: info,kitgit=debug + volumes: + - kitgit-data:/data + ports: + - "8080:8080" + - "2222:2222" + depends_on: + postgres: + condition: service_healthy + authentik-server: + condition: service_started + +volumes: + pgdata: + authentik-media: + authentik-templates: + kitgit-data: @@ -0,0 +1,2 @@ +CREATE DATABASE kitgit; +CREATE DATABASE authentik; @@ -0,0 +1,192 @@ +-- kitgit schema (phases 1–4) + +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + oidc_sub TEXT NOT NULL UNIQUE, + username TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + bio TEXT NOT NULL DEFAULT '', + avatar_path TEXT, + avatar_url TEXT, + is_site_admin BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT users_username_format CHECK (username ~ '^[a-z0-9][a-z0-9_-]{0,38}$') +); + +CREATE TABLE sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX sessions_user_id_idx ON sessions(user_id); +CREATE INDEX sessions_expires_at_idx ON sessions(expires_at); + +CREATE TABLE ssh_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT 'key', + public_key TEXT NOT NULL, + fingerprint TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX ssh_keys_user_id_idx ON ssh_keys(user_id); + +CREATE TABLE repositories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public', 'private')), + default_branch TEXT NOT NULL DEFAULT 'main', + archived BOOLEAN NOT NULL DEFAULT FALSE, + -- units + issues_enabled BOOLEAN NOT NULL DEFAULT TRUE, + pulls_enabled BOOLEAN NOT NULL DEFAULT TRUE, + releases_enabled BOOLEAN NOT NULL DEFAULT TRUE, + -- pull settings + allow_merge BOOLEAN NOT NULL DEFAULT TRUE, + allow_squash BOOLEAN NOT NULL DEFAULT TRUE, + allow_rebase BOOLEAN NOT NULL DEFAULT TRUE, + default_merge_style TEXT NOT NULL DEFAULT 'merge' + CHECK (default_merge_style IN ('merge', 'squash', 'rebase')), + -- branch protection (phase 4) + protect_default_branch BOOLEAN NOT NULL DEFAULT FALSE, + protect_block_force_push BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (owner_id, name), + CONSTRAINT repositories_name_format CHECK (name ~ '^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$') +); +CREATE INDEX repositories_owner_id_idx ON repositories(owner_id); + +CREATE TABLE collaborators ( + repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('read', 'write', 'admin')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (repo_id, user_id) +); + +CREATE TABLE language_stats ( + repo_id UUID PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE, + stats JSONB NOT NULL DEFAULT '{}'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE activity_events ( + id BIGSERIAL PRIMARY KEY, + actor_id UUID REFERENCES users(id) ON DELETE SET NULL, + repo_id UUID REFERENCES repositories(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + summary TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX activity_events_created_at_idx ON activity_events(created_at DESC); +CREATE INDEX activity_events_actor_id_idx ON activity_events(actor_id); +CREATE INDEX activity_events_repo_id_idx ON activity_events(repo_id); + +CREATE TABLE commit_activity ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + day DATE NOT NULL, + count INT NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, day) +); + +CREATE TABLE issues ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + number INT NOT NULL, + author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + closed_at TIMESTAMPTZ, + UNIQUE (repo_id, number) +); +CREATE INDEX issues_repo_id_idx ON issues(repo_id); + +CREATE TABLE pull_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + number INT NOT NULL, + author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed', 'merged')), + source_branch TEXT NOT NULL, + target_branch TEXT NOT NULL, + merge_commit TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + merged_at TIMESTAMPTZ, + closed_at TIMESTAMPTZ, + UNIQUE (repo_id, number) +); +CREATE INDEX pull_requests_repo_id_idx ON pull_requests(repo_id); + +CREATE TABLE comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + issue_id UUID REFERENCES issues(id) ON DELETE CASCADE, + pull_id UUID REFERENCES pull_requests(id) ON DELETE CASCADE, + body TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT comments_target CHECK ( + (issue_id IS NOT NULL AND pull_id IS NULL) OR + (issue_id IS NULL AND pull_id IS NOT NULL) + ) +); +CREATE INDEX comments_issue_id_idx ON comments(issue_id); +CREATE INDEX comments_pull_id_idx ON comments(pull_id); + +CREATE TABLE issue_labels ( + issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + label TEXT NOT NULL, + PRIMARY KEY (issue_id, label) +); + +CREATE TABLE releases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tag_name TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (repo_id, tag_name) +); + +CREATE TABLE release_assets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + release_id UUID NOT NULL REFERENCES releases(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + stored_path TEXT NOT NULL, + size_bytes BIGINT NOT NULL DEFAULT 0, + content_type TEXT NOT NULL DEFAULT 'application/octet-stream', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (release_id, filename) +); + +CREATE TABLE repo_counters ( + repo_id UUID PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE, + next_issue INT NOT NULL DEFAULT 1, + next_pull INT NOT NULL DEFAULT 1 +); + +CREATE TABLE oidc_pending ( + state TEXT PRIMARY KEY, + pkce_verifier TEXT NOT NULL, + nonce TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); @@ -0,0 +1,3 @@ +-- site admins (forge-wide, not repo ACL) +ALTER TABLE users ADD COLUMN IF NOT EXISTS is_site_admin BOOLEAN NOT NULL DEFAULT FALSE; +CREATE INDEX IF NOT EXISTS users_is_site_admin_idx ON users(is_site_admin) WHERE is_site_admin = TRUE; @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS site_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' +); + +INSERT INTO site_settings (key, value) VALUES ('motd', '') +ON CONFLICT (key) DO NOTHING; @@ -0,0 +1,91 @@ +-- Social, privacy, branch rules, LFS metadata + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS show_email BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS vigilant_mode BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE sessions + ADD COLUMN IF NOT EXISTS user_agent TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS ip_address TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(); + +ALTER TABLE repositories + ADD COLUMN IF NOT EXISTS fork_of_id UUID REFERENCES repositories(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS stars_count INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS watches_count INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS forks_count INT NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS repositories_fork_of_id_idx ON repositories(fork_of_id); + +CREATE TABLE IF NOT EXISTS user_emails ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + email TEXT NOT NULL, + verified BOOLEAN NOT NULL DEFAULT FALSE, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, email) +); +CREATE INDEX IF NOT EXISTS user_emails_user_id_idx ON user_emails(user_id); +CREATE INDEX IF NOT EXISTS user_emails_email_idx ON user_emails(email); + +CREATE TABLE IF NOT EXISTS gpg_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT 'gpg', + public_key TEXT NOT NULL, + fingerprint TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS gpg_keys_user_id_idx ON gpg_keys(user_id); + +CREATE TABLE IF NOT EXISTS repo_stars ( + repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (repo_id, user_id) +); + +CREATE TABLE IF NOT EXISTS repo_watches ( + repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (repo_id, user_id) +); + +CREATE TABLE IF NOT EXISTS comment_reactions ( + comment_id UUID NOT NULL REFERENCES comments(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (comment_id, user_id, emoji), + CONSTRAINT comment_reactions_emoji CHECK (emoji IN ('+1', '-1', 'laugh', 'hooray', 'confused', 'heart', 'rocket', 'eyes')) +); +CREATE INDEX IF NOT EXISTS comment_reactions_comment_id_idx ON comment_reactions(comment_id); + +CREATE TABLE IF NOT EXISTS branch_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + pattern TEXT NOT NULL, + require_pr BOOLEAN NOT NULL DEFAULT FALSE, + block_force_push BOOLEAN NOT NULL DEFAULT TRUE, + allow_deletions BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (repo_id, pattern) +); +CREATE INDEX IF NOT EXISTS branch_rules_repo_id_idx ON branch_rules(repo_id); + +CREATE TABLE IF NOT EXISTS lfs_objects ( + oid TEXT PRIMARY KEY, + size_bytes BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS lfs_repo_objects ( + repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + oid TEXT NOT NULL REFERENCES lfs_objects(oid) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (repo_id, oid) +); +ALTER TABLE pull_requests + ADD COLUMN IF NOT EXISTS source_repo_id UUID REFERENCES repositories(id) ON DELETE SET NULL; @@ -0,0 +1,24 @@ +-- App-local TOTP MFA (kitgit UI; Authentik stays backend-only) + +CREATE TABLE user_mfa ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + -- base32 TOTP secret; only set when enabled=true + totp_secret TEXT, + -- pending enrollment secret until confirmed with a valid code + pending_secret TEXT, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + -- SHA-256 hex hashes of unused recovery codes + recovery_code_hashes TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE mfa_pending_logins ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX mfa_pending_logins_expires_at_idx ON mfa_pending_logins(expires_at); +CREATE INDEX mfa_pending_logins_user_id_idx ON mfa_pending_logins(user_id); @@ -0,0 +1,6 @@ +-- Draft / prerelease flags and update timestamp for releases + +ALTER TABLE releases + ADD COLUMN IF NOT EXISTS is_prerelease BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS is_draft BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now(); @@ -0,0 +1,1075 @@ +use crate::config::Config; +use crate::db::models::User; +use crate::db::queries; +use anyhow::{anyhow, Context, Result}; +use axum::http::HeaderMap; +use axum::http::HeaderValue; +use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata}; +use openidconnect::reqwest; +use openidconnect::{ + AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet, + EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, + TokenResponse, +}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use sqlx::PgPool; +use std::sync::Arc; +use url::Url; + +pub const SESSION_COOKIE: &str = "kitgit_session"; +pub const MFA_PENDING_COOKIE: &str = "kitgit_mfa_pending"; + +/// Result of password login after Authentik accepts credentials. +pub enum LoginOutcome { + /// Full kitgit session cookie value. + Complete { user: User, token: String }, + /// Password ok; TOTP/recovery still required. Cookie value for pending MFA. + MfaRequired { pending_token: String }, +} + +type OidcClient = CoreClient< + EndpointSet, + EndpointNotSet, + EndpointNotSet, + EndpointNotSet, + EndpointMaybeSet, + EndpointMaybeSet, +>; + +#[derive(Clone)] +pub struct AuthState { + pub pool: PgPool, + pub config: Arc<Config>, + pub http: reqwest::Client, + pub oidc_enabled: bool, +} + +impl AuthState { + pub async fn new(pool: PgPool, config: Arc<Config>) -> Result<Self> { + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(15)) + .connect_timeout(std::time::Duration::from_secs(5)) + .build()?; + let oidc_enabled = !config.oidc_issuer.is_empty() && !config.oidc_client_secret.is_empty(); + let authentik_ready = !config.authentik_base().is_empty(); + if !oidc_enabled && !authentik_ready { + tracing::error!("Auth not configured — set Authentik / OIDC env vars"); + } else if oidc_enabled { + if let Err(e) = discover_client(&config, &http).await { + tracing::warn!("OIDC discovery failed at startup: {e:#}; will retry on demand"); + } + } + Ok(Self { + pool, + config, + http, + oidc_enabled, + }) + } +} + +async fn discover_client(config: &Config, http: &reqwest::Client) -> Result<OidcClient> { + let discovery = config.discovery_issuer(); + let issuer = IssuerUrl::new(discovery.to_string())?; + let meta = CoreProviderMetadata::discover_async(issuer, http) + .await + .context("OIDC discovery")?; + let client = CoreClient::from_provider_metadata( + meta, + ClientId::new(config.oidc_client_id.clone()), + Some(ClientSecret::new(config.oidc_client_secret.clone())), + ) + .set_redirect_uri(RedirectUrl::new(config.oidc_redirect_url.clone())?); + Ok(client) +} + +/// Rewrite URL host from internal discovery host to public issuer host (browser-facing). +fn rewrite_public_url(raw: &str, config: &Config) -> Result<String> { + let public = Url::parse(&config.oidc_issuer).context("parse public issuer")?; + let discovery = Url::parse(config.discovery_issuer()).context("parse discovery issuer")?; + let mut u = Url::parse(raw).context("parse auth url")?; + if u.host_str() == discovery.host_str() && discovery.host_str() != public.host_str() { + let _ = u.set_scheme(public.scheme()); + let _ = u.set_host(public.host_str()); + let _ = u.set_port(public.port()); + } + Ok(u.to_string()) +} + +pub fn hash_token(token: &str) -> String { + let mut h = Sha256::new(); + h.update(token.as_bytes()); + hex::encode(h.finalize()) +} + +pub fn new_session_token() -> String { + use rand::RngCore; + let mut buf = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut buf); + hex::encode(buf) +} + +pub fn session_cookie_header(token: &str, max_age_secs: i64) -> HeaderValue { + let v = format!( + "{SESSION_COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age_secs}" + ); + HeaderValue::from_str(&v).expect("cookie") +} + +pub fn clear_session_cookie() -> HeaderValue { + HeaderValue::from_static("kitgit_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0") +} + +pub fn mfa_pending_cookie_header(token: &str, max_age_secs: i64) -> HeaderValue { + let v = format!( + "{MFA_PENDING_COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age_secs}" + ); + HeaderValue::from_str(&v).expect("cookie") +} + +pub fn clear_mfa_pending_cookie() -> HeaderValue { + HeaderValue::from_static("kitgit_mfa_pending=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0") +} + +pub fn mfa_pending_from_headers(headers: &HeaderMap) -> Option<String> { + let cookie = headers.get(axum::http::header::COOKIE)?.to_str().ok()?; + for part in cookie.split(';') { + let part = part.trim(); + if let Some(rest) = part.strip_prefix(&format!("{MFA_PENDING_COOKIE}=")) { + if !rest.is_empty() { + return Some(rest.to_string()); + } + } + } + None +} + +pub fn token_from_headers(headers: &HeaderMap) -> Option<String> { + let cookie = headers.get(axum::http::header::COOKIE)?.to_str().ok()?; + for part in cookie.split(';') { + let part = part.trim(); + if let Some(rest) = part.strip_prefix(&format!("{SESSION_COOKIE}=")) { + if !rest.is_empty() { + return Some(rest.to_string()); + } + } + } + None +} + +pub async fn current_user(auth: &AuthState, headers: &HeaderMap) -> Result<Option<User>> { + if let Some(token) = token_from_headers(headers) { + let hash = hash_token(&token); + if let Some(u) = queries::user_from_session(&auth.pool, &hash).await? { + return Ok(Some(u)); + } + } + Ok(None) +} + +pub async fn begin_login(auth: &AuthState) -> Result<(String, HeaderMap)> { + if !auth.oidc_enabled { + return Err(anyhow!("OIDC not configured")); + } + let client = discover_client(&auth.config, &auth.http).await?; + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + let (auth_url, csrf, nonce) = client + .authorize_url( + CoreAuthenticationFlow::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ) + .add_scope(Scope::new("openid".into())) + .add_scope(Scope::new("profile".into())) + .add_scope(Scope::new("email".into())) + .set_pkce_challenge(pkce_challenge) + .url(); + + queries::store_oidc_pending( + &auth.pool, + csrf.secret(), + pkce_verifier.secret(), + nonce.secret(), + ) + .await?; + + let public_url = rewrite_public_url(auth_url.as_str(), &auth.config)?; + Ok((public_url, HeaderMap::new())) +} + +pub async fn finish_login(auth: &AuthState, code: &str, state: &str) -> Result<(User, String)> { + if !auth.oidc_enabled { + return Err(anyhow!("OIDC not configured")); + } + let client = discover_client(&auth.config, &auth.http).await?; + let (verifier, nonce) = queries::take_oidc_pending(&auth.pool, state) + .await? + .ok_or_else(|| anyhow!("unknown OIDC state"))?; + + let token_response = client + .exchange_code(AuthorizationCode::new(code.to_string()))? + .set_pkce_verifier(PkceCodeVerifier::new(verifier)) + .request_async(&auth.http) + .await + .context("token exchange")?; + + let id_token = token_response + .id_token() + .ok_or_else(|| anyhow!("no id_token"))?; + let claims = id_token + .claims(&client.id_token_verifier(), &Nonce::new(nonce)) + .context("verify id_token")?; + + let sub = claims.subject().to_string(); + let email = claims + .email() + .map(|e| e.to_string()) + .unwrap_or_default(); + let name = claims + .name() + .and_then(|n| n.get(None)) + .map(|n| n.to_string()) + .unwrap_or_else(|| email.clone()); + let preferred = claims + .preferred_username() + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) + .or_else(|| email.split('@').next().map(|s| s.to_lowercase())) + .unwrap_or_else(|| format!("user{}", &sub[..8.min(sub.len())])); + + let username = sanitize_username(&preferred); + let picture = claims + .picture() + .and_then(|p| p.get(None)) + .map(|u| u.to_string()); + + let mut user = queries::upsert_user_from_oidc( + &auth.pool, + &sub, + &username, + &name, + &email, + picture.as_deref(), + ) + .await?; + + // 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?; + tracing::info!("bootstrap site admin: {}", user.username); + } + + let token = new_session_token(); + queries::create_session(&auth.pool, user.id, &hash_token(&token), 14).await?; + Ok((user, token)) +} + +pub async fn logout(auth: &AuthState, headers: &HeaderMap) -> Result<()> { + if let Some(token) = token_from_headers(headers) { + queries::delete_session(&auth.pool, &hash_token(&token)).await?; + } + Ok(()) +} + +fn sanitize_username(raw: &str) -> String { + let mut s: String = raw + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c.to_ascii_lowercase() + } else { + '-' + } + }) + .collect(); + while s.starts_with('-') { + s.remove(0); + } + if s.is_empty() { + s = "user".into(); + } + s.truncate(39); + s +} + +// ── Authentik API (kitgit-hosted login & signup) ───────────────────────────── + +#[derive(Debug, Deserialize)] +struct FlowChallenge { + component: String, + #[serde(default)] + to: Option<String>, + #[serde(default)] + response_errors: Option<serde_json::Value>, + #[serde(default)] + password_fields: Option<bool>, + /// Present on identification challenges (null when captcha disabled). + #[serde(default)] + captcha_stage: Option<serde_json::Value>, +} + +#[derive(Debug, Deserialize)] +struct AuthentikMe { + #[serde(default)] + pk: i64, + username: String, + #[serde(default)] + name: String, + #[serde(default)] + email: String, + #[serde(default)] + uid: String, +} + +/// Authentik `/api/v3/core/users/me/` returns `{ "user": { ... } }` (SessionUserSerializer). +#[derive(Debug, Deserialize)] +struct AuthentikMeEnvelope { + user: AuthentikMe, +} + +#[derive(Debug, Deserialize)] +struct TokenResponseJson { + access_token: Option<String>, + id_token: Option<String>, + #[serde(default)] + error: Option<String>, + #[serde(default)] + error_description: Option<String>, +} + +#[derive(Debug, Deserialize)] +struct UserinfoJson { + sub: Option<String>, + preferred_username: Option<String>, + name: Option<String>, + email: Option<String>, +} + +fn new_http_client() -> Result<::reqwest::Client> { + Ok(::reqwest::Client::builder() + .cookie_store(true) + .redirect(::reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(20)) + .connect_timeout(std::time::Duration::from_secs(5)) + .build()?) +} + +fn truncate(s: &str, n: usize) -> String { + let t = s.trim(); + if t.chars().count() <= n { + t.to_string() + } else { + format!("{}…", t.chars().take(n).collect::<String>()) + } +} + +/// Rewrite Authentik redirect targets onto the internal base (Docker DNS). +fn rewrite_internal(internal_base: &str, location: &str) -> String { + let base = Url::parse(internal_base).ok(); + if location.starts_with("http://") || location.starts_with("https://") { + if let (Ok(mut loc), Some(b)) = (Url::parse(location), base) { + let _ = loc.set_scheme(b.scheme()); + let _ = loc.set_host(b.host_str()); + let _ = loc.set_port(b.port()); + return loc.to_string(); + } + return location.to_string(); + } + if let Some(b) = base { + if let Ok(joined) = b.join(location) { + return joined.to_string(); + } + } + format!( + "{}{}", + internal_base.trim_end_matches('/'), + if location.starts_with('/') { + location.to_string() + } else { + format!("/{location}") + } + ) +} + +fn capture_csrf(resp: &::reqwest::Response, csrf: &mut Option<String>) { + for val in resp.headers().get_all(::reqwest::header::SET_COOKIE) { + let Ok(s) = val.to_str() else { continue }; + let name_val = s.split(';').next().unwrap_or("").trim(); + if let Some(v) = name_val + .strip_prefix("authentik_csrf=") + .or_else(|| name_val.strip_prefix("csrftoken=")) + { + if !v.is_empty() { + *csrf = Some(v.to_string()); + } + } + } +} + +fn json_headers(csrf: &Option<String>, referer: Option<&str>) -> ::reqwest::header::HeaderMap { + let mut headers = ::reqwest::header::HeaderMap::new(); + headers.insert( + ::reqwest::header::CONTENT_TYPE, + ::reqwest::header::HeaderValue::from_static("application/json"), + ); + headers.insert( + ::reqwest::header::ACCEPT, + ::reqwest::header::HeaderValue::from_static("application/json"), + ); + headers.insert( + ::reqwest::header::USER_AGENT, + ::reqwest::header::HeaderValue::from_static("kitgit/0.1"), + ); + // Tip Authentik toward JSON challenge responses (not HTML/302 UI redirects). + headers.insert( + ::reqwest::header::HeaderName::from_static("x-requested-with"), + ::reqwest::header::HeaderValue::from_static("XMLHttpRequest"), + ); + if let Some(r) = referer { + if let Ok(v) = ::reqwest::header::HeaderValue::from_str(r) { + headers.insert(::reqwest::header::REFERER, v); + } + } + if let Some(csrf) = csrf { + if let Ok(v) = ::reqwest::header::HeaderValue::from_str(csrf) { + headers.insert("X-authentik-CSRF", v.clone()); + headers.insert("X-CSRFToken", v); + } + } + headers +} + +fn bearer_headers(token: &str) -> ::reqwest::header::HeaderMap { + let mut headers = ::reqwest::header::HeaderMap::new(); + headers.insert( + ::reqwest::header::ACCEPT, + ::reqwest::header::HeaderValue::from_static("application/json"), + ); + headers.insert( + ::reqwest::header::CONTENT_TYPE, + ::reqwest::header::HeaderValue::from_static("application/json"), + ); + if let Ok(v) = ::reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")) { + headers.insert(::reqwest::header::AUTHORIZATION, v); + } + headers +} + +async fn read_challenge( + resp: ::reqwest::Response, + csrf: &mut Option<String>, + internal_base: &str, + executor_url: &str, + client: &::reqwest::Client, + depth: u8, +) -> Result<FlowChallenge> { + capture_csrf(&resp, csrf); + let status = resp.status(); + + // Authentik often answers stage POSTs with 302. Prefer staying on the JSON + // executor API — Location may point at the HTML /if/flow UI. + if status.is_redirection() { + let loc = resp + .headers() + .get(::reqwest::header::LOCATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + tracing::debug!("authentik {status} Location={loc}"); + if depth >= 10 { + anyhow::bail!("authentik redirect loop"); + } + let next = if loc.contains("/api/v3/flows/executor/") { + rewrite_internal(internal_base, &loc) + } else { + // Browser UI or empty Location → re-GET the API executor with cookies. + executor_url.to_string() + }; + let follow = client + .get(&next) + .headers(json_headers(csrf, Some(&next))) + .send() + .await + .context("authentik follow redirect")?; + return Box::pin(read_challenge( + follow, + csrf, + internal_base, + executor_url, + client, + depth + 1, + )) + .await; + } + + let text = resp.text().await.unwrap_or_default(); + if text.trim().is_empty() { + // Empty non-redirect — try executor GET once (session may already have advanced). + if depth < 10 { + tracing::debug!("authentik empty body ({status}); re-GET executor"); + let follow = client + .get(executor_url) + .headers(json_headers(csrf, Some(executor_url))) + .send() + .await + .context("authentik empty-body recovery GET")?; + return Box::pin(read_challenge( + follow, + csrf, + internal_base, + executor_url, + client, + depth + 1, + )) + .await; + } + anyhow::bail!("authentik returned empty body ({status})"); + } + if !status.is_success() && status.as_u16() != 400 { + tracing::warn!("authentik HTTP {status}: {}", truncate(&text, 400)); + anyhow::bail!("authentik error ({status}): {}", truncate(&text, 200)); + } + let challenge: FlowChallenge = serde_json::from_str(&text).map_err(|e| { + tracing::warn!( + "authentik JSON parse fail ({status}): {}", + truncate(&text, 400) + ); + anyhow!("could not parse authentik response ({status}): {e}") + })?; + if challenge.component == "ak-stage-access-denied" { + anyhow::bail!("invalid username or password"); + } + if let Some(errs) = challenge.response_errors.as_ref().and_then(|e| e.as_object()) { + if !errs.is_empty() { + tracing::warn!("authentik stage errors: {errs:?}"); + // Surface captcha misconfig clearly; otherwise treat as bad credentials. + if errs.contains_key("captcha_stage") || errs.contains_key("captcha_token") { + anyhow::bail!("login misconfigured: captcha required by Authentik (disable captcha on identification stage)"); + } + if errs.contains_key("uid_field") + || errs.values().any(|v| { + v.to_string().to_lowercase().contains("invalid_identifier") + || v.to_string().to_lowercase().contains("failed to authenticate") + }) + { + anyhow::bail!("invalid username or password"); + } + anyhow::bail!("invalid username or password"); + } + } + Ok(challenge) +} + +async fn flow_get( + client: &::reqwest::Client, + url: &str, + csrf: &mut Option<String>, + internal_base: &str, +) -> Result<FlowChallenge> { + let resp = client + .get(url) + .headers(json_headers(csrf, Some(url))) + .send() + .await + .context("authentik flow GET")?; + read_challenge(resp, csrf, internal_base, url, client, 0).await +} + +async fn flow_post( + client: &::reqwest::Client, + url: &str, + csrf: &mut Option<String>, + internal_base: &str, + body: serde_json::Value, +) -> Result<FlowChallenge> { + tracing::debug!("authentik POST {url} body={}", truncate(&body.to_string(), 200)); + let resp = client + .post(url) + .headers(json_headers(csrf, Some(url))) + .json(&body) + .send() + .await + .context("authentik flow POST")?; + read_challenge(resp, csrf, internal_base, url, client, 0).await +} + +async fn run_password_flow( + client: &::reqwest::Client, + base: &str, + flow_slug: &str, + username: &str, + password: &str, +) -> Result<()> { + let url = format!("{base}/api/v3/flows/executor/{flow_slug}/"); + let mut csrf = None; + let mut challenge = flow_get(client, &url, &mut csrf, base).await?; + let mut password_sent = false; + let mut identification_sent = false; + + for _ in 0..12 { + match challenge.component.as_str() { + "xak-flow-redirect" => { + // Login finished — session cookie is set. Optionally hit `to`. + if let Some(to) = challenge.to.as_deref() { + let next = rewrite_internal(base, to); + if next.contains("/api/") { + let _ = client + .get(&next) + .headers(json_headers(&csrf, Some(&next))) + .send() + .await; + } + } + return Ok(()); + } + "ak-stage-access-denied" => anyhow::bail!("invalid username or password"), + "ak-stage-identification" => { + if identification_sent { + anyhow::bail!("invalid username or password"); + } + identification_sent = true; + if challenge.captcha_stage.as_ref().is_some_and(|v| !v.is_null()) { + anyhow::bail!( + "login misconfigured: Authentik identification stage has captcha enabled; disable it for kitgit" + ); + } + let with_password = challenge.password_fields == Some(true); + // captcha_stage/captcha_token must be present as empty strings (not JSON null) + // on Authentik builds that validate those keys. + challenge = flow_post( + client, + &url, + &mut csrf, + base, + serde_json::json!({ + "component": "ak-stage-identification", + "uid_field": username, + "password": if with_password { password } else { "" }, + "captcha_token": "", + "captcha_stage": "", + }), + ) + .await?; + } + "ak-stage-password" => { + if password_sent { + anyhow::bail!("invalid username or password"); + } + password_sent = true; + challenge = flow_post( + client, + &url, + &mut csrf, + base, + serde_json::json!({ + "component": "ak-stage-password", + "password": password, + }), + ) + .await?; + } + "ak-stage-user-login" => { + challenge = flow_post( + client, + &url, + &mut csrf, + base, + serde_json::json!({ "component": "ak-stage-user-login" }), + ) + .await?; + } + "ak-stage-captcha" => { + anyhow::bail!( + "login misconfigured: Authentik captcha stage is enabled; disable captcha for kitgit" + ); + } + "ak-stage-authenticator-validate" => { + // Never send the browser to Authentik MFA; kitgit uses app-local MFA. + anyhow::bail!("invalid username or password"); + } + other => { + tracing::debug!("authentik stage {other}, acknowledging"); + challenge = flow_post( + client, + &url, + &mut csrf, + base, + serde_json::json!({ "component": other }), + ) + .await + .with_context(|| format!("unexpected authentik stage: {other}"))?; + } + } + } + anyhow::bail!("authentik login flow did not complete") +} + +async fn try_password_grant( + auth: &AuthState, + username: &str, + password: &str, +) -> Result<AuthentikMe> { + let base = auth.config.authentik_base(); + let client = new_http_client()?; + let token_url = format!("{base}/application/o/token/"); + let resp = client + .post(&token_url) + .header(::reqwest::header::ACCEPT, "application/json") + .form(&[ + ("grant_type", "password"), + ("username", username), + ("password", password), + ("client_id", auth.config.oidc_client_id.as_str()), + ("client_secret", auth.config.oidc_client_secret.as_str()), + ("scope", "openid profile email"), + ]) + .send() + .await + .context("password grant")?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + let parsed: TokenResponseJson = serde_json::from_str(&text).unwrap_or(TokenResponseJson { + access_token: None, + id_token: None, + error: Some(format!("http_{status}")), + error_description: Some(truncate(&text, 200)), + }); + if let Some(err) = parsed.error { + anyhow::bail!( + "password grant unavailable ({err}): {}", + parsed.error_description.unwrap_or_default() + ); + } + let access = parsed + .access_token + .ok_or_else(|| anyhow!("password grant: no access_token ({status}) {}", truncate(&text, 200)))?; + + let ui = client + .get(format!("{base}/application/o/userinfo/")) + .header(::reqwest::header::AUTHORIZATION, format!("Bearer {access}")) + .header(::reqwest::header::ACCEPT, "application/json") + .send() + .await + .context("userinfo")?; + let ui_status = ui.status(); + let ui_text = ui.text().await.unwrap_or_default(); + if !ui_status.is_success() { + anyhow::bail!("userinfo {ui_status}: {}", truncate(&ui_text, 200)); + } + let info: UserinfoJson = serde_json::from_str(&ui_text).context("parse userinfo")?; + let username = info + .preferred_username + .filter(|s| !s.is_empty()) + .or_else(|| info.email.as_ref().and_then(|e| e.split('@').next().map(|s| s.to_string()))) + .unwrap_or_else(|| username.to_string()); + Ok(AuthentikMe { + pk: 0, + username, + name: info.name.unwrap_or_default(), + email: info.email.unwrap_or_default(), + uid: info.sub.unwrap_or_default(), + }) +} + +async fn authentik_me(client: &::reqwest::Client, base: &str) -> Result<AuthentikMe> { + let resp = client + .get(format!("{base}/api/v3/core/users/me/")) + .header(::reqwest::header::ACCEPT, "application/json") + .send() + .await + .context("authentik users/me")?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + tracing::warn!("users/me {status}: {}", truncate(&text, 300)); + anyhow::bail!("session not established ({status})"); + } + // Nested `{ "user": {...} }` is the normal Authentik session shape; also accept flat. + if let Ok(env) = serde_json::from_str::<AuthentikMeEnvelope>(&text) { + return Ok(env.user); + } + serde_json::from_str(&text).with_context(|| { + format!("parse users/me: {}", truncate(&text, 200)) + }) +} + +async fn user_from_authentik_me(auth: &AuthState, me: &AuthentikMe) -> Result<User> { + let sub = if !me.uid.is_empty() { + me.uid.clone() + } else if me.pk != 0 { + format!("ak:{}", me.pk) + } else { + format!("ak:{}", me.username) + }; + let username = sanitize_username(&me.username); + let name = if me.name.is_empty() { + username.clone() + } else { + me.name.clone() + }; + let mut user = queries::upsert_user_from_oidc( + &auth.pool, + &sub, + &username, + &name, + &me.email, + None, + ) + .await?; + if queries::site_admin_count(&auth.pool).await? == 0 { + user = queries::set_site_admin(&auth.pool, user.id, true).await?; + tracing::info!("bootstrap site admin: {}", user.username); + } + Ok(user) +} + +async fn create_kitgit_session(auth: &AuthState, user: &User) -> Result<(User, String)> { + let token = new_session_token(); + queries::create_session(&auth.pool, user.id, &hash_token(&token), 14).await?; + Ok((user.clone(), token)) +} + +/// Verify username/password against Authentik only (no kitgit session, no MFA gate). +pub async fn verify_password( + auth: &AuthState, + username: &str, + password: &str, +) -> Result<User> { + let base = auth.config.authentik_base(); + if base.is_empty() { + return Err(anyhow!("identity provider not configured")); + } + let username = username.trim(); + if username.is_empty() || password.is_empty() { + anyhow::bail!("username and password required"); + } + + match try_password_grant(auth, username, password).await { + Ok(me) => { + tracing::info!("password verified via grant: {}", me.username); + return user_from_authentik_me(auth, &me).await; + } + Err(e) => { + tracing::debug!("password grant unavailable, trying flow: {e:#}"); + } + } + + let client = new_http_client()?; + run_password_flow( + &client, + &base, + &auth.config.authentik_auth_flow, + username, + password, + ) + .await + .map_err(|e| { + let msg = e.to_string(); + tracing::warn!("authentik flow login failed: {e:#}"); + if msg.contains("misconfigured") || msg.contains("captcha") { + e + } else { + anyhow!("invalid username or password") + } + })?; + let me = authentik_me(&client, &base) + .await + .context("password accepted but user could not be read")?; + user_from_authentik_me(auth, &me).await +} + +/// Authenticate with Authentik, then enforce app-local MFA when enabled. +pub async fn login_with_password( + auth: &AuthState, + username: &str, + password: &str, +) -> Result<LoginOutcome> { + let user = verify_password(auth, username, password).await?; + 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?; + queries::create_mfa_pending_login(&auth.pool, user.id, &hash_token(&pending), 10).await?; + return Ok(LoginOutcome::MfaRequired { + pending_token: pending, + }); + } + let (user, token) = create_kitgit_session(auth, &user).await?; + Ok(LoginOutcome::Complete { user, token }) +} + +/// Complete login after TOTP / recovery code for a pending MFA cookie. +pub async fn complete_mfa_login( + auth: &AuthState, + pending_token: &str, + code: &str, +) -> Result<(User, String)> { + let user_id = queries::take_mfa_pending_login(&auth.pool, &hash_token(pending_token)) + .await? + .ok_or_else(|| anyhow!("verification expired; log in again"))?; + let mfa = queries::get_user_mfa(&auth.pool, user_id) + .await? + .ok_or_else(|| anyhow!("two-factor authentication is not set up"))?; + if !mfa.enabled { + anyhow::bail!("two-factor authentication is not set up"); + } + let secret = mfa + .totp_secret + .as_deref() + .ok_or_else(|| anyhow!("two-factor authentication is not set up"))?; + + let code = code.trim(); + let ok = if crate::mfa::verify_totp(secret, code) { + true + } else if let Some(idx) = crate::mfa::verify_recovery_code(&mfa.recovery_code_hashes, code) { + let mut hashes = mfa.recovery_code_hashes; + hashes.remove(idx); + queries::mfa_set_recovery_hashes(&auth.pool, user_id, &hashes).await?; + true + } else { + false + }; + if !ok { + // Re-store pending so the user can retry within the window. + queries::create_mfa_pending_login(&auth.pool, user_id, &hash_token(pending_token), 10) + .await?; + anyhow::bail!("invalid authentication code"); + } + + let user = queries::get_user_by_id(&auth.pool, user_id) + .await? + .ok_or_else(|| anyhow!("user not found"))?; + create_kitgit_session(auth, &user).await +} + +/// Create user via Authentik API token, then log them into kitgit. +pub async fn signup_with_password( + auth: &AuthState, + username: &str, + email: &str, + password: &str, + display_name: &str, +) -> Result<LoginOutcome> { + let username = sanitize_username(username); + if username.len() < 2 { + anyhow::bail!("username too short"); + } + if password.len() < 8 { + anyhow::bail!("password must be at least 8 characters"); + } + let email = email.trim(); + if email.is_empty() || !email.contains('@') { + anyhow::bail!("valid email required"); + } + let base = auth.config.authentik_base(); + if base.is_empty() { + return Err(anyhow!("identity provider not configured")); + } + let api_token = auth.config.authentik_token(); + if api_token.is_empty() { + anyhow::bail!("signup unavailable"); + } + + let client = new_http_client()?; + let name = if display_name.trim().is_empty() { + username.clone() + } else { + display_name.trim().to_string() + }; + + let create_resp = client + .post(format!("{base}/api/v3/core/users/")) + .headers(bearer_headers(&api_token)) + .json(&serde_json::json!({ + "username": username, + "name": name, + "email": email, + "path": "users", + "is_active": true, + "type": "internal", + "attributes": {}, + "groups": [], + })) + .send() + .await + .context("create authentik user")?; + let create_status = create_resp.status(); + let create_body = create_resp.text().await.unwrap_or_default(); + if !create_status.is_success() { + tracing::warn!("create user {create_status}: {}", truncate(&create_body, 400)); + let lower = create_body.to_lowercase(); + if lower.contains("unique") || lower.contains("already") || create_status.as_u16() == 400 { + anyhow::bail!("username or email already taken"); + } + if create_status.as_u16() == 401 || create_status.as_u16() == 403 { + anyhow::bail!("signup unavailable"); + } + anyhow::bail!("could not create account"); + } + let created: serde_json::Value = + serde_json::from_str(&create_body).context("parse created user")?; + let pk = created + .get("pk") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow!("no pk in created user"))?; + + let pw_resp = client + .post(format!("{base}/api/v3/core/users/{pk}/set_password/")) + .headers(bearer_headers(&api_token)) + .json(&serde_json::json!({ "password": password })) + .send() + .await + .context("set authentik password")?; + if !pw_resp.status().is_success() { + let t = pw_resp.text().await.unwrap_or_default(); + tracing::warn!("set_password failed: {}", truncate(&t, 300)); + anyhow::bail!("could not set password"); + } + + login_with_password(auth, &username, password).await +} + +/// Look up Authentik user pk by username (admin API). +pub async fn authentik_user_pk(auth: &AuthState, username: &str) -> Result<i64> { + let base = auth.config.authentik_base(); + let token = auth.config.authentik_token(); + if token.is_empty() { + anyhow::bail!("account API unavailable"); + } + let client = new_http_client()?; + let list = client + .get(format!( + "{base}/api/v3/core/users/?username={}", + urlencoding::encode(username) + )) + .bearer_auth(&token) + .send() + .await + .context("list users")?; + let body: serde_json::Value = list.json().await.context("parse users")?; + body.pointer("/results/0/pk") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow!("account not found")) +} + +pub async fn authentik_set_password(auth: &AuthState, pk: i64, password: &str) -> Result<()> { + let base = auth.config.authentik_base(); + let token = auth.config.authentik_token(); + if token.is_empty() { + anyhow::bail!("account API unavailable"); + } + let client = new_http_client()?; + let resp = client + .post(format!("{base}/api/v3/core/users/{pk}/set_password/")) + .bearer_auth(&token) + .json(&serde_json::json!({ "password": password })) + .send() + .await + .context("set password")?; + if !resp.status().is_success() { + anyhow::bail!("could not change password"); + } + Ok(()) +} + @@ -0,0 +1,166 @@ +use anyhow::{Context, Result}; +use clap::Parser; +use serde::Deserialize; +use std::net::SocketAddr; +use std::path::PathBuf; +use url::Url; + +#[derive(Debug, Clone, Parser)] +#[command(name = "kitgit", about = "self-hosted git. templates, not a product page.")] +pub struct Config { + #[arg(long, env = "KITGIT_DATABASE_URL", default_value = "postgres://kitgit:kitgit@127.0.0.1:5432/kitgit")] + pub database_url: String, + + #[arg(long, env = "KITGIT_DATA_DIR", default_value = "./data")] + pub data_dir: PathBuf, + + #[arg(long, env = "KITGIT_STATIC_DIR", default_value = "./static")] + pub static_dir: PathBuf, + + #[arg(long, env = "KITGIT_HTTP_BIND", default_value = "0.0.0.0:8080")] + pub http_bind: SocketAddr, + + #[arg(long, env = "KITGIT_SSH_BIND", default_value = "0.0.0.0:2222")] + pub ssh_bind: SocketAddr, + + #[arg(long, env = "KITGIT_PUBLIC_URL", default_value = "http://localhost:8080")] + pub public_url: String, + + #[arg(long, env = "KITGIT_SESSION_SECRET", default_value = "change-me-kitgit-session-secret")] + pub session_secret: String, + + /// Public OIDC issuer (browser-facing), e.g. http://localhost:9000/application/o/kitgit/ + #[arg(long, env = "KITGIT_OIDC_ISSUER", default_value = "")] + pub oidc_issuer: String, + + /// Optional internal discovery base (container DNS). Falls back to oidc_issuer. + /// Example: http://authentik-server:9000/application/o/kitgit/ + #[arg(long, env = "KITGIT_OIDC_DISCOVERY_URL", default_value = "")] + pub oidc_discovery_url: String, + + #[arg(long, env = "KITGIT_OIDC_CLIENT_ID", default_value = "kitgit")] + pub oidc_client_id: String, + + #[arg(long, env = "KITGIT_OIDC_CLIENT_SECRET", default_value = "")] + pub oidc_client_secret: String, + + #[arg(long, env = "KITGIT_OIDC_REDIRECT_URL", default_value = "http://localhost:8080/auth/callback")] + pub oidc_redirect_url: String, + + /// Authentik base URL for flow/API calls (internal Docker DNS preferred). + /// Example: http://authentik-server:9000 + #[arg(long, env = "KITGIT_AUTHENTIK_URL", default_value = "")] + pub authentik_url: String, + + /// API token (intent=api) for creating users on signup. Also accepted as AUTHENTIK_TOKEN. + #[arg(long, env = "KITGIT_AUTHENTIK_API_TOKEN", default_value = "")] + pub authentik_api_token: String, + + #[arg(long, env = "KITGIT_AUTHENTIK_AUTH_FLOW", default_value = "default-authentication-flow")] + pub authentik_auth_flow: String, + + #[arg(long, env = "KITGIT_CONFIG", default_value = "")] + pub config_file: String, +} + +#[derive(Debug, Deserialize, Default)] +struct FileConfig { + database_url: Option<String>, + data_dir: Option<PathBuf>, + public_url: Option<String>, + session_secret: Option<String>, + oidc_issuer: Option<String>, + oidc_discovery_url: Option<String>, + oidc_client_id: Option<String>, + oidc_client_secret: Option<String>, + oidc_redirect_url: Option<String>, +} + +impl Config { + pub fn load() -> Result<Self> { + let mut cfg = Config::parse(); + if !cfg.config_file.is_empty() { + let text = std::fs::read_to_string(&cfg.config_file) + .with_context(|| format!("read config {}", cfg.config_file))?; + let file: FileConfig = toml::from_str(&text)?; + if let Some(v) = file.database_url { + cfg.database_url = v; + } + if let Some(v) = file.data_dir { + cfg.data_dir = v; + } + if let Some(v) = file.public_url { + cfg.public_url = v; + } + if let Some(v) = file.session_secret { + cfg.session_secret = v; + } + if let Some(v) = file.oidc_issuer { + cfg.oidc_issuer = v; + } + if let Some(v) = file.oidc_discovery_url { + cfg.oidc_discovery_url = v; + } + if let Some(v) = file.oidc_client_id { + cfg.oidc_client_id = v; + } + if let Some(v) = file.oidc_client_secret { + cfg.oidc_client_secret = v; + } + if let Some(v) = file.oidc_redirect_url { + cfg.oidc_redirect_url = v; + } + } + Ok(cfg) + } + + pub fn discovery_issuer(&self) -> &str { + if self.oidc_discovery_url.is_empty() { + &self.oidc_issuer + } else { + &self.oidc_discovery_url + } + } + + /// Base URL for Authentik API / flow executor (no trailing slash). + pub fn authentik_base(&self) -> String { + if !self.authentik_url.is_empty() { + return self.authentik_url.trim_end_matches('/').to_string(); + } + Url::parse(self.discovery_issuer()) + .ok() + .map(|u| { + let mut origin = format!("{}://{}", u.scheme(), u.host_str().unwrap_or("localhost")); + if let Some(port) = u.port() { + origin.push(':'); + origin.push_str(&port.to_string()); + } + origin + }) + .unwrap_or_default() + } + + /// Service API token for Authentik admin API (signup). + pub fn authentik_token(&self) -> String { + if !self.authentik_api_token.is_empty() { + return self.authentik_api_token.clone(); + } + std::env::var("AUTHENTIK_TOKEN").unwrap_or_default() + } + + pub fn repos_dir(&self) -> PathBuf { + self.data_dir.join("repos") + } + + pub fn avatars_dir(&self) -> PathBuf { + self.data_dir.join("avatars") + } + + pub fn releases_dir(&self) -> PathBuf { + self.data_dir.join("releases") + } + + pub fn ssh_host_key_path(&self) -> PathBuf { + self.data_dir.join("ssh_host_ed25519_key") + } +} @@ -0,0 +1,68 @@ +pub mod models; +pub mod queries; + +use anyhow::{Context, Result}; +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; +use std::path::Path; + +pub async fn connect(database_url: &str) -> Result<PgPool> { + let pool = PgPoolOptions::new() + .max_connections(20) + .connect(database_url) + .await + .context("connect postgres")?; + Ok(pool) +} + +pub async fn migrate(pool: &PgPool, migrations_dir: &Path) -> Result<()> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS schema_migrations ( + filename TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + "#, + ) + .execute(pool) + .await?; + + let mut files: Vec<_> = std::fs::read_dir(migrations_dir) + .with_context(|| format!("read migrations {}", migrations_dir.display()))? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("sql")) + .collect(); + files.sort(); + + for path in files { + let filename = path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or_default() + .to_string(); + let applied: Option<(String,)> = + sqlx::query_as("SELECT filename FROM schema_migrations WHERE filename = $1") + .bind(&filename) + .fetch_optional(pool) + .await?; + if applied.is_some() { + continue; + } + let sql = std::fs::read_to_string(&path) + .with_context(|| format!("read {}", path.display()))?; + let sql = sql.strip_prefix('\u{feff}').unwrap_or(&sql); + let mut tx = pool.begin().await?; + sqlx::raw_sql(sql) + .execute(&mut *tx) + .await + .with_context(|| format!("apply migration {filename}"))?; + sqlx::query("INSERT INTO schema_migrations (filename) VALUES ($1)") + .bind(&filename) + .execute(&mut *tx) + .await?; + tx.commit().await?; + tracing::info!("applied migration {filename}"); + } + Ok(()) +} @@ -0,0 +1,239 @@ +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct User { + pub id: Uuid, + pub oidc_sub: String, + pub username: String, + pub display_name: String, + pub email: String, + pub bio: String, + pub avatar_path: Option<String>, + pub avatar_url: Option<String>, + pub is_site_admin: bool, + pub show_email: bool, + pub vigilant_mode: bool, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct UserMfa { + pub user_id: Uuid, + pub totp_secret: Option<String>, + pub pending_secret: Option<String>, + pub enabled: bool, + pub recovery_code_hashes: Vec<String>, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct Session { + pub id: Uuid, + pub user_id: Uuid, + pub token_hash: String, + pub expires_at: DateTime<Utc>, + pub created_at: DateTime<Utc>, + pub user_agent: String, + pub ip_address: String, + pub last_seen_at: DateTime<Utc>, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct Repository { + pub id: Uuid, + pub owner_id: Uuid, + pub name: String, + pub description: String, + pub visibility: String, + pub default_branch: String, + pub archived: bool, + pub issues_enabled: bool, + pub pulls_enabled: bool, + pub releases_enabled: bool, + pub allow_merge: bool, + pub allow_squash: bool, + pub allow_rebase: bool, + pub default_merge_style: String, + pub protect_default_branch: bool, + pub protect_block_force_push: bool, + pub fork_of_id: Option<Uuid>, + pub stars_count: i32, + pub watches_count: i32, + pub forks_count: i32, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct UserEmail { + pub id: Uuid, + pub user_id: Uuid, + pub email: String, + pub verified: bool, + pub is_primary: bool, + pub created_at: DateTime<Utc>, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct GpgKey { + pub id: Uuid, + pub user_id: Uuid, + pub name: String, + pub public_key: String, + pub fingerprint: String, + pub created_at: DateTime<Utc>, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct BranchRule { + pub id: Uuid, + pub repo_id: Uuid, + pub pattern: String, + pub require_pr: bool, + pub block_force_push: bool, + pub allow_deletions: bool, + pub created_at: DateTime<Utc>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct CommentReaction { + pub comment_id: Uuid, + pub user_id: Uuid, + pub emoji: String, + pub created_at: DateTime<Utc>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct SshKey { + pub id: Uuid, + pub user_id: Uuid, + pub name: String, + pub public_key: String, + pub fingerprint: String, + pub created_at: DateTime<Utc>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct Collaborator { + pub repo_id: Uuid, + pub user_id: Uuid, + pub role: String, + pub created_at: DateTime<Utc>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct ActivityEvent { + pub id: i64, + pub actor_id: Option<Uuid>, + pub repo_id: Option<Uuid>, + pub kind: String, + pub summary: String, + pub payload: serde_json::Value, + pub created_at: DateTime<Utc>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct Issue { + pub id: Uuid, + pub repo_id: Uuid, + pub number: i32, + pub author_id: Uuid, + pub title: String, + pub body: String, + pub state: String, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, + pub closed_at: Option<DateTime<Utc>>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct PullRequest { + pub id: Uuid, + pub repo_id: Uuid, + pub number: i32, + pub author_id: Uuid, + pub title: String, + pub body: String, + pub state: String, + pub source_branch: String, + pub target_branch: String, + pub merge_commit: Option<String>, + pub source_repo_id: Option<Uuid>, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, + pub merged_at: Option<DateTime<Utc>>, + pub closed_at: Option<DateTime<Utc>>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct Comment { + pub id: Uuid, + pub repo_id: Uuid, + pub author_id: Uuid, + pub issue_id: Option<Uuid>, + pub pull_id: Option<Uuid>, + pub body: String, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct Release { + pub id: Uuid, + pub repo_id: Uuid, + pub author_id: Uuid, + pub tag_name: String, + pub title: String, + pub body: String, + pub is_prerelease: bool, + pub is_draft: bool, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct ReleaseAsset { + pub id: Uuid, + pub release_id: Uuid, + pub filename: String, + pub stored_path: String, + pub size_bytes: i64, + pub content_type: String, + pub created_at: DateTime<Utc>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct CommitDay { + pub day: NaiveDate, + pub count: i32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Access { + None, + Read, + Write, + Admin, + Owner, +} + +impl Access { + pub fn can_read(self) -> bool { + !matches!(self, Access::None) + } + pub fn can_write(self) -> bool { + matches!(self, Access::Write | Access::Admin | Access::Owner) + } + pub fn can_admin(self) -> bool { + matches!(self, Access::Admin | Access::Owner) + } + pub fn can_owner(self) -> bool { + matches!(self, Access::Owner) + } +} @@ -0,0 +1,1845 @@ +use super::models::*; +use anyhow::Result; +use chrono::{DateTime, NaiveDate, Utc}; +use sqlx::PgPool; +use uuid::Uuid; + +pub async fn upsert_user_from_oidc( + pool: &PgPool, + oidc_sub: &str, + username: &str, + display_name: &str, + email: &str, + avatar_url: Option<&str>, +) -> Result<User> { + let avatar_url = avatar_url + .filter(|u| !u.is_empty() && !u.starts_with("/avatars/")); + let user = sqlx::query_as::<_, User>( + r#" + INSERT INTO users (oidc_sub, username, display_name, email, avatar_url) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (oidc_sub) DO UPDATE SET + display_name = EXCLUDED.display_name, + email = EXCLUDED.email, + avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url), + updated_at = now() + RETURNING * + "#, + ) + .bind(oidc_sub) + .bind(username) + .bind(display_name) + .bind(email) + .bind(avatar_url) + .fetch_one(pool) + .await?; + Ok(user) +} + +pub async fn site_admin_count(pool: &PgPool) -> Result<i64> { + let (n,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM users WHERE is_site_admin = TRUE") + .fetch_one(pool) + .await?; + Ok(n) +} + +pub async fn set_site_admin(pool: &PgPool, id: Uuid, is_admin: bool) -> Result<User> { + Ok(sqlx::query_as::<_, User>( + "UPDATE users SET is_site_admin = $2, updated_at = now() WHERE id = $1 RETURNING *", + ) + .bind(id) + .bind(is_admin) + .fetch_one(pool) + .await?) +} + +pub async fn list_users(pool: &PgPool) -> Result<Vec<User>> { + Ok(sqlx::query_as::<_, User>( + "SELECT * FROM users ORDER BY username", + ) + .fetch_all(pool) + .await?) +} + +pub async fn get_user_by_id(pool: &PgPool, id: Uuid) -> Result<Option<User>> { + Ok(sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await?) +} + +pub async fn get_user_by_username(pool: &PgPool, username: &str) -> Result<Option<User>> { + Ok( + sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = $1") + .bind(username) + .fetch_optional(pool) + .await?, + ) +} + +pub async fn update_user_profile( + pool: &PgPool, + id: Uuid, + display_name: &str, + bio: &str, + avatar_path: Option<&str>, +) -> Result<User> { + Ok(sqlx::query_as::<_, User>( + r#" + UPDATE users SET + display_name = $2, + bio = $3, + avatar_path = COALESCE($4, avatar_path), + avatar_url = CASE WHEN $4 IS NOT NULL THEN NULL ELSE avatar_url END, + updated_at = now() + WHERE id = $1 + RETURNING * + "#, + ) + .bind(id) + .bind(display_name) + .bind(bio) + .bind(avatar_path) + .fetch_one(pool) + .await?) +} + +pub async fn get_setting(pool: &PgPool, key: &str) -> Result<String> { + let row: Option<(String,)> = sqlx::query_as("SELECT value FROM site_settings WHERE key = $1") + .bind(key) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| r.0).unwrap_or_default()) +} + +pub async fn set_setting(pool: &PgPool, key: &str, value: &str) -> Result<()> { + sqlx::query( + r#" + INSERT INTO site_settings (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + "#, + ) + .bind(key) + .bind(value) + .execute(pool) + .await?; + Ok(()) +} + + +pub async fn create_session(pool: &PgPool, user_id: Uuid, token_hash: &str, days: i64) -> Result<()> { + let expires = Utc::now() + chrono::Duration::days(days); + sqlx::query( + "INSERT INTO sessions (user_id, token_hash, expires_at) VALUES ($1, $2, $3)", + ) + .bind(user_id) + .bind(token_hash) + .bind(expires) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn user_from_session(pool: &PgPool, token_hash: &str) -> Result<Option<User>> { + Ok(sqlx::query_as::<_, User>( + 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() + "#, + ) + .bind(token_hash) + .fetch_optional(pool) + .await?) +} + +pub async fn delete_session(pool: &PgPool, token_hash: &str) -> Result<()> { + sqlx::query("DELETE FROM sessions WHERE token_hash = $1") + .bind(token_hash) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn store_oidc_pending( + pool: &PgPool, + state: &str, + pkce_verifier: &str, + nonce: &str, +) -> Result<()> { + sqlx::query( + "INSERT INTO oidc_pending (state, pkce_verifier, nonce) VALUES ($1, $2, $3) + ON CONFLICT (state) DO UPDATE SET pkce_verifier = EXCLUDED.pkce_verifier, nonce = EXCLUDED.nonce", + ) + .bind(state) + .bind(pkce_verifier) + .bind(nonce) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn take_oidc_pending(pool: &PgPool, state: &str) -> Result<Option<(String, String)>> { + let row: Option<(String, String)> = sqlx::query_as( + "DELETE FROM oidc_pending WHERE state = $1 RETURNING pkce_verifier, nonce", + ) + .bind(state) + .fetch_optional(pool) + .await?; + Ok(row) +} + +pub async fn create_repo( + pool: &PgPool, + owner_id: Uuid, + name: &str, + description: &str, + visibility: &str, +) -> Result<Repository> { + let mut tx = pool.begin().await?; + let repo = sqlx::query_as::<_, Repository>( + r#" + INSERT INTO repositories (owner_id, name, description, visibility) + VALUES ($1, $2, $3, $4) + RETURNING * + "#, + ) + .bind(owner_id) + .bind(name) + .bind(description) + .bind(visibility) + .fetch_one(&mut *tx) + .await?; + sqlx::query("INSERT INTO repo_counters (repo_id) VALUES ($1)") + .bind(repo.id) + .execute(&mut *tx) + .await?; + sqlx::query("INSERT INTO language_stats (repo_id) VALUES ($1)") + .bind(repo.id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(repo) +} + +pub async fn get_repo(pool: &PgPool, owner: &str, name: &str) -> Result<Option<(Repository, User)>> { + let owner_user = match get_user_by_username(pool, owner).await? { + Some(u) => u, + None => return Ok(None), + }; + let repo = sqlx::query_as::<_, Repository>( + "SELECT * FROM repositories WHERE owner_id = $1 AND name = $2", + ) + .bind(owner_user.id) + .bind(name) + .fetch_optional(pool) + .await?; + 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#" + SELECT r.* FROM repositories r + WHERE r.owner_id = $1 + AND ( + r.visibility = 'public' + OR r.owner_id = $2 + OR EXISTS ( + SELECT 1 FROM collaborators c + WHERE c.repo_id = r.id AND c.user_id = $2 + ) + ) + ORDER BY r.updated_at DESC + "#, + ) + .bind(owner_id) + .bind(viewer) + .fetch_all(pool) + .await?) +} + +pub async fn list_public_repos( + pool: &PgPool, + search: Option<&str>, + limit: i64, +) -> Result<Vec<(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 q = search.map(|s| s.trim()).filter(|s| !s.is_empty()); + let rows: Vec<Row> = if let Some(term) = q { + let like = format!("%{}%", term.replace('%', "\\%").replace('_', "\\_")); + 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.visibility = 'public' + AND r.archived = false + AND ( + 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 + "#, + ) + .bind(like) + .bind(limit) + .fetch_all(pool) + .await? + } else { + 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.visibility = 'public' + AND r.archived = false + ORDER BY r.updated_at DESC + LIMIT $1 + "#, + ) + .bind(limit) + .fetch_all(pool) + .await? + }; + + Ok(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()) +} + +pub async fn delete_repo(pool: &PgPool, id: Uuid) -> Result<()> { + sqlx::query("DELETE FROM repositories WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn update_repo_settings( + pool: &PgPool, + id: Uuid, + description: &str, + visibility: &str, + default_branch: &str, + issues_enabled: bool, + pulls_enabled: bool, + releases_enabled: bool, + allow_merge: bool, + allow_squash: bool, + allow_rebase: bool, + default_merge_style: &str, + protect_default_branch: bool, + protect_block_force_push: bool, +) -> Result<Repository> { + Ok(sqlx::query_as::<_, Repository>( + r#" + UPDATE repositories SET + description = $2, + visibility = $3, + default_branch = $4, + issues_enabled = $5, + pulls_enabled = $6, + releases_enabled = $7, + allow_merge = $8, + allow_squash = $9, + allow_rebase = $10, + default_merge_style = $11, + protect_default_branch = $12, + protect_block_force_push = $13, + updated_at = now() + WHERE id = $1 + RETURNING * + "#, + ) + .bind(id) + .bind(description) + .bind(visibility) + .bind(default_branch) + .bind(issues_enabled) + .bind(pulls_enabled) + .bind(releases_enabled) + .bind(allow_merge) + .bind(allow_squash) + .bind(allow_rebase) + .bind(default_merge_style) + .bind(protect_default_branch) + .bind(protect_block_force_push) + .fetch_one(pool) + .await?) +} + +pub async fn transfer_repo(pool: &PgPool, id: Uuid, new_owner_id: Uuid) -> Result<Repository> { + Ok(sqlx::query_as::<_, Repository>( + "UPDATE repositories SET owner_id = $2, updated_at = now() WHERE id = $1 RETURNING *", + ) + .bind(id) + .bind(new_owner_id) + .fetch_one(pool) + .await?) +} + +pub async fn set_archived(pool: &PgPool, id: Uuid, archived: bool) -> Result<()> { + sqlx::query("UPDATE repositories SET archived = $2, updated_at = now() WHERE id = $1") + .bind(id) + .bind(archived) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn repo_access(pool: &PgPool, repo: &Repository, user_id: Option<Uuid>) -> Result<Access> { + if let Some(uid) = user_id { + if uid == repo.owner_id { + return Ok(Access::Owner); + } + if let Some(c) = sqlx::query_as::<_, Collaborator>( + "SELECT * FROM collaborators WHERE repo_id = $1 AND user_id = $2", + ) + .bind(repo.id) + .bind(uid) + .fetch_optional(pool) + .await? + { + return Ok(match c.role.as_str() { + "admin" => Access::Admin, + "write" => Access::Write, + _ => Access::Read, + }); + } + } + if repo.visibility == "public" { + return Ok(Access::Read); + } + Ok(Access::None) +} + +pub async fn add_collaborator(pool: &PgPool, repo_id: Uuid, user_id: Uuid, role: &str) -> Result<()> { + sqlx::query( + r#" + INSERT INTO collaborators (repo_id, user_id, role) VALUES ($1, $2, $3) + ON CONFLICT (repo_id, user_id) DO UPDATE SET role = EXCLUDED.role + "#, + ) + .bind(repo_id) + .bind(user_id) + .bind(role) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn remove_collaborator(pool: &PgPool, repo_id: Uuid, user_id: Uuid) -> Result<()> { + sqlx::query("DELETE FROM collaborators WHERE repo_id = $1 AND user_id = $2") + .bind(repo_id) + .bind(user_id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn list_collaborators(pool: &PgPool, repo_id: Uuid) -> Result<Vec<(Collaborator, User)>> { + let rows = sqlx::query_as::<_, Collaborator>( + "SELECT * FROM collaborators WHERE repo_id = $1 ORDER BY created_at", + ) + .bind(repo_id) + .fetch_all(pool) + .await?; + let mut out = Vec::new(); + for c in rows { + if let Some(u) = get_user_by_id(pool, c.user_id).await? { + out.push((c, u)); + } + } + Ok(out) +} + +pub async fn add_ssh_key( + pool: &PgPool, + user_id: Uuid, + name: &str, + public_key: &str, + fingerprint: &str, +) -> Result<SshKey> { + Ok(sqlx::query_as::<_, SshKey>( + r#" + INSERT INTO ssh_keys (user_id, name, public_key, fingerprint) + VALUES ($1, $2, $3, $4) + RETURNING * + "#, + ) + .bind(user_id) + .bind(name) + .bind(public_key) + .bind(fingerprint) + .fetch_one(pool) + .await?) +} + +pub async fn list_ssh_keys(pool: &PgPool, user_id: Uuid) -> Result<Vec<SshKey>> { + Ok( + sqlx::query_as::<_, SshKey>("SELECT * FROM ssh_keys WHERE user_id = $1 ORDER BY created_at") + .bind(user_id) + .fetch_all(pool) + .await?, + ) +} + +pub async fn delete_ssh_key(pool: &PgPool, user_id: Uuid, id: Uuid) -> Result<()> { + sqlx::query("DELETE FROM ssh_keys WHERE id = $1 AND user_id = $2") + .bind(id) + .bind(user_id) + .execute(pool) + .await?; + Ok(()) +} + +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('='); + Ok(sqlx::query_as::<_, User>( + r#" + SELECT u.* FROM users u + JOIN ssh_keys k ON k.user_id = u.id + WHERE rtrim(k.fingerprint, '=') = $1 + "#, + ) + .bind(normalized) + .fetch_optional(pool) + .await?) +} + +pub async fn record_activity( + pool: &PgPool, + actor_id: Option<Uuid>, + repo_id: Option<Uuid>, + kind: &str, + summary: &str, + payload: serde_json::Value, +) -> Result<()> { + sqlx::query( + "INSERT INTO activity_events (actor_id, repo_id, kind, summary, payload) VALUES ($1,$2,$3,$4,$5)", + ) + .bind(actor_id) + .bind(repo_id) + .bind(kind) + .bind(summary) + .bind(payload) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn latest_activity(pool: &PgPool, limit: i64) -> Result<Vec<(ActivityEvent, Option<User>, Option<Repository>, Option<String>)>> { + let events = sqlx::query_as::<_, ActivityEvent>( + "SELECT * FROM activity_events ORDER BY created_at DESC LIMIT $1", + ) + .bind(limit) + .fetch_all(pool) + .await?; + hydrate_activity(pool, events).await +} + +pub async fn latest_activity_for_user( + pool: &PgPool, + user_id: Uuid, + limit: i64, +) -> Result<Vec<(ActivityEvent, Option<User>, Option<Repository>, Option<String>)>> { + let events = sqlx::query_as::<_, ActivityEvent>( + "SELECT * FROM activity_events WHERE actor_id = $1 ORDER BY created_at DESC LIMIT $2", + ) + .bind(user_id) + .bind(limit) + .fetch_all(pool) + .await?; + hydrate_activity(pool, events).await +} + +async fn hydrate_activity( + pool: &PgPool, + events: Vec<ActivityEvent>, +) -> Result<Vec<(ActivityEvent, Option<User>, Option<Repository>, Option<String>)>> { + let mut out = Vec::new(); + for e in events { + let actor = match e.actor_id { + Some(id) => get_user_by_id(pool, id).await?, + None => None, + }; + 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) + } + None => (None, None), + }; + out.push((e, actor, repo, owner_name)); + } + Ok(out) +} + +pub async fn bump_commit_activity(pool: &PgPool, user_id: Uuid, day: NaiveDate, n: i32) -> Result<()> { + sqlx::query( + r#" + INSERT INTO commit_activity (user_id, day, count) VALUES ($1, $2, $3) + ON CONFLICT (user_id, day) DO UPDATE SET count = commit_activity.count + EXCLUDED.count + "#, + ) + .bind(user_id) + .bind(day) + .bind(n) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn commit_graph(pool: &PgPool, user_id: Uuid, days: i64) -> Result<Vec<CommitDay>> { + Ok(sqlx::query_as::<_, CommitDay>( + r#" + SELECT day, count FROM commit_activity + WHERE user_id = $1 AND day >= (CURRENT_DATE - $2::int) + ORDER BY day + "#, + ) + .bind(user_id) + .bind(days as i32) + .fetch_all(pool) + .await?) +} + +pub async fn set_language_stats(pool: &PgPool, repo_id: Uuid, stats: serde_json::Value) -> Result<()> { + sqlx::query( + r#" + INSERT INTO language_stats (repo_id, stats, updated_at) VALUES ($1, $2, now()) + ON CONFLICT (repo_id) DO UPDATE SET stats = EXCLUDED.stats, updated_at = now() + "#, + ) + .bind(repo_id) + .bind(stats) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn get_language_stats(pool: &PgPool, repo_id: Uuid) -> Result<serde_json::Value> { + let row: Option<(serde_json::Value,)> = + sqlx::query_as("SELECT stats FROM language_stats WHERE repo_id = $1") + .bind(repo_id) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| r.0).unwrap_or_else(|| serde_json::json!({}))) +} + +pub async fn next_issue_number(pool: &PgPool, repo_id: Uuid) -> Result<i32> { + let (n,): (i32,) = sqlx::query_as( + "UPDATE repo_counters SET next_issue = next_issue + 1 WHERE repo_id = $1 RETURNING next_issue - 1", + ) + .bind(repo_id) + .fetch_one(pool) + .await?; + Ok(n) +} + +pub async fn next_pull_number(pool: &PgPool, repo_id: Uuid) -> Result<i32> { + let (n,): (i32,) = sqlx::query_as( + "UPDATE repo_counters SET next_pull = next_pull + 1 WHERE repo_id = $1 RETURNING next_pull - 1", + ) + .bind(repo_id) + .fetch_one(pool) + .await?; + Ok(n) +} + +pub async fn create_issue( + pool: &PgPool, + repo_id: Uuid, + author_id: Uuid, + number: i32, + title: &str, + body: &str, +) -> Result<Issue> { + Ok(sqlx::query_as::<_, Issue>( + r#" + INSERT INTO issues (repo_id, number, author_id, title, body) + VALUES ($1,$2,$3,$4,$5) RETURNING * + "#, + ) + .bind(repo_id) + .bind(number) + .bind(author_id) + .bind(title) + .bind(body) + .fetch_one(pool) + .await?) +} + +pub async fn list_issues(pool: &PgPool, repo_id: Uuid, state: Option<&str>) -> Result<Vec<Issue>> { + if let Some(s) = state { + Ok(sqlx::query_as::<_, Issue>( + "SELECT * FROM issues WHERE repo_id = $1 AND state = $2 ORDER BY number DESC", + ) + .bind(repo_id) + .bind(s) + .fetch_all(pool) + .await?) + } else { + Ok(sqlx::query_as::<_, Issue>( + "SELECT * FROM issues WHERE repo_id = $1 ORDER BY number DESC", + ) + .bind(repo_id) + .fetch_all(pool) + .await?) + } +} + +pub async fn get_issue(pool: &PgPool, repo_id: Uuid, number: i32) -> Result<Option<Issue>> { + Ok( + sqlx::query_as::<_, Issue>("SELECT * FROM issues WHERE repo_id = $1 AND number = $2") + .bind(repo_id) + .bind(number) + .fetch_optional(pool) + .await?, + ) +} + +pub async fn set_issue_state(pool: &PgPool, id: Uuid, state: &str) -> Result<Issue> { + let closed = if state == "closed" { + Some(Utc::now()) + } else { + None + }; + Ok(sqlx::query_as::<_, Issue>( + "UPDATE issues SET state = $2, closed_at = $3, updated_at = now() WHERE id = $1 RETURNING *", + ) + .bind(id) + .bind(state) + .bind(closed) + .fetch_one(pool) + .await?) +} + +pub async fn create_pull( + pool: &PgPool, + repo_id: Uuid, + author_id: Uuid, + number: i32, + title: &str, + body: &str, + source: &str, + target: &str, +) -> Result<PullRequest> { + create_pull_ex(pool, repo_id, author_id, number, title, body, source, target, None).await +} + +pub async fn create_pull_ex( + pool: &PgPool, + repo_id: Uuid, + author_id: Uuid, + number: i32, + title: &str, + body: &str, + source: &str, + target: &str, + source_repo_id: Option<Uuid>, +) -> Result<PullRequest> { + Ok(sqlx::query_as::<_, PullRequest>( + r#" + INSERT INTO pull_requests + (repo_id, number, author_id, title, body, source_branch, target_branch, source_repo_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING * + "#, + ) + .bind(repo_id) + .bind(number) + .bind(author_id) + .bind(title) + .bind(body) + .bind(source) + .bind(target) + .bind(source_repo_id) + .fetch_one(pool) + .await?) +} + +pub async fn list_pulls(pool: &PgPool, repo_id: Uuid, state: Option<&str>) -> Result<Vec<PullRequest>> { + if let Some(s) = state { + Ok(sqlx::query_as::<_, PullRequest>( + "SELECT * FROM pull_requests WHERE repo_id = $1 AND state = $2 ORDER BY number DESC", + ) + .bind(repo_id) + .bind(s) + .fetch_all(pool) + .await?) + } else { + Ok(sqlx::query_as::<_, PullRequest>( + "SELECT * FROM pull_requests WHERE repo_id = $1 ORDER BY number DESC", + ) + .bind(repo_id) + .fetch_all(pool) + .await?) + } +} + +pub async fn get_pull(pool: &PgPool, repo_id: Uuid, number: i32) -> Result<Option<PullRequest>> { + Ok(sqlx::query_as::<_, PullRequest>( + "SELECT * FROM pull_requests WHERE repo_id = $1 AND number = $2", + ) + .bind(repo_id) + .bind(number) + .fetch_optional(pool) + .await?) +} + +pub async fn set_pull_state( + pool: &PgPool, + id: Uuid, + state: &str, + merge_commit: Option<&str>, +) -> Result<PullRequest> { + let now = Utc::now(); + Ok(sqlx::query_as::<_, PullRequest>( + r#" + UPDATE pull_requests SET + state = $2, + merge_commit = COALESCE($3, merge_commit), + merged_at = CASE WHEN $2 = 'merged' THEN $4 ELSE merged_at END, + closed_at = CASE WHEN $2 IN ('closed','merged') THEN $4 ELSE NULL END, + updated_at = now() + WHERE id = $1 + RETURNING * + "#, + ) + .bind(id) + .bind(state) + .bind(merge_commit) + .bind(now) + .fetch_one(pool) + .await?) +} + +pub async fn add_comment( + pool: &PgPool, + repo_id: Uuid, + author_id: Uuid, + issue_id: Option<Uuid>, + pull_id: Option<Uuid>, + body: &str, +) -> Result<Comment> { + Ok(sqlx::query_as::<_, Comment>( + r#" + INSERT INTO comments (repo_id, author_id, issue_id, pull_id, body) + VALUES ($1,$2,$3,$4,$5) RETURNING * + "#, + ) + .bind(repo_id) + .bind(author_id) + .bind(issue_id) + .bind(pull_id) + .bind(body) + .fetch_one(pool) + .await?) +} + +pub async fn list_issue_comments(pool: &PgPool, issue_id: Uuid) -> Result<Vec<Comment>> { + Ok(sqlx::query_as::<_, Comment>( + "SELECT * FROM comments WHERE issue_id = $1 ORDER BY created_at", + ) + .bind(issue_id) + .fetch_all(pool) + .await?) +} + +pub async fn list_pull_comments(pool: &PgPool, pull_id: Uuid) -> Result<Vec<Comment>> { + Ok(sqlx::query_as::<_, Comment>( + "SELECT * FROM comments WHERE pull_id = $1 ORDER BY created_at", + ) + .bind(pull_id) + .fetch_all(pool) + .await?) +} + +pub async fn create_release( + pool: &PgPool, + repo_id: Uuid, + author_id: Uuid, + tag_name: &str, + title: &str, + body: &str, + is_prerelease: bool, + is_draft: bool, +) -> Result<Release> { + Ok(sqlx::query_as::<_, Release>( + r#" + INSERT INTO releases (repo_id, author_id, tag_name, title, body, is_prerelease, is_draft) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING * + "#, + ) + .bind(repo_id) + .bind(author_id) + .bind(tag_name) + .bind(title) + .bind(body) + .bind(is_prerelease) + .bind(is_draft) + .fetch_one(pool) + .await?) +} + +pub async fn update_release( + pool: &PgPool, + release_id: Uuid, + tag_name: &str, + title: &str, + body: &str, + is_prerelease: bool, + is_draft: bool, +) -> Result<Release> { + Ok(sqlx::query_as::<_, Release>( + r#" + UPDATE releases + SET tag_name = $2, + title = $3, + body = $4, + is_prerelease = $5, + is_draft = $6, + updated_at = now() + WHERE id = $1 + RETURNING * + "#, + ) + .bind(release_id) + .bind(tag_name) + .bind(title) + .bind(body) + .bind(is_prerelease) + .bind(is_draft) + .fetch_one(pool) + .await?) +} + +pub async fn delete_release(pool: &PgPool, release_id: Uuid) -> Result<()> { + sqlx::query("DELETE FROM releases WHERE id = $1") + .bind(release_id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn list_releases(pool: &PgPool, repo_id: Uuid) -> Result<Vec<Release>> { + Ok(sqlx::query_as::<_, Release>( + "SELECT * FROM releases WHERE repo_id = $1 ORDER BY created_at DESC", + ) + .bind(repo_id) + .fetch_all(pool) + .await?) +} + +pub async fn get_release(pool: &PgPool, repo_id: Uuid, tag: &str) -> Result<Option<Release>> { + Ok(sqlx::query_as::<_, Release>( + "SELECT * FROM releases WHERE repo_id = $1 AND tag_name = $2", + ) + .bind(repo_id) + .bind(tag) + .fetch_optional(pool) + .await?) +} + +pub async fn add_release_asset( + pool: &PgPool, + release_id: Uuid, + filename: &str, + stored_path: &str, + size_bytes: i64, + content_type: &str, +) -> Result<ReleaseAsset> { + Ok(sqlx::query_as::<_, ReleaseAsset>( + r#" + INSERT INTO release_assets (release_id, filename, stored_path, size_bytes, content_type) + VALUES ($1,$2,$3,$4,$5) RETURNING * + "#, + ) + .bind(release_id) + .bind(filename) + .bind(stored_path) + .bind(size_bytes) + .bind(content_type) + .fetch_one(pool) + .await?) +} + +pub async fn list_release_assets(pool: &PgPool, release_id: Uuid) -> Result<Vec<ReleaseAsset>> { + Ok(sqlx::query_as::<_, ReleaseAsset>( + "SELECT * FROM release_assets WHERE release_id = $1 ORDER BY filename", + ) + .bind(release_id) + .fetch_all(pool) + .await?) +} + +pub async fn get_asset(pool: &PgPool, id: Uuid) -> Result<Option<ReleaseAsset>> { + Ok( + sqlx::query_as::<_, ReleaseAsset>("SELECT * FROM release_assets WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await?, + ) +} + +pub async fn delete_release_asset(pool: &PgPool, id: Uuid) -> Result<Option<ReleaseAsset>> { + Ok(sqlx::query_as::<_, ReleaseAsset>( + "DELETE FROM release_assets WHERE id = $1 RETURNING *", + ) + .bind(id) + .fetch_optional(pool) + .await?) +} + +pub async fn rename_release_tag( + pool: &PgPool, + repo_id: Uuid, + old_tag: &str, + new_tag: &str, +) -> Result<Option<Release>> { + Ok(sqlx::query_as::<_, Release>( + r#" + UPDATE releases + SET tag_name = $3, updated_at = now() + WHERE repo_id = $1 AND tag_name = $2 + RETURNING * + "#, + ) + .bind(repo_id) + .bind(old_tag) + .bind(new_tag) + .fetch_optional(pool) + .await?) +} + +pub async fn rewrite_asset_paths_for_tag( + pool: &PgPool, + release_id: Uuid, + old_prefix: &str, + new_prefix: &str, +) -> Result<()> { + sqlx::query( + r#" + UPDATE release_assets + SET stored_path = $3 || substr(stored_path, length($2) + 1) + WHERE release_id = $1 AND stored_path LIKE $2 || '%' + "#, + ) + .bind(release_id) + .bind(old_prefix) + .bind(new_prefix) + .execute(pool) + .await?; + Ok(()) +} + + +// ── stars / watches / forks ────────────────────────────────────────────────── + +pub async fn toggle_star(pool: &PgPool, repo_id: Uuid, user_id: Uuid) -> Result<bool> { + let existing: Option<(Uuid,)> = sqlx::query_as( + "SELECT user_id FROM repo_stars WHERE repo_id = $1 AND user_id = $2", + ) + .bind(repo_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + if existing.is_some() { + sqlx::query("DELETE FROM repo_stars WHERE repo_id = $1 AND user_id = $2") + .bind(repo_id) + .bind(user_id) + .execute(pool) + .await?; + sqlx::query("UPDATE repositories SET stars_count = GREATEST(stars_count - 1, 0) WHERE id = $1") + .bind(repo_id) + .execute(pool) + .await?; + Ok(false) + } else { + sqlx::query("INSERT INTO repo_stars (repo_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING") + .bind(repo_id) + .bind(user_id) + .execute(pool) + .await?; + sqlx::query("UPDATE repositories SET stars_count = stars_count + 1 WHERE id = $1") + .bind(repo_id) + .execute(pool) + .await?; + Ok(true) + } +} + +pub async fn is_starred(pool: &PgPool, repo_id: Uuid, user_id: Uuid) -> Result<bool> { + let row: Option<(Uuid,)> = sqlx::query_as( + "SELECT user_id FROM repo_stars WHERE repo_id = $1 AND user_id = $2", + ) + .bind(repo_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + Ok(row.is_some()) +} + +pub async fn toggle_watch(pool: &PgPool, repo_id: Uuid, user_id: Uuid) -> Result<bool> { + let existing: Option<(Uuid,)> = sqlx::query_as( + "SELECT user_id FROM repo_watches WHERE repo_id = $1 AND user_id = $2", + ) + .bind(repo_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + if existing.is_some() { + sqlx::query("DELETE FROM repo_watches WHERE repo_id = $1 AND user_id = $2") + .bind(repo_id) + .bind(user_id) + .execute(pool) + .await?; + sqlx::query("UPDATE repositories SET watches_count = GREATEST(watches_count - 1, 0) WHERE id = $1") + .bind(repo_id) + .execute(pool) + .await?; + Ok(false) + } else { + sqlx::query("INSERT INTO repo_watches (repo_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING") + .bind(repo_id) + .bind(user_id) + .execute(pool) + .await?; + sqlx::query("UPDATE repositories SET watches_count = watches_count + 1 WHERE id = $1") + .bind(repo_id) + .execute(pool) + .await?; + Ok(true) + } +} + +pub async fn is_watching(pool: &PgPool, repo_id: Uuid, user_id: Uuid) -> Result<bool> { + let row: Option<(Uuid,)> = sqlx::query_as( + "SELECT user_id FROM repo_watches WHERE repo_id = $1 AND user_id = $2", + ) + .bind(repo_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + Ok(row.is_some()) +} + +/// Public repos the user has starred (private stars are never returned). +pub async fn list_public_starred_repos( + pool: &PgPool, + user_id: Uuid, + limit: i64, +) -> Result<Vec<(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 rows: Vec<Row> = sqlx::query_as( + r#" + SELECT r.*, u.username AS owner_username + FROM repo_stars s + JOIN repositories r ON r.id = s.repo_id + JOIN users u ON u.id = r.owner_id + WHERE s.user_id = $1 + AND r.visibility = 'public' + ORDER BY s.created_at DESC + LIMIT $2 + "#, + ) + .bind(user_id) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(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()) +} + +/// Recent activity on public repos the user is watching. +pub async fn latest_activity_for_watched_repos( + pool: &PgPool, + user_id: Uuid, + limit: i64, +) -> Result<Vec<(ActivityEvent, Option<User>, Option<Repository>, Option<String>)>> { + let events = sqlx::query_as::<_, ActivityEvent>( + r#" + SELECT ae.* + FROM activity_events ae + JOIN repo_watches w ON w.repo_id = ae.repo_id AND w.user_id = $1 + JOIN repositories r ON r.id = ae.repo_id AND r.visibility = 'public' + ORDER BY ae.created_at DESC + LIMIT $2 + "#, + ) + .bind(user_id) + .bind(limit) + .fetch_all(pool) + .await?; + hydrate_activity(pool, events).await +} + +pub async fn create_fork( + pool: &PgPool, + owner_id: Uuid, + name: &str, + description: &str, + visibility: &str, + fork_of_id: Uuid, +) -> Result<Repository> { + let mut tx = pool.begin().await?; + let repo = sqlx::query_as::<_, Repository>( + r#" + INSERT INTO repositories (owner_id, name, description, visibility, fork_of_id) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + "#, + ) + .bind(owner_id) + .bind(name) + .bind(description) + .bind(visibility) + .bind(fork_of_id) + .fetch_one(&mut *tx) + .await?; + sqlx::query("INSERT INTO repo_counters (repo_id) VALUES ($1)") + .bind(repo.id) + .execute(&mut *tx) + .await?; + sqlx::query("INSERT INTO language_stats (repo_id) VALUES ($1)") + .bind(repo.id) + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE repositories SET forks_count = forks_count + 1 WHERE id = $1") + .bind(fork_of_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(repo) +} + +pub async fn get_fork_of_user(pool: &PgPool, fork_of_id: Uuid, owner_id: Uuid) -> Result<Option<Repository>> { + Ok(sqlx::query_as::<_, Repository>( + "SELECT * FROM repositories WHERE fork_of_id = $1 AND owner_id = $2", + ) + .bind(fork_of_id) + .bind(owner_id) + .fetch_optional(pool) + .await?) +} + +// ── reactions ──────────────────────────────────────────────────────────────── + +pub async fn toggle_reaction( + pool: &PgPool, + comment_id: Uuid, + user_id: Uuid, + emoji: &str, +) -> Result<()> { + let existing: Option<(Uuid,)> = sqlx::query_as( + "SELECT user_id FROM comment_reactions WHERE comment_id = $1 AND user_id = $2 AND emoji = $3", + ) + .bind(comment_id) + .bind(user_id) + .bind(emoji) + .fetch_optional(pool) + .await?; + if existing.is_some() { + sqlx::query( + "DELETE FROM comment_reactions WHERE comment_id = $1 AND user_id = $2 AND emoji = $3", + ) + .bind(comment_id) + .bind(user_id) + .bind(emoji) + .execute(pool) + .await?; + } else { + sqlx::query( + "INSERT INTO comment_reactions (comment_id, user_id, emoji) VALUES ($1, $2, $3)", + ) + .bind(comment_id) + .bind(user_id) + .bind(emoji) + .execute(pool) + .await?; + } + Ok(()) +} + +pub async fn list_reaction_counts( + pool: &PgPool, + comment_id: Uuid, + viewer: Option<Uuid>, +) -> Result<Vec<(String, i64, bool)>> { + #[derive(sqlx::FromRow)] + struct Row { + emoji: String, + count: i64, + mine: bool, + } + let rows: Vec<Row> = sqlx::query_as( + r#" + SELECT emoji, + COUNT(*)::bigint AS count, + BOOL_OR(user_id = $2) AS mine + FROM comment_reactions + WHERE comment_id = $1 + GROUP BY emoji + ORDER BY emoji + "#, + ) + .bind(comment_id) + .bind(viewer) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(|r| (r.emoji, r.count, r.mine)).collect()) +} + +// ── branch rules ───────────────────────────────────────────────────────────── + +pub async fn list_branch_rules(pool: &PgPool, repo_id: Uuid) -> Result<Vec<BranchRule>> { + Ok(sqlx::query_as::<_, BranchRule>( + "SELECT * FROM branch_rules WHERE repo_id = $1 ORDER BY pattern", + ) + .bind(repo_id) + .fetch_all(pool) + .await?) +} + +pub async fn add_branch_rule( + pool: &PgPool, + repo_id: Uuid, + pattern: &str, + require_pr: bool, + block_force_push: bool, + allow_deletions: bool, +) -> Result<BranchRule> { + Ok(sqlx::query_as::<_, BranchRule>( + r#" + INSERT INTO branch_rules (repo_id, pattern, require_pr, block_force_push, allow_deletions) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + "#, + ) + .bind(repo_id) + .bind(pattern) + .bind(require_pr) + .bind(block_force_push) + .bind(allow_deletions) + .fetch_one(pool) + .await?) +} + +pub async fn delete_branch_rule(pool: &PgPool, id: Uuid, repo_id: Uuid) -> Result<()> { + sqlx::query("DELETE FROM branch_rules WHERE id = $1 AND repo_id = $2") + .bind(id) + .bind(repo_id) + .execute(pool) + .await?; + Ok(()) +} + +// ── user emails / privacy / sessions / gpg ─────────────────────────────────── + +pub async fn update_username(pool: &PgPool, id: Uuid, username: &str) -> Result<User> { + Ok(sqlx::query_as::<_, User>( + "UPDATE users SET username = $2, updated_at = now() WHERE id = $1 RETURNING *", + ) + .bind(id) + .bind(username) + .fetch_one(pool) + .await?) +} + +pub async fn update_privacy( + pool: &PgPool, + id: Uuid, + show_email: bool, + vigilant_mode: bool, +) -> Result<User> { + Ok(sqlx::query_as::<_, User>( + r#" + UPDATE users SET show_email = $2, vigilant_mode = $3, updated_at = now() + WHERE id = $1 RETURNING * + "#, + ) + .bind(id) + .bind(show_email) + .bind(vigilant_mode) + .fetch_one(pool) + .await?) +} + +pub async fn list_user_emails(pool: &PgPool, user_id: Uuid) -> Result<Vec<UserEmail>> { + Ok(sqlx::query_as::<_, UserEmail>( + "SELECT * FROM user_emails WHERE user_id = $1 ORDER BY is_primary DESC, email", + ) + .bind(user_id) + .fetch_all(pool) + .await?) +} + +pub async fn add_user_email(pool: &PgPool, user_id: Uuid, email: &str) -> Result<UserEmail> { + Ok(sqlx::query_as::<_, UserEmail>( + r#" + INSERT INTO user_emails (user_id, email, verified, is_primary) + VALUES ($1, $2, false, false) + RETURNING * + "#, + ) + .bind(user_id) + .bind(email) + .fetch_one(pool) + .await?) +} + +pub async fn delete_user_email(pool: &PgPool, id: Uuid, user_id: Uuid) -> Result<()> { + sqlx::query("DELETE FROM user_emails WHERE id = $1 AND user_id = $2 AND is_primary = false") + .bind(id) + .bind(user_id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn ensure_primary_email(pool: &PgPool, user_id: Uuid, email: &str) -> Result<()> { + if email.trim().is_empty() { + return Ok(()); + } + sqlx::query( + r#" + INSERT INTO user_emails (user_id, email, verified, is_primary) + VALUES ($1, $2, true, true) + ON CONFLICT (user_id, email) DO UPDATE SET is_primary = true, verified = true + "#, + ) + .bind(user_id) + .bind(email.trim()) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn list_sessions(pool: &PgPool, user_id: Uuid) -> Result<Vec<Session>> { + Ok(sqlx::query_as::<_, Session>( + "SELECT * FROM sessions WHERE user_id = $1 AND expires_at > now() ORDER BY last_seen_at DESC", + ) + .bind(user_id) + .fetch_all(pool) + .await?) +} + +pub async fn delete_session_by_id(pool: &PgPool, id: Uuid, user_id: Uuid) -> Result<()> { + sqlx::query("DELETE FROM sessions WHERE id = $1 AND user_id = $2") + .bind(id) + .bind(user_id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn delete_other_sessions(pool: &PgPool, user_id: Uuid, keep_hash: &str) -> Result<()> { + sqlx::query("DELETE FROM sessions WHERE user_id = $1 AND token_hash <> $2") + .bind(user_id) + .bind(keep_hash) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn list_gpg_keys(pool: &PgPool, user_id: Uuid) -> Result<Vec<GpgKey>> { + Ok(sqlx::query_as::<_, GpgKey>( + "SELECT * FROM gpg_keys WHERE user_id = $1 ORDER BY created_at DESC", + ) + .bind(user_id) + .fetch_all(pool) + .await?) +} + +pub async fn add_gpg_key( + pool: &PgPool, + user_id: Uuid, + name: &str, + public_key: &str, + fingerprint: &str, +) -> Result<GpgKey> { + Ok(sqlx::query_as::<_, GpgKey>( + r#" + INSERT INTO gpg_keys (user_id, name, public_key, fingerprint) + VALUES ($1, $2, $3, $4) RETURNING * + "#, + ) + .bind(user_id) + .bind(name) + .bind(public_key) + .bind(fingerprint) + .fetch_one(pool) + .await?) +} + +pub async fn delete_gpg_key(pool: &PgPool, id: Uuid, user_id: Uuid) -> Result<()> { + sqlx::query("DELETE FROM gpg_keys WHERE id = $1 AND user_id = $2") + .bind(id) + .bind(user_id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn delete_user(pool: &PgPool, id: Uuid) -> Result<()> { + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn get_user_mfa(pool: &PgPool, user_id: Uuid) -> Result<Option<UserMfa>> { + Ok(sqlx::query_as::<_, UserMfa>("SELECT * FROM user_mfa WHERE user_id = $1") + .bind(user_id) + .fetch_optional(pool) + .await?) +} + +pub async fn mfa_is_enabled(pool: &PgPool, user_id: Uuid) -> Result<bool> { + let row: Option<(bool,)> = sqlx::query_as( + "SELECT enabled FROM user_mfa WHERE user_id = $1 AND enabled = TRUE AND totp_secret IS NOT NULL", + ) + .bind(user_id) + .fetch_optional(pool) + .await?; + Ok(row.is_some()) +} + +pub async fn mfa_start_enroll(pool: &PgPool, user_id: Uuid, pending_secret: &str) -> Result<UserMfa> { + Ok(sqlx::query_as::<_, UserMfa>( + r#" + INSERT INTO user_mfa (user_id, pending_secret, enabled, totp_secret, recovery_code_hashes, updated_at) + VALUES ($1, $2, FALSE, NULL, '{}', now()) + ON CONFLICT (user_id) DO UPDATE SET + pending_secret = EXCLUDED.pending_secret, + updated_at = now() + RETURNING * + "#, + ) + .bind(user_id) + .bind(pending_secret) + .fetch_one(pool) + .await?) +} + +pub async fn mfa_confirm_enroll( + pool: &PgPool, + user_id: Uuid, + totp_secret: &str, + recovery_hashes: &[String], +) -> Result<UserMfa> { + Ok(sqlx::query_as::<_, UserMfa>( + r#" + UPDATE user_mfa SET + totp_secret = $2, + pending_secret = NULL, + enabled = TRUE, + recovery_code_hashes = $3, + updated_at = now() + WHERE user_id = $1 + RETURNING * + "#, + ) + .bind(user_id) + .bind(totp_secret) + .bind(recovery_hashes) + .fetch_one(pool) + .await?) +} + +pub async fn mfa_disable(pool: &PgPool, user_id: Uuid) -> Result<()> { + sqlx::query( + r#" + UPDATE user_mfa SET + enabled = FALSE, + totp_secret = NULL, + pending_secret = NULL, + recovery_code_hashes = '{}', + updated_at = now() + WHERE user_id = $1 + "#, + ) + .bind(user_id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn mfa_set_recovery_hashes( + pool: &PgPool, + user_id: Uuid, + hashes: &[String], +) -> Result<()> { + sqlx::query( + "UPDATE user_mfa SET recovery_code_hashes = $2, updated_at = now() WHERE user_id = $1", + ) + .bind(user_id) + .bind(hashes) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn create_mfa_pending_login( + pool: &PgPool, + user_id: Uuid, + token_hash: &str, + minutes: i64, +) -> Result<()> { + sqlx::query( + r#" + INSERT INTO mfa_pending_logins (user_id, token_hash, expires_at) + VALUES ($1, $2, now() + ($3::text || ' minutes')::interval) + "#, + ) + .bind(user_id) + .bind(token_hash) + .bind(minutes) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn take_mfa_pending_login(pool: &PgPool, token_hash: &str) -> Result<Option<Uuid>> { + let row: Option<(Uuid,)> = sqlx::query_as( + r#" + DELETE FROM mfa_pending_logins + WHERE token_hash = $1 AND expires_at > now() + RETURNING user_id + "#, + ) + .bind(token_hash) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| r.0)) +} + +pub async fn delete_mfa_pending_for_user(pool: &PgPool, user_id: Uuid) -> Result<()> { + sqlx::query("DELETE FROM mfa_pending_logins WHERE user_id = $1") + .bind(user_id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn export_user_data(pool: &PgPool, user_id: Uuid) -> Result<serde_json::Value> { + let user = get_user_by_id(pool, user_id).await?.ok_or_else(|| anyhow::anyhow!("missing"))?; + let emails = list_user_emails(pool, user_id).await?; + let keys = sqlx::query_as::<_, SshKey>("SELECT * FROM ssh_keys WHERE user_id = $1") + .bind(user_id) + .fetch_all(pool) + .await?; + let repos = sqlx::query_as::<_, Repository>("SELECT * FROM repositories WHERE owner_id = $1") + .bind(user_id) + .fetch_all(pool) + .await?; + Ok(serde_json::json!({ + "user": { + "id": user.id, + "username": user.username, + "display_name": user.display_name, + "email": user.email, + "bio": user.bio, + "show_email": user.show_email, + "vigilant_mode": user.vigilant_mode, + "created_at": user.created_at, + }, + "emails": emails, + "ssh_keys": keys.iter().map(|k| serde_json::json!({ + "name": k.name, + "fingerprint": k.fingerprint, + "created_at": k.created_at, + })).collect::<Vec<_>>(), + "repositories": repos.iter().map(|r| serde_json::json!({ + "name": r.name, + "description": r.description, + "visibility": r.visibility, + "created_at": r.created_at, + })).collect::<Vec<_>>(), + })) +} + +pub async fn open_pull_for_branch( + pool: &PgPool, + repo_id: Uuid, + source_branch: &str, +) -> Result<Option<i32>> { + let row: Option<(i32,)> = sqlx::query_as( + r#" + SELECT number FROM pull_requests + WHERE repo_id = $1 AND source_branch = $2 AND state = 'open' + ORDER BY number DESC LIMIT 1 + "#, + ) + .bind(repo_id) + .bind(source_branch) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| r.0)) +} + +pub async fn register_lfs_object(pool: &PgPool, repo_id: Uuid, oid: &str, size: i64) -> Result<()> { + sqlx::query( + r#" + INSERT INTO lfs_objects (oid, size_bytes) VALUES ($1, $2) + ON CONFLICT (oid) DO UPDATE SET size_bytes = EXCLUDED.size_bytes + "#, + ) + .bind(oid) + .bind(size) + .execute(pool) + .await?; + sqlx::query( + r#" + INSERT INTO lfs_repo_objects (repo_id, oid) VALUES ($1, $2) + ON CONFLICT DO NOTHING + "#, + ) + .bind(repo_id) + .bind(oid) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn lfs_object_size(pool: &PgPool, oid: &str) -> Result<Option<i64>> { + let row: Option<(i64,)> = sqlx::query_as("SELECT size_bytes FROM lfs_objects WHERE oid = $1") + .bind(oid) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| r.0)) +} @@ -0,0 +1,2938 @@ +{ + "1C Enterprise": { + "color": "#814CCC", + "url": "https://github.com/trending?l=1C-Enterprise" + }, + "2-Dimensional Array": { + "color": "#38761D", + "url": "https://github.com/trending?l=2-Dimensional-Array" + }, + "4D": { + "color": "#004289", + "url": "https://github.com/trending?l=4D" + }, + "ABAP": { + "color": "#E8274B", + "url": "https://github.com/trending?l=ABAP" + }, + "ABAP CDS": { + "color": "#555e25", + "url": "https://github.com/trending?l=ABAP-CDS" + }, + "ActionScript": { + "color": "#882B0F", + "url": "https://github.com/trending?l=ActionScript" + }, + "Ada": { + "color": "#02f88c", + "url": "https://github.com/trending?l=Ada" + }, + "Adblock Filter List": { + "color": "#800000", + "url": "https://github.com/trending?l=Adblock-Filter-List" + }, + "Adobe Font Metrics": { + "color": "#fa0f00", + "url": "https://github.com/trending?l=Adobe-Font-Metrics" + }, + "Agda": { + "color": "#315665", + "url": "https://github.com/trending?l=Agda" + }, + "AGS Script": { + "color": "#B9D9FF", + "url": "https://github.com/trending?l=AGS-Script" + }, + "AIDL": { + "color": "#34EB6B", + "url": "https://github.com/trending?l=AIDL" + }, + "Aiken": { + "color": "#640ff8", + "url": "https://github.com/trending?l=Aiken" + }, + "AL": { + "color": "#3AA2B5", + "url": "https://github.com/trending?l=AL" + }, + "ALGOL": { + "color": "#D1E0DB", + "url": "https://github.com/trending?l=ALGOL" + }, + "Alloy": { + "color": "#64C800", + "url": "https://github.com/trending?l=Alloy" + }, + "Alpine Abuild": { + "color": "#0D597F", + "url": "https://github.com/trending?l=Alpine-Abuild" + }, + "Altium Designer": { + "color": "#A89663", + "url": "https://github.com/trending?l=Altium-Designer" + }, + "AMPL": { + "color": "#E6EFBB", + "url": "https://github.com/trending?l=AMPL" + }, + "AngelScript": { + "color": "#C7D7DC", + "url": "https://github.com/trending?l=AngelScript" + }, + "Answer Set Programming": { + "color": "#A9CC29", + "url": "https://github.com/trending?l=Answer-Set-Programming" + }, + "Ant Build System": { + "color": "#A9157E", + "url": "https://github.com/trending?l=Ant-Build-System" + }, + "Antlers": { + "color": "#ff269e", + "url": "https://github.com/trending?l=Antlers" + }, + "ANTLR": { + "color": "#9DC3FF", + "url": "https://github.com/trending?l=ANTLR" + }, + "ApacheConf": { + "color": "#d12127", + "url": "https://github.com/trending?l=ApacheConf" + }, + "Apex": { + "color": "#1797c0", + "url": "https://github.com/trending?l=Apex" + }, + "API Blueprint": { + "color": "#2ACCA8", + "url": "https://github.com/trending?l=API-Blueprint" + }, + "APL": { + "color": "#5A8164", + "url": "https://github.com/trending?l=APL" + }, + "Apollo Guidance Computer": { + "color": "#0B3D91", + "url": "https://github.com/trending?l=Apollo-Guidance-Computer" + }, + "AppleScript": { + "color": "#101F1F", + "url": "https://github.com/trending?l=AppleScript" + }, + "Arc": { + "color": "#aa2afe", + "url": "https://github.com/trending?l=Arc" + }, + "AsciiDoc": { + "color": "#73a0c5", + "url": "https://github.com/trending?l=AsciiDoc" + }, + "ASL": { + "color": null, + "url": "https://github.com/trending?l=ASL" + }, + "ASP.NET": { + "color": "#9400ff", + "url": "https://github.com/trending?l=ASP.NET" + }, + "AspectJ": { + "color": "#a957b0", + "url": "https://github.com/trending?l=AspectJ" + }, + "Assembly": { + "color": "#6E4C13", + "url": "https://github.com/trending?l=Assembly" + }, + "Astro": { + "color": "#ff5a03", + "url": "https://github.com/trending?l=Astro" + }, + "Asymptote": { + "color": "#ff0000", + "url": "https://github.com/trending?l=Asymptote" + }, + "ATS": { + "color": "#1ac620", + "url": "https://github.com/trending?l=ATS" + }, + "Augeas": { + "color": "#9CC134", + "url": "https://github.com/trending?l=Augeas" + }, + "AutoHotkey": { + "color": "#6594b9", + "url": "https://github.com/trending?l=AutoHotkey" + }, + "AutoIt": { + "color": "#1C3552", + "url": "https://github.com/trending?l=AutoIt" + }, + "Avro IDL": { + "color": "#0040FF", + "url": "https://github.com/trending?l=Avro-IDL" + }, + "Awk": { + "color": "#c30e9b", + "url": "https://github.com/trending?l=Awk" + }, + "B (Formal Method)": { + "color": "#8aa8c5", + "url": "https://github.com/trending?l=B-(Formal-Method)" + }, + "B4X": { + "color": "#00e4ff", + "url": "https://github.com/trending?l=B4X" + }, + "Ballerina": { + "color": "#FF5000", + "url": "https://github.com/trending?l=Ballerina" + }, + "BAML": { + "color": "#a855f7", + "url": "https://github.com/trending?l=BAML" + }, + "BASIC": { + "color": "#ff0000", + "url": "https://github.com/trending?l=BASIC" + }, + "Batchfile": { + "color": "#C1F12E", + "url": "https://github.com/trending?l=Batchfile" + }, + "Beef": { + "color": "#a52f4e", + "url": "https://github.com/trending?l=Beef" + }, + "Befunge": { + "color": null, + "url": "https://github.com/trending?l=Befunge" + }, + "Berry": { + "color": "#15A13C", + "url": "https://github.com/trending?l=Berry" + }, + "BibTeX": { + "color": "#778899", + "url": "https://github.com/trending?l=BibTeX" + }, + "BibTeX Style": { + "color": null, + "url": "https://github.com/trending?l=BibTeX-Style" + }, + "Bicep": { + "color": "#519aba", + "url": "https://github.com/trending?l=Bicep" + }, + "Bikeshed": { + "color": "#5562ac", + "url": "https://github.com/trending?l=Bikeshed" + }, + "Bison": { + "color": "#6A463F", + "url": "https://github.com/trending?l=Bison" + }, + "BitBake": { + "color": "#00bce4", + "url": "https://github.com/trending?l=BitBake" + }, + "Blade": { + "color": "#f7523f", + "url": "https://github.com/trending?l=Blade" + }, + "BlitzBasic": { + "color": "#00FFAE", + "url": "https://github.com/trending?l=BlitzBasic" + }, + "BlitzMax": { + "color": "#cd6400", + "url": "https://github.com/trending?l=BlitzMax" + }, + "Blueprint": { + "color": "#3584E4", + "url": "https://github.com/trending?l=Blueprint" + }, + "Bluespec": { + "color": "#12223c", + "url": "https://github.com/trending?l=Bluespec" + }, + "Bluespec BH": { + "color": "#12223c", + "url": "https://github.com/trending?l=Bluespec-BH" + }, + "Boo": { + "color": "#d4bec1", + "url": "https://github.com/trending?l=Boo" + }, + "Boogie": { + "color": "#c80fa0", + "url": "https://github.com/trending?l=Boogie" + }, + "BQN": { + "color": "#2b7067", + "url": "https://github.com/trending?l=BQN" + }, + "Brainfuck": { + "color": "#2F2530", + "url": "https://github.com/trending?l=Brainfuck" + }, + "BrighterScript": { + "color": "#66AABB", + "url": "https://github.com/trending?l=BrighterScript" + }, + "Brightscript": { + "color": "#662D91", + "url": "https://github.com/trending?l=Brightscript" + }, + "Browserslist": { + "color": "#ffd539", + "url": "https://github.com/trending?l=Browserslist" + }, + "Bru": { + "color": "#F4AA41", + "url": "https://github.com/trending?l=Bru" + }, + "BuildStream": { + "color": "#006bff", + "url": "https://github.com/trending?l=BuildStream" + }, + "C": { + "color": "#555555", + "url": "https://github.com/trending?l=C" + }, + "C#": { + "color": "#7355dd", + "url": "https://github.com/trending?l=Csharp" + }, + "C++": { + "color": "#f34b7d", + "url": "https://github.com/trending?l=C++" + }, + "C2hs Haskell": { + "color": null, + "url": "https://github.com/trending?l=C2hs-Haskell" + }, + "C3": { + "color": "#2563eb", + "url": "https://github.com/trending?l=C3" + }, + "Cabal Config": { + "color": "#483465", + "url": "https://github.com/trending?l=Cabal-Config" + }, + "Caddyfile": { + "color": "#22b638", + "url": "https://github.com/trending?l=Caddyfile" + }, + "Cadence": { + "color": "#00ef8b", + "url": "https://github.com/trending?l=Cadence" + }, + "Cairo": { + "color": "#ff4a48", + "url": "https://github.com/trending?l=Cairo" + }, + "Cairo Zero": { + "color": "#ff4a48", + "url": "https://github.com/trending?l=Cairo-Zero" + }, + "CameLIGO": { + "color": "#3be133", + "url": "https://github.com/trending?l=CameLIGO" + }, + "Cangjie": { + "color": "#00868B", + "url": "https://github.com/trending?l=Cangjie" + }, + "CAP CDS": { + "color": "#0092d1", + "url": "https://github.com/trending?l=CAP-CDS" + }, + "Cap'n Proto": { + "color": "#c42727", + "url": "https://github.com/trending?l=Cap'n-Proto" + }, + "Carbon": { + "color": "#222222", + "url": "https://github.com/trending?l=Carbon" + }, + "CartoCSS": { + "color": null, + "url": "https://github.com/trending?l=CartoCSS" + }, + "Ceylon": { + "color": "#dfa535", + "url": "https://github.com/trending?l=Ceylon" + }, + "Chapel": { + "color": "#8dc63f", + "url": "https://github.com/trending?l=Chapel" + }, + "Charity": { + "color": null, + "url": "https://github.com/trending?l=Charity" + }, + "ChucK": { + "color": "#3f8000", + "url": "https://github.com/trending?l=ChucK" + }, + "Circom": { + "color": "#707575", + "url": "https://github.com/trending?l=Circom" + }, + "Cirru": { + "color": "#ccccff", + "url": "https://github.com/trending?l=Cirru" + }, + "Clarion": { + "color": "#db901e", + "url": "https://github.com/trending?l=Clarion" + }, + "Clarity": { + "color": "#5546ff", + "url": "https://github.com/trending?l=Clarity" + }, + "Classic ASP": { + "color": "#6a40fd", + "url": "https://github.com/trending?l=Classic-ASP" + }, + "Clean": { + "color": "#3F85AF", + "url": "https://github.com/trending?l=Clean" + }, + "Click": { + "color": "#E4E6F3", + "url": "https://github.com/trending?l=Click" + }, + "CLIPS": { + "color": "#00A300", + "url": "https://github.com/trending?l=CLIPS" + }, + "Clojure": { + "color": "#db5855", + "url": "https://github.com/trending?l=Clojure" + }, + "Closure Templates": { + "color": "#0d948f", + "url": "https://github.com/trending?l=Closure-Templates" + }, + "Cloud Firestore Security Rules": { + "color": "#FFA000", + "url": "https://github.com/trending?l=Cloud-Firestore-Security-Rules" + }, + "Clue": { + "color": "#0009b5", + "url": "https://github.com/trending?l=Clue" + }, + "CMake": { + "color": "#DA3434", + "url": "https://github.com/trending?l=CMake" + }, + "COBOL": { + "color": null, + "url": "https://github.com/trending?l=COBOL" + }, + "CodeQL": { + "color": "#140f46", + "url": "https://github.com/trending?l=CodeQL" + }, + "CoffeeScript": { + "color": "#244776", + "url": "https://github.com/trending?l=CoffeeScript" + }, + "ColdFusion": { + "color": "#ed2cd6", + "url": "https://github.com/trending?l=ColdFusion" + }, + "ColdFusion CFC": { + "color": "#ed2cd6", + "url": "https://github.com/trending?l=ColdFusion-CFC" + }, + "COLLADA": { + "color": "#F1A42B", + "url": "https://github.com/trending?l=COLLADA" + }, + "Common Lisp": { + "color": "#3fb68b", + "url": "https://github.com/trending?l=Common-Lisp" + }, + "Common Workflow Language": { + "color": "#B5314C", + "url": "https://github.com/trending?l=Common-Workflow-Language" + }, + "Component Pascal": { + "color": "#B0CE4E", + "url": "https://github.com/trending?l=Component-Pascal" + }, + "Cooklang": { + "color": "#E15A29", + "url": "https://github.com/trending?l=Cooklang" + }, + "Cool": { + "color": null, + "url": "https://github.com/trending?l=Cool" + }, + "CQL": { + "color": "#006091", + "url": "https://github.com/trending?l=CQL" + }, + "crontab": { + "color": "#ead7ac", + "url": "https://github.com/trending?l=crontab" + }, + "Crystal": { + "color": "#000100", + "url": "https://github.com/trending?l=Crystal" + }, + "CSON": { + "color": "#244776", + "url": "https://github.com/trending?l=CSON" + }, + "Csound": { + "color": "#1a1a1a", + "url": "https://github.com/trending?l=Csound" + }, + "Csound Document": { + "color": "#1a1a1a", + "url": "https://github.com/trending?l=Csound-Document" + }, + "Csound Score": { + "color": "#1a1a1a", + "url": "https://github.com/trending?l=Csound-Score" + }, + "CSS": { + "color": "#663399", + "url": "https://github.com/trending?l=CSS" + }, + "CSV": { + "color": "#237346", + "url": "https://github.com/trending?l=CSV" + }, + "Cuda": { + "color": "#3A4E3A", + "url": "https://github.com/trending?l=Cuda" + }, + "CUE": { + "color": "#5886E1", + "url": "https://github.com/trending?l=CUE" + }, + "Curry": { + "color": "#531242", + "url": "https://github.com/trending?l=Curry" + }, + "CWeb": { + "color": "#00007a", + "url": "https://github.com/trending?l=CWeb" + }, + "Cycript": { + "color": null, + "url": "https://github.com/trending?l=Cycript" + }, + "Cylc": { + "color": "#00b3fd", + "url": "https://github.com/trending?l=Cylc" + }, + "Cypher": { + "color": "#34c0eb", + "url": "https://github.com/trending?l=Cypher" + }, + "Cython": { + "color": "#fedf5b", + "url": "https://github.com/trending?l=Cython" + }, + "D": { + "color": "#ba595e", + "url": "https://github.com/trending?l=D" + }, + "D2": { + "color": "#526ee8", + "url": "https://github.com/trending?l=D2" + }, + "Dafny": { + "color": "#FFEC25", + "url": "https://github.com/trending?l=Dafny" + }, + "Darcs Patch": { + "color": "#8eff23", + "url": "https://github.com/trending?l=Darcs-Patch" + }, + "Dart": { + "color": "#00B4AB", + "url": "https://github.com/trending?l=Dart" + }, + "Daslang": { + "color": "#d3d3d3", + "url": "https://github.com/trending?l=Daslang" + }, + "DataWeave": { + "color": "#003a52", + "url": "https://github.com/trending?l=DataWeave" + }, + "Debian Package Control File": { + "color": "#D70751", + "url": "https://github.com/trending?l=Debian-Package-Control-File" + }, + "DenizenScript": { + "color": "#FBEE96", + "url": "https://github.com/trending?l=DenizenScript" + }, + "Dhall": { + "color": "#dfafff", + "url": "https://github.com/trending?l=Dhall" + }, + "DIGITAL Command Language": { + "color": null, + "url": "https://github.com/trending?l=DIGITAL-Command-Language" + }, + "DirectX 3D File": { + "color": "#aace60", + "url": "https://github.com/trending?l=DirectX-3D-File" + }, + "DM": { + "color": "#447265", + "url": "https://github.com/trending?l=DM" + }, + "Dockerfile": { + "color": "#384d54", + "url": "https://github.com/trending?l=Dockerfile" + }, + "Dogescript": { + "color": "#cca760", + "url": "https://github.com/trending?l=Dogescript" + }, + "Dotenv": { + "color": "#e5d559", + "url": "https://github.com/trending?l=Dotenv" + }, + "DTrace": { + "color": null, + "url": "https://github.com/trending?l=DTrace" + }, + "Dune": { + "color": "#89421e", + "url": "https://github.com/trending?l=Dune" + }, + "Dylan": { + "color": "#6c616e", + "url": "https://github.com/trending?l=Dylan" + }, + "E": { + "color": "#ccce35", + "url": "https://github.com/trending?l=E" + }, + "Earthly": { + "color": "#2af0ff", + "url": "https://github.com/trending?l=Earthly" + }, + "Easybuild": { + "color": "#069406", + "url": "https://github.com/trending?l=Easybuild" + }, + "eC": { + "color": "#913960", + "url": "https://github.com/trending?l=eC" + }, + "Ecere Projects": { + "color": "#913960", + "url": "https://github.com/trending?l=Ecere-Projects" + }, + "ECL": { + "color": "#8a1267", + "url": "https://github.com/trending?l=ECL" + }, + "ECLiPSe": { + "color": "#001d9d", + "url": "https://github.com/trending?l=ECLiPSe" + }, + "Ecmarkup": { + "color": "#eb8131", + "url": "https://github.com/trending?l=Ecmarkup" + }, + "Edge": { + "color": "#0dffe0", + "url": "https://github.com/trending?l=Edge" + }, + "EdgeQL": { + "color": "#31A7FF", + "url": "https://github.com/trending?l=EdgeQL" + }, + "EditorConfig": { + "color": "#fff1f2", + "url": "https://github.com/trending?l=EditorConfig" + }, + "Eiffel": { + "color": "#4d6977", + "url": "https://github.com/trending?l=Eiffel" + }, + "EJS": { + "color": "#a91e50", + "url": "https://github.com/trending?l=EJS" + }, + "Elixir": { + "color": "#6e4a7e", + "url": "https://github.com/trending?l=Elixir" + }, + "Elm": { + "color": "#60B5CC", + "url": "https://github.com/trending?l=Elm" + }, + "Elvish": { + "color": "#55BB55", + "url": "https://github.com/trending?l=Elvish" + }, + "Elvish Transcript": { + "color": "#55BB55", + "url": "https://github.com/trending?l=Elvish-Transcript" + }, + "Emacs Lisp": { + "color": "#c065db", + "url": "https://github.com/trending?l=Emacs-Lisp" + }, + "EmberScript": { + "color": "#FFF4F3", + "url": "https://github.com/trending?l=EmberScript" + }, + "EQ": { + "color": "#a78649", + "url": "https://github.com/trending?l=EQ" + }, + "Erlang": { + "color": "#B83998", + "url": "https://github.com/trending?l=Erlang" + }, + "Euphoria": { + "color": "#FF790B", + "url": "https://github.com/trending?l=Euphoria" + }, + "F#": { + "color": "#b845fc", + "url": "https://github.com/trending?l=Fsharp" + }, + "F*": { + "color": "#572e30", + "url": "https://github.com/trending?l=F*" + }, + "Factor": { + "color": "#636746", + "url": "https://github.com/trending?l=Factor" + }, + "Fancy": { + "color": "#7b9db4", + "url": "https://github.com/trending?l=Fancy" + }, + "Fantom": { + "color": "#14253c", + "url": "https://github.com/trending?l=Fantom" + }, + "Faust": { + "color": "#c37240", + "url": "https://github.com/trending?l=Faust" + }, + "Fennel": { + "color": "#fff3d7", + "url": "https://github.com/trending?l=Fennel" + }, + "FIGlet Font": { + "color": "#FFDDBB", + "url": "https://github.com/trending?l=FIGlet-Font" + }, + "Filebench WML": { + "color": "#F6B900", + "url": "https://github.com/trending?l=Filebench-WML" + }, + "Filterscript": { + "color": null, + "url": "https://github.com/trending?l=Filterscript" + }, + "FIRRTL": { + "color": "#2f632f", + "url": "https://github.com/trending?l=FIRRTL" + }, + "fish": { + "color": "#4aae47", + "url": "https://github.com/trending?l=fish" + }, + "FlatBuffers": { + "color": "#ed284a", + "url": "https://github.com/trending?l=FlatBuffers" + }, + "Flix": { + "color": "#d44a45", + "url": "https://github.com/trending?l=Flix" + }, + "Fluent": { + "color": "#ffcc33", + "url": "https://github.com/trending?l=Fluent" + }, + "FLUX": { + "color": "#88ccff", + "url": "https://github.com/trending?l=FLUX" + }, + "Forth": { + "color": "#341708", + "url": "https://github.com/trending?l=Forth" + }, + "Fortran": { + "color": "#4d41b1", + "url": "https://github.com/trending?l=Fortran" + }, + "Fortran Free Form": { + "color": "#4d41b1", + "url": "https://github.com/trending?l=Fortran-Free-Form" + }, + "FreeBASIC": { + "color": "#141AC9", + "url": "https://github.com/trending?l=FreeBASIC" + }, + "FreeMarker": { + "color": "#0050b2", + "url": "https://github.com/trending?l=FreeMarker" + }, + "Frege": { + "color": "#00cafe", + "url": "https://github.com/trending?l=Frege" + }, + "Futhark": { + "color": "#5f021f", + "url": "https://github.com/trending?l=Futhark" + }, + "G-code": { + "color": "#D08CF2", + "url": "https://github.com/trending?l=G-code" + }, + "Game Maker Language": { + "color": "#71b417", + "url": "https://github.com/trending?l=Game-Maker-Language" + }, + "GAML": { + "color": "#FFC766", + "url": "https://github.com/trending?l=GAML" + }, + "GAMS": { + "color": "#f49a22", + "url": "https://github.com/trending?l=GAMS" + }, + "GAP": { + "color": "#0000cc", + "url": "https://github.com/trending?l=GAP" + }, + "GCC Machine Description": { + "color": "#FFCFAB", + "url": "https://github.com/trending?l=GCC-Machine-Description" + }, + "GDB": { + "color": null, + "url": "https://github.com/trending?l=GDB" + }, + "GDScript": { + "color": "#355570", + "url": "https://github.com/trending?l=GDScript" + }, + "GDShader": { + "color": "#478CBF", + "url": "https://github.com/trending?l=GDShader" + }, + "GEDCOM": { + "color": "#003058", + "url": "https://github.com/trending?l=GEDCOM" + }, + "Gemfile.lock": { + "color": "#701516", + "url": "https://github.com/trending?l=Gemfile.lock" + }, + "Gemini": { + "color": "#ff6900", + "url": "https://github.com/trending?l=Gemini" + }, + "Genero 4gl": { + "color": "#63408e", + "url": "https://github.com/trending?l=Genero-4gl" + }, + "Genero per": { + "color": "#d8df39", + "url": "https://github.com/trending?l=Genero-per" + }, + "Genie": { + "color": "#fb855d", + "url": "https://github.com/trending?l=Genie" + }, + "Genshi": { + "color": "#951531", + "url": "https://github.com/trending?l=Genshi" + }, + "Gentoo Ebuild": { + "color": "#9400ff", + "url": "https://github.com/trending?l=Gentoo-Ebuild" + }, + "Gentoo Eclass": { + "color": "#9400ff", + "url": "https://github.com/trending?l=Gentoo-Eclass" + }, + "Gerber Image": { + "color": "#d20b00", + "url": "https://github.com/trending?l=Gerber-Image" + }, + "Gherkin": { + "color": "#5B2063", + "url": "https://github.com/trending?l=Gherkin" + }, + "Git Attributes": { + "color": "#F44D27", + "url": "https://github.com/trending?l=Git-Attributes" + }, + "Git Commit": { + "color": "#F44D27", + "url": "https://github.com/trending?l=Git-Commit" + }, + "Git Config": { + "color": "#F44D27", + "url": "https://github.com/trending?l=Git-Config" + }, + "Git Revision List": { + "color": "#F44D27", + "url": "https://github.com/trending?l=Git-Revision-List" + }, + "Gleam": { + "color": "#ffaff3", + "url": "https://github.com/trending?l=Gleam" + }, + "Glimmer JS": { + "color": "#F5835F", + "url": "https://github.com/trending?l=Glimmer-JS" + }, + "Glimmer TS": { + "color": "#3178c6", + "url": "https://github.com/trending?l=Glimmer-TS" + }, + "GLSL": { + "color": "#5686a5", + "url": "https://github.com/trending?l=GLSL" + }, + "Glyph": { + "color": "#c1ac7f", + "url": "https://github.com/trending?l=Glyph" + }, + "Gno": { + "color": "#226c57", + "url": "https://github.com/trending?l=Gno" + }, + "Gnuplot": { + "color": "#f0a9f0", + "url": "https://github.com/trending?l=Gnuplot" + }, + "Go": { + "color": "#00ADD8", + "url": "https://github.com/trending?l=Go" + }, + "Go Checksums": { + "color": "#00ADD8", + "url": "https://github.com/trending?l=Go-Checksums" + }, + "Go Module": { + "color": "#00ADD8", + "url": "https://github.com/trending?l=Go-Module" + }, + "Go Template": { + "color": "#00ADD8", + "url": "https://github.com/trending?l=Go-Template" + }, + "Go Workspace": { + "color": "#00ADD8", + "url": "https://github.com/trending?l=Go-Workspace" + }, + "Godot Resource": { + "color": "#355570", + "url": "https://github.com/trending?l=Godot-Resource" + }, + "Golo": { + "color": "#88562A", + "url": "https://github.com/trending?l=Golo" + }, + "Gosu": { + "color": "#82937f", + "url": "https://github.com/trending?l=Gosu" + }, + "Grace": { + "color": "#615f8b", + "url": "https://github.com/trending?l=Grace" + }, + "Gradle": { + "color": "#02303a", + "url": "https://github.com/trending?l=Gradle" + }, + "Gradle Kotlin DSL": { + "color": "#02303a", + "url": "https://github.com/trending?l=Gradle-Kotlin-DSL" + }, + "Grammatical Framework": { + "color": "#ff0000", + "url": "https://github.com/trending?l=Grammatical-Framework" + }, + "GraphQL": { + "color": "#e10098", + "url": "https://github.com/trending?l=GraphQL" + }, + "Graphviz (DOT)": { + "color": "#2596be", + "url": "https://github.com/trending?l=Graphviz-(DOT)" + }, + "Groovy": { + "color": "#4298b8", + "url": "https://github.com/trending?l=Groovy" + }, + "Groovy Server Pages": { + "color": "#4298b8", + "url": "https://github.com/trending?l=Groovy-Server-Pages" + }, + "GSC": { + "color": "#FF6800", + "url": "https://github.com/trending?l=GSC" + }, + "GtkRC": { + "color": "#7fe719", + "url": "https://github.com/trending?l=GtkRC" + }, + "Hack": { + "color": "#878787", + "url": "https://github.com/trending?l=Hack" + }, + "Haml": { + "color": "#ece2a9", + "url": "https://github.com/trending?l=Haml" + }, + "Handlebars": { + "color": "#f7931e", + "url": "https://github.com/trending?l=Handlebars" + }, + "HAProxy": { + "color": "#106da9", + "url": "https://github.com/trending?l=HAProxy" + }, + "Harbour": { + "color": "#0e60e3", + "url": "https://github.com/trending?l=Harbour" + }, + "Hare": { + "color": "#9d7424", + "url": "https://github.com/trending?l=Hare" + }, + "Haskell": { + "color": "#5e5086", + "url": "https://github.com/trending?l=Haskell" + }, + "Haxe": { + "color": "#df7900", + "url": "https://github.com/trending?l=Haxe" + }, + "HCL": { + "color": "#844FBA", + "url": "https://github.com/trending?l=HCL" + }, + "HIP": { + "color": "#4F3A4F", + "url": "https://github.com/trending?l=HIP" + }, + "HiveQL": { + "color": "#dce200", + "url": "https://github.com/trending?l=HiveQL" + }, + "HLSL": { + "color": "#aace60", + "url": "https://github.com/trending?l=HLSL" + }, + "HOCON": { + "color": "#9ff8ee", + "url": "https://github.com/trending?l=HOCON" + }, + "HolyC": { + "color": "#ffefaf", + "url": "https://github.com/trending?l=HolyC" + }, + "hoon": { + "color": "#00b171", + "url": "https://github.com/trending?l=hoon" + }, + "Hosts File": { + "color": "#308888", + "url": "https://github.com/trending?l=Hosts-File" + }, + "HTML": { + "color": "#e34c26", + "url": "https://github.com/trending?l=HTML" + }, + "HTML+ECR": { + "color": "#2e1052", + "url": "https://github.com/trending?l=HTML+ECR" + }, + "HTML+EEX": { + "color": "#6e4a7e", + "url": "https://github.com/trending?l=HTML+EEX" + }, + "HTML+ERB": { + "color": "#701516", + "url": "https://github.com/trending?l=HTML+ERB" + }, + "HTML+PHP": { + "color": "#4f5d95", + "url": "https://github.com/trending?l=HTML+PHP" + }, + "HTML+Razor": { + "color": "#512be4", + "url": "https://github.com/trending?l=HTML+Razor" + }, + "HTTP": { + "color": "#005C9C", + "url": "https://github.com/trending?l=HTTP" + }, + "Hurl": { + "color": "#FF0288", + "url": "https://github.com/trending?l=Hurl" + }, + "HXML": { + "color": "#f68712", + "url": "https://github.com/trending?l=HXML" + }, + "Hy": { + "color": "#7790B2", + "url": "https://github.com/trending?l=Hy" + }, + "HyPhy": { + "color": null, + "url": "https://github.com/trending?l=HyPhy" + }, + "iCalendar": { + "color": "#ec564c", + "url": "https://github.com/trending?l=iCalendar" + }, + "IDL": { + "color": "#a3522f", + "url": "https://github.com/trending?l=IDL" + }, + "Idris": { + "color": "#b30000", + "url": "https://github.com/trending?l=Idris" + }, + "Ignore List": { + "color": "#000000", + "url": "https://github.com/trending?l=Ignore-List" + }, + "IGOR Pro": { + "color": "#0000cc", + "url": "https://github.com/trending?l=IGOR-Pro" + }, + "IL Assembly": { + "color": "#512BD4", + "url": "https://github.com/trending?l=IL-Assembly" + }, + "ImageJ Macro": { + "color": "#99AAFF", + "url": "https://github.com/trending?l=ImageJ-Macro" + }, + "Imba": { + "color": "#16cec6", + "url": "https://github.com/trending?l=Imba" + }, + "Inform 7": { + "color": null, + "url": "https://github.com/trending?l=Inform-7" + }, + "INI": { + "color": "#d1dbe0", + "url": "https://github.com/trending?l=INI" + }, + "Ink": { + "color": null, + "url": "https://github.com/trending?l=Ink" + }, + "Inno Setup": { + "color": "#264b99", + "url": "https://github.com/trending?l=Inno-Setup" + }, + "Io": { + "color": "#a9188d", + "url": "https://github.com/trending?l=Io" + }, + "Ioke": { + "color": "#078193", + "url": "https://github.com/trending?l=Ioke" + }, + "Isabelle": { + "color": "#FEFE00", + "url": "https://github.com/trending?l=Isabelle" + }, + "Isabelle ROOT": { + "color": "#FEFE00", + "url": "https://github.com/trending?l=Isabelle-ROOT" + }, + "ISPC": { + "color": "#2D68B1", + "url": "https://github.com/trending?l=ISPC" + }, + "J": { + "color": "#9EEDFF", + "url": "https://github.com/trending?l=J" + }, + "Jac": { + "color": "#FC792D", + "url": "https://github.com/trending?l=Jac" + }, + "Jai": { + "color": "#ab8b4b", + "url": "https://github.com/trending?l=Jai" + }, + "Janet": { + "color": "#0886a5", + "url": "https://github.com/trending?l=Janet" + }, + "JAR Manifest": { + "color": "#b07219", + "url": "https://github.com/trending?l=JAR-Manifest" + }, + "Jasmin": { + "color": "#d03600", + "url": "https://github.com/trending?l=Jasmin" + }, + "Java": { + "color": "#b07219", + "url": "https://github.com/trending?l=Java" + }, + "Java Properties": { + "color": "#2A6277", + "url": "https://github.com/trending?l=Java-Properties" + }, + "Java Server Pages": { + "color": "#2A6277", + "url": "https://github.com/trending?l=Java-Server-Pages" + }, + "Java Template Engine": { + "color": "#2A6277", + "url": "https://github.com/trending?l=Java-Template-Engine" + }, + "JavaScript": { + "color": "#f1e05a", + "url": "https://github.com/trending?l=JavaScript" + }, + "JavaScript+ERB": { + "color": "#f1e05a", + "url": "https://github.com/trending?l=JavaScript+ERB" + }, + "JCL": { + "color": "#d90e09", + "url": "https://github.com/trending?l=JCL" + }, + "Jest Snapshot": { + "color": "#15c213", + "url": "https://github.com/trending?l=Jest-Snapshot" + }, + "JetBrains MPS": { + "color": "#21D789", + "url": "https://github.com/trending?l=JetBrains-MPS" + }, + "JFlex": { + "color": "#DBCA00", + "url": "https://github.com/trending?l=JFlex" + }, + "Jinja": { + "color": "#a52a22", + "url": "https://github.com/trending?l=Jinja" + }, + "Jison": { + "color": "#56b3cb", + "url": "https://github.com/trending?l=Jison" + }, + "Jison Lex": { + "color": "#56b3cb", + "url": "https://github.com/trending?l=Jison-Lex" + }, + "Jolie": { + "color": "#843179", + "url": "https://github.com/trending?l=Jolie" + }, + "jq": { + "color": "#c7254e", + "url": "https://github.com/trending?l=jq" + }, + "JSON": { + "color": "#292929", + "url": "https://github.com/trending?l=JSON" + }, + "JSON with Comments": { + "color": "#292929", + "url": "https://github.com/trending?l=JSON-with-Comments" + }, + "JSON5": { + "color": "#267CB9", + "url": "https://github.com/trending?l=JSON5" + }, + "JSONiq": { + "color": "#40d47e", + "url": "https://github.com/trending?l=JSONiq" + }, + "JSONLD": { + "color": "#0c479c", + "url": "https://github.com/trending?l=JSONLD" + }, + "Jsonnet": { + "color": "#0064bd", + "url": "https://github.com/trending?l=Jsonnet" + }, + "Julia": { + "color": "#a270ba", + "url": "https://github.com/trending?l=Julia" + }, + "Julia REPL": { + "color": "#a270ba", + "url": "https://github.com/trending?l=Julia-REPL" + }, + "Jupyter Notebook": { + "color": "#DA5B0B", + "url": "https://github.com/trending?l=Jupyter-Notebook" + }, + "Just": { + "color": "#384d54", + "url": "https://github.com/trending?l=Just" + }, + "Kaitai Struct": { + "color": "#773b37", + "url": "https://github.com/trending?l=Kaitai-Struct" + }, + "KakouneScript": { + "color": "#6f8042", + "url": "https://github.com/trending?l=KakouneScript" + }, + "KCL": { + "color": "#7ABABF", + "url": "https://github.com/trending?l=KCL" + }, + "KDL": { + "color": "#ffb3b3", + "url": "https://github.com/trending?l=KDL" + }, + "KerboScript": { + "color": "#41adf0", + "url": "https://github.com/trending?l=KerboScript" + }, + "KFramework": { + "color": "#4195c5", + "url": "https://github.com/trending?l=KFramework" + }, + "KiCad Layout": { + "color": "#2f4aab", + "url": "https://github.com/trending?l=KiCad-Layout" + }, + "KiCad Legacy Layout": { + "color": "#2f4aab", + "url": "https://github.com/trending?l=KiCad-Legacy-Layout" + }, + "KiCad Schematic": { + "color": "#2f4aab", + "url": "https://github.com/trending?l=KiCad-Schematic" + }, + "Koka": { + "color": "#215166", + "url": "https://github.com/trending?l=Koka" + }, + "KoLmafia ASH": { + "color": "#B9D9B9", + "url": "https://github.com/trending?l=KoLmafia-ASH" + }, + "Kotlin": { + "color": "#A97BFF", + "url": "https://github.com/trending?l=Kotlin" + }, + "KRL": { + "color": "#28430A", + "url": "https://github.com/trending?l=KRL" + }, + "kvlang": { + "color": "#1da6e0", + "url": "https://github.com/trending?l=kvlang" + }, + "LabVIEW": { + "color": "#fede06", + "url": "https://github.com/trending?l=LabVIEW" + }, + "Lambdapi": { + "color": "#8027a3", + "url": "https://github.com/trending?l=Lambdapi" + }, + "Langium": { + "color": "#2c8c87", + "url": "https://github.com/trending?l=Langium" + }, + "Lark": { + "color": "#2980B9", + "url": "https://github.com/trending?l=Lark" + }, + "Lasso": { + "color": "#999999", + "url": "https://github.com/trending?l=Lasso" + }, + "Latte": { + "color": "#f2a542", + "url": "https://github.com/trending?l=Latte" + }, + "Lean": { + "color": null, + "url": "https://github.com/trending?l=Lean" + }, + "Lean 4": { + "color": null, + "url": "https://github.com/trending?l=Lean-4" + }, + "Leo": { + "color": "#C4FFC2", + "url": "https://github.com/trending?l=Leo" + }, + "Less": { + "color": "#1d365d", + "url": "https://github.com/trending?l=Less" + }, + "Lex": { + "color": "#DBCA00", + "url": "https://github.com/trending?l=Lex" + }, + "LFE": { + "color": "#4C3023", + "url": "https://github.com/trending?l=LFE" + }, + "LigoLANG": { + "color": "#0e74ff", + "url": "https://github.com/trending?l=LigoLANG" + }, + "LilyPond": { + "color": "#9ccc7c", + "url": "https://github.com/trending?l=LilyPond" + }, + "Limbo": { + "color": null, + "url": "https://github.com/trending?l=Limbo" + }, + "Linear Programming": { + "color": null, + "url": "https://github.com/trending?l=Linear-Programming" + }, + "Linker Script": { + "color": null, + "url": "https://github.com/trending?l=Linker-Script" + }, + "Liquid": { + "color": "#67b8de", + "url": "https://github.com/trending?l=Liquid" + }, + "Liquidsoap": { + "color": "#990066", + "url": "https://github.com/trending?l=Liquidsoap" + }, + "Literate Agda": { + "color": "#315665", + "url": "https://github.com/trending?l=Literate-Agda" + }, + "Literate CoffeeScript": { + "color": "#244776", + "url": "https://github.com/trending?l=Literate-CoffeeScript" + }, + "Literate Haskell": { + "color": "#5e5086", + "url": "https://github.com/trending?l=Literate-Haskell" + }, + "LiveCode Script": { + "color": "#0c5ba5", + "url": "https://github.com/trending?l=LiveCode-Script" + }, + "LiveScript": { + "color": "#499886", + "url": "https://github.com/trending?l=LiveScript" + }, + "LLVM": { + "color": "#185619", + "url": "https://github.com/trending?l=LLVM" + }, + "Logos": { + "color": null, + "url": "https://github.com/trending?l=Logos" + }, + "Logtalk": { + "color": "#295b9a", + "url": "https://github.com/trending?l=Logtalk" + }, + "LOLCODE": { + "color": "#cc9900", + "url": "https://github.com/trending?l=LOLCODE" + }, + "LookML": { + "color": "#652B81", + "url": "https://github.com/trending?l=LookML" + }, + "LoomScript": { + "color": null, + "url": "https://github.com/trending?l=LoomScript" + }, + "LSL": { + "color": "#3d9970", + "url": "https://github.com/trending?l=LSL" + }, + "Lua": { + "color": "#000080", + "url": "https://github.com/trending?l=Lua" + }, + "Luau": { + "color": "#00A2FF", + "url": "https://github.com/trending?l=Luau" + }, + "M": { + "color": null, + "url": "https://github.com/trending?l=M" + }, + "M3U": { + "color": "#179C7D", + "url": "https://github.com/trending?l=M3U" + }, + "M4": { + "color": null, + "url": "https://github.com/trending?l=M4" + }, + "M4Sugar": { + "color": null, + "url": "https://github.com/trending?l=M4Sugar" + }, + "Macaulay2": { + "color": "#d8ffff", + "url": "https://github.com/trending?l=Macaulay2" + }, + "Makefile": { + "color": "#427819", + "url": "https://github.com/trending?l=Makefile" + }, + "Mako": { + "color": "#7e858d", + "url": "https://github.com/trending?l=Mako" + }, + "Markdown": { + "color": "#083fa1", + "url": "https://github.com/trending?l=Markdown" + }, + "Marko": { + "color": "#42bff2", + "url": "https://github.com/trending?l=Marko" + }, + "Mask": { + "color": "#f97732", + "url": "https://github.com/trending?l=Mask" + }, + "Mathematical Programming System": { + "color": "#0530ad", + "url": "https://github.com/trending?l=Mathematical-Programming-System" + }, + "MATLAB": { + "color": "#e16737", + "url": "https://github.com/trending?l=MATLAB" + }, + "Max": { + "color": "#c4a79c", + "url": "https://github.com/trending?l=Max" + }, + "MAXScript": { + "color": "#00a6a6", + "url": "https://github.com/trending?l=MAXScript" + }, + "mcfunction": { + "color": "#E22837", + "url": "https://github.com/trending?l=mcfunction" + }, + "mdsvex": { + "color": "#5f9ea0", + "url": "https://github.com/trending?l=mdsvex" + }, + "MDX": { + "color": "#fcb32c", + "url": "https://github.com/trending?l=MDX" + }, + "Mercury": { + "color": "#ff2b2b", + "url": "https://github.com/trending?l=Mercury" + }, + "Mermaid": { + "color": "#ff3670", + "url": "https://github.com/trending?l=Mermaid" + }, + "Meson": { + "color": "#007800", + "url": "https://github.com/trending?l=Meson" + }, + "Metal": { + "color": "#8f14e9", + "url": "https://github.com/trending?l=Metal" + }, + "MeTTa": { + "color": "#6a5acd", + "url": "https://github.com/trending?l=MeTTa" + }, + "MiniD": { + "color": null, + "url": "https://github.com/trending?l=MiniD" + }, + "MiniYAML": { + "color": "#ff1111", + "url": "https://github.com/trending?l=MiniYAML" + }, + "MiniZinc": { + "color": "#06a9e6", + "url": "https://github.com/trending?l=MiniZinc" + }, + "Mint": { + "color": "#02b046", + "url": "https://github.com/trending?l=Mint" + }, + "Mirah": { + "color": "#c7a938", + "url": "https://github.com/trending?l=Mirah" + }, + "mIRC Script": { + "color": "#3d57c3", + "url": "https://github.com/trending?l=mIRC-Script" + }, + "MLIR": { + "color": "#5EC8DB", + "url": "https://github.com/trending?l=MLIR" + }, + "Modelica": { + "color": "#de1d31", + "url": "https://github.com/trending?l=Modelica" + }, + "Modula-2": { + "color": "#10253f", + "url": "https://github.com/trending?l=Modula-2" + }, + "Modula-3": { + "color": "#223388", + "url": "https://github.com/trending?l=Modula-3" + }, + "Module Management System": { + "color": null, + "url": "https://github.com/trending?l=Module-Management-System" + }, + "Mojo": { + "color": "#ff4c1f", + "url": "https://github.com/trending?l=Mojo" + }, + "Monkey": { + "color": null, + "url": "https://github.com/trending?l=Monkey" + }, + "Monkey C": { + "color": "#8D6747", + "url": "https://github.com/trending?l=Monkey-C" + }, + "Moocode": { + "color": null, + "url": "https://github.com/trending?l=Moocode" + }, + "MoonBit": { + "color": "#b92381", + "url": "https://github.com/trending?l=MoonBit" + }, + "MoonScript": { + "color": "#ff4585", + "url": "https://github.com/trending?l=MoonScript" + }, + "Motoko": { + "color": "#fbb03b", + "url": "https://github.com/trending?l=Motoko" + }, + "Motorola 68K Assembly": { + "color": "#005daa", + "url": "https://github.com/trending?l=Motorola-68K-Assembly" + }, + "Move": { + "color": "#4a137a", + "url": "https://github.com/trending?l=Move" + }, + "MQL4": { + "color": "#62A8D6", + "url": "https://github.com/trending?l=MQL4" + }, + "MQL5": { + "color": "#4A76B8", + "url": "https://github.com/trending?l=MQL5" + }, + "MTML": { + "color": "#b7e1f4", + "url": "https://github.com/trending?l=MTML" + }, + "MUF": { + "color": null, + "url": "https://github.com/trending?l=MUF" + }, + "mupad": { + "color": "#244963", + "url": "https://github.com/trending?l=mupad" + }, + "Mustache": { + "color": "#724b3b", + "url": "https://github.com/trending?l=Mustache" + }, + "Myghty": { + "color": null, + "url": "https://github.com/trending?l=Myghty" + }, + "nanorc": { + "color": "#2d004d", + "url": "https://github.com/trending?l=nanorc" + }, + "Nasal": { + "color": "#1d2c4e", + "url": "https://github.com/trending?l=Nasal" + }, + "NASL": { + "color": null, + "url": "https://github.com/trending?l=NASL" + }, + "NCL": { + "color": "#28431f", + "url": "https://github.com/trending?l=NCL" + }, + "Nearley": { + "color": "#990000", + "url": "https://github.com/trending?l=Nearley" + }, + "Nemerle": { + "color": "#3d3c6e", + "url": "https://github.com/trending?l=Nemerle" + }, + "nesC": { + "color": "#94B0C7", + "url": "https://github.com/trending?l=nesC" + }, + "NetLinx": { + "color": "#0aa0ff", + "url": "https://github.com/trending?l=NetLinx" + }, + "NetLinx+ERB": { + "color": "#747faa", + "url": "https://github.com/trending?l=NetLinx+ERB" + }, + "NetLogo": { + "color": "#ff6375", + "url": "https://github.com/trending?l=NetLogo" + }, + "NewLisp": { + "color": "#87AED7", + "url": "https://github.com/trending?l=NewLisp" + }, + "Nextflow": { + "color": "#3ac486", + "url": "https://github.com/trending?l=Nextflow" + }, + "Nginx": { + "color": "#009639", + "url": "https://github.com/trending?l=Nginx" + }, + "Nickel": { + "color": "#E0C3FC", + "url": "https://github.com/trending?l=Nickel" + }, + "Nim": { + "color": "#ffc200", + "url": "https://github.com/trending?l=Nim" + }, + "Nit": { + "color": "#009917", + "url": "https://github.com/trending?l=Nit" + }, + "Nix": { + "color": "#7e7eff", + "url": "https://github.com/trending?l=Nix" + }, + "NMODL": { + "color": "#00356B", + "url": "https://github.com/trending?l=NMODL" + }, + "Noir": { + "color": "#2f1f49", + "url": "https://github.com/trending?l=Noir" + }, + "NPM Config": { + "color": "#cb3837", + "url": "https://github.com/trending?l=NPM-Config" + }, + "NSIS": { + "color": null, + "url": "https://github.com/trending?l=NSIS" + }, + "Nu": { + "color": "#c9df40", + "url": "https://github.com/trending?l=Nu" + }, + "NumPy": { + "color": "#9C8AF9", + "url": "https://github.com/trending?l=NumPy" + }, + "Nunjucks": { + "color": "#3d8137", + "url": "https://github.com/trending?l=Nunjucks" + }, + "Nushell": { + "color": "#4E9906", + "url": "https://github.com/trending?l=Nushell" + }, + "NWScript": { + "color": "#111522", + "url": "https://github.com/trending?l=NWScript" + }, + "OASv2-json": { + "color": "#85ea2d", + "url": "https://github.com/trending?l=OASv2-json" + }, + "OASv2-yaml": { + "color": "#85ea2d", + "url": "https://github.com/trending?l=OASv2-yaml" + }, + "OASv3-json": { + "color": "#85ea2d", + "url": "https://github.com/trending?l=OASv3-json" + }, + "OASv3-yaml": { + "color": "#85ea2d", + "url": "https://github.com/trending?l=OASv3-yaml" + }, + "Oberon": { + "color": null, + "url": "https://github.com/trending?l=Oberon" + }, + "Objective-C": { + "color": "#438eff", + "url": "https://github.com/trending?l=Objective-C" + }, + "Objective-C++": { + "color": "#6866fb", + "url": "https://github.com/trending?l=Objective-C++" + }, + "Objective-J": { + "color": "#ff0c5a", + "url": "https://github.com/trending?l=Objective-J" + }, + "ObjectScript": { + "color": "#424893", + "url": "https://github.com/trending?l=ObjectScript" + }, + "OCaml": { + "color": "#ef7a08", + "url": "https://github.com/trending?l=OCaml" + }, + "Odin": { + "color": "#60AFFE", + "url": "https://github.com/trending?l=Odin" + }, + "Omgrofl": { + "color": "#cabbff", + "url": "https://github.com/trending?l=Omgrofl" + }, + "OMNeT++ MSG": { + "color": "#a0e0a0", + "url": "https://github.com/trending?l=OMNeT++-MSG" + }, + "OMNeT++ NED": { + "color": "#08607c", + "url": "https://github.com/trending?l=OMNeT++-NED" + }, + "ooc": { + "color": "#b0b77e", + "url": "https://github.com/trending?l=ooc" + }, + "Opa": { + "color": null, + "url": "https://github.com/trending?l=Opa" + }, + "Opal": { + "color": "#f7ede0", + "url": "https://github.com/trending?l=Opal" + }, + "Open Policy Agent": { + "color": "#7d9199", + "url": "https://github.com/trending?l=Open-Policy-Agent" + }, + "OpenAPI Specification v2": { + "color": "#85ea2d", + "url": "https://github.com/trending?l=OpenAPI-Specification-v2" + }, + "OpenAPI Specification v3": { + "color": "#85ea2d", + "url": "https://github.com/trending?l=OpenAPI-Specification-v3" + }, + "OpenCL": { + "color": "#ed2e2d", + "url": "https://github.com/trending?l=OpenCL" + }, + "OpenEdge ABL": { + "color": "#5ce600", + "url": "https://github.com/trending?l=OpenEdge-ABL" + }, + "OpenQASM": { + "color": "#AA70FF", + "url": "https://github.com/trending?l=OpenQASM" + }, + "OpenRC runscript": { + "color": null, + "url": "https://github.com/trending?l=OpenRC-runscript" + }, + "OpenSCAD": { + "color": "#e5cd45", + "url": "https://github.com/trending?l=OpenSCAD" + }, + "Option List": { + "color": "#476732", + "url": "https://github.com/trending?l=Option-List" + }, + "Org": { + "color": "#77aa99", + "url": "https://github.com/trending?l=Org" + }, + "OverpassQL": { + "color": "#cce2aa", + "url": "https://github.com/trending?l=OverpassQL" + }, + "OverPy": { + "color": "#78b355", + "url": "https://github.com/trending?l=OverPy" + }, + "Ox": { + "color": null, + "url": "https://github.com/trending?l=Ox" + }, + "Oxygene": { + "color": "#cdd0e3", + "url": "https://github.com/trending?l=Oxygene" + }, + "Oz": { + "color": "#fab738", + "url": "https://github.com/trending?l=Oz" + }, + "P4": { + "color": "#7055b5", + "url": "https://github.com/trending?l=P4" + }, + "Pact": { + "color": "#F7A8B8", + "url": "https://github.com/trending?l=Pact" + }, + "Pan": { + "color": "#cc0000", + "url": "https://github.com/trending?l=Pan" + }, + "Papyrus": { + "color": "#6600cc", + "url": "https://github.com/trending?l=Papyrus" + }, + "Parrot": { + "color": "#f3ca0a", + "url": "https://github.com/trending?l=Parrot" + }, + "Parrot Assembly": { + "color": null, + "url": "https://github.com/trending?l=Parrot-Assembly" + }, + "Parrot Internal Representation": { + "color": null, + "url": "https://github.com/trending?l=Parrot-Internal-Representation" + }, + "Pascal": { + "color": "#E3F171", + "url": "https://github.com/trending?l=Pascal" + }, + "Pawn": { + "color": "#dbb284", + "url": "https://github.com/trending?l=Pawn" + }, + "PDDL": { + "color": "#0d00ff", + "url": "https://github.com/trending?l=PDDL" + }, + "PEG.js": { + "color": "#234d6b", + "url": "https://github.com/trending?l=PEG.js" + }, + "Pep8": { + "color": "#C76F5B", + "url": "https://github.com/trending?l=Pep8" + }, + "Perl": { + "color": "#0298c3", + "url": "https://github.com/trending?l=Perl" + }, + "PHP": { + "color": "#4F5D95", + "url": "https://github.com/trending?l=PHP" + }, + "PicoLisp": { + "color": "#6067af", + "url": "https://github.com/trending?l=PicoLisp" + }, + "PigLatin": { + "color": "#fcd7de", + "url": "https://github.com/trending?l=PigLatin" + }, + "Pike": { + "color": "#005390", + "url": "https://github.com/trending?l=Pike" + }, + "Pip Requirements": { + "color": "#FFD343", + "url": "https://github.com/trending?l=Pip-Requirements" + }, + "pkg-config": { + "color": "#2b5e82", + "url": "https://github.com/trending?l=pkg-config" + }, + "Pkl": { + "color": "#6b9543", + "url": "https://github.com/trending?l=Pkl" + }, + "PlantUML": { + "color": "#fbbd16", + "url": "https://github.com/trending?l=PlantUML" + }, + "PLpgSQL": { + "color": "#336790", + "url": "https://github.com/trending?l=PLpgSQL" + }, + "PLSQL": { + "color": "#dad8d8", + "url": "https://github.com/trending?l=PLSQL" + }, + "PogoScript": { + "color": "#d80074", + "url": "https://github.com/trending?l=PogoScript" + }, + "Polar": { + "color": "#ae81ff", + "url": "https://github.com/trending?l=Polar" + }, + "Pony": { + "color": null, + "url": "https://github.com/trending?l=Pony" + }, + "Portugol": { + "color": "#f8bd00", + "url": "https://github.com/trending?l=Portugol" + }, + "PostCSS": { + "color": "#dc3a0c", + "url": "https://github.com/trending?l=PostCSS" + }, + "PostScript": { + "color": "#da291c", + "url": "https://github.com/trending?l=PostScript" + }, + "POV-Ray SDL": { + "color": "#6bac65", + "url": "https://github.com/trending?l=POV-Ray-SDL" + }, + "Power Query": { + "color": "#d38e0d", + "url": "https://github.com/trending?l=Power-Query" + }, + "PowerBuilder": { + "color": "#8f0f8d", + "url": "https://github.com/trending?l=PowerBuilder" + }, + "PowerShell": { + "color": "#012456", + "url": "https://github.com/trending?l=PowerShell" + }, + "Praat": { + "color": "#c8506d", + "url": "https://github.com/trending?l=Praat" + }, + "Prisma": { + "color": "#0c344b", + "url": "https://github.com/trending?l=Prisma" + }, + "Pro*C": { + "color": "#bb8368", + "url": "https://github.com/trending?l=Pro*C" + }, + "Processing": { + "color": "#0096D8", + "url": "https://github.com/trending?l=Processing" + }, + "Procfile": { + "color": "#3B2F63", + "url": "https://github.com/trending?l=Procfile" + }, + "Prolog": { + "color": "#74283c", + "url": "https://github.com/trending?l=Prolog" + }, + "Promela": { + "color": "#de0000", + "url": "https://github.com/trending?l=Promela" + }, + "Propeller Spin": { + "color": "#7fa2a7", + "url": "https://github.com/trending?l=Propeller-Spin" + }, + "Pug": { + "color": "#a86454", + "url": "https://github.com/trending?l=Pug" + }, + "Puppet": { + "color": "#302B6D", + "url": "https://github.com/trending?l=Puppet" + }, + "PureBasic": { + "color": "#5a6986", + "url": "https://github.com/trending?l=PureBasic" + }, + "PureScript": { + "color": "#1D222D", + "url": "https://github.com/trending?l=PureScript" + }, + "Pyret": { + "color": "#ee1e10", + "url": "https://github.com/trending?l=Pyret" + }, + "Python": { + "color": "#3572A5", + "url": "https://github.com/trending?l=Python" + }, + "Python console": { + "color": "#3572A5", + "url": "https://github.com/trending?l=Python-console" + }, + "Python traceback": { + "color": "#3572A5", + "url": "https://github.com/trending?l=Python-traceback" + }, + "q": { + "color": "#0040cd", + "url": "https://github.com/trending?l=q" + }, + "Q#": { + "color": "#fed659", + "url": "https://github.com/trending?l=Qsharp" + }, + "QMake": { + "color": null, + "url": "https://github.com/trending?l=QMake" + }, + "QML": { + "color": "#44a51c", + "url": "https://github.com/trending?l=QML" + }, + "Qt Script": { + "color": "#00b841", + "url": "https://github.com/trending?l=Qt-Script" + }, + "Quake": { + "color": "#882233", + "url": "https://github.com/trending?l=Quake" + }, + "QuakeC": { + "color": "#975777", + "url": "https://github.com/trending?l=QuakeC" + }, + "QuickBASIC": { + "color": "#008080", + "url": "https://github.com/trending?l=QuickBASIC" + }, + "Quint": { + "color": "#9d6ce5", + "url": "https://github.com/trending?l=Quint" + }, + "R": { + "color": "#198CE7", + "url": "https://github.com/trending?l=R" + }, + "Racket": { + "color": "#3c5caa", + "url": "https://github.com/trending?l=Racket" + }, + "Ragel": { + "color": "#9d5200", + "url": "https://github.com/trending?l=Ragel" + }, + "Raku": { + "color": "#0000fb", + "url": "https://github.com/trending?l=Raku" + }, + "RAML": { + "color": "#77d9fb", + "url": "https://github.com/trending?l=RAML" + }, + "Rascal": { + "color": "#fffaa0", + "url": "https://github.com/trending?l=Rascal" + }, + "RAScript": { + "color": "#2C97FA", + "url": "https://github.com/trending?l=RAScript" + }, + "RBS": { + "color": "#701516", + "url": "https://github.com/trending?l=RBS" + }, + "RDoc": { + "color": "#701516", + "url": "https://github.com/trending?l=RDoc" + }, + "REALbasic": { + "color": null, + "url": "https://github.com/trending?l=REALbasic" + }, + "Reason": { + "color": "#ff5847", + "url": "https://github.com/trending?l=Reason" + }, + "ReasonLIGO": { + "color": "#ff5847", + "url": "https://github.com/trending?l=ReasonLIGO" + }, + "Rebol": { + "color": "#358a5b", + "url": "https://github.com/trending?l=Rebol" + }, + "Record Jar": { + "color": "#0673ba", + "url": "https://github.com/trending?l=Record-Jar" + }, + "Red": { + "color": "#f50000", + "url": "https://github.com/trending?l=Red" + }, + "Redcode": { + "color": null, + "url": "https://github.com/trending?l=Redcode" + }, + "Redscript": { + "color": "#f44336", + "url": "https://github.com/trending?l=Redscript" + }, + "Regular Expression": { + "color": "#009a00", + "url": "https://github.com/trending?l=Regular-Expression" + }, + "Ren'Py": { + "color": "#ff7f7f", + "url": "https://github.com/trending?l=Ren'Py" + }, + "RenderScript": { + "color": null, + "url": "https://github.com/trending?l=RenderScript" + }, + "ReScript": { + "color": "#ed5051", + "url": "https://github.com/trending?l=ReScript" + }, + "reStructuredText": { + "color": "#141414", + "url": "https://github.com/trending?l=reStructuredText" + }, + "REXX": { + "color": "#d90e09", + "url": "https://github.com/trending?l=REXX" + }, + "Rez": { + "color": "#FFDAB3", + "url": "https://github.com/trending?l=Rez" + }, + "Ring": { + "color": "#2D54CB", + "url": "https://github.com/trending?l=Ring" + }, + "Riot": { + "color": "#A71E49", + "url": "https://github.com/trending?l=Riot" + }, + "RMarkdown": { + "color": "#198ce7", + "url": "https://github.com/trending?l=RMarkdown" + }, + "RobotFramework": { + "color": "#00c0b5", + "url": "https://github.com/trending?l=RobotFramework" + }, + "Roc": { + "color": "#7c38f5", + "url": "https://github.com/trending?l=Roc" + }, + "Rocq Prover": { + "color": "#d0b68c", + "url": "https://github.com/trending?l=Rocq-Prover" + }, + "Roff": { + "color": "#ecdebe", + "url": "https://github.com/trending?l=Roff" + }, + "Roff Manpage": { + "color": "#ecdebe", + "url": "https://github.com/trending?l=Roff-Manpage" + }, + "RON": { + "color": "#a62c00", + "url": "https://github.com/trending?l=RON" + }, + "ROS Interface": { + "color": "#22314e", + "url": "https://github.com/trending?l=ROS-Interface" + }, + "Rouge": { + "color": "#cc0088", + "url": "https://github.com/trending?l=Rouge" + }, + "RouterOS Script": { + "color": "#DE3941", + "url": "https://github.com/trending?l=RouterOS-Script" + }, + "RPC": { + "color": null, + "url": "https://github.com/trending?l=RPC" + }, + "RPGLE": { + "color": "#2BDE21", + "url": "https://github.com/trending?l=RPGLE" + }, + "Ruby": { + "color": "#701516", + "url": "https://github.com/trending?l=Ruby" + }, + "RUNOFF": { + "color": "#665a4e", + "url": "https://github.com/trending?l=RUNOFF" + }, + "Rust": { + "color": "#dea584", + "url": "https://github.com/trending?l=Rust" + }, + "Sage": { + "color": null, + "url": "https://github.com/trending?l=Sage" + }, + "Sail": { + "color": "#259dd5", + "url": "https://github.com/trending?l=Sail" + }, + "SaltStack": { + "color": "#646464", + "url": "https://github.com/trending?l=SaltStack" + }, + "SAS": { + "color": "#B34936", + "url": "https://github.com/trending?l=SAS" + }, + "Sass": { + "color": "#a53b70", + "url": "https://github.com/trending?l=Sass" + }, + "Scala": { + "color": "#c22d40", + "url": "https://github.com/trending?l=Scala" + }, + "Scaml": { + "color": "#bd181a", + "url": "https://github.com/trending?l=Scaml" + }, + "Scenic": { + "color": "#fdc700", + "url": "https://github.com/trending?l=Scenic" + }, + "Scheme": { + "color": "#1e4aec", + "url": "https://github.com/trending?l=Scheme" + }, + "Scilab": { + "color": "#ca0f21", + "url": "https://github.com/trending?l=Scilab" + }, + "SCSS": { + "color": "#c6538c", + "url": "https://github.com/trending?l=SCSS" + }, + "sed": { + "color": "#64b970", + "url": "https://github.com/trending?l=sed" + }, + "Self": { + "color": "#0579aa", + "url": "https://github.com/trending?l=Self" + }, + "ShaderLab": { + "color": "#222c37", + "url": "https://github.com/trending?l=ShaderLab" + }, + "Shell": { + "color": "#89e051", + "url": "https://github.com/trending?l=Shell" + }, + "ShellCheck Config": { + "color": "#cecfcb", + "url": "https://github.com/trending?l=ShellCheck-Config" + }, + "ShellSession": { + "color": null, + "url": "https://github.com/trending?l=ShellSession" + }, + "Shen": { + "color": "#120F14", + "url": "https://github.com/trending?l=Shen" + }, + "Sieve": { + "color": null, + "url": "https://github.com/trending?l=Sieve" + }, + "Simple File Verification": { + "color": "#C9BFED", + "url": "https://github.com/trending?l=Simple-File-Verification" + }, + "Singularity": { + "color": "#64E6AD", + "url": "https://github.com/trending?l=Singularity" + }, + "Slang": { + "color": "#1fbec9", + "url": "https://github.com/trending?l=Slang" + }, + "Slash": { + "color": "#007eff", + "url": "https://github.com/trending?l=Slash" + }, + "Slice": { + "color": "#003fa2", + "url": "https://github.com/trending?l=Slice" + }, + "Slim": { + "color": "#2b2b2b", + "url": "https://github.com/trending?l=Slim" + }, + "Slint": { + "color": "#2379F4", + "url": "https://github.com/trending?l=Slint" + }, + "Smali": { + "color": null, + "url": "https://github.com/trending?l=Smali" + }, + "Smalltalk": { + "color": "#596706", + "url": "https://github.com/trending?l=Smalltalk" + }, + "Smarty": { + "color": "#f0c040", + "url": "https://github.com/trending?l=Smarty" + }, + "Smithy": { + "color": "#c44536", + "url": "https://github.com/trending?l=Smithy" + }, + "SmPL": { + "color": "#c94949", + "url": "https://github.com/trending?l=SmPL" + }, + "SMT": { + "color": null, + "url": "https://github.com/trending?l=SMT" + }, + "Snakemake": { + "color": "#419179", + "url": "https://github.com/trending?l=Snakemake" + }, + "Solidity": { + "color": "#AA6746", + "url": "https://github.com/trending?l=Solidity" + }, + "SourcePawn": { + "color": "#f69e1d", + "url": "https://github.com/trending?l=SourcePawn" + }, + "SPARQL": { + "color": "#0C4597", + "url": "https://github.com/trending?l=SPARQL" + }, + "SpiceDB Schema": { + "color": "#a5318a", + "url": "https://github.com/trending?l=SpiceDB-Schema" + }, + "SQF": { + "color": "#3F3F3F", + "url": "https://github.com/trending?l=SQF" + }, + "SQL": { + "color": "#e38c00", + "url": "https://github.com/trending?l=SQL" + }, + "SQLPL": { + "color": "#e38c00", + "url": "https://github.com/trending?l=SQLPL" + }, + "Squirrel": { + "color": "#800000", + "url": "https://github.com/trending?l=Squirrel" + }, + "SRecode Template": { + "color": "#348a34", + "url": "https://github.com/trending?l=SRecode-Template" + }, + "Stan": { + "color": "#b2011d", + "url": "https://github.com/trending?l=Stan" + }, + "Standard ML": { + "color": "#dc566d", + "url": "https://github.com/trending?l=Standard-ML" + }, + "Starlark": { + "color": "#76d275", + "url": "https://github.com/trending?l=Starlark" + }, + "Stata": { + "color": "#1a5f91", + "url": "https://github.com/trending?l=Stata" + }, + "STL": { + "color": "#373b5e", + "url": "https://github.com/trending?l=STL" + }, + "StringTemplate": { + "color": "#3fb34f", + "url": "https://github.com/trending?l=StringTemplate" + }, + "Stylus": { + "color": "#ff6347", + "url": "https://github.com/trending?l=Stylus" + }, + "SubRip Text": { + "color": "#9e0101", + "url": "https://github.com/trending?l=SubRip-Text" + }, + "SugarSS": { + "color": "#2fcc9f", + "url": "https://github.com/trending?l=SugarSS" + }, + "SuperCollider": { + "color": "#46390b", + "url": "https://github.com/trending?l=SuperCollider" + }, + "SurrealQL": { + "color": "#ff00a0", + "url": "https://github.com/trending?l=SurrealQL" + }, + "Survex data": { + "color": "#ffcc99", + "url": "https://github.com/trending?l=Survex-data" + }, + "Svelte": { + "color": "#ff3e00", + "url": "https://github.com/trending?l=Svelte" + }, + "SVG": { + "color": "#ff9900", + "url": "https://github.com/trending?l=SVG" + }, + "Sway": { + "color": "#00F58C", + "url": "https://github.com/trending?l=Sway" + }, + "Sweave": { + "color": "#198ce7", + "url": "https://github.com/trending?l=Sweave" + }, + "Swift": { + "color": "#F05138", + "url": "https://github.com/trending?l=Swift" + }, + "SWIG": { + "color": null, + "url": "https://github.com/trending?l=SWIG" + }, + "SystemVerilog": { + "color": "#DAE1C2", + "url": "https://github.com/trending?l=SystemVerilog" + }, + "Tact": { + "color": "#48b5ff", + "url": "https://github.com/trending?l=Tact" + }, + "Talon": { + "color": "#333333", + "url": "https://github.com/trending?l=Talon" + }, + "Tcl": { + "color": "#e4cc98", + "url": "https://github.com/trending?l=Tcl" + }, + "Tcsh": { + "color": null, + "url": "https://github.com/trending?l=Tcsh" + }, + "Teal": { + "color": "#00B1BC", + "url": "https://github.com/trending?l=Teal" + }, + "templ": { + "color": "#66D0DD", + "url": "https://github.com/trending?l=templ" + }, + "Terra": { + "color": "#00004c", + "url": "https://github.com/trending?l=Terra" + }, + "Terraform Template": { + "color": "#7b42bb", + "url": "https://github.com/trending?l=Terraform-Template" + }, + "TeX": { + "color": "#3D6117", + "url": "https://github.com/trending?l=TeX" + }, + "TextGrid": { + "color": "#c8506d", + "url": "https://github.com/trending?l=TextGrid" + }, + "Textile": { + "color": "#ffe7ac", + "url": "https://github.com/trending?l=Textile" + }, + "TextMate Properties": { + "color": "#df66e4", + "url": "https://github.com/trending?l=TextMate-Properties" + }, + "Thrift": { + "color": "#D12127", + "url": "https://github.com/trending?l=Thrift" + }, + "TI Program": { + "color": "#A0AA87", + "url": "https://github.com/trending?l=TI-Program" + }, + "TL-Verilog": { + "color": "#C40023", + "url": "https://github.com/trending?l=TL-Verilog" + }, + "TLA": { + "color": "#4b0079", + "url": "https://github.com/trending?l=TLA" + }, + "TMDL": { + "color": "#f0c913", + "url": "https://github.com/trending?l=TMDL" + }, + "Toit": { + "color": "#c2c9fb", + "url": "https://github.com/trending?l=Toit" + }, + "TOML": { + "color": "#9c4221", + "url": "https://github.com/trending?l=TOML" + }, + "Tor Config": { + "color": "#59316b", + "url": "https://github.com/trending?l=Tor-Config" + }, + "Tree-sitter Query": { + "color": "#8ea64c", + "url": "https://github.com/trending?l=Tree-sitter-Query" + }, + "TSQL": { + "color": "#e38c00", + "url": "https://github.com/trending?l=TSQL" + }, + "TSV": { + "color": "#237346", + "url": "https://github.com/trending?l=TSV" + }, + "TSX": { + "color": "#3178c6", + "url": "https://github.com/trending?l=TSX" + }, + "Turing": { + "color": "#cf142b", + "url": "https://github.com/trending?l=Turing" + }, + "Twig": { + "color": "#c1d026", + "url": "https://github.com/trending?l=Twig" + }, + "TXL": { + "color": "#0178b8", + "url": "https://github.com/trending?l=TXL" + }, + "TypeScript": { + "color": "#3178c6", + "url": "https://github.com/trending?l=TypeScript" + }, + "TypeSpec": { + "color": "#4A3665", + "url": "https://github.com/trending?l=TypeSpec" + }, + "Typst": { + "color": "#239dad", + "url": "https://github.com/trending?l=Typst" + }, + "Unified Parallel C": { + "color": "#4e3617", + "url": "https://github.com/trending?l=Unified-Parallel-C" + }, + "Unity3D Asset": { + "color": "#222c37", + "url": "https://github.com/trending?l=Unity3D-Asset" + }, + "Unix Assembly": { + "color": null, + "url": "https://github.com/trending?l=Unix-Assembly" + }, + "Uno": { + "color": "#9933cc", + "url": "https://github.com/trending?l=Uno" + }, + "UnrealScript": { + "color": "#a54c4d", + "url": "https://github.com/trending?l=UnrealScript" + }, + "Untyped Plutus Core": { + "color": "#36adbd", + "url": "https://github.com/trending?l=Untyped-Plutus-Core" + }, + "UrWeb": { + "color": "#ccccee", + "url": "https://github.com/trending?l=UrWeb" + }, + "V": { + "color": "#4f87c4", + "url": "https://github.com/trending?l=V" + }, + "Vala": { + "color": "#a56de2", + "url": "https://github.com/trending?l=Vala" + }, + "Valve Data Format": { + "color": "#f26025", + "url": "https://github.com/trending?l=Valve-Data-Format" + }, + "VBA": { + "color": "#867db1", + "url": "https://github.com/trending?l=VBA" + }, + "VBScript": { + "color": "#15dcdc", + "url": "https://github.com/trending?l=VBScript" + }, + "vCard": { + "color": "#ee2647", + "url": "https://github.com/trending?l=vCard" + }, + "VCL": { + "color": "#148AA8", + "url": "https://github.com/trending?l=VCL" + }, + "Velocity Template Language": { + "color": "#507cff", + "url": "https://github.com/trending?l=Velocity-Template-Language" + }, + "Vento": { + "color": "#ff0080", + "url": "https://github.com/trending?l=Vento" + }, + "Verilog": { + "color": "#b2b7f8", + "url": "https://github.com/trending?l=Verilog" + }, + "VHDL": { + "color": "#adb2cb", + "url": "https://github.com/trending?l=VHDL" + }, + "Vim Help File": { + "color": "#199f4b", + "url": "https://github.com/trending?l=Vim-Help-File" + }, + "Vim Script": { + "color": "#199f4b", + "url": "https://github.com/trending?l=Vim-Script" + }, + "Vim Snippet": { + "color": "#199f4b", + "url": "https://github.com/trending?l=Vim-Snippet" + }, + "Visual Basic .NET": { + "color": "#945db7", + "url": "https://github.com/trending?l=Visual-Basic-.NET" + }, + "Visual Basic 6.0": { + "color": "#2c6353", + "url": "https://github.com/trending?l=Visual-Basic-6.0" + }, + "Volt": { + "color": "#1F1F1F", + "url": "https://github.com/trending?l=Volt" + }, + "Vue": { + "color": "#41b883", + "url": "https://github.com/trending?l=Vue" + }, + "Vyper": { + "color": "#9F4CF2", + "url": "https://github.com/trending?l=Vyper" + }, + "WDL": { + "color": "#42f1f4", + "url": "https://github.com/trending?l=WDL" + }, + "Web Ontology Language": { + "color": "#5b70bd", + "url": "https://github.com/trending?l=Web-Ontology-Language" + }, + "WebAssembly": { + "color": "#04133b", + "url": "https://github.com/trending?l=WebAssembly" + }, + "WebAssembly Interface Type": { + "color": "#6250e7", + "url": "https://github.com/trending?l=WebAssembly-Interface-Type" + }, + "WebIDL": { + "color": null, + "url": "https://github.com/trending?l=WebIDL" + }, + "WGSL": { + "color": "#1a5e9a", + "url": "https://github.com/trending?l=WGSL" + }, + "Whiley": { + "color": "#d5c397", + "url": "https://github.com/trending?l=Whiley" + }, + "Wikitext": { + "color": "#fc5757", + "url": "https://github.com/trending?l=Wikitext" + }, + "Windows Registry Entries": { + "color": "#52d5ff", + "url": "https://github.com/trending?l=Windows-Registry-Entries" + }, + "wisp": { + "color": "#7582D1", + "url": "https://github.com/trending?l=wisp" + }, + "Witcher Script": { + "color": "#ff0000", + "url": "https://github.com/trending?l=Witcher-Script" + }, + "Wolfram Language": { + "color": "#dd1100", + "url": "https://github.com/trending?l=Wolfram-Language" + }, + "Wollok": { + "color": "#a23738", + "url": "https://github.com/trending?l=Wollok" + }, + "World of Warcraft Addon Data": { + "color": "#f7e43f", + "url": "https://github.com/trending?l=World-of-Warcraft-Addon-Data" + }, + "Wren": { + "color": "#383838", + "url": "https://github.com/trending?l=Wren" + }, + "X10": { + "color": "#4B6BEF", + "url": "https://github.com/trending?l=X10" + }, + "xBase": { + "color": "#403a40", + "url": "https://github.com/trending?l=xBase" + }, + "XC": { + "color": "#99DA07", + "url": "https://github.com/trending?l=XC" + }, + "Xmake": { + "color": "#22a079", + "url": "https://github.com/trending?l=Xmake" + }, + "XML": { + "color": "#0060ac", + "url": "https://github.com/trending?l=XML" + }, + "XML Property List": { + "color": "#0060ac", + "url": "https://github.com/trending?l=XML-Property-List" + }, + "Xojo": { + "color": "#81bd41", + "url": "https://github.com/trending?l=Xojo" + }, + "Xonsh": { + "color": "#285EEF", + "url": "https://github.com/trending?l=Xonsh" + }, + "XProc": { + "color": null, + "url": "https://github.com/trending?l=XProc" + }, + "XQuery": { + "color": "#5232e7", + "url": "https://github.com/trending?l=XQuery" + }, + "XS": { + "color": null, + "url": "https://github.com/trending?l=XS" + }, + "XSLT": { + "color": "#EB8CEB", + "url": "https://github.com/trending?l=XSLT" + }, + "Xtend": { + "color": "#24255d", + "url": "https://github.com/trending?l=Xtend" + }, + "Yacc": { + "color": "#4B6C4B", + "url": "https://github.com/trending?l=Yacc" + }, + "YAML": { + "color": "#cb171e", + "url": "https://github.com/trending?l=YAML" + }, + "YARA": { + "color": "#220000", + "url": "https://github.com/trending?l=YARA" + }, + "YASnippet": { + "color": "#32AB90", + "url": "https://github.com/trending?l=YASnippet" + }, + "Yul": { + "color": "#794932", + "url": "https://github.com/trending?l=Yul" + }, + "ZAP": { + "color": "#0d665e", + "url": "https://github.com/trending?l=ZAP" + }, + "Zeek": { + "color": null, + "url": "https://github.com/trending?l=Zeek" + }, + "ZenScript": { + "color": "#00BCD1", + "url": "https://github.com/trending?l=ZenScript" + }, + "Zephir": { + "color": "#118f9e", + "url": "https://github.com/trending?l=Zephir" + }, + "Zig": { + "color": "#ec915c", + "url": "https://github.com/trending?l=Zig" + }, + "ZIL": { + "color": "#dc75e5", + "url": "https://github.com/trending?l=ZIL" + }, + "Zimpl": { + "color": "#d67711", + "url": "https://github.com/trending?l=Zimpl" + }, + "Zmodel": { + "color": "#ff7100", + "url": "https://github.com/trending?l=Zmodel" + } +} @@ -0,0 +1,292 @@ +use crate::db::models::{Access, Repository, User}; +use crate::db::queries; +use crate::git::repo::bare_path; +use crate::state::AppState; +use anyhow::Result; +use axum::body::Body; +use axum::extract::{Path, Query, State}; +use axum::http::{header, HeaderMap, Request, Response, StatusCode}; +use axum::response::IntoResponse; +use base64::Engine; +use futures::StreamExt; +use serde::Deserialize; +use sqlx::PgPool; +use std::process::Stdio; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; +use tokio_util::io::ReaderStream; + +#[derive(Deserialize)] +pub struct ServiceQuery { + pub service: Option<String>, +} + +pub async fn info_refs( + State(state): State<AppState>, + Path((owner, repo)): Path<(String, String)>, + Query(q): Query<ServiceQuery>, + headers: HeaderMap, +) -> impl IntoResponse { + let repo_name = strip_git_suffix(&repo); + match authorize_git(&state, &owner, repo_name, &headers).await { + Ok((access, user, _repo)) => { + if !access.can_read() { + return if user.is_none() { + unauthorized() + } else { + forbidden() + }; + } + let service = match q.service.as_deref() { + Some("git-upload-pack") => "git-upload-pack", + Some("git-receive-pack") => { + // Unauthenticated → 401 so git prompts for credentials. + // Authenticated but no write → 403. + if !access.can_write() { + return if user.is_none() { + unauthorized() + } else { + forbidden() + }; + } + "git-receive-pack" + } + _ => { + return ( + StatusCode::BAD_REQUEST, + "missing or unsupported service", + ) + .into_response(); + } + }; + let path = bare_path(&state.config.repos_dir(), &owner, repo_name); + match advertise_refs(&path, service).await { + Ok(body) => Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + format!("application/x-{service}-advertisement"), + ) + .header(header::CACHE_CONTROL, "no-cache") + .body(Body::from(body)) + .unwrap() + .into_response(), + Err(e) => { + tracing::error!("info/refs: {e:#}"); + StatusCode::NOT_FOUND.into_response() + } + } + } + Err(_) => unauthorized(), + } +} + +pub async fn upload_pack( + State(state): State<AppState>, + Path((owner, repo)): Path<(String, String)>, + headers: HeaderMap, + req: Request<Body>, +) -> impl IntoResponse { + let repo_name = strip_git_suffix(&repo); + match authorize_git(&state, &owner, repo_name, &headers).await { + Ok((access, user, _)) if access.can_read() => { + let path = bare_path(&state.config.repos_dir(), &owner, repo_name); + rpc_service(&path, "git-upload-pack", req).await + } + Ok((_, user, _)) => { + if user.is_none() { + unauthorized() + } else { + forbidden() + } + } + Err(_) => unauthorized(), + } +} + +pub async fn receive_pack( + State(state): State<AppState>, + Path((owner, repo)): Path<(String, String)>, + headers: HeaderMap, + req: Request<Body>, +) -> impl IntoResponse { + let repo_name = strip_git_suffix(&repo); + match authorize_git(&state, &owner, repo_name, &headers).await { + Ok((access, user, repository)) if access.can_write() => { + if repository.archived { + return ( + StatusCode::FORBIDDEN, + "repository is archived", + ) + .into_response(); + } + let path = bare_path(&state.config.repos_dir(), &owner, repo_name); + let resp = rpc_service(&path, "git-receive-pack", req).await; + if let Some(u) = user { + let _ = post_push_hooks(&state, &u, &repository, &owner).await; + } + resp + } + Ok((_, user, _)) => { + if user.is_none() { + unauthorized() + } else { + forbidden() + } + } + Err(_) => unauthorized(), + } +} + +async fn post_push_hooks( + state: &AppState, + user: &User, + repo: &Repository, + owner: &str, +) -> Result<()> { + queries::record_activity( + &state.pool, + Some(user.id), + Some(repo.id), + "push", + "pushed", + serde_json::json!({}), + ) + .await?; + queries::bump_commit_activity(&state.pool, user.id, chrono::Utc::now().date_naive(), 1).await?; + + if let Ok(g) = crate::git::open_bare(&state.config.repos_dir(), owner, &repo.name) { + let branch = repo.default_branch.clone(); + if let Ok(files) = crate::git::walk_files(&g, &branch) { + let stats = crate::git::languages::detect_languages(&files); + queries::set_language_stats(&state.pool, repo.id, stats).await?; + } + } + let _ = sqlx::query("UPDATE repositories SET updated_at = now() WHERE id = $1") + .bind(repo.id) + .execute(&state.pool) + .await; + Ok(()) +} + +async fn advertise_refs(path: &std::path::Path, service: &str) -> Result<Vec<u8>> { + let output = Command::new(service) + .arg("--stateless-rpc") + .arg("--advertise-refs") + .arg(path) + .output() + .await?; + if !output.status.success() { + anyhow::bail!( + "{} failed: {}", + service, + String::from_utf8_lossy(&output.stderr) + ); + } + let mut body = Vec::new(); + let header = format!("# service={service}\n"); + body.extend_from_slice(pkt_line(&header).as_slice()); + body.extend_from_slice(b"0000"); + body.extend_from_slice(&output.stdout); + Ok(body) +} + +fn pkt_line(s: &str) -> Vec<u8> { + let len = s.len() + 4; + format!("{len:04x}{s}").into_bytes() +} + +async fn rpc_service(path: &std::path::Path, service: &str, req: Request<Body>) -> Response<Body> { + let mut child = match Command::new(service) + .arg("--stateless-rpc") + .arg(path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(e) => { + tracing::error!("spawn {service}: {e}"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut body = req.into_body().into_data_stream(); + + tokio::spawn(async move { + while let Some(Ok(chunk)) = body.next().await { + if stdin.write_all(&chunk).await.is_err() { + break; + } + } + let _ = stdin.shutdown().await; + }); + + let stream = ReaderStream::new(stdout); + Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + format!("application/x-{service}-result"), + ) + .header(header::CACHE_CONTROL, "no-cache") + .body(Body::from_stream(stream)) + .unwrap() +} + +async fn authorize_git( + state: &AppState, + owner: &str, + name: &str, + headers: &HeaderMap, +) -> Result<(Access, Option<User>, Repository)> { + let (repo, owner_user) = queries::get_repo(&state.pool, owner, name) + .await? + .ok_or_else(|| anyhow::anyhow!("not found"))?; + + let mut user = crate::auth::current_user(&state.auth, headers).await?; + if user.is_none() { + user = basic_auth_user(&state.pool, headers).await?; + } + let access = queries::repo_access(&state.pool, &repo, user.as_ref().map(|u| u.id)).await?; + let _ = owner_user; + Ok((access, user, repo)) +} + +async fn basic_auth_user(pool: &PgPool, headers: &HeaderMap) -> Result<Option<User>> { + let Some(auth) = headers.get(header::AUTHORIZATION) else { + return Ok(None); + }; + let Ok(s) = auth.to_str() else { + return Ok(None); + }; + let Some(b64) = s.strip_prefix("Basic ") else { + return Ok(None); + }; + let decoded = base64::engine::general_purpose::STANDARD.decode(b64)?; + let pair = String::from_utf8(decoded)?; + let (username, _password) = pair.split_once(':').unwrap_or((pair.as_str(), "")); + // 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?) +} + +fn strip_git_suffix(name: &str) -> &str { + name.strip_suffix(".git").unwrap_or(name) +} + +fn unauthorized() -> axum::response::Response { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header(header::WWW_AUTHENTICATE, "Basic realm=\"kitgit\"") + .body(Body::from("unauthorized")) + .unwrap() +} + +fn forbidden() -> axum::response::Response { + (StatusCode::FORBIDDEN, "forbidden").into_response() +} @@ -0,0 +1,470 @@ +use serde::Deserialize; + +use serde_json::{json, Value}; + +use std::collections::HashMap; + +use std::path::Path; + +use std::sync::OnceLock; + + + +const DEFAULT_LANG_COLOR: &str = "#858585"; + +/// Languages below this share of total bytes are omitted from the bar / list. + +const MIN_DISPLAY_PERCENT: f64 = 2.0; + + + +#[derive(Deserialize)] + +struct ColorEntry { + + color: Option<String>, + +} + + + +fn colors_map() -> &'static HashMap<String, String> { + + static MAP: OnceLock<HashMap<String, String>> = OnceLock::new(); + + MAP.get_or_init(|| { + + let raw: HashMap<String, ColorEntry> = + + serde_json::from_str(include_str!("github-colors.json")).unwrap_or_default(); + + raw.into_iter() + + .filter_map(|(name, entry)| entry.color.map(|color| (name, color))) + + .collect() + + }) + +} + + + +/// GitHub linguist color for a language name ([ozh/github-colors](https://github.com/ozh/github-colors)). + +pub fn language_color(name: &str) -> &'static str { + + colors_map() + + .get(name) + + .map(String::as_str) + + .unwrap_or(DEFAULT_LANG_COLOR) + +} + + + +pub fn detect_languages(files: &[(String, usize)]) -> Value { + + let mut counts: HashMap<&'static str, usize> = HashMap::new(); + + for (path, size) in files { + + if let Some(lang) = lang_for_path(path) { + + *counts.entry(lang).or_default() += *size.max(&1); + + } + + } + + let total: usize = counts.values().sum::<usize>().max(1); + + let mut items: Vec<_> = counts.into_iter().collect(); + + items.sort_by(|a, b| b.1.cmp(&a.1)); + + + + // Drop languages under the display threshold, then renormalize percents. + + let kept: Vec<_> = items + + .into_iter() + + .filter(|(_, bytes)| ((*bytes as f64) * 100.0 / total as f64) >= MIN_DISPLAY_PERCENT) + + .collect(); + + let kept_total: usize = kept.iter().map(|(_, b)| *b).sum::<usize>().max(1); + + + + let map: serde_json::Map<String, Value> = kept + + .into_iter() + + .map(|(k, v)| { + + let pct = (v as f64) * 100.0 / kept_total as f64; + + ( + + k.to_string(), + + json!({ + + "bytes": v, + + "percent": (pct * 10.0).round() / 10.0, + + "color": language_color(k), + + }), + + ) + + }) + + .collect(); + + Value::Object(map) + +} + + + +/// Ensure stored language stats include GitHub colors (older rows may omit them). + +/// Also filters out languages under 2% and renormalizes displayed percentages. + +pub fn enrich_language_colors(stats: Value) -> Value { + + let Value::Object(map) = stats else { + + return stats; + + }; + + + + let mut items: Vec<(String, f64, usize, String)> = Vec::new(); + + for (name, meta) in map { + + let Value::Object(obj) = meta else { + + continue; + + }; + + let percent = obj + + .get("percent") + + .and_then(|v| v.as_f64()) + + .unwrap_or(0.0); + + if percent < MIN_DISPLAY_PERCENT { + + continue; + + } + + let bytes = obj + + .get("bytes") + + .and_then(|v| v.as_u64()) + + .map(|b| b as usize) + + .unwrap_or(0); + + let color = obj + + .get("color") + + .and_then(|c| c.as_str()) + + .filter(|c| !c.is_empty()) + + .map(|c| c.to_string()) + + .unwrap_or_else(|| language_color(&name).to_string()); + + items.push((name, percent, bytes, color)); + + } + + + + let weight_total: f64 = if items.iter().any(|(_, _, b, _)| *b > 0) { + + items.iter().map(|(_, _, b, _)| *b as f64).sum::<f64>().max(1.0) + + } else { + + items.iter().map(|(_, p, _, _)| *p).sum::<f64>().max(1.0) + + }; + + + + let mut out = serde_json::Map::new(); + + for (name, percent, bytes, color) in items { + + let pct = if bytes > 0 { + + (bytes as f64) * 100.0 / weight_total + + } else { + + percent * 100.0 / weight_total + + }; + + out.insert( + + name, + + json!({ + + "bytes": bytes, + + "percent": (pct * 10.0).round() / 10.0, + + "color": color, + + }), + + ); + + } + + Value::Object(out) + +} + + + +fn lang_for_path(path: &str) -> Option<&'static str> { + + let name = Path::new(path) + + .file_name() + + .and_then(|s| s.to_str()) + + .unwrap_or(""); + + if name == "Dockerfile" || name.starts_with("Dockerfile.") { + + return Some("Dockerfile"); + + } + + if name == "Makefile" || name == "makefile" { + + return Some("Makefile"); + + } + + let ext = Path::new(path) + + .extension() + + .and_then(|s| s.to_str()) + + .unwrap_or("") + + .to_ascii_lowercase(); + + Some(match ext.as_str() { + + "rs" => "Rust", + + "go" => "Go", + + "py" => "Python", + + "js" | "mjs" | "cjs" => "JavaScript", + + "ts" | "tsx" => "TypeScript", + + "jsx" => "JavaScript", + + "java" => "Java", + + "kt" | "kts" => "Kotlin", + + "c" | "h" => "C", + + "cpp" | "cc" | "cxx" | "hpp" => "C++", + + "cs" => "C#", + + "rb" => "Ruby", + + "php" => "PHP", + + "swift" => "Swift", + + "scala" => "Scala", + + "hs" => "Haskell", + + "lua" => "Lua", + + "sh" | "bash" | "zsh" => "Shell", + + "ps1" => "PowerShell", + + "html" | "htm" => "HTML", + + "css" => "CSS", + + "scss" | "sass" => "SCSS", + + "md" | "markdown" => "Markdown", + + "json" => "JSON", + + "yml" | "yaml" => "YAML", + + "toml" => "TOML", + + "xml" => "XML", + + "sql" => "SQL", + + "r" => "R", + + "dart" => "Dart", + + "vue" => "Vue", + + "svelte" => "Svelte", + + "zig" => "Zig", + + "nim" => "Nim", + + "ex" | "exs" => "Elixir", + + "erl" => "Erlang", + + "clj" | "cljs" => "Clojure", + + "tf" => "HCL", + + "proto" => "Protocol Buffer", + + _ => return None, + + }) + +} + + + +#[cfg(test)] + +mod tests { + + use super::*; + + + + #[test] + + fn rust_uses_github_color() { + + assert_eq!(language_color("Rust"), "#dea584"); + + assert_eq!(language_color("Python"), "#3572A5"); + + assert_eq!(language_color("NotARealLang"), DEFAULT_LANG_COLOR); + + } + + + + #[test] + + fn detect_includes_color() { + + let stats = detect_languages(&[("main.rs".into(), 100)]); + + let rust = stats.get("Rust").unwrap(); + + assert_eq!(rust["color"], "#dea584"); + + } + + + + #[test] + + fn detect_excludes_under_two_percent() { + + let stats = detect_languages(&[ + + ("main.rs".into(), 980), + + ("tiny.py".into(), 10), // ~1% + + ("app.js".into(), 10), // ~1% + + ]); + + assert!(stats.get("Rust").is_some()); + + assert!(stats.get("Python").is_none()); + + assert!(stats.get("JavaScript").is_none()); + + let pct = stats["Rust"]["percent"].as_f64().unwrap(); + + assert!((pct - 100.0).abs() < 0.01); + + } + + + + #[test] + + fn enrich_filters_and_renormalizes() { + + let stats = json!({ + + "Rust": { "bytes": 90, "percent": 90.0, "color": "#dea584" }, + + "Python": { "bytes": 1, "percent": 1.0 }, + + "Go": { "bytes": 9, "percent": 9.0 }, + + }); + + let enriched = enrich_language_colors(stats); + + assert!(enriched.get("Python").is_none()); + + assert!(enriched.get("Rust").is_some()); + + assert!(enriched.get("Go").is_some()); + + let rust = enriched["Rust"]["percent"].as_f64().unwrap(); + + let go = enriched["Go"]["percent"].as_f64().unwrap(); + + assert!((rust + go - 100.0).abs() < 0.2); + + } + +} + @@ -0,0 +1,232 @@ +//! Minimal Git LFS batch + basic transfer API. + +use crate::db::queries; +use crate::state::AppState; +use crate::web::routes::{load_repo_context, AppError, AppResult}; +use axum::body::Body; +use axum::extract::{Path, State}; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::path::PathBuf; +use tokio::fs; +use tokio::io::AsyncWriteExt; + +fn lfs_root(state: &AppState) -> PathBuf { + state.config.data_dir.join("lfs") +} + +fn oid_path(state: &AppState, oid: &str) -> PathBuf { + let prefix = if oid.len() >= 2 { &oid[..2] } else { "xx" }; + lfs_root(state).join(prefix).join(oid) +} + +#[derive(Deserialize)] +pub struct LfsBatchRequest { + pub operation: String, + #[serde(default)] + pub transfers: Vec<String>, + pub objects: Vec<LfsObjectSpec>, +} + +#[derive(Deserialize, Serialize, Clone)] +pub struct LfsObjectSpec { + pub oid: String, + pub size: i64, +} + +#[derive(Serialize)] +pub struct LfsBatchResponse { + pub transfer: String, + pub objects: Vec<LfsObjectResult>, +} + +#[derive(Serialize)] +pub struct LfsObjectResult { + pub oid: String, + pub size: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub actions: Option<LfsActions>, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option<LfsError>, +} + +#[derive(Serialize)] +pub struct LfsActions { + #[serde(skip_serializing_if = "Option::is_none")] + pub download: Option<LfsAction>, + #[serde(skip_serializing_if = "Option::is_none")] + pub upload: Option<LfsAction>, +} + +#[derive(Serialize)] +pub struct LfsAction { + pub href: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub header: Option<std::collections::HashMap<String, String>>, + pub expires_in: u64, +} + +#[derive(Serialize)] +pub struct LfsError { + pub code: u16, + pub message: String, +} + +pub async fn lfs_batch( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + Json(req): Json<LfsBatchRequest>, +) -> AppResult<impl IntoResponse> { + let (repository, _o, _viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let need_write = req.operation == "upload"; + if need_write && !access.can_write() { + return Err(AppError::forbidden()); + } + if !access.can_read() { + return Err(AppError::forbidden()); + } + + let base = state.config.public_url.trim_end_matches('/'); + let mut objects = Vec::new(); + for obj in req.objects { + if !obj.oid.chars().all(|c| c.is_ascii_hexdigit()) || obj.oid.len() != 64 { + objects.push(LfsObjectResult { + oid: obj.oid, + size: obj.size, + actions: None, + error: Some(LfsError { + code: 422, + message: "invalid oid".into(), + }), + }); + continue; + } + let path = oid_path(&state, &obj.oid); + let exists = path.exists(); + let href = format!("{base}/{owner}/{repo}/info/lfs/objects/{}/{}", obj.oid, obj.size); + let mut actions = LfsActions { + download: None, + upload: None, + }; + if req.operation == "download" { + if exists { + actions.download = Some(LfsAction { + href: href.clone(), + header: None, + expires_in: 3600, + }); + } else { + objects.push(LfsObjectResult { + oid: obj.oid, + size: obj.size, + actions: None, + error: Some(LfsError { + code: 404, + message: "object not found".into(), + }), + }); + continue; + } + } else if req.operation == "upload" { + if !exists { + actions.upload = Some(LfsAction { + href: href.clone(), + header: None, + expires_in: 3600, + }); + } + // verify/download optional after upload + actions.download = Some(LfsAction { + href, + header: None, + expires_in: 3600, + }); + let _ = queries::register_lfs_object(&state.pool, repository.id, &obj.oid, obj.size).await; + } + objects.push(LfsObjectResult { + oid: obj.oid, + size: obj.size, + actions: Some(actions), + error: None, + }); + } + + Ok(( + [(header::CONTENT_TYPE, "application/vnd.git-lfs+json")], + Json(LfsBatchResponse { + transfer: "basic".into(), + objects, + }), + )) +} + +pub async fn lfs_upload( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, oid, size)): Path<(String, String, String, i64)>, + body: Body, +) -> AppResult<Response> { + let (repository, _o, _viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !access.can_write() { + return Err(AppError::forbidden()); + } + if oid.len() != 64 || !oid.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(AppError::bad("invalid oid")); + } + + let path = oid_path(&state, &oid); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await?; + } + + let bytes = axum::body::to_bytes(body, 1024 * 1024 * 1024) + .await + .map_err(|e| AppError::bad(e.to_string()))?; + if size >= 0 && bytes.len() as i64 != size { + return Err(AppError::bad("size mismatch")); + } + let mut hasher = Sha256::new(); + hasher.update(&bytes); + let dig = hex::encode(hasher.finalize()); + if dig != oid { + return Err(AppError::bad("oid mismatch")); + } + + let mut file = fs::File::create(&path).await?; + file.write_all(&bytes).await?; + file.flush().await?; + queries::register_lfs_object(&state.pool, repository.id, &oid, bytes.len() as i64).await?; + Ok(StatusCode::OK.into_response()) +} + +pub async fn lfs_download( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, oid, _size)): Path<(String, String, String, i64)>, +) -> AppResult<Response> { + let (_repository, _o, _viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !access.can_read() { + return Err(AppError::forbidden()); + } + let path = oid_path(&state, &oid); + if !path.exists() { + return Err(AppError::not_found()); + } + let data = fs::read(&path).await?; + let len = data.len().to_string(); + Ok(( + [ + (header::CONTENT_TYPE, "application/octet-stream"), + (header::CONTENT_LENGTH, len.as_str()), + ], + data, + ) + .into_response()) +} @@ -0,0 +1,7 @@ +pub mod http; +pub mod languages; +pub mod lfs; +pub mod repo; +pub mod ssh; + +pub use repo::*; @@ -0,0 +1,797 @@ +use anyhow::{anyhow, Context, Result}; +use git2::{ + BranchType, Commit, DiffOptions, ObjectType, Oid, Repository as G2Repo, Signature, Sort, Tree, + TreeWalkMode, TreeWalkResult, +}; +use std::path::{Path, PathBuf}; + +pub fn bare_path(repos_root: &Path, owner: &str, name: &str) -> PathBuf { + repos_root.join(owner).join(format!("{name}.git")) +} + +pub fn init_bare(repos_root: &Path, owner: &str, name: &str, default_branch: &str) -> Result<PathBuf> { + let path = bare_path(repos_root, owner, name); + if path.exists() { + return Err(anyhow!("repository already exists on disk")); + } + std::fs::create_dir_all(path.parent().unwrap())?; + let repo = G2Repo::init_bare(&path)?; + repo.set_head(&format!("refs/heads/{default_branch}"))?; + // Allow HTTP push receive + repo.config()?.set_bool("http.receivepack", true)?; + repo.config()?.set_bool("http.uploadpack", true)?; + // Git LFS pointer filter (clients still need git-lfs installed) + repo.config()?.set_bool("lfs.locksverify", false)?; + sync_branch_protection(&path, default_branch, false, true)?; + Ok(path) +} + +/// Create an initial README commit on `branch` so the default branch exists. +pub fn seed_initial_commit( + repos_root: &Path, + owner: &str, + name: &str, + branch: &str, + author_name: &str, + author_email: &str, +) -> Result<String> { + let readme = format!( + "# {name}\n\nInitialized on kitgit.\n" + ); + commit_file( + repos_root, + owner, + name, + branch, + "README.md", + readme.as_bytes(), + author_name, + author_email, + "Initial commit", + ) +} + +pub fn ahead_behind(repo: &G2Repo, branch: &str, base: &str) -> Result<(usize, usize)> { + let local = resolve_ref(repo, branch)?; + let upstream = resolve_ref(repo, base)?; + Ok(repo.graph_ahead_behind(local, upstream)?) +} + +pub fn branch_tip_time(repo: &G2Repo, branch: &str) -> Result<i64> { + let oid = resolve_ref(repo, branch)?; + let c = repo.find_commit(oid)?; + Ok(c.time().seconds()) +} + +pub fn delete_branch(repo: &G2Repo, branch: &str) -> Result<()> { + let mut b = repo + .find_branch(branch, BranchType::Local) + .with_context(|| format!("branch {branch}"))?; + b.delete()?; + Ok(()) +} + +pub fn rename_branch(repo: &G2Repo, old: &str, new: &str) -> Result<()> { + let mut b = repo + .find_branch(old, BranchType::Local) + .with_context(|| format!("branch {old}"))?; + b.rename(new, false)?; + Ok(()) +} + +/// Copy a bare repository on disk (for forks). +pub fn clone_bare(src: &Path, dest: &Path) -> Result<()> { + if dest.exists() { + return Err(anyhow!("destination already exists")); + } + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + let status = std::process::Command::new("git") + .args([ + "clone", + "--bare", + "--quiet", + src.to_str().unwrap(), + dest.to_str().unwrap(), + ]) + .status()?; + if !status.success() { + return Err(anyhow!("git clone --bare failed")); + } + let repo = G2Repo::open_bare(dest)?; + repo.config()?.set_bool("http.receivepack", true)?; + repo.config()?.set_bool("http.uploadpack", true)?; + Ok(()) +} + +/// Install/update an `update` hook that blocks force-pushes to the default branch. +pub fn sync_branch_protection( + bare: &Path, + default_branch: &str, + protect: bool, + block_force: bool, +) -> Result<()> { + let hooks = bare.join("hooks"); + std::fs::create_dir_all(&hooks)?; + let update = hooks.join("update"); + if protect && block_force { + let script = format!( + r#"#!/bin/sh +refname="$1" +oldrev="$2" +newrev="$3" +default="{default_branch}" +zero="0000000000000000000000000000000000000000" +if [ "$refname" = "refs/heads/$default" ] && [ "$oldrev" != "$zero" ] && [ "$newrev" != "$zero" ]; then + if ! git merge-base --is-ancestor "$oldrev" "$newrev" 2>/dev/null; then + echo "kitgit: force-push to protected branch '$default' denied" >&2 + exit 1 + fi +fi +exit 0 +"# + ); + std::fs::write(&update, script)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&update)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&update, perms)?; + } + } else if update.exists() { + let _ = std::fs::remove_file(&update); + } + Ok(()) +} + +pub fn remove_bare(repos_root: &Path, owner: &str, name: &str) -> Result<()> { + let path = bare_path(repos_root, owner, name); + if path.exists() { + std::fs::remove_dir_all(&path)?; + } + Ok(()) +} + +pub fn open_bare(repos_root: &Path, owner: &str, name: &str) -> Result<G2Repo> { + let path = bare_path(repos_root, owner, name); + G2Repo::open_bare(&path).with_context(|| format!("open {}", path.display())) +} + +#[derive(Debug, Clone)] +pub struct TreeEntry { + pub name: String, + pub path: String, + pub is_dir: bool, + pub mode: String, +} + +#[derive(Debug, Clone)] +pub struct CommitInfo { + pub id: String, + pub short_id: String, + pub message: String, + pub author: String, + pub email: String, + pub time: i64, +} + +pub fn list_branches(repo: &G2Repo) -> Result<Vec<String>> { + let mut out = Vec::new(); + for b in repo.branches(Some(BranchType::Local))? { + let (branch, _) = b?; + if let Some(name) = branch.name()? { + out.push(name.to_string()); + } + } + out.sort(); + Ok(out) +} + +pub fn resolve_ref(repo: &G2Repo, reference: &str) -> Result<Oid> { + let obj = repo.revparse_single(reference)?; + Ok(obj.peel_to_commit()?.id()) +} + +pub fn list_tree(repo: &G2Repo, reference: &str, path: &str) -> Result<Vec<TreeEntry>> { + let commit = repo.revparse_single(reference)?.peel_to_commit()?; + let tree = commit.tree()?; + let tree = if path.is_empty() || path == "/" { + tree + } else { + let entry = tree + .get_path(Path::new(path)) + .with_context(|| format!("path {path}"))?; + entry + .to_object(repo)? + .peel_to_tree() + .context("not a directory")? + }; + + let mut entries = Vec::new(); + for i in 0..tree.len() { + let e = tree.get(i).unwrap(); + let name = e.name().unwrap_or("").to_string(); + let is_dir = e.kind() == Some(ObjectType::Tree); + let child_path = if path.is_empty() { + name.clone() + } else { + format!("{path}/{name}") + }; + entries.push(TreeEntry { + name, + path: child_path, + is_dir, + mode: format!("{:o}", e.filemode()), + }); + } + entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name))); + Ok(entries) +} + +pub fn read_blob(repo: &G2Repo, reference: &str, path: &str) -> Result<(Vec<u8>, bool)> { + let commit = repo.revparse_single(reference)?.peel_to_commit()?; + let tree = commit.tree()?; + let entry = tree.get_path(Path::new(path))?; + let blob = entry.to_object(repo)?.peel_to_blob()?; + let data = blob.content().to_vec(); + let binary = data.iter().any(|&b| b == 0); + Ok((data, binary)) +} + +/// Find a README in `dir` only (GitHub-style). Empty `dir` = repo root. +/// Does not fall back to parent/root READMEs when browsing a subdirectory. +pub fn find_readme( + repo: &G2Repo, + reference: &str, + dir: &str, +) -> Result<Option<(String, String)>> { + let names = [ + "README.md", + "Readme.md", + "readme.md", + "README.MD", + "README", + "README.txt", + ]; + let dir = dir.trim_matches('/'); + for n in names { + let path = if dir.is_empty() { + n.to_string() + } else { + format!("{dir}/{n}") + }; + if let Ok((data, binary)) = read_blob(repo, reference, &path) { + if !binary { + return Ok(Some((n.to_string(), String::from_utf8_lossy(&data).into()))); + } + } + } + Ok(None) +} + +pub fn list_commits(repo: &G2Repo, reference: &str, limit: usize) -> Result<Vec<CommitInfo>> { + let oid = resolve_ref(repo, reference)?; + let mut revwalk = repo.revwalk()?; + revwalk.push(oid)?; + revwalk.set_sorting(Sort::TIME)?; + let mut out = Vec::new(); + for (i, id) in revwalk.enumerate() { + if i >= limit { + break; + } + let id = id?; + let c = repo.find_commit(id)?; + let msg = c.summary().unwrap_or("").to_string(); + let author = c.author(); + out.push(CommitInfo { + id: id.to_string(), + short_id: id.to_string()[..7.min(id.to_string().len())].to_string(), + message: msg, + author: author.name().unwrap_or("").to_string(), + email: author.email().unwrap_or("").to_string(), + time: author.when().seconds(), + }); + } + Ok(out) +} + +pub fn get_commit(repo: &G2Repo, id: &str) -> Result<CommitInfo> { + let oid = Oid::from_str(id)?; + let c = repo.find_commit(oid)?; + let author = c.author(); + Ok(CommitInfo { + id: oid.to_string(), + short_id: oid.to_string()[..7].to_string(), + message: c.message().unwrap_or("").to_string(), + author: author.name().unwrap_or("").to_string(), + email: author.email().unwrap_or("").to_string(), + time: author.when().seconds(), + }) +} + +pub fn commit_diff(repo: &G2Repo, id: &str) -> Result<String> { + let oid = Oid::from_str(id)?; + let commit = repo.find_commit(oid)?; + let tree = commit.tree()?; + let parent_tree = if commit.parent_count() > 0 { + Some(commit.parent(0)?.tree()?) + } else { + None + }; + let mut opts = DiffOptions::new(); + let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut opts))?; + let mut buf = String::new(); + diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { + let origin = line.origin(); + let content = std::str::from_utf8(line.content()).unwrap_or(""); + match origin { + '+' | '-' | ' ' | '@' => { + buf.push(origin); + buf.push_str(content); + } + _ => buf.push_str(content), + } + true + })?; + Ok(buf) +} + +pub fn branch_diff(repo: &G2Repo, source: &str, target: &str) -> Result<String> { + let src = repo.revparse_single(source)?.peel_to_tree()?; + let tgt = repo.revparse_single(target)?.peel_to_tree()?; + let mut opts = DiffOptions::new(); + let diff = repo.diff_tree_to_tree(Some(&tgt), Some(&src), Some(&mut opts))?; + let mut buf = String::new(); + diff.print(git2::DiffFormat::Patch, |_d, _h, line| { + let origin = line.origin(); + let content = std::str::from_utf8(line.content()).unwrap_or(""); + match origin { + '+' | '-' | ' ' | '@' => { + buf.push(origin); + buf.push_str(content); + } + _ => buf.push_str(content), + } + true + })?; + Ok(buf) +} + +pub fn commits_between(repo: &G2Repo, source: &str, target: &str, limit: usize) -> Result<Vec<CommitInfo>> { + let src = resolve_ref(repo, source)?; + let tgt = resolve_ref(repo, target)?; + let mut revwalk = repo.revwalk()?; + revwalk.push(src)?; + revwalk.hide(tgt)?; + revwalk.set_sorting(Sort::TIME)?; + let mut out = Vec::new(); + for (i, id) in revwalk.enumerate() { + if i >= limit { + break; + } + let id = id?; + let c = repo.find_commit(id)?; + let author = c.author(); + out.push(CommitInfo { + id: id.to_string(), + short_id: id.to_string()[..7].to_string(), + message: c.summary().unwrap_or("").to_string(), + author: author.name().unwrap_or("").to_string(), + email: author.email().unwrap_or("").to_string(), + time: author.when().seconds(), + }); + } + Ok(out) +} + +pub fn merge_branches( + repos_root: &Path, + owner: &str, + name: &str, + source: &str, + target: &str, + style: &str, + message: &str, + author_name: &str, + author_email: &str, +) -> Result<String> { + // Clone bare repo into a temp worktree, merge there, push back. + // After clone only the default branch exists locally; source/target must + // be resolved via origin/* (or created as local tracking branches). + let bare = bare_path(repos_root, owner, name); + let tmp = tempfile_dir()?; + let cleanup = |dir: &Path| { + let _ = std::fs::remove_dir_all(dir); + }; + + let clone = std::process::Command::new("git") + .args([ + "clone", + "--quiet", + bare.to_str().unwrap(), + tmp.to_str().unwrap(), + ]) + .output() + .with_context(|| format!("clone {}", bare.display()))?; + if !clone.status.success() { + cleanup(&tmp); + return Err(anyhow!( + "clone for merge failed: {}", + String::from_utf8_lossy(&clone.stderr).trim() + )); + } + + let run = |args: &[&str]| -> Result<()> { + let out = std::process::Command::new("git") + .args(args) + .current_dir(&tmp) + .output()?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + let stderr = stderr.trim(); + if stderr.is_empty() { + return Err(anyhow!("git {} failed", args.join(" "))); + } + return Err(anyhow!("git {} failed: {}", args.join(" "), stderr)); + } + Ok(()) + }; + + // Identity required for merge/squash/rebase commits. + let committer_name = if author_name.trim().is_empty() { + "kitgit" + } else { + author_name.trim() + }; + let committer_email = if author_email.trim().is_empty() { + "kitgit@localhost" + } else { + author_email.trim() + }; + if let Err(e) = (|| -> Result<()> { + run(&["config", "user.name", committer_name])?; + run(&["config", "user.email", committer_email])?; + + let origin_source = format!("origin/{source}"); + let origin_target = format!("origin/{target}"); + + // Materialize local target from the remote-tracking ref. + run(&["checkout", "-B", target, &origin_target])?; + + match style { + "squash" => { + run(&["merge", "--squash", &origin_source])?; + run(&["commit", "-m", message])?; + } + "rebase" => { + run(&["checkout", "-B", source, &origin_source])?; + run(&["rebase", target])?; + run(&["checkout", target])?; + run(&["merge", source])?; + } + _ => { + run(&["merge", "--no-ff", "-m", message, &origin_source])?; + } + } + run(&["push", "origin", target])?; + Ok(()) + })() { + cleanup(&tmp); + return Err(e); + } + + let out = std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(&tmp) + .output()?; + cleanup(&tmp); + if !out.status.success() { + return Err(anyhow!( + "git rev-parse HEAD failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +fn tempfile_dir() -> Result<PathBuf> { + let p = std::env::temp_dir().join(format!("kitgit-merge-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&p)?; + Ok(p) +} + +/// Point HEAD at `refs/heads/{branch}` (creates the symbolic ref if needed). +pub fn set_head_branch(repo: &G2Repo, branch: &str) -> Result<()> { + let name = format!("refs/heads/{branch}"); + repo.set_head(&name)?; + Ok(()) +} + +/// Commit a single file into `branch` at `path` (relative to repo root). +pub fn commit_file( + repos_root: &Path, + owner: &str, + name: &str, + branch: &str, + path: &str, + content: &[u8], + author_name: &str, + author_email: &str, + message: &str, +) -> Result<String> { + let path = path.trim_matches('/'); + if path.is_empty() || path.contains("..") { + anyhow::bail!("invalid path"); + } + let repo = open_bare(repos_root, owner, name)?; + let sig = Signature::now(author_name, author_email)?; + + let parent = match resolve_ref(&repo, branch) { + Ok(oid) => Some(repo.find_commit(oid)?), + Err(_) => None, + }; + let parent_tree = parent.as_ref().and_then(|c| c.tree().ok()); + + let mut builder = match &parent_tree { + Some(t) => repo.treebuilder(Some(t))?, + None => repo.treebuilder(None)?, + }; + + // Build nested trees for path components. + let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect(); + if parts.is_empty() { + anyhow::bail!("invalid path"); + } + fn insert_path( + repo: &G2Repo, + parent_builder: &mut git2::TreeBuilder<'_>, + parent_tree: Option<&Tree>, + parts: &[&str], + blob_oid: Oid, + ) -> Result<()> { + if parts.len() == 1 { + parent_builder.insert(parts[0], blob_oid, 0o100644)?; + return Ok(()); + } + let dirname = parts[0]; + let rest = &parts[1..]; + let child_tree = parent_tree + .and_then(|t| t.get_name(dirname)) + .filter(|e| e.kind() == Some(ObjectType::Tree)) + .and_then(|e| e.to_object(repo).ok()) + .and_then(|o| o.peel_to_tree().ok()); + let mut child_builder = match &child_tree { + Some(t) => repo.treebuilder(Some(t))?, + None => repo.treebuilder(None)?, + }; + insert_path( + repo, + &mut child_builder, + child_tree.as_ref(), + rest, + blob_oid, + )?; + let child_oid = child_builder.write()?; + parent_builder.insert(dirname, child_oid, 0o040000)?; + Ok(()) + } + + let blob_oid = repo.blob(content)?; + insert_path( + &repo, + &mut builder, + parent_tree.as_ref(), + &parts, + blob_oid, + )?; + let tree_oid = builder.write()?; + let tree = repo.find_tree(tree_oid)?; + + let parents: Vec<&Commit> = parent.as_ref().into_iter().collect(); + let oid = repo.commit( + Some(&format!("refs/heads/{branch}")), + &sig, + &sig, + message, + &tree, + &parents, + )?; + Ok(oid.to_string()) +} + +/// Create a zip archive of `reference` via `git archive`. +/// When `path` is `Some`, only that subtree is included (same as `git archive <ref> <path>`). +pub fn archive_zip( + repos_root: &Path, + owner: &str, + name: &str, + reference: &str, + path: Option<&str>, +) -> Result<Vec<u8>> { + let bare = bare_path(repos_root, owner, name); + let mut cmd = std::process::Command::new("git"); + cmd.args(["archive", "--format=zip", reference]) + .current_dir(&bare); + if let Some(p) = path { + let p = p.trim_matches('/'); + if !p.is_empty() { + if p.contains("..") || p.starts_with('/') || p.contains('\\') { + anyhow::bail!("invalid archive path"); + } + cmd.arg(p); + } + } + let out = cmd.output()?; + if !out.status.success() { + anyhow::bail!( + "git archive failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + Ok(out.stdout) +} + +pub fn walk_files(repo: &G2Repo, reference: &str) -> Result<Vec<(String, usize)>> { + let commit = repo.revparse_single(reference)?.peel_to_commit()?; + let tree = commit.tree()?; + let mut files = Vec::new(); + tree.walk(TreeWalkMode::PreOrder, |root, entry| { + if entry.kind() == Some(ObjectType::Blob) { + let name = entry.name().unwrap_or(""); + let path = format!("{root}{name}"); + if let Ok(obj) = entry.to_object(repo) { + if let Ok(blob) = obj.peel_to_blob() { + files.push((path, blob.size())); + } + } + } + TreeWalkResult::Ok + })?; + Ok(files) +} + +#[derive(Debug, Clone)] +pub struct TagInfo { + pub name: String, + pub target: String, + pub short_id: String, + pub message: String, + pub time: i64, + pub is_annotated: bool, +} + +pub fn list_tags(repo: &G2Repo) -> Result<Vec<TagInfo>> { + let names = repo.tag_names(None)?; + let mut out = Vec::new(); + for name in names.iter().flatten() { + let obj = match repo.revparse_single(name) { + Ok(o) => o, + Err(_) => continue, + }; + let (is_annotated, message, time) = if let Ok(tag) = obj.peel_to_tag() { + let msg = tag.message().unwrap_or("").trim().to_string(); + let t = tag + .tagger() + .map(|s| s.when().seconds()) + .unwrap_or(0); + (true, msg, t) + } else { + (false, String::new(), 0) + }; + let commit = match obj.peel_to_commit() { + Ok(c) => c, + Err(_) => continue, + }; + let oid = commit.id(); + let time = if time == 0 { + commit.time().seconds() + } else { + time + }; + let message = if message.is_empty() { + commit.summary().unwrap_or("").to_string() + } else { + message + }; + out.push(TagInfo { + name: name.to_string(), + target: oid.to_string(), + short_id: oid.to_string().chars().take(7).collect(), + message, + time, + is_annotated, + }); + } + out.sort_by(|a, b| b.time.cmp(&a.time).then(a.name.cmp(&b.name))); + Ok(out) +} + +pub fn tag_exists(repo: &G2Repo, tag: &str) -> bool { + repo.revparse_single(&format!("refs/tags/{tag}")).is_ok() + || repo.revparse_single(tag).is_ok() +} + +pub fn delete_tag(repo: &G2Repo, tag: &str) -> Result<()> { + let name = tag.trim_start_matches("refs/tags/"); + repo.tag_delete(name) + .with_context(|| format!("delete tag {name}"))?; + Ok(()) +} + +/// Rename a tag by recreating it at the same target (annotated when possible). +pub fn rename_tag(repo: &G2Repo, old: &str, new: &str) -> Result<()> { + let old = old.trim_start_matches("refs/tags/"); + let new = new.trim_start_matches("refs/tags/"); + if old == new { + return Ok(()); + } + if tag_exists(repo, new) { + return Err(anyhow!("tag {new} already exists")); + } + let obj = repo + .revparse_single(&format!("refs/tags/{old}")) + .or_else(|_| repo.revparse_single(old)) + .with_context(|| format!("tag {old}"))?; + let commit = obj.peel_to_commit()?; + let message = obj + .peel_to_tag() + .ok() + .and_then(|t| t.message().map(|m| m.to_string())) + .unwrap_or_default(); + let bare = repo.path(); + create_tag_at(bare, new, &commit.id().to_string(), &message)?; + delete_tag(repo, old)?; + Ok(()) +} + +/// Create an annotated tag at HEAD (or lightweight fallback). +pub fn create_tag(repos_root: &Path, owner: &str, name: &str, tag: &str, message: &str) -> Result<()> { + let bare = bare_path(repos_root, owner, name); + create_tag_at(&bare, tag, "HEAD", message) +} + +/// Create a tag pointing at `target` (commit SHA, branch, or other rev). +pub fn create_tag_at(bare: &Path, tag: &str, target: &str, message: &str) -> Result<()> { + let git_dir = bare.to_str().ok_or_else(|| anyhow!("invalid git path"))?; + let msg = if message.trim().is_empty() { + tag + } else { + message + }; + let status = std::process::Command::new("git") + .args([ + "--git-dir", + git_dir, + "tag", + "-a", + tag, + target, + "-m", + msg, + ]) + .status()?; + if !status.success() { + let status = std::process::Command::new("git") + .args(["--git-dir", git_dir, "tag", tag, target]) + .status()?; + if !status.success() { + return Err(anyhow!("failed to create tag {tag}")); + } + } + Ok(()) +} + +/// Move an existing tag to a new target (delete + recreate). +pub fn retarget_tag(bare: &Path, tag: &str, target: &str, message: &str) -> Result<()> { + let repo = open_bare_path(bare)?; + if tag_exists(&repo, tag) { + delete_tag(&repo, tag)?; + } + create_tag_at(bare, tag, target, message) +} + +pub fn open_bare_path(bare: &Path) -> Result<G2Repo> { + Ok(G2Repo::open_bare(bare)?) +} @@ -0,0 +1,313 @@ +use crate::db::queries; +use crate::git::repo::bare_path; +use crate::state::AppState; +use anyhow::{Context, Result}; +use base64::Engine; +use russh::keys::{decode_secret_key, encode_pkcs8_pem, PublicKey as RPublicKey, PublicKeyBase64}; +use russh::server::{Auth, Handler, Msg, Server, Session}; +use russh::{Channel, ChannelId, CryptoVec, MethodKind, MethodSet}; +use sha2::{Digest, Sha256}; +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::Mutex; +use uuid::Uuid; + +fn publickey_methods() -> MethodSet { + let mut m = MethodSet::empty(); + m.push(MethodKind::PublicKey); + m +} + +pub async fn run_ssh(state: AppState) -> Result<()> { + std::fs::create_dir_all(&state.config.data_dir)?; + let key_path = state.config.ssh_host_key_path(); + let key = load_or_create_host_key(&key_path)?; + + let config = russh::server::Config { + inactivity_timeout: Some(std::time::Duration::from_secs(3600)), + auth_rejection_time: std::time::Duration::from_secs(1), + keys: vec![key], + methods: publickey_methods(), + ..Default::default() + }; + let bind = state.config.ssh_bind; + let config = Arc::new(config); + let mut server = SshServer { state }; + tracing::info!("ssh listening on {bind}"); + server.run_on_address(config, bind).await?; + Ok(()) +} + +fn load_or_create_host_key(path: &PathBuf) -> Result<russh::keys::PrivateKey> { + if path.exists() { + let data = std::fs::read_to_string(path)?; + return decode_secret_key(&data, None).context("decode host key"); + } + let key = russh::keys::PrivateKey::random( + &mut rand::thread_rng(), + russh::keys::Algorithm::Ed25519, + )?; + let mut pem = Vec::new(); + encode_pkcs8_pem(&key, &mut pem)?; + std::fs::write(path, pem)?; + Ok(key) +} + +pub fn fingerprint_ssh_pubkey(line: &str) -> Result<String> { + let parts: Vec<_> = line.split_whitespace().collect(); + if parts.len() < 2 { + anyhow::bail!("invalid public key"); + } + let raw = base64::engine::general_purpose::STANDARD.decode(parts[1])?; + Ok(ssh_fingerprint_sha256(&raw)) +} + +fn fingerprint_public_key(key: &RPublicKey) -> String { + // Must match the authorized_keys blob (type || key material), same as + // OpenSSH `ssh-keygen -lf` SHA256 fingerprints (base64, no padding). + let blob = key.public_key_bytes(); + ssh_fingerprint_sha256(&blob) +} + +fn ssh_fingerprint_sha256(blob: &[u8]) -> String { + let mut h = Sha256::new(); + h.update(blob); + format!( + "SHA256:{}", + base64::engine::general_purpose::STANDARD_NO_PAD.encode(h.finalize()) + ) +} + +struct SshServer { + state: AppState, +} + +impl Server for SshServer { + type Handler = SshHandler; + + fn new_client(&mut self, _: Option<std::net::SocketAddr>) -> Self::Handler { + SshHandler { + state: self.state.clone(), + user_id: None, + stdin: None, + receive_meta: None, + } + } +} + +struct SshHandler { + state: AppState, + user_id: Option<Uuid>, + stdin: Option<Arc<Mutex<tokio::process::ChildStdin>>>, + receive_meta: Option<(String, String)>, +} + +impl Handler for SshHandler { + type Error = anyhow::Error; + + async fn auth_publickey( + &mut self, + _user: &str, + public_key: &RPublicKey, + ) -> 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? { + self.user_id = Some(user.id); + return Ok(Auth::Accept); + } + tracing::debug!("ssh publickey rejected fp={fp}"); + Ok(Auth::Reject { + proceed_with_methods: Some(publickey_methods()), + }) + } + + async fn auth_password( + &mut self, + _user: &str, + _password: &str, + ) -> Result<Auth, Self::Error> { + // Password auth is not supported — keys only. + Ok(Auth::Reject { + proceed_with_methods: Some(publickey_methods()), + }) + } + + async fn channel_open_session( + &mut self, + _channel: Channel<Msg>, + _session: &mut Session, + ) -> Result<bool, Self::Error> { + Ok(true) + } + + async fn exec_request( + &mut self, + channel: ChannelId, + data: &[u8], + session: &mut Session, + ) -> Result<(), Self::Error> { + let cmd = String::from_utf8_lossy(data).to_string(); + let Some(user_id) = self.user_id else { + session.channel_failure(channel)?; + return Ok(()); + }; + + let (service, repo_path) = match parse_git_command(&cmd) { + Ok(v) => v, + Err(_) => { + session.data(channel, CryptoVec::from_slice(b"unsupported command\n"))?; + session.exit_status_request(channel, 1)?; + session.eof(channel)?; + session.close(channel)?; + return Ok(()); + } + }; + let (owner, name) = split_repo_path(&repo_path)?; + let Some((repo, _)) = queries::get_repo(&self.state.pool, &owner, &name).await? else { + session.data(channel, CryptoVec::from_slice(b"repository not found\n"))?; + session.exit_status_request(channel, 1)?; + session.eof(channel)?; + session.close(channel)?; + return Ok(()); + }; + let access = queries::repo_access(&self.state.pool, &repo, Some(user_id)).await?; + let allowed = match service { + "git-upload-pack" => access.can_read(), + "git-receive-pack" => access.can_write() && !repo.archived, + _ => false, + }; + if !allowed { + session.data(channel, CryptoVec::from_slice(b"access denied\n"))?; + session.exit_status_request(channel, 1)?; + session.eof(channel)?; + session.close(channel)?; + return Ok(()); + } + + let bare = bare_path(&self.state.config.repos_dir(), &owner, &name); + let mut child = tokio::process::Command::new(service) + .arg(bare.as_os_str()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + let stdin = child.stdin.take().unwrap(); + let mut stdout = child.stdout.take().unwrap(); + self.stdin = Some(Arc::new(Mutex::new(stdin))); + if service == "git-receive-pack" { + self.receive_meta = Some((owner.clone(), name.clone())); + } + + let handle = session.handle(); + tokio::spawn(async move { + let mut buf = [0u8; 16384]; + loop { + match stdout.read(&mut buf).await { + Ok(0) => break, + Ok(n) => { + let _ = handle.data(channel, CryptoVec::from_slice(&buf[..n])).await; + } + Err(_) => break, + } + } + let code = child.wait().await.ok().and_then(|s| s.code()).unwrap_or(1) as u32; + let _ = handle.eof(channel).await; + let _ = handle.exit_status_request(channel, code).await; + let _ = handle.close(channel).await; + }); + + Ok(()) + } + + async fn data( + &mut self, + _channel: ChannelId, + data: &[u8], + _session: &mut Session, + ) -> Result<(), Self::Error> { + if let Some(stdin) = &self.stdin { + let mut g = stdin.lock().await; + g.write_all(data).await?; + } + Ok(()) + } + + async fn channel_eof( + &mut self, + _channel: ChannelId, + _session: &mut Session, + ) -> Result<(), Self::Error> { + if let Some(stdin) = self.stdin.take() { + let mut g = stdin.lock().await; + let _ = g.shutdown().await; + } + if let (Some(uid), Some((owner, name))) = (self.user_id, self.receive_meta.take()) { + if let Ok(Some((repo, _))) = queries::get_repo(&self.state.pool, &owner, &name).await { + if let Ok(Some(user)) = queries::get_user_by_id(&self.state.pool, uid).await { + let _ = queries::record_activity( + &self.state.pool, + Some(user.id), + Some(repo.id), + "push", + "pushed", + serde_json::json!({}), + ) + .await; + let _ = queries::bump_commit_activity( + &self.state.pool, + user.id, + chrono::Utc::now().date_naive(), + 1, + ) + .await; + if let Ok(g) = crate::git::open_bare(&self.state.config.repos_dir(), &owner, &name) + { + if let Ok(files) = crate::git::walk_files(&g, &repo.default_branch) { + let stats = crate::git::languages::detect_languages(&files); + let _ = queries::set_language_stats(&self.state.pool, repo.id, stats).await; + } + } + } + } + } + Ok(()) + } +} + +fn parse_git_command(cmd: &str) -> Result<(&'static str, String)> { + let cmd = cmd.trim(); + if cmd.contains("git-upload-pack") { + return Ok(("git-upload-pack", extract_quoted_path(cmd)?)); + } + if cmd.contains("git-receive-pack") { + return Ok(("git-receive-pack", extract_quoted_path(cmd)?)); + } + anyhow::bail!("unsupported command") +} + +fn extract_quoted_path(cmd: &str) -> Result<String> { + if let Some(start) = cmd.find('\'') { + let rest = &cmd[start + 1..]; + if let Some(end) = rest.find('\'') { + return Ok(rest[..end].to_string()); + } + } + let parts: Vec<_> = cmd.split_whitespace().collect(); + Ok(parts + .last() + .context("no path")? + .trim_matches('"') + .to_string()) +} + +fn split_repo_path(path: &str) -> Result<(String, String)> { + let path = path.trim_start_matches('/'); + let path = path.strip_suffix(".git").unwrap_or(path); + let mut parts = path.split('/'); + let owner = parts.next().context("owner")?.to_string(); + let name = parts.next().context("name")?.to_string(); + Ok((owner, name)) +} @@ -0,0 +1,61 @@ +use syntect::html::{ClassStyle, ClassedHTMLGenerator}; +use syntect::parsing::SyntaxSet; +use syntect::util::LinesWithEndings; +use std::sync::OnceLock; + +fn syntaxes() -> &'static SyntaxSet { + static SS: OnceLock<SyntaxSet> = OnceLock::new(); + SS.get_or_init(SyntaxSet::load_defaults_newlines) +} + +/// Highlight `content` for `path`, returning a self-contained `.kg-codeview`. +/// +/// Syntect keeps spans open across lines, so the highlighted HTML must stay one +/// contiguous block. Line numbers live in a sibling gutter with the same +/// monospace metrics / line-height / `white-space: pre` so rows stay aligned. +pub fn highlight(path: &str, content: &str) -> String { + let ss = syntaxes(); + let syntax = ss + .find_syntax_by_extension( + std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""), + ) + .or_else(|| { + std::path::Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .and_then(|n| ss.find_syntax_by_name(n)) + }) + .or_else(|| ss.find_syntax_by_first_line(content.lines().next().unwrap_or(""))) + .unwrap_or_else(|| ss.find_syntax_plain_text()); + + let mut generator = + ClassedHTMLGenerator::new_with_class_style(syntax, ss, ClassStyle::Spaced); + for line in LinesWithEndings::from(content) { + let _ = generator.parse_html_for_line_which_includes_newline(line); + } + // Trim trailing newlines so <pre> visual rows match str::lines() (a trailing + // \n inside <pre> otherwise paints an extra blank row vs the gutter). + let mut html = generator.finalize(); + while html.ends_with('\n') { + html.pop(); + } + + let line_count = html.lines().count().max(1); + let mut nums = String::with_capacity(line_count * 4); + for n in 1..=line_count { + if n > 1 { + nums.push('\n'); + } + nums.push_str(&n.to_string()); + } + + format!( + "<div class=\"kg-codeview kg-blob\">\ + <div class=\"kg-blob__nums\" aria-hidden=\"true\">{nums}</div>\ + <pre class=\"kg-blob__code\"><code class=\"kg-highlight\">{html}</code></pre>\ + </div>" + ) +} @@ -0,0 +1,57 @@ +mod auth; +mod config; +mod db; +mod git; +mod highlight; +mod markdown; +mod mfa; +mod state; +mod web; + +use crate::auth::AuthState; +use crate::config::Config; +use crate::state::AppState; +use anyhow::Result; +use std::sync::Arc; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .init(); + + let config = Config::load()?; + let config = Arc::new(config); + + std::fs::create_dir_all(config.repos_dir())?; + std::fs::create_dir_all(config.avatars_dir())?; + std::fs::create_dir_all(config.releases_dir())?; + std::fs::create_dir_all(config.data_dir.join("lfs"))?; + std::fs::create_dir_all(&config.data_dir)?; + + let pool = db::connect(&config.database_url).await?; + db::migrate(&pool, std::path::Path::new("./migrations")).await?; + + let auth = AuthState::new(pool.clone(), config.clone()).await?; + let state = AppState { + pool, + config: config.clone(), + auth, + }; + + let ssh_state = state.clone(); + tokio::spawn(async move { + if let Err(e) = git::ssh::run_ssh(ssh_state).await { + tracing::error!("ssh server exited: {e:#}"); + } + }); + + let app = web::app_router(state); + let listener = tokio::net::TcpListener::bind(config.http_bind).await?; + tracing::info!("http listening on {}", config.http_bind); + axum::serve(listener, app).await?; + Ok(()) +} @@ -0,0 +1,209 @@ +use pulldown_cmark::{html, Options, Parser}; +use std::borrow::Cow; + +/// Render Markdown to HTML. Protects TeX math from Markdown emphasis/escaping, +/// then leaves `$…$` / `$$…$$` for client-side KaTeX (+ KaTeX fonts). +pub fn render_markdown(src: &str) -> String { + let src = src.strip_prefix('\u{feff}').unwrap_or(src); + let src = preprocess_math_fences(src); + let (protected, slots) = protect_math(&src); + let mut opts = Options::empty(); + opts.insert(Options::ENABLE_TABLES); + opts.insert(Options::ENABLE_STRIKETHROUGH); + opts.insert(Options::ENABLE_TASKLISTS); + opts.insert(Options::ENABLE_FOOTNOTES); + let parser = Parser::new_ext(&protected, opts); + let mut out = String::new(); + html::push_html(&mut out, parser); + restore_math(&out, &slots) +} + +fn preprocess_math_fences(src: &str) -> String { + let mut out = String::with_capacity(src.len()); + let mut lines = src.lines().peekable(); + while let Some(line) = lines.next() { + let trimmed = line.trim(); + let fence = if trimmed.starts_with("```math") || trimmed.starts_with("```latex") { + Some(true) + } else if trimmed.starts_with("~~~math") || trimmed.starts_with("~~~latex") { + Some(true) + } else { + None + }; + if fence.is_some() { + let closer = if trimmed.starts_with("```") { "```" } else { "~~~" }; + out.push_str("$$\n"); + while let Some(inner) = lines.next() { + if inner.trim().starts_with(closer) { + break; + } + out.push_str(inner); + out.push('\n'); + } + out.push_str("$$\n"); + continue; + } + out.push_str(line); + out.push('\n'); + } + out +} + +fn protect_math(src: &str) -> (String, Vec<String>) { + let mut out = String::with_capacity(src.len()); + let mut slots = Vec::new(); + let bytes = src.as_bytes(); + let mut i = 0; + let mut in_fence = false; + let mut fence_char = b'`'; + let mut fence_len = 0usize; + + while i < bytes.len() { + // Track fenced code blocks so we don't treat $ inside them as math. + if !in_fence && (bytes[i] == b'`' || bytes[i] == b'~') { + let ch = bytes[i]; + let mut n = 0; + while i + n < bytes.len() && bytes[i + n] == ch { + n += 1; + } + if n >= 3 && (i == 0 || bytes[i - 1] == b'\n') { + in_fence = true; + fence_char = ch; + fence_len = n; + out.push_str(&src[i..i + n]); + i += n; + continue; + } + } else if in_fence && bytes[i] == fence_char { + let mut n = 0; + while i + n < bytes.len() && bytes[i + n] == fence_char { + n += 1; + } + if n >= fence_len && (i == 0 || bytes[i - 1] == b'\n') { + in_fence = false; + out.push_str(&src[i..i + n]); + i += n; + continue; + } + } + + if !in_fence && bytes[i] == b'$' { + let display = i + 1 < bytes.len() && bytes[i + 1] == b'$'; + let start = if display { i + 2 } else { i + 1 }; + let delim = if display { "$$" } else { "$" }; + if let Some(end) = find_math_end(bytes, start, display) { + let body = &src[start..end]; + // Skip empty / whitespace-only (likely not math). + if !body.trim().is_empty() { + let token = format!("\u{E000}MATH{}\u{E001}", slots.len()); + slots.push(format!("{delim}{body}{delim}")); + out.push_str(&token); + i = end + if display { 2 } else { 1 }; + continue; + } + } + } + + // Also protect \( ... \) and \[ ... \] + if !in_fence && bytes[i] == b'\\' && i + 1 < bytes.len() { + let open = bytes[i + 1]; + if open == b'(' || open == b'[' { + let close = if open == b'(' { b')' } else { b']' }; + let start = i + 2; + if let Some(end) = find_escaped_math_end(bytes, start, close) { + let body = &src[start..end]; + if !body.trim().is_empty() { + let token = format!("\u{E000}MATH{}\u{E001}", slots.len()); + let delim_open = if open == b'(' { "\\(" } else { "\\[" }; + let delim_close = if open == b'(' { "\\)" } else { "\\]" }; + slots.push(format!("{delim_open}{body}{delim_close}")); + out.push_str(&token); + i = end + 2; + continue; + } + } + } + } + + out.push(src[i..].chars().next().unwrap()); + i += src[i..].chars().next().unwrap().len_utf8(); + } + + (out, slots) +} + +fn find_math_end(bytes: &[u8], start: usize, display: bool) -> Option<usize> { + let mut i = start; + while i < bytes.len() { + if bytes[i] == b'\\' { + i += 2; + continue; + } + if display { + if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'$' { + return Some(i); + } + } else if bytes[i] == b'$' { + // Don't treat $$ as inline closer. + if i + 1 < bytes.len() && bytes[i + 1] == b'$' { + return None; + } + // No newlines in inline math (common Markdown convention). + if bytes[start..i].contains(&b'\n') { + return None; + } + return Some(i); + } + i += 1; + } + None +} + +fn find_escaped_math_end(bytes: &[u8], start: usize, close: u8) -> Option<usize> { + let mut i = start; + while i + 1 < bytes.len() { + if bytes[i] == b'\\' && bytes[i + 1] == close { + return Some(i); + } + i += 1; + } + None +} + +fn restore_math(html: &str, slots: &[String]) -> String { + let mut out = Cow::Borrowed(html); + for (idx, math) in slots.iter().enumerate() { + let token = format!("\u{E000}MATH{idx}\u{E001}"); + // Markdown may HTML-escape nothing for private-use chars; also handle entities if any. + if out.contains(&token) { + out = Cow::Owned(out.replace(&token, math)); + } + } + out.into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preserves_inline_math_with_underscore() { + let html = render_markdown("Euler $e^{i\\pi}+1=0$ and $x_1$."); + assert!(html.contains("$x_1$"), "{html}"); + assert!(!html.contains("<em>"), "{html}"); + } + + #[test] + fn preserves_display_math() { + let html = render_markdown("$$\n\\int_0^1 x^2\\,dx\n$$"); + assert!(html.contains("$$"), "{html}"); + assert!(html.contains("\\int_0^1"), "{html}"); + } + + #[test] + fn latex_fence_to_display() { + let html = render_markdown("```latex\n\\frac{a}{b}\n```"); + assert!(html.contains("$$"), "{html}"); + assert!(html.contains("\\frac{a}{b}"), "{html}"); + } +} @@ -0,0 +1,161 @@ +//! App-local TOTP MFA (enforced in kitgit after Authentik password succeeds). + +use anyhow::{anyhow, Result}; +use base64::Engine; +use data_encoding::BASE32_NOPAD; +use hmac::{Hmac, Mac}; +use qrcode::render::svg; +use qrcode::QrCode; +use rand::RngCore; +use sha1::Sha1; +use sha2::{Digest, Sha256}; +use std::time::{SystemTime, UNIX_EPOCH}; + +type HmacSha1 = Hmac<Sha1>; + +const TOTP_DIGITS: u32 = 6; +const TOTP_STEP: u64 = 30; +const TOTP_WINDOW: i64 = 1; +const RECOVERY_CODE_COUNT: usize = 8; + +pub fn generate_totp_secret() -> String { + let mut buf = [0u8; 20]; + rand::thread_rng().fill_bytes(&mut buf); + BASE32_NOPAD.encode(&buf) +} + +pub fn otpauth_uri(username: &str, secret_base32: &str, issuer: &str) -> String { + let label_raw = format!("{issuer}:{username}"); + let label = urlencoding::encode(&label_raw); + let issuer_q = urlencoding::encode(issuer); + format!( + "otpauth://totp/{label}?secret={secret_base32}&issuer={issuer_q}&digits={TOTP_DIGITS}&period={TOTP_STEP}" + ) +} + +pub fn qr_svg_data_uri(otpauth: &str) -> Result<String> { + let code = QrCode::new(otpauth.as_bytes()).map_err(|e| anyhow!("qr encode: {e}"))?; + let svg = code + .render::<svg::Color>() + .min_dimensions(180, 180) + .dark_color(svg::Color("#111111")) + .light_color(svg::Color("#ffffff")) + .build(); + Ok(format!( + "data:image/svg+xml;base64,{}", + base64::engine::general_purpose::STANDARD.encode(svg.as_bytes()) + )) +} + +fn hotp(secret: &[u8], counter: u64) -> Result<u32> { + let mut mac = + HmacSha1::new_from_slice(secret).map_err(|_| anyhow!("invalid hmac key"))?; + mac.update(&counter.to_be_bytes()); + let result = mac.finalize().into_bytes(); + let offset = (result[19] & 0x0f) as usize; + let code = ((u32::from(result[offset]) & 0x7f) << 24) + | ((u32::from(result[offset + 1]) & 0xff) << 16) + | ((u32::from(result[offset + 2]) & 0xff) << 8) + | (u32::from(result[offset + 3]) & 0xff); + Ok(code % 10u32.pow(TOTP_DIGITS)) +} + +fn decode_secret(secret_base32: &str) -> Result<Vec<u8>> { + let cleaned: String = secret_base32 + .chars() + .filter(|c| !c.is_whitespace() && *c != '=') + .map(|c| c.to_ascii_uppercase()) + .collect(); + BASE32_NOPAD + .decode(cleaned.as_bytes()) + .map_err(|_| anyhow!("invalid authenticator secret")) +} + +fn time_counter(now: u64) -> u64 { + now / TOTP_STEP +} + +pub fn verify_totp(secret_base32: &str, code: &str) -> bool { + let code = code.trim().replace(' ', ""); + if code.len() != TOTP_DIGITS as usize || !code.chars().all(|c| c.is_ascii_digit()) { + return false; + } + let Ok(expected) = code.parse::<u32>() else { + return false; + }; + let Ok(secret) = decode_secret(secret_base32) else { + return false; + }; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let counter = time_counter(now) as i64; + for skew in -TOTP_WINDOW..=TOTP_WINDOW { + let c = (counter + skew) as u64; + if let Ok(n) = hotp(&secret, c) { + if n == expected { + return true; + } + } + } + false +} + +pub fn generate_recovery_codes() -> Vec<String> { + let mut out = Vec::with_capacity(RECOVERY_CODE_COUNT); + let mut rng = rand::thread_rng(); + for _ in 0..RECOVERY_CODE_COUNT { + let mut buf = [0u8; 5]; + rng.fill_bytes(&mut buf); + let hex = hex::encode(buf); + out.push(format!("{}-{}", &hex[..5], &hex[5..])); + } + out +} + +pub fn hash_recovery_code(code: &str) -> String { + let normalized = code.trim().to_lowercase().replace([' ', '-'], ""); + let mut h = Sha256::new(); + h.update(normalized.as_bytes()); + hex::encode(h.finalize()) +} + +pub fn verify_recovery_code(hashes: &[String], code: &str) -> Option<usize> { + let want = hash_recovery_code(code); + hashes.iter().position(|h| h == &want) +} + +/// Normalize user-facing auth errors so "Authentik" never appears in the browser. +pub fn sanitize_user_error(msg: &str) -> String { + let lower = msg.to_lowercase(); + if lower.contains("invalid username or password") { + return "invalid username or password".into(); + } + if lower.contains("mfa") || lower.contains("authenticator") { + return "additional verification required".into(); + } + if lower.contains("misconfigured") || lower.contains("captcha") { + return "login is temporarily unavailable".into(); + } + if lower.contains("already taken") { + return "username or email already taken".into(); + } + if lower.contains("too short") || lower.contains("at least") { + return msg.to_string(); + } + if lower.contains("email") && lower.contains("required") { + return msg.to_string(); + } + if lower.contains("signup unavailable") || lower.contains("api token") { + return "signup is temporarily unavailable".into(); + } + if lower.contains("authentik") || lower.contains("oidc") { + return "authentication failed".into(); + } + // Keep short, safe messages; hide internal detail. + if msg.len() < 80 && !lower.contains("http") && !lower.contains("api/") { + return msg.to_string(); + } + "authentication failed".into() +} @@ -0,0 +1,11 @@ +use crate::auth::AuthState; +use crate::config::Config; +use sqlx::PgPool; +use std::sync::Arc; + +#[derive(Clone)] +pub struct AppState { + pub pool: PgPool, + pub config: Arc<Config>, + pub auth: AuthState, +} @@ -0,0 +1,288 @@ +pub mod routes; +pub mod routes_extra; +pub mod templates; + +use crate::git; +use crate::state::AppState; +use axum::extract::{DefaultBodyLimit, Request}; +use axum::http::{header, StatusCode}; +use axum::middleware::{from_fn, Next}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::Router; +use tower_http::limit::RequestBodyLimitLayer; +use tower_http::services::ServeDir; + +/// Rewrite `/owner/repo.git` → `/owner/repo` and drop a trailing slash so +/// browser pages and git smart-HTTP both work with classic forge URLs. +fn normalize_repo_path(path: &str) -> String { + let mut path = path.to_string(); + if path.len() > 1 && path.ends_with('/') { + path.pop(); + } + if let Some(idx) = path.find(".git/") { + path = format!("{}{}", &path[..idx], &path[idx + 4..]); + } else if path.ends_with(".git") { + path.truncate(path.len() - 4); + } + if path.is_empty() { + "/".into() + } else { + path + } +} + +async fn normalize_git_url(req: Request, next: Next) -> Response { + let path = req.uri().path(); + let normalized = normalize_repo_path(path); + if normalized != path { + // 308 keeps method/body (needed for git-receive-pack POST). + let dest = match req.uri().query() { + Some(q) => format!("{normalized}?{q}"), + None => normalized, + }; + return ( + StatusCode::PERMANENT_REDIRECT, + [(header::LOCATION, dest)], + ) + .into_response(); + } + next.run(req).await +} + +pub fn app_router(state: AppState) -> Router { + let static_dir = state.config.static_dir.clone(); + Router::new() + .route("/", get(routes::home)) + .route("/explore", get(routes::explore)) + .route( + "/auth/login", + get(routes::auth_login_page).post(routes::auth_login_submit), + ) + .route( + "/auth/mfa", + get(routes::auth_mfa_page).post(routes::auth_mfa_submit), + ) + .route( + "/auth/signup", + get(routes::auth_signup_page).post(routes::auth_signup_submit), + ) + .route("/auth/oidc", get(routes::auth_oidc_start)) + .route("/auth/callback", get(routes::auth_callback)) + .route("/auth/logout", get(routes::auth_logout)) + .route("/admin", get(routes::admin_panel)) + .route("/admin/users", post(routes::admin_set_user)) + .route("/admin/motd", post(routes::admin_save_motd)) + .route("/new", get(routes::new_repo_form).post(routes::new_repo)) + .route( + "/settings/profile", + get(routes::profile_settings).post(routes::profile_settings_save), + ) + .route("/settings/account", get(routes_extra::account_settings)) + .route("/settings/mfa", get(routes_extra::mfa_settings)) + .route("/settings/mfa/enroll", post(routes_extra::mfa_enroll)) + .route("/settings/mfa/confirm", post(routes_extra::mfa_confirm)) + .route("/settings/mfa/disable", post(routes_extra::mfa_disable)) + .route( + "/settings/account/username", + post(routes_extra::account_change_username), + ) + .route( + "/settings/account/password", + post(routes_extra::account_change_password), + ) + .route( + "/settings/account/privacy", + post(routes_extra::account_privacy), + ) + .route( + "/settings/account/emails", + post(routes_extra::account_add_email), + ) + .route( + "/settings/account/emails/{id}/delete", + post(routes_extra::account_delete_email), + ) + .route( + "/settings/account/sessions/{id}/revoke", + post(routes_extra::account_revoke_session), + ) + .route( + "/settings/account/sessions/revoke-others", + post(routes_extra::account_revoke_others), + ) + .route("/settings/account/export", get(routes_extra::account_export)) + .route( + "/settings/account/delete", + post(routes_extra::account_delete), + ) + .route( + "/settings/keys", + get(routes::keys_settings).post(routes::keys_add), + ) + .route("/settings/keys/{id}/delete", post(routes::keys_delete)) + .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)) + .route("/{owner}/{repo}/info/refs", get(git::http::info_refs)) + .route( + "/{owner}/{repo}/git-upload-pack", + post(git::http::upload_pack), + ) + .route( + "/{owner}/{repo}/git-receive-pack", + post(git::http::receive_pack), + ) + .route( + "/{owner}/{repo}/info/lfs/objects/batch", + post(git::lfs::lfs_batch), + ) + .route( + "/{owner}/{repo}/info/lfs/objects/{oid}/{size}", + get(git::lfs::lfs_download).put(git::lfs::lfs_upload), + ) + .route("/{owner}/{repo}", get(routes::repo_home)) + .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)) + .route("/{owner}/{repo}/star", post(routes_extra::repo_star)) + .route("/{owner}/{repo}/watch", post(routes_extra::repo_watch)) + .route("/{owner}/{repo}/fork", post(routes_extra::repo_fork)) + .route( + "/{owner}/{repo}/comments/{comment_id}/react", + post(routes_extra::comment_react), + ) + .route("/{owner}/{repo}/commits", get(routes::repo_commits)) + .route("/{owner}/{repo}/commit/{id}", get(routes::repo_commit)) + .route("/{owner}/{repo}/diff/{id}", get(routes::repo_diff)) + .route("/{owner}/{repo}/archive.zip", get(routes::repo_archive_zip)) + .route("/{owner}/{repo}/upload", post(routes::repo_upload)) + .route("/{owner}/{repo}/branches", get(routes::repo_branches)) + .route( + "/{owner}/{repo}/branches/{branch}/rename", + post(routes_extra::branch_rename), + ) + .route( + "/{owner}/{repo}/branches/{branch}/delete", + post(routes_extra::branch_delete), + ) + .route("/{owner}/{repo}/tags", post(routes_extra::tag_create)) + .route( + "/{owner}/{repo}/tags/{tag}/rename", + post(routes_extra::tag_rename), + ) + .route( + "/{owner}/{repo}/tags/{tag}/delete", + post(routes_extra::tag_delete), + ) + .route( + "/{owner}/{repo}/issues", + get(routes::issues_list).post(routes::issue_create), + ) + .route("/{owner}/{repo}/issues/new", get(routes::issue_new)) + .route( + "/{owner}/{repo}/issues/{number}", + get(routes::issue_view).post(routes::issue_comment), + ) + .route( + "/{owner}/{repo}/issues/{number}/close", + post(routes::issue_close), + ) + .route( + "/{owner}/{repo}/issues/{number}/reopen", + post(routes::issue_reopen), + ) + .route( + "/{owner}/{repo}/pulls", + get(routes::pulls_list).post(routes::pull_create), + ) + .route("/{owner}/{repo}/pulls/new", get(routes::pull_new)) + .route( + "/{owner}/{repo}/pulls/{number}", + get(routes::pull_view).post(routes::pull_comment), + ) + .route( + "/{owner}/{repo}/pulls/{number}/merge", + post(routes::pull_merge), + ) + .route( + "/{owner}/{repo}/pulls/{number}/close", + post(routes::pull_close), + ) + .route( + "/{owner}/{repo}/releases", + get(routes::releases_list).post(routes::release_create), + ) + .route("/{owner}/{repo}/releases/new", get(routes::release_new)) + .route("/{owner}/{repo}/releases/{tag}", get(routes::release_view)) + .route( + "/{owner}/{repo}/releases/{tag}/edit", + get(routes::release_edit).post(routes::release_update), + ) + .route( + "/{owner}/{repo}/releases/{tag}/delete", + post(routes::release_delete), + ) + .route( + "/{owner}/{repo}/releases/{tag}/upload", + post(routes::release_upload), + ) + .route( + "/{owner}/{repo}/releases/{tag}/assets/{id}/delete", + post(routes::asset_delete), + ) + .route("/{owner}/{repo}/assets/{id}", get(routes::asset_download)) + .route( + "/{owner}/{repo}/settings", + get(routes::repo_settings).post(routes::repo_settings_save), + ) + .route( + "/{owner}/{repo}/settings/collaborators", + post(routes::collab_add), + ) + .route( + "/{owner}/{repo}/settings/collaborators/{user_id}/remove", + post(routes::collab_remove), + ) + .route( + "/{owner}/{repo}/settings/branch-rules", + post(routes_extra::branch_rule_add), + ) + .route( + "/{owner}/{repo}/settings/branch-rules/{id}/delete", + post(routes_extra::branch_rule_delete), + ) + .route( + "/{owner}/{repo}/settings/danger/archive", + post(routes::repo_archive), + ) + .route( + "/{owner}/{repo}/settings/danger/delete", + post(routes::repo_delete), + ) + .route( + "/{owner}/{repo}/settings/danger/transfer", + post(routes::repo_transfer), + ) + .route("/{username}", get(routes::profile)) + .nest_service("/static", ServeDir::new(static_dir)) + .layer(from_fn(normalize_git_url)) + .layer(DefaultBodyLimit::max(100 * 1024 * 1024)) + .layer(RequestBodyLimitLayer::new(100 * 1024 * 1024)) + .with_state(state) +} + +#[cfg(test)] +mod path_tests { + use super::normalize_repo_path; + + #[test] + fn strips_git_suffix_and_slash() { + assert_eq!(normalize_repo_path("/o/r.git"), "/o/r"); + assert_eq!(normalize_repo_path("/o/r.git/"), "/o/r"); + assert_eq!(normalize_repo_path("/o/r.git/info/refs"), "/o/r/info/refs"); + assert_eq!(normalize_repo_path("/o/r/"), "/o/r"); + assert_eq!(normalize_repo_path("/"), "/"); + } +} + @@ -0,0 +1,3474 @@ +use crate::auth::{ + self, clear_mfa_pending_cookie, clear_session_cookie, current_user, mfa_pending_cookie_header, + mfa_pending_from_headers, session_cookie_header, AuthState, LoginOutcome, +}; +use crate::db::models::{Access, Comment, Repository, User}; +use crate::db::queries; +use crate::git; +use crate::git::ssh::fingerprint_ssh_pubkey; +use crate::highlight::highlight; +use crate::markdown::render_markdown; +use crate::state::AppState; +use crate::web::templates::*; +use axum::extract::{Form, Multipart, Path, Query, State}; +use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, LOCATION, SET_COOKIE}; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::body::Body; +use serde::Deserialize; +use std::path::PathBuf; +use uuid::Uuid; + +// ── helpers ────────────────────────────────────────────────────────────────── + +pub type AppResult<T> = Result<T, AppError>; + +pub struct AppError { + pub status: StatusCode, + pub message: String, +} + +impl AppError { + pub fn new(status: StatusCode, message: impl Into<String>) -> Self { + Self { + status, + message: message.into(), + } + } + pub fn bad(msg: impl Into<String>) -> Self { + Self::new(StatusCode::BAD_REQUEST, msg) + } + pub fn unauthorized() -> Self { + Self::new(StatusCode::UNAUTHORIZED, "login required") + } + pub fn forbidden() -> Self { + Self::new(StatusCode::FORBIDDEN, "forbidden") + } + pub fn not_found() -> Self { + Self::new(StatusCode::NOT_FOUND, "not found") + } + pub fn internal(err: impl std::fmt::Display) -> Self { + tracing::error!("internal: {err:#}"); + Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error") + } + pub fn with_message(mut self, msg: String) -> Self { + self.message = msg; + self + } +} + +impl From<anyhow::Error> for AppError { + fn from(e: anyhow::Error) -> Self { + Self::internal(e) + } +} + +impl From<sqlx::Error> for AppError { + fn from(e: sqlx::Error) -> Self { + Self::internal(e) + } +} + +impl From<std::io::Error> for AppError { + fn from(e: std::io::Error) -> Self { + Self::internal(e) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let body = ErrorTemplate { + viewer: None, + status: self.status.as_u16(), + message: self.message, + }; + (self.status, body).into_response() + } +} + +fn redirect(to: &str) -> Response { + Redirect::to(to).into_response() +} + +pub fn redirect_see_other(to: &str) -> Response { + (StatusCode::SEE_OTHER, [(LOCATION, to.to_string())]).into_response() +} + +fn redirect_with_cookie(to: &str, cookie: HeaderValue) -> Response { + let location = HeaderValue::from_str(to).unwrap_or_else(|_| HeaderValue::from_static("/")); + ( + StatusCode::SEE_OTHER, + [(LOCATION, location), (SET_COOKIE, cookie)], + ) + .into_response() +} + +fn redirect_with_cookies(to: &str, cookies: Vec<HeaderValue>) -> Response { + let location = HeaderValue::from_str(to).unwrap_or_else(|_| HeaderValue::from_static("/")); + let mut res = (StatusCode::SEE_OTHER, [(LOCATION, location)]).into_response(); + for c in cookies { + res.headers_mut().append(SET_COOKIE, c); + } + res +} + +pub fn avatar_url_for(user: &User) -> String { + let bust = user.updated_at.timestamp(); + // Always go through our avatar endpoint when a local file exists. + if user.avatar_path.is_some() { + return format!("/avatars/{}?v={}", user.id, bust); + } + // External OIDC pictures are fine in <img src>, but never use our own + // /avatars/{id} URL as avatar_url — that creates a redirect loop. + if let Some(ref url) = user.avatar_url { + if !url.is_empty() && !is_self_avatar_url(url, user.id) { + return url.clone(); + } + } + format!("/avatars/{}?v={}", user.id, bust) +} + +fn is_self_avatar_url(url: &str, user_id: Uuid) -> bool { + let id = user_id.to_string(); + if url == format!("/avatars/{id}") || url.starts_with(&format!("/avatars/{id}?")) { + return true; + } + // Absolute URLs that point back at this app's avatar route. + if let Ok(u) = url::Url::parse(url) { + let path = u.path().trim_end_matches('/'); + if path == format!("/avatars/{id}") { + return true; + } + } + false +} + +fn sniff_image_ext(data: &[u8], declared_ct: &str) -> Option<&'static str> { + if data.starts_with(&[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']) { + return Some("png"); + } + if data.len() >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff { + return Some("jpg"); + } + if data.len() >= 6 && (data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a")) { + return Some("gif"); + } + if data.len() >= 12 && data.starts_with(b"RIFF") && &data[8..12] == b"WEBP" { + return Some("webp"); + } + match declared_ct { + "image/png" => Some("png"), + "image/jpeg" | "image/jpg" => Some("jpg"), + "image/gif" => Some("gif"), + "image/webp" => Some("webp"), + _ if declared_ct.starts_with("image/") => Some("bin"), + _ => None, + } +} + +fn initials_avatar_svg(user: &User) -> Vec<u8> { + let label = if !user.display_name.trim().is_empty() { + user.display_name.as_str() + } else { + user.username.as_str() + }; + let mut initials = String::new(); + for part in label.split(|c: char| c.is_whitespace() || c == '-' || c == '_') { + if let Some(ch) = part.chars().find(|c| c.is_alphanumeric()) { + initials.push(ch.to_ascii_uppercase()); + if initials.chars().count() == 2 { + break; + } + } + } + if initials.is_empty() { + initials.push('?'); + } + // Deterministic muted tone from user id. + let bytes = user.id.as_bytes(); + let h = bytes.iter().fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(*b as u32)); + let r = 40 + (h % 80) as u8; + let g = 40 + ((h >> 8) % 80) as u8; + let b = 40 + ((h >> 16) % 80) as u8; + let bg = format!("#{r:02x}{g:02x}{b:02x}"); + let safe: String = initials + .chars() + .map(|c| match c { + '&' => "&".into(), + '<' => "<".into(), + '>' => ">".into(), + '"' => """.into(), + _ => c.to_string(), + }) + .collect(); + format!( + r##"<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"> +<rect width="64" height="64" fill="{bg}"/> +<text x="32" y="34" text-anchor="middle" dominant-baseline="middle" + font-family="JetBrains Mono, ui-monospace, monospace" font-size="22" font-weight="700" fill="#f4f1ea">{safe}</text> +</svg>"## + ) + .into_bytes() +} + +const DIFF_RENDER_MAX_LINES: usize = 50; + +fn language_stat_views(stats: serde_json::Value) -> Vec<LanguageStatView> { + let enriched = git::languages::enrich_language_colors(stats); + let Some(obj) = enriched.as_object() else { + return Vec::new(); + }; + let mut out: Vec<LanguageStatView> = obj + .iter() + .map(|(name, meta)| LanguageStatView { + name: name.clone(), + percent: meta + .get("percent") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0), + color: meta + .get("color") + .and_then(|v| v.as_str()) + .unwrap_or("#858585") + .to_string(), + }) + .collect(); + out.sort_by(|a, b| { + b.percent + .partial_cmp(&a.percent) + .unwrap_or(std::cmp::Ordering::Equal) + }); + out +} + +fn reaction_label(emoji: &str) -> String { + match emoji { + "+1" => "ðŸ‘".into(), + "-1" => "👎".into(), + "heart" => "â¤ï¸".into(), + "laugh" => "😄".into(), + "rocket" => "🚀".into(), + "eyes" => "👀".into(), + other => other.to_string(), + } +} + +fn html_escape(s: &str) -> String { + s.chars() + .map(|c| match c { + '&' => "&".into(), + '<' => "<".into(), + '>' => ">".into(), + '"' => """.into(), + _ => c.to_string(), + }) + .collect() +} + +fn readme_to_html(name: &str, src: &str) -> String { + if name.ends_with(".md") || name.ends_with(".MD") { + render_markdown(src) + } else { + format!("<pre>{}</pre>", html_escape(src)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CommitRefKind { + /// Explicit pull-request wording (`pull request #N`, `Merge pull request #N`). + Pull, + /// Explicit issue wording (`issue #N`). + Issue, + /// Closing keywords (`fixes #N`, `closes #N`, …): prefer issue if it exists, else pull. + Closing, + /// Bare `#N` — default to issues. + Bare, +} + +fn classify_commit_ref(before: &str) -> CommitRefKind { + let lower: String = before + .chars() + .rev() + .take_while(|c| c.is_ascii_alphabetic() || c.is_whitespace()) + .collect::<String>() + .chars() + .rev() + .collect::<String>() + .to_ascii_lowercase(); + let trimmed = lower.trim_end(); + if trimmed.ends_with("pull request") || trimmed.ends_with("pull requests") { + return CommitRefKind::Pull; + } + // "pull #N" but not as part of another word + if trimmed == "pull" || trimmed.ends_with(" pull") { + return CommitRefKind::Pull; + } + if trimmed.ends_with("issue") || trimmed.ends_with("issues") { + return CommitRefKind::Issue; + } + for kw in [ + "fix", "fixes", "fixed", "fixing", "close", "closes", "closed", "closing", "resolve", + "resolves", "resolved", "resolving", + ] { + if trimmed == kw || trimmed.ends_with(&format!(" {kw}")) { + return CommitRefKind::Closing; + } + } + CommitRefKind::Bare +} + +fn parse_hash_number(s: &str) -> Option<(usize, i32)> { + // `s` starts at '#' + if !s.starts_with('#') { + return None; + } + let digits: String = s[1..].chars().take_while(|c| c.is_ascii_digit()).collect(); + if digits.is_empty() { + return None; + } + let n: i32 = digits.parse().ok()?; + if n <= 0 { + return None; + } + Some((1 + digits.len(), n)) +} + +/// Escape a commit message and turn GitHub-style `#N` refs into issue/PR links. +async fn linkify_commit_message( + pool: &sqlx::PgPool, + repo_id: Uuid, + owner: &str, + repo: &str, + message: &str, +) -> String { + let mut out = String::with_capacity(message.len() + 32); + let mut i = 0; + let bytes = message.as_bytes(); + while i < bytes.len() { + if bytes[i] == b'#' { + if let Some((len, number)) = parse_hash_number(&message[i..]) { + // Avoid matching mid-word like `C#1` or `foo#1` — require start or non-alnum before. + let ok_boundary = i == 0 + || !message[..i] + .chars() + .next_back() + .is_some_and(|c| c.is_ascii_alphanumeric()); + if ok_boundary { + let kind = classify_commit_ref(&message[..i]); + let href = match kind { + CommitRefKind::Pull => format!("/{owner}/{repo}/pulls/{number}"), + CommitRefKind::Issue | CommitRefKind::Bare => { + format!("/{owner}/{repo}/issues/{number}") + } + CommitRefKind::Closing => { + if queries::get_issue(pool, repo_id, number) + .await + .ok() + .flatten() + .is_some() + { + format!("/{owner}/{repo}/issues/{number}") + } else if queries::get_pull(pool, repo_id, number) + .await + .ok() + .flatten() + .is_some() + { + format!("/{owner}/{repo}/pulls/{number}") + } else { + format!("/{owner}/{repo}/issues/{number}") + } + } + }; + let label = html_escape(&message[i..i + len]); + out.push_str(&format!(r#"<a href="{href}">{label}</a>"#)); + i += len; + continue; + } + } + } + // Copy one char, escaping as needed. + let ch = message[i..].chars().next().unwrap(); + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + _ => out.push(ch), + } + i += ch.len_utf8(); + } + out +} + +#[cfg(test)] +mod commit_ref_tests { + use super::{classify_commit_ref, parse_hash_number, CommitRefKind}; + + #[test] + fn parse_hash_number_basic() { + assert_eq!(parse_hash_number("#12 from"), Some((3, 12))); + assert_eq!(parse_hash_number("#0"), None); + assert_eq!(parse_hash_number("#"), None); + assert_eq!(parse_hash_number("12"), None); + } + + #[test] + fn classify_merge_pull_request() { + assert_eq!( + classify_commit_ref("Merge pull request "), + CommitRefKind::Pull + ); + assert_eq!(classify_commit_ref("fixes "), CommitRefKind::Closing); + assert_eq!(classify_commit_ref("closes "), CommitRefKind::Closing); + assert_eq!(classify_commit_ref("issue "), CommitRefKind::Issue); + assert_eq!(classify_commit_ref("hello "), CommitRefKind::Bare); + } +} + +fn diff_line_class(line: &str) -> &'static str { + if line.starts_with("+++") || line.starts_with("---") { + "kg-diff__meta" + } else if line.starts_with('+') { + "kg-diff__add" + } else if line.starts_with('-') { + "kg-diff__del" + } else if line.starts_with("@@") { + "kg-diff__hunk" + } else if line.starts_with('\\') { + // Unified-diff meta, e.g. `\ No newline at end of file` + "kg-diff__meta" + } else if line.starts_with("diff ") + || line.starts_with("index ") + || line.starts_with("similarity ") + || line.starts_with("rename ") + || line.starts_with("new file ") + || line.starts_with("deleted file ") + || line.starts_with("old mode ") + || line.starts_with("new mode ") + { + "kg-diff__meta" + } else { + "" + } +} + +fn append_diff_line(out: &mut String, line: &str) { + let class = diff_line_class(line); + if class.is_empty() { + out.push_str(&html_escape(line)); + } else { + out.push_str(&format!( + "<span class=\"{class}\">{}</span>", + html_escape(line) + )); + } + out.push('\n'); +} + +fn render_diff_html(diff: &str) -> String { + let lines: Vec<&str> = diff.lines().collect(); + let (html, truncated, total) = render_diff_lines(&lines, Some(DIFF_RENDER_MAX_LINES)); + if truncated { + format!( + "{html}<p class=\"kg-diff__truncated\">Large diffs are not rendered by default. Showing the first {DIFF_RENDER_MAX_LINES} of {total} lines.</p>" + ) + } else { + html + } +} + +/// Render unified-diff lines. When `max_lines` is set, stop after that many +/// lines (GitHub-style truncation for oversized patches). +fn render_diff_lines(lines: &[&str], max_lines: Option<usize>) -> (String, bool, usize) { + let total = lines.len(); + let limit = max_lines.unwrap_or(total); + let truncated = total > limit; + let mut out = String::from("<pre class=\"kg-diff\">"); + for line in lines.iter().take(limit) { + append_diff_line(&mut out, line); + } + out.push_str("</pre>"); + (out, truncated, total) +} + +fn diff_file_path(header: &str) -> String { + // `diff --git a/path b/path` (paths may contain spaces rarely; take b/ side) + if let Some(rest) = header.strip_prefix("diff --git ") { + let parts: Vec<&str> = rest.split_whitespace().collect(); + if parts.len() >= 2 { + let b = parts[1]; + return b.strip_prefix("b/").unwrap_or(b).to_string(); + } + if let Some(a) = parts.first() { + return a.strip_prefix("a/").unwrap_or(a).to_string(); + } + } + "unknown".into() +} + +fn anchor_for_path(path: &str, idx: usize) -> String { + let safe: String = path + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect(); + format!("diff-{idx}-{safe}") +} + +fn parse_diff_files(diff: &str) -> Vec<DiffFileView> { + let mut files = Vec::new(); + if diff.trim().is_empty() { + return files; + } + let mut current: Option<(String, Vec<String>)> = None; + + for line in diff.lines() { + if line.starts_with("diff --git ") { + if let Some((path, lines)) = current.take() { + files.push(build_diff_file(files.len(), path, &lines)); + } + current = Some((diff_file_path(line), vec![line.to_string()])); + } else if let Some((_, ref mut lines)) = current { + lines.push(line.to_string()); + } else { + // Preamble without a file header — treat as one blob. + current = Some(("diff".into(), vec![line.to_string()])); + } + } + if let Some((path, lines)) = current { + files.push(build_diff_file(files.len(), path, &lines)); + } + files +} + +fn build_diff_file(idx: usize, path: String, lines: &[String]) -> DiffFileView { + let mut additions = 0u32; + let mut deletions = 0u32; + let mut binary = false; + for line in lines { + if line.starts_with("Binary files ") || line.contains("GIT binary patch") { + binary = true; + } + if line.starts_with('+') && !line.starts_with("+++") { + additions += 1; + } else if line.starts_with('-') && !line.starts_with("---") { + deletions += 1; + } + } + let refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect(); + let (html, truncated, total_lines) = if binary { + ( + String::from( + "<pre class=\"kg-diff\"><span class=\"kg-diff__meta\">Binary file not shown.</span></pre>", + ), + false, + refs.len(), + ) + } else { + render_diff_lines(&refs, Some(DIFF_RENDER_MAX_LINES)) + }; + DiffFileView { + anchor: anchor_for_path(&path, idx), + path, + additions, + deletions, + html, + truncated, + total_lines, + binary, + } +} + +fn latest_commit_view(grepo: &git2::Repository, branch: &str) -> Option<CommitView> { + git::list_commits(grepo, branch, 1) + .ok() + .and_then(|mut v| v.drain(..).next()) + .map(commit_view) +} + +fn clone_urls(state: &AppState, owner: &str, repo: &str) -> (String, String) { + let base = state.config.public_url.trim_end_matches('/'); + // Always advertise the classic `.git` HTTP URL (middleware strips it). + let http = format!("{base}/{owner}/{repo}.git"); + let host = url::Url::parse(&state.config.public_url) + .ok() + .and_then(|u| u.host_str().map(|h| h.to_string())) + .unwrap_or_else(|| "localhost".into()); + let port = state.config.ssh_bind.port(); + // Always use ssh:// form. `git@host:2222/path` is parsed as host=host path=2222/path + // on port 22 — which hits the system sshd and asks for a password. + let ssh = format!("ssh://git@{host}:{port}/{owner}/{repo}.git"); + (http, ssh) +} + +fn valid_repo_name(name: &str) -> bool { + if name.is_empty() || name.len() > 100 { + return false; + } + if name == "." || name == ".." || name.starts_with('.') { + return false; + } + name.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') +} + +pub async fn require_login(auth: &AuthState, headers: &HeaderMap) -> AppResult<User> { + current_user(auth, headers) + .await? + .ok_or_else(AppError::unauthorized) +} + +pub async fn load_repo_context( + state: &AppState, + owner: &str, + repo: &str, + headers: &HeaderMap, +) -> AppResult<(Repository, User, Option<User>, Access)> { + let repo = repo.strip_suffix(".git").unwrap_or(repo); + let (repository, owner_user) = queries::get_repo(&state.pool, owner, repo) + .await? + .ok_or_else(AppError::not_found)?; + let viewer = current_user(&state.auth, headers).await?; + let access = queries::repo_access(&state.pool, &repository, viewer.as_ref().map(|u| u.id)).await?; + if !access.can_read() { + return Err(AppError::not_found()); + } + Ok((repository, owner_user, viewer, access)) +} + +fn split_ref_path(grepo: &git2::Repository, rest: &str) -> (String, String) { + let rest = rest.trim_matches('/'); + if rest.is_empty() { + return ("HEAD".into(), String::new()); + } + if git::resolve_ref(grepo, rest).is_ok() { + return (rest.to_string(), String::new()); + } + let parts: Vec<&str> = rest.split('/').collect(); + for i in (1..parts.len()).rev() { + let reference = parts[..i].join("/"); + if git::resolve_ref(grepo, &reference).is_ok() { + return (reference, parts[i..].join("/")); + } + } + ( + parts[0].to_string(), + if parts.len() > 1 { + parts[1..].join("/") + } else { + String::new() + }, + ) +} + +fn breadcrumbs(path: &str) -> Vec<(String, String)> { + let mut out = Vec::new(); + if path.is_empty() { + return out; + } + let mut acc = String::new(); + for part in path.split('/') { + if !acc.is_empty() { + acc.push('/'); + } + acc.push_str(part); + out.push((part.to_string(), acc.clone())); + } + out +} + +async fn comment_views( + pool: &sqlx::PgPool, + comments: Vec<Comment>, + viewer_id: Option<uuid::Uuid>, +) -> AppResult<Vec<CommentView>> { + let mut out = Vec::with_capacity(comments.len()); + for c in comments { + let author = queries::get_user_by_id(pool, c.author_id) + .await? + .ok_or_else(AppError::not_found)?; + let avatar = avatar_url_for(&author); + let reactions = queries::list_reaction_counts(pool, c.id, viewer_id) + .await? + .into_iter() + .map(|(emoji, count, mine)| ReactionView { + label: reaction_label(&emoji), + emoji, + count, + mine, + }) + .collect(); + out.push(CommentView { + id: c.id, + author, + avatar_url: avatar, + body_html: render_markdown(&c.body), + created_at: c.created_at, + reactions, + }); + } + Ok(out) +} + +fn checkbox(v: &Option<String>) -> bool { + matches!(v.as_deref(), Some("on") | Some("true") | Some("1") | Some("yes")) +} + +/// Split an activity summary into prefix text plus an optional issue/PR link. +/// Uses `payload.number` with owner/repo from the hydrated event (not regex on display). +fn activity_summary_parts( + kind: &str, + summary: &str, + payload: &serde_json::Value, + owner: Option<&str>, + repo: Option<&str>, +) -> (String, Option<String>, Option<String>) { + let Some(number) = payload.get("number").and_then(|v| v.as_i64()) else { + return (summary.to_string(), None, None); + }; + let number = number as i32; + let (Some(owner), Some(repo)) = (owner, repo) else { + return (summary.to_string(), None, None); + }; + + let (label_candidates, href) = match kind { + "issue.open" | "issue.comment" => ( + vec![format!("issue #{number}")], + format!("/{owner}/{repo}/issues/{number}"), + ), + "pull.open" | "pull.comment" | "pull.merge" => ( + vec![ + format!("pull #{number}"), + format!("pull request #{number}"), + ], + format!("/{owner}/{repo}/pulls/{number}"), + ), + _ => return (summary.to_string(), None, None), + }; + + for label in label_candidates { + if let Some(prefix) = summary.strip_suffix(&label) { + return (prefix.to_string(), Some(label), Some(href)); + } + } + + (summary.to_string(), None, None) +} + +fn map_activity_rows( + raw: Vec<( + crate::db::models::ActivityEvent, + Option<crate::db::models::User>, + Option<crate::db::models::Repository>, + Option<String>, + )>, +) -> Vec<ActivityRow> { + let mut activities = Vec::with_capacity(raw.len()); + for (event, actor, repo, owner_name) in raw { + let actor_username = actor.as_ref().map(|u| u.username.clone()); + let actor_avatar = actor.as_ref().map(avatar_url_for); + let repo_name = repo.as_ref().map(|r| r.name.clone()); + let (summary, ref_label, ref_href) = activity_summary_parts( + &event.kind, + &event.summary, + &event.payload, + owner_name.as_deref(), + repo_name.as_deref(), + ); + activities.push(ActivityRow { + kind: event.kind, + summary, + ref_label, + ref_href, + actor_username, + actor_avatar, + repo_owner: owner_name, + repo_name, + created_at: event.created_at, + }); + } + activities +} + +// ── home / auth ────────────────────────────────────────────────────────────── + +pub async fn home( + State(state): State<AppState>, + headers: HeaderMap, +) -> AppResult<impl IntoResponse> { + let viewer = current_user(&state.auth, &headers).await?; + let motd = queries::get_setting(&state.pool, "motd").await.unwrap_or_default(); + + // Logged-out: branded landing only (no activity / repos). + let Some(user) = viewer else { + return Ok(HomeTemplate { + viewer: None, + motd, + my_repos: Vec::new(), + activities: Vec::new(), + }); + }; + + let my_repos = queries::list_user_repos(&state.pool, user.id, Some(user.id)) + .await + .unwrap_or_default(); + let raw = queries::latest_activity_for_user(&state.pool, user.id, 40).await?; + let activities = map_activity_rows(raw); + Ok(HomeTemplate { + viewer: Some(user), + motd, + my_repos, + activities, + }) +} + +#[derive(Deserialize)] +pub struct ExploreQuery { + pub q: Option<String>, +} + +pub async fn explore( + State(state): State<AppState>, + headers: HeaderMap, + Query(q): Query<ExploreQuery>, +) -> AppResult<impl IntoResponse> { + let viewer = current_user(&state.auth, &headers).await?; + let query = q.q.unwrap_or_default(); + let rows = queries::list_public_repos( + &state.pool, + if query.trim().is_empty() { + None + } else { + Some(query.as_str()) + }, + 50, + ) + .await?; + let repos = rows + .into_iter() + .map(|(repo, owner)| ExploreRepo { owner, repo }) + .collect(); + Ok(ExploreTemplate { + viewer, + repos, + query, + }) +} + +pub async fn auth_login_page( + State(state): State<AppState>, + headers: HeaderMap, +) -> AppResult<impl IntoResponse> { + if current_user(&state.auth, &headers).await?.is_some() { + return Ok(redirect("/").into_response()); + } + Ok(LoginTemplate { + viewer: None, + error: None, + } + .into_response()) +} + +#[derive(Deserialize)] +pub struct LoginForm { + pub username: String, + pub password: String, +} + +pub async fn auth_login_submit( + State(state): State<AppState>, + Form(form): Form<LoginForm>, +) -> AppResult<Response> { + match auth::login_with_password(&state.auth, &form.username, &form.password).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), + )), + Err(e) => { + tracing::warn!("login failed: {e:#}"); + Ok(LoginTemplate { + viewer: None, + error: Some(crate::mfa::sanitize_user_error(&e.to_string())), + } + .into_response()) + } + } +} + +pub async fn auth_mfa_page( + State(state): State<AppState>, + headers: HeaderMap, +) -> AppResult<Response> { + if current_user(&state.auth, &headers).await?.is_some() { + return Ok(redirect("/").into_response()); + } + if mfa_pending_from_headers(&headers).is_none() { + return Ok(redirect("/auth/login").into_response()); + } + Ok(MfaChallengeTemplate { + viewer: None, + error: None, + } + .into_response()) +} + +#[derive(Deserialize)] +pub struct MfaChallengeForm { + pub code: String, +} + +pub async fn auth_mfa_submit( + State(state): State<AppState>, + headers: HeaderMap, + Form(form): Form<MfaChallengeForm>, +) -> AppResult<Response> { + let Some(pending) = mfa_pending_from_headers(&headers) else { + return Ok(redirect("/auth/login")); + }; + match auth::complete_mfa_login(&state.auth, &pending, &form.code).await { + Ok((_user, token)) => Ok(redirect_with_cookies( + "/", + vec![ + session_cookie_header(&token, 14 * 24 * 3600), + clear_mfa_pending_cookie(), + ], + )), + Err(e) => { + tracing::warn!("mfa challenge failed: {e:#}"); + let msg = crate::mfa::sanitize_user_error(&e.to_string()); + if msg.contains("expired") { + return Ok(redirect_with_cookie("/auth/login", clear_mfa_pending_cookie())); + } + Ok(MfaChallengeTemplate { + viewer: None, + error: Some(msg), + } + .into_response()) + } + } +} + +/// Legacy OIDC browser start — disabled so users never leave kitgit UI. +pub async fn auth_oidc_start() -> AppResult<Response> { + Ok(redirect("/auth/login")) +} + +pub async fn auth_signup_page( + State(state): State<AppState>, + headers: HeaderMap, +) -> AppResult<impl IntoResponse> { + if current_user(&state.auth, &headers).await?.is_some() { + return Ok(redirect("/").into_response()); + } + Ok(SignupTemplate { + viewer: None, + error: None, + } + .into_response()) +} + +#[derive(Deserialize)] +pub struct SignupForm { + pub username: String, + pub email: String, + pub password: String, + pub display_name: Option<String>, +} + +pub async fn auth_signup_submit( + State(state): State<AppState>, + Form(form): Form<SignupForm>, +) -> AppResult<Response> { + match auth::signup_with_password( + &state.auth, + &form.username, + &form.email, + &form.password, + form.display_name.as_deref().unwrap_or(""), + ) + .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), + )), + Err(e) => { + tracing::warn!("signup failed: {e:#}"); + Ok(SignupTemplate { + viewer: None, + error: Some(crate::mfa::sanitize_user_error(&e.to_string())), + } + .into_response()) + } + } +} + +#[derive(Deserialize)] +pub struct CallbackQuery { + pub code: Option<String>, + pub state: Option<String>, + pub error: Option<String>, +} + +pub async fn auth_callback( + State(state): State<AppState>, + Query(q): Query<CallbackQuery>, +) -> AppResult<Response> { + if let Some(err) = q.error { + return Err(AppError::bad(format!("oidc error: {err}"))); + } + let code = q.code.ok_or_else(|| AppError::bad("missing code"))?; + let st = q.state.ok_or_else(|| AppError::bad("missing state"))?; + let (_user, token) = auth::finish_login(&state.auth, &code, &st).await?; + let cookie = session_cookie_header(&token, 14 * 24 * 3600); + Ok(redirect_with_cookie("/", cookie)) +} + +pub async fn auth_logout( + State(state): State<AppState>, + headers: HeaderMap, +) -> AppResult<Response> { + auth::logout(&state.auth, &headers).await?; + Ok(redirect_with_cookie("/", clear_session_cookie())) +} + +// ── site admin panel ───────────────────────────────────────────────────────── + +async fn require_site_admin(auth: &AuthState, headers: &HeaderMap) -> AppResult<User> { + let user = require_login(auth, headers).await?; + if !user.is_site_admin { + return Err(AppError::forbidden()); + } + Ok(user) +} + +pub async fn admin_panel( + State(state): State<AppState>, + headers: HeaderMap, +) -> 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(); + Ok(AdminTemplate { + viewer: Some(viewer), + users, + motd, + }) +} + +#[derive(Deserialize)] +pub struct AdminToggleForm { + pub user_id: Uuid, + pub is_site_admin: Option<String>, +} + +pub async fn admin_set_user( + State(state): State<AppState>, + headers: HeaderMap, + Form(form): Form<AdminToggleForm>, +) -> AppResult<Response> { + let viewer = require_site_admin(&state.auth, &headers).await?; + let make_admin = matches!( + form.is_site_admin.as_deref(), + Some("on") | Some("true") | Some("1") | Some("yes") + ); + // Prevent removing your own admin bit if you're the last admin. + if form.user_id == viewer.id && !make_admin { + let count = queries::site_admin_count(&state.pool).await?; + if count <= 1 { + return Err(AppError::bad("cannot remove the last site admin")); + } + } + queries::set_site_admin(&state.pool, form.user_id, make_admin).await?; + Ok(redirect_see_other("/admin")) +} + +#[derive(Deserialize)] +pub struct AdminMotdForm { + pub motd: Option<String>, +} + +pub async fn admin_save_motd( + State(state): State<AppState>, + headers: HeaderMap, + Form(form): Form<AdminMotdForm>, +) -> AppResult<Response> { + let _viewer = require_site_admin(&state.auth, &headers).await?; + queries::set_setting(&state.pool, "motd", form.motd.unwrap_or_default().trim()).await?; + Ok(redirect_see_other("/admin")) +} + +// ── new repo ───────────────────────────────────────────────────────────────── + +pub async fn new_repo_form( + State(state): State<AppState>, + headers: HeaderMap, +) -> AppResult<impl IntoResponse> { + let viewer = require_login(&state.auth, &headers).await?; + Ok(NewRepoTemplate { + viewer: Some(viewer), + error: None, + }) +} + +#[derive(Deserialize)] +pub struct NewRepoForm { + pub name: String, + pub description: Option<String>, + pub visibility: Option<String>, +} + +pub async fn new_repo( + State(state): State<AppState>, + headers: HeaderMap, + Form(form): Form<NewRepoForm>, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + let name = form.name.trim().to_string(); + let description = form.description.unwrap_or_default(); + let visibility = match form.visibility.as_deref() { + Some("private") => "private", + _ => "public", + }; + + if !valid_repo_name(&name) { + return Ok(NewRepoTemplate { + viewer: Some(user), + error: Some("invalid repository name".into()), + } + .into_response()); + } + + if queries::get_repo(&state.pool, &user.username, &name) + .await? + .is_some() + { + return Ok(NewRepoTemplate { + viewer: Some(user), + error: Some("repository already exists".into()), + } + .into_response()); + } + + let repo = queries::create_repo(&state.pool, user.id, &name, &description, visibility).await?; + git::init_bare( + &state.config.repos_dir(), + &user.username, + &name, + &repo.default_branch, + )?; + let author_name = if user.display_name.is_empty() { + user.username.as_str() + } else { + user.display_name.as_str() + }; + let author_email = if user.email.is_empty() { + format!("{}@users.kitgit", user.username) + } else { + user.email.clone() + }; + let _ = git::seed_initial_commit( + &state.config.repos_dir(), + &user.username, + &name, + &repo.default_branch, + author_name, + &author_email, + ); + let _ = queries::ensure_primary_email(&state.pool, user.id, &user.email).await; + queries::record_activity( + &state.pool, + Some(user.id), + Some(repo.id), + "repo.create", + "created repository", + serde_json::json!({}), + ) + .await?; + + Ok(redirect_see_other(&format!("/{}/{}", user.username, name))) +} + +// ── profile settings ───────────────────────────────────────────────────────── + +pub async fn profile_settings( + State(state): State<AppState>, + headers: HeaderMap, +) -> AppResult<impl IntoResponse> { + let user = require_login(&state.auth, &headers).await?; + let avatar_url = avatar_url_for(&user); + Ok(ProfileSettingsTemplate { + viewer: Some(user.clone()), + user, + avatar_url, + error: None, + }) +} + +pub async fn profile_settings_save( + State(state): State<AppState>, + headers: HeaderMap, + mut multipart: Multipart, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + let mut display_name = user.display_name.clone(); + let mut bio = user.bio.clone(); + let mut avatar_path: Option<String> = None; + let mut avatar_error: Option<String> = None; + + while let Some(field) = multipart.next_field().await.map_err(|e| { + // Surface limit/parse failures as a page error instead of a dropped connection. + AppError::bad(format!("could not read upload: {e}")) + })? { + let name = field.name().unwrap_or("").to_string(); + match name.as_str() { + "display_name" => { + display_name = field + .text() + .await + .map_err(|e| AppError::bad(e.to_string()))?; + } + "bio" => { + bio = field + .text() + .await + .map_err(|e| AppError::bad(e.to_string()))?; + } + "avatar" => { + let filename = field.file_name().unwrap_or("").to_string(); + if filename.is_empty() { + let _ = field.bytes().await; + continue; + } + let ct = field + .content_type() + .unwrap_or("application/octet-stream") + .to_string(); + let data = match field.bytes().await { + Ok(d) => d, + Err(e) => { + avatar_error = Some(format!("could not read avatar: {e}")); + continue; + } + }; + if data.is_empty() { + continue; + } + // 5MB soft cap after raising the HTTP body limit. + if data.len() > 5 * 1024 * 1024 { + avatar_error = Some("avatar too large (max 5MB)".into()); + continue; + } + let Some(ext) = sniff_image_ext(&data, &ct) else { + avatar_error = Some( + "avatar must be png, jpeg, gif, or webp (heic not supported)".into(), + ); + continue; + }; + if let Err(e) = std::fs::create_dir_all(state.config.avatars_dir()) { + return Err(AppError::internal(format!("avatars dir: {e}"))); + } + let rel = format!("{}.{}", user.id, ext); + let path = state.config.avatars_dir().join(&rel); + if let Err(e) = std::fs::write(&path, &data) { + return Err(AppError::internal(format!("could not save avatar: {e}"))); + } + avatar_path = Some(rel); + } + _ => { + let _ = field.bytes().await; + } + } + } + + let updated = queries::update_user_profile( + &state.pool, + user.id, + display_name.trim(), + bio.trim(), + avatar_path.as_deref(), + ) + .await?; + + if let Some(err) = avatar_error { + let avatar_url = avatar_url_for(&updated); + return Ok(ProfileSettingsTemplate { + viewer: Some(updated.clone()), + user: updated, + avatar_url, + error: Some(err), + } + .into_response()); + } + + Ok(redirect_see_other("/settings/profile")) +} + +// ── SSH keys ───────────────────────────────────────────────────────────────── + +pub async fn keys_settings( + State(state): State<AppState>, + headers: HeaderMap, +) -> AppResult<impl IntoResponse> { + let user = require_login(&state.auth, &headers).await?; + let keys = queries::list_ssh_keys(&state.pool, user.id).await?; + let gpg_keys = queries::list_gpg_keys(&state.pool, user.id).await?; + Ok(KeysSettingsTemplate { + viewer: Some(user), + keys, + gpg_keys, + error: None, + }) +} + +#[derive(Deserialize)] +pub struct AddKeyForm { + pub name: String, + pub public_key: String, +} + +pub async fn keys_add( + State(state): State<AppState>, + headers: HeaderMap, + Form(form): Form<AddKeyForm>, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + let name = form.name.trim(); + let public_key = form.public_key.trim(); + 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?; + return Ok(KeysSettingsTemplate { + viewer: Some(user), + keys, + gpg_keys, + error: Some("name and public key required".into()), + } + .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) + .await + .map_err(|e| AppError::bad(format!("could not add key: {e}")))?; + Ok(redirect_see_other("/settings/keys")) +} + +pub async fn keys_delete( + State(state): State<AppState>, + headers: HeaderMap, + Path(id): Path<Uuid>, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + queries::delete_ssh_key(&state.pool, user.id, id).await?; + Ok(redirect_see_other("/settings/keys")) +} + +// ── avatar ─────────────────────────────────────────────────────────────────── + +pub async fn avatar( + State(state): State<AppState>, + Path(user_id): Path<Uuid>, +) -> AppResult<Response> { + let user = queries::get_user_by_id(&state.pool, user_id) + .await? + .ok_or_else(AppError::not_found)?; + + if let Some(ref rel) = user.avatar_path { + // Only allow basename under avatars_dir (no path traversal). + let base = std::path::Path::new(rel) + .file_name() + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(rel)); + let path = state.config.avatars_dir().join(base); + if path.is_file() { + let data = std::fs::read(&path)?; + let mime = mime_guess::from_path(&path) + .first_or_octet_stream() + .essence_str() + .to_string(); + return Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, mime) + // Cache-bust via ?v= on URLs; avoid year-long immutable (stale avatars). + .header( + axum::http::header::CACHE_CONTROL, + "public, max-age=3600, must-revalidate", + ) + .body(Body::from(data)) + .unwrap()); + } + } + + // Never HTTP-redirect from /avatars/{id}. Self-referential avatar_url values + // (e.g. "/avatars/{id}") make the browser loop forever on <img>. + let svg = initials_avatar_svg(&user); + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "image/svg+xml; charset=utf-8") + .header(axum::http::header::CACHE_CONTROL, "public, max-age=60") + .body(Body::from(svg)) + .unwrap()) +} + +// ── profile ────────────────────────────────────────────────────────────────── + +pub async fn profile( + State(state): State<AppState>, + headers: HeaderMap, + Path(username): Path<String>, +) -> AppResult<impl IntoResponse> { + let viewer = current_user(&state.auth, &headers).await?; + let profile = queries::get_user_by_username(&state.pool, &username) + .await? + .ok_or_else(AppError::not_found)?; + let viewer_id = viewer.as_ref().map(|u| u.id); + let repos = queries::list_user_repos(&state.pool, profile.id, viewer_id).await?; + let starred_raw = + queries::list_public_starred_repos(&state.pool, profile.id, 30).await?; + let starred = starred_raw + .into_iter() + .map(|(repo, owner)| ExploreRepo { owner, repo }) + .collect(); + let watched_raw = + queries::latest_activity_for_watched_repos(&state.pool, profile.id, 30).await?; + let watched_activity = map_activity_rows(watched_raw); + let graph = queries::commit_graph(&state.pool, profile.id, 365).await?; + let is_self = viewer + .as_ref() + .map(|v| v.id == profile.id) + .unwrap_or(false); + let avatar_url = avatar_url_for(&profile); + let has_activity = graph.iter().any(|d| d.count > 0); + Ok(ProfileTemplate { + viewer, + profile, + avatar_url, + repos, + starred, + watched_activity, + graph, + has_activity, + is_self, + }) +} + +// ── repository browse ──────────────────────────────────────────────────────── + +pub async fn repo_home( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + let owner_avatar = avatar_url_for(&owner_user); + + let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo).ok(); + let current_branch = repository.default_branch.clone(); + let mut branches = Vec::new(); + let mut entries = Vec::new(); + let mut readme_html = None; + let mut empty = true; + let mut languages = queries::get_language_stats(&state.pool, repository.id).await?; + + if let Some(ref g) = grepo { + branches = git::list_branches(g).unwrap_or_default(); + if git::resolve_ref(g, ¤t_branch).is_ok() { + empty = false; + entries = git::list_tree(g, ¤t_branch, "") + .unwrap_or_default() + .into_iter() + .map(|e| TreeEntryView { + name: e.name, + path: e.path, + is_dir: e.is_dir, + mode: e.mode, + }) + .collect(); + if let Ok(Some((name, src))) = git::find_readme(g, ¤t_branch, "") { + readme_html = Some(readme_to_html(&name, &src)); + } + if languages.as_object().map(|o| o.is_empty()).unwrap_or(true) { + if let Ok(files) = git::walk_files(g, ¤t_branch) { + let stats = git::languages::detect_languages(&files); + let _ = queries::set_language_stats(&state.pool, repository.id, stats.clone()) + .await; + languages = stats; + } + } + } + } + + let latest_commit = grepo + .as_ref() + .and_then(|g| latest_commit_view(g, ¤t_branch)); + 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)) + } + None => None, + } + } else { + None + }; + let (starred, watching) = if let Some(ref u) = viewer { + ( + queries::is_starred(&state.pool, repository.id, u.id).await?, + queries::is_watching(&state.pool, repository.id, u.id).await?, + ) + } else { + (false, false) + }; + + Ok(RepoHomeTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + owner_avatar, + clone_http, + clone_ssh, + branches, + current_branch, + entries, + readme_html, + languages, + empty, + latest_commit, + forked_from, + starred, + watching, + }) +} + +pub async fn repo_tree( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, rest)): Path<(String, String, String)>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo) + .map_err(|_| AppError::not_found())?; + let (branch, path) = split_ref_path(&grepo, &rest); + let entries = git::list_tree(&grepo, &branch, &path) + .map_err(|e| AppError::not_found().with_message(e.to_string()))? + .into_iter() + .map(|e| TreeEntryView { + name: e.name, + path: e.path, + is_dir: e.is_dir, + mode: e.mode, + }) + .collect(); + let readme_html = match git::find_readme(&grepo, &branch, &path) { + Ok(Some((name, src))) => Some(readme_to_html(&name, &src)), + _ => None, + }; + let branches = git::list_branches(&grepo).unwrap_or_default(); + let latest_commit = latest_commit_view(&grepo, &branch); + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + Ok(RepoTreeTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + branches, + branch, + path: path.clone(), + breadcrumbs: breadcrumbs(&path), + entries, + readme_html, + latest_commit, + clone_http, + clone_ssh, + }) +} + +pub async fn repo_blob( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, rest)): Path<(String, String, String)>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo) + .map_err(|_| AppError::not_found())?; + let (branch, path) = split_ref_path(&grepo, &rest); + if path.is_empty() { + return Err(AppError::bad("missing file path")); + } + let (data, binary) = git::read_blob(&grepo, &branch, &path) + .map_err(|_| AppError::not_found())?; + let size = data.len(); + let (content_html, is_markdown) = if binary { + (None, false) + } else { + let text = String::from_utf8_lossy(&data); + if path.ends_with(".md") || path.ends_with(".MD") { + (Some(render_markdown(&text)), true) + } else { + (Some(highlight(&path, &text)), false) + } + }; + let branches = git::list_branches(&grepo).unwrap_or_default(); + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + Ok(RepoBlobTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + branches, + branch, + path: path.clone(), + breadcrumbs: breadcrumbs(&path), + binary, + size, + content_html, + is_markdown, + clone_http, + clone_ssh, + }) +} + +#[derive(Deserialize)] +pub struct BranchQuery { + pub branch: Option<String>, +} + +pub async fn repo_commits( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + Query(q): Query<BranchQuery>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo) + .map_err(|_| AppError::not_found())?; + let branch = q + .branch + .unwrap_or_else(|| repository.default_branch.clone()); + let commits = git::list_commits(&grepo, &branch, 100) + .unwrap_or_default() + .into_iter() + .map(commit_view) + .collect(); + let branches = git::list_branches(&grepo).unwrap_or_default(); + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + Ok(RepoCommitsTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + branches, + branch, + commits, + clone_http, + clone_ssh, + }) +} + +pub async fn repo_commit( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, id)): Path<(String, String, String)>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + 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 diff = git::commit_diff(&grepo, &id).unwrap_or_default(); + let diff_html = render_diff_html(&diff); + let message_html = linkify_commit_message( + &state.pool, + repository.id, + &owner, + &repo, + &commit.message, + ) + .await; + Ok(RepoCommitTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + commit: commit_view(commit), + message_html, + diff_html, + clone_http: clone_urls(&state, &owner, &repo).0, + clone_ssh: clone_urls(&state, &owner, &repo).1, + }) +} + +pub async fn repo_diff( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, id)): Path<(String, String, String)>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + 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 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); + Ok(RepoDiffTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + commit: commit_view(commit), + diff_html, + clone_http, + clone_ssh, + }) +} + +#[derive(Deserialize)] +pub struct ArchiveQuery { + #[serde(rename = "ref")] + pub ref_name: Option<String>, + /// Optional subtree path (directory or file) within the ref. + pub path: Option<String>, +} + +pub async fn repo_archive_zip( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + Query(q): Query<ArchiveQuery>, +) -> AppResult<Response> { + let (repository, _owner_user, _viewer, _access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let reference = q + .ref_name + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| repository.default_branch.clone()); + let path = q + .path + .as_deref() + .map(|s| s.trim_matches('/')) + .filter(|s| !s.is_empty()); + let bytes = git::archive_zip( + &state.config.repos_dir(), + &owner, + &repo, + &reference, + path, + ) + .map_err(|e| AppError::bad(e.to_string()))?; + let ref_slug = reference.replace('/', "-"); + let filename = match path { + Some(p) => { + let base = p.rsplit('/').next().unwrap_or(p); + format!("{repo}-{ref_slug}-{base}.zip") + } + None => format!("{repo}-{ref_slug}.zip"), + }; + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/zip") + .header( + CONTENT_DISPOSITION, + format!("attachment; filename=\"{filename}\""), + ) + .body(Body::from(bytes)) + .unwrap()) +} + +pub async fn repo_upload( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + mut multipart: Multipart, +) -> AppResult<Response> { + let (repository, _owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let user = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_write() { + return Err(AppError::forbidden()); + } + let mut branch = repository.default_branch.clone(); + let mut dir = String::new(); + let mut message = String::from("Upload file"); + let mut file_name = String::new(); + let mut file_bytes: Option<bytes::Bytes> = None; + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| AppError::bad(e.to_string()))? + { + let name = field.name().unwrap_or("").to_string(); + match name.as_str() { + "branch" => { + branch = field + .text() + .await + .map_err(|e| AppError::bad(e.to_string()))?; + } + "path" => { + dir = field + .text() + .await + .map_err(|e| AppError::bad(e.to_string()))? + .trim_matches('/') + .to_string(); + } + "message" => { + let m = field + .text() + .await + .map_err(|e| AppError::bad(e.to_string()))?; + if !m.trim().is_empty() { + message = m; + } + } + "file" => { + file_name = field.file_name().unwrap_or("").to_string(); + if file_name.is_empty() { + let _ = field.bytes().await; + continue; + } + // sanitize filename + file_name = file_name + .rsplit(['/', '\\']) + .next() + .unwrap_or(&file_name) + .to_string(); + if file_name.contains("..") || file_name.is_empty() { + return Err(AppError::bad("invalid filename")); + } + file_bytes = Some( + field + .bytes() + .await + .map_err(|e| AppError::bad(e.to_string()))?, + ); + } + _ => { + let _ = field.bytes().await; + } + } + } + + let data = file_bytes.ok_or_else(|| AppError::bad("file required"))?; + if data.len() > 50 * 1024 * 1024 { + return Err(AppError::bad("file too large (max 50MB)")); + } + let dest = if dir.is_empty() { + file_name.clone() + } else { + format!("{dir}/{file_name}") + }; + let author = if user.display_name.is_empty() { + user.username.clone() + } else { + user.display_name.clone() + }; + git::commit_file( + &state.config.repos_dir(), + &owner, + &repo, + &branch, + &dest, + &data, + &author, + &user.email, + &message, + ) + .map_err(|e| AppError::bad(e.to_string()))?; + + queries::record_activity( + &state.pool, + Some(user.id), + Some(repository.id), + "push", + "uploaded file", + serde_json::json!({ "path": dest }), + ) + .await?; + + let redirect_path = if dir.is_empty() { + format!("/{owner}/{repo}/tree/{branch}") + } else { + format!("/{owner}/{repo}/tree/{branch}/{dir}") + }; + Ok(redirect_see_other(&redirect_path)) +} + +pub async fn repo_branches( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo) + .map_err(|_| AppError::not_found())?; + let names = git::list_branches(&grepo).unwrap_or_default(); + let default_branch = repository.default_branch.clone(); + let open_pulls = queries::list_pulls(&state.pool, repository.id, Some("open")) + .await + .unwrap_or_default(); + let branches: Vec<BranchRow> = names + .into_iter() + .map(|name| { + let is_default = name == default_branch; + let (ahead, behind) = if is_default { + (0usize, 0usize) + } else { + git::ahead_behind(&grepo, &name, &default_branch).unwrap_or((0, 0)) + }; + let pull_number = open_pulls + .iter() + .find(|p| p.source_branch == name) + .map(|p| p.number); + let updated = git::list_commits(&grepo, &name, 1) + .ok() + .and_then(|mut v| v.pop()) + .map(|c| c.time.to_string()) + .unwrap_or_default(); + BranchRow { + name, + is_default, + updated, + ahead, + behind, + pull_number, + } + }) + .collect(); + let release_tags: std::collections::HashSet<String> = + queries::list_releases(&state.pool, repository.id) + .await + .unwrap_or_default() + .into_iter() + .map(|r| r.tag_name) + .collect(); + let tags: Vec<TagRow> = git::list_tags(&grepo) + .unwrap_or_default() + .into_iter() + .map(|t| TagRow { + has_release: release_tags.contains(&t.name), + name: t.name, + short_id: t.short_id, + target: t.target, + message: t.message, + updated: t.time.to_string(), + }) + .collect(); + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + Ok(RepoBranchesTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + branches, + tags, + clone_http, + clone_ssh, + error: None, + }) +} + +fn commit_view(c: git::CommitInfo) -> CommitView { + CommitView { + id: c.id, + short_id: c.short_id, + message: c.message, + author: c.author, + email: c.email, + time: c.time, + } +} + +// ── issues ─────────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct StateFilter { + pub state: Option<String>, +} + +pub async fn issues_list( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + Query(q): Query<StateFilter>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !repository.issues_enabled { + return Err(AppError::not_found()); + } + // Default to open-only (GitHub-style) when no state query param is set. + let filter = match q.state.as_deref() { + Some("closed") => "closed", + _ => "open", + }; + let issues = queries::list_issues(&state.pool, repository.id, Some(filter)).await?; + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + Ok(IssuesListTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + issues, + state_filter: filter.to_string(), + clone_http, + clone_ssh, + }) +} + +pub async fn issue_new( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !repository.issues_enabled { + return Err(AppError::not_found()); + } + let _ = viewer.as_ref().ok_or_else(AppError::unauthorized)?; + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + Ok(IssueNewTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + error: None, + clone_http, + clone_ssh, + }) +} + +#[derive(Deserialize)] +pub struct IssueCreateForm { + pub title: String, + pub body: Option<String>, +} + +pub async fn issue_create( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + Form(form): Form<IssueCreateForm>, +) -> AppResult<Response> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !repository.issues_enabled { + return Err(AppError::not_found()); + } + let user = viewer.ok_or_else(AppError::unauthorized)?; + let title = form.title.trim(); + if title.is_empty() { + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + return Ok(IssueNewTemplate { + viewer: Some(user), + owner: owner_user, + repo: repository, + access, + error: Some("title required".into()), + clone_http, + clone_ssh, + } + .into_response()); + } + let body = form.body.unwrap_or_default(); + let number = queries::next_issue_number(&state.pool, repository.id).await?; + let issue = + queries::create_issue(&state.pool, repository.id, user.id, number, title, &body).await?; + queries::record_activity( + &state.pool, + Some(user.id), + Some(repository.id), + "issue.open", + &format!("opened issue #{number}"), + serde_json::json!({ "number": number }), + ) + .await?; + Ok(redirect_see_other(&format!( + "/{owner}/{repo}/issues/{}", + issue.number + ))) +} + +pub async fn issue_view( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, number)): Path<(String, String, i32)>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !repository.issues_enabled { + return Err(AppError::not_found()); + } + let issue = queries::get_issue(&state.pool, repository.id, number) + .await? + .ok_or_else(AppError::not_found)?; + let author = queries::get_user_by_id(&state.pool, issue.author_id) + .await? + .ok_or_else(AppError::not_found)?; + let comments = comment_views( + &state.pool, + queries::list_issue_comments(&state.pool, issue.id).await?, + viewer.as_ref().map(|u| u.id), + ) + .await?; + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + Ok(IssueViewTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + author_avatar: avatar_url_for(&author), + body_html: render_markdown(&issue.body), + issue, + author, + comments, + clone_http, + clone_ssh, + }) +} + +#[derive(Deserialize)] +pub struct CommentForm { + pub body: String, +} + +pub async fn issue_comment( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, number)): Path<(String, String, i32)>, + Form(form): Form<CommentForm>, +) -> AppResult<Response> { + let (repository, _owner_user, viewer, _access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let user = viewer.ok_or_else(AppError::unauthorized)?; + let issue = queries::get_issue(&state.pool, repository.id, number) + .await? + .ok_or_else(AppError::not_found)?; + let body = form.body.trim(); + if body.is_empty() { + return Err(AppError::bad("empty comment")); + } + queries::add_comment( + &state.pool, + repository.id, + user.id, + Some(issue.id), + None, + body, + ) + .await?; + queries::record_activity( + &state.pool, + Some(user.id), + Some(repository.id), + "issue.comment", + &format!("commented on issue #{number}"), + serde_json::json!({ "number": number }), + ) + .await?; + Ok(redirect_see_other(&format!( + "/{owner}/{repo}/issues/{number}" + ))) +} + +pub async fn issue_close( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, number)): Path<(String, String, i32)>, +) -> AppResult<Response> { + let (repository, _o, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let user = viewer.ok_or_else(AppError::unauthorized)?; + let issue = queries::get_issue(&state.pool, repository.id, number) + .await? + .ok_or_else(AppError::not_found)?; + if !(access.can_write() || issue.author_id == user.id) { + return Err(AppError::forbidden()); + } + queries::set_issue_state(&state.pool, issue.id, "closed").await?; + Ok(redirect_see_other(&format!( + "/{owner}/{repo}/issues/{number}" + ))) +} + +pub async fn issue_reopen( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, number)): Path<(String, String, i32)>, +) -> AppResult<Response> { + let (repository, _o, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let user = viewer.ok_or_else(AppError::unauthorized)?; + let issue = queries::get_issue(&state.pool, repository.id, number) + .await? + .ok_or_else(AppError::not_found)?; + if !(access.can_write() || issue.author_id == user.id) { + return Err(AppError::forbidden()); + } + queries::set_issue_state(&state.pool, issue.id, "open").await?; + Ok(redirect_see_other(&format!( + "/{owner}/{repo}/issues/{number}" + ))) +} + +// ── pull requests ──────────────────────────────────────────────────────────── + +pub async fn pulls_list( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + Query(q): Query<StateFilter>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !repository.pulls_enabled { + return Err(AppError::not_found()); + } + let filter = q + .state + .as_deref() + .filter(|s| *s == "open" || *s == "closed" || *s == "merged"); + let pulls = queries::list_pulls(&state.pool, repository.id, filter).await?; + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + Ok(PullsListTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + pulls, + state_filter: filter.unwrap_or("open").to_string(), + clone_http, + clone_ssh, + }) +} + +pub async fn pull_new( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !repository.pulls_enabled { + return Err(AppError::not_found()); + } + let _ = viewer.as_ref().ok_or_else(AppError::unauthorized)?; + let branches = git::open_bare(&state.config.repos_dir(), &owner, &repo) + .ok() + .and_then(|g| git::list_branches(&g).ok()) + .unwrap_or_default(); + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + Ok(PullNewTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + branches, + error: None, + clone_http, + clone_ssh, + upstream: None, + }) +} + +#[derive(Deserialize)] +pub struct PullCreateForm { + pub title: String, + pub body: Option<String>, + pub source_branch: String, + pub target_branch: String, +} + +pub async fn pull_create( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + Form(form): Form<PullCreateForm>, +) -> AppResult<Response> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !repository.pulls_enabled { + return Err(AppError::not_found()); + } + let user = viewer.ok_or_else(AppError::unauthorized)?; + let title = form.title.trim(); + let source = form.source_branch.trim(); + let target = form.target_branch.trim(); + if title.is_empty() || source.is_empty() || target.is_empty() { + let branches = git::open_bare(&state.config.repos_dir(), &owner, &repo) + .ok() + .and_then(|g| git::list_branches(&g).ok()) + .unwrap_or_default(); + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + return Ok(PullNewTemplate { + viewer: Some(user), + owner: owner_user, + repo: repository, + access, + branches, + error: Some("title, source, and target required".into()), + clone_http, + clone_ssh, + upstream: None, + } + .into_response()); + } + if source == target { + return Err(AppError::bad("source and target must differ")); + } + let body = form.body.unwrap_or_default(); + let number = queries::next_pull_number(&state.pool, repository.id).await?; + let pull = queries::create_pull( + &state.pool, + repository.id, + user.id, + number, + title, + &body, + source, + target, + ) + .await?; + queries::record_activity( + &state.pool, + Some(user.id), + Some(repository.id), + "pull.open", + &format!("opened pull #{number}"), + serde_json::json!({ "number": number }), + ) + .await?; + Ok(redirect_see_other(&format!( + "/{owner}/{repo}/pulls/{}", + pull.number + ))) +} + +#[derive(Deserialize)] +pub struct PullTabQuery { + pub tab: Option<String>, +} + +pub async fn pull_view( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, number)): Path<(String, String, i32)>, + Query(q): Query<PullTabQuery>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !repository.pulls_enabled { + return Err(AppError::not_found()); + } + let pull = queries::get_pull(&state.pool, repository.id, number) + .await? + .ok_or_else(AppError::not_found)?; + let author = queries::get_user_by_id(&state.pool, pull.author_id) + .await? + .ok_or_else(AppError::not_found)?; + let comments = comment_views( + &state.pool, + queries::list_pull_comments(&state.pool, pull.id).await?, + viewer.as_ref().map(|u| u.id), + ) + .await?; + + let tab = match q.tab.as_deref() { + Some("commits") => "commits", + Some("files") => "files", + _ => "conversation", + } + .to_string(); + + 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) + .unwrap_or_default() + .into_iter() + .map(commit_view) + .collect(); + let diff = + git::branch_diff(&g, &pull.source_branch, &pull.target_branch).unwrap_or_default(); + diff_files = parse_diff_files(&diff); + } + + let can_merge = access.can_write() && pull.state == "open"; + let mut merge_styles = Vec::new(); + if repository.allow_merge { + merge_styles.push("merge".to_string()); + } + if repository.allow_squash { + merge_styles.push("squash".to_string()); + } + if repository.allow_rebase { + merge_styles.push("rebase".to_string()); + } + + let conversation_count = comments.len() + 1; + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + Ok(PullViewTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + author_avatar: avatar_url_for(&author), + body_html: render_markdown(&pull.body), + pull, + author, + comments, + commits, + conversation_count, + diff_files, + tab, + can_merge, + merge_styles, + clone_http, + clone_ssh, + }) +} + +pub async fn pull_comment( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, number)): Path<(String, String, i32)>, + Form(form): Form<CommentForm>, +) -> AppResult<Response> { + let (repository, _o, viewer, _a) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let user = viewer.ok_or_else(AppError::unauthorized)?; + let pull = queries::get_pull(&state.pool, repository.id, number) + .await? + .ok_or_else(AppError::not_found)?; + let body = form.body.trim(); + if body.is_empty() { + return Err(AppError::bad("empty comment")); + } + queries::add_comment( + &state.pool, + repository.id, + user.id, + None, + Some(pull.id), + body, + ) + .await?; + queries::record_activity( + &state.pool, + Some(user.id), + Some(repository.id), + "pull.comment", + &format!("commented on pull #{number}"), + serde_json::json!({ "number": number }), + ) + .await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/pulls/{number}"))) +} + +#[derive(Deserialize)] +pub struct MergeForm { + pub style: Option<String>, +} + +pub async fn pull_merge( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, number)): Path<(String, String, i32)>, + Form(form): Form<MergeForm>, +) -> AppResult<Response> { + let (repository, _o, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let user = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_write() { + return Err(AppError::forbidden()); + } + if repository.archived { + return Err(AppError::forbidden()); + } + let pull = queries::get_pull(&state.pool, repository.id, number) + .await? + .ok_or_else(AppError::not_found)?; + if pull.state != "open" { + return Err(AppError::bad("pull is not open")); + } + + let style = form + .style + .as_deref() + .unwrap_or(repository.default_merge_style.as_str()); + let allowed = match style { + "squash" => repository.allow_squash, + "rebase" => repository.allow_rebase, + _ => repository.allow_merge, + }; + if !allowed { + return Err(AppError::bad("merge style not allowed")); + } + + let message = format!("Merge pull request #{number} from {}", pull.source_branch); + let author = if user.display_name.is_empty() { + user.username.as_str() + } else { + user.display_name.as_str() + }; + let merge_commit = git::merge_branches( + &state.config.repos_dir(), + &owner, + &repo, + &pull.source_branch, + &pull.target_branch, + style, + &message, + author, + &user.email, + ) + .map_err(|e| AppError::bad(format!("merge failed: {e}")))?; + + queries::set_pull_state(&state.pool, pull.id, "merged", Some(&merge_commit)).await?; + queries::record_activity( + &state.pool, + Some(user.id), + Some(repository.id), + "pull.merge", + &format!("merged pull #{number}"), + serde_json::json!({ "number": number, "commit": merge_commit }), + ) + .await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/pulls/{number}"))) +} + +pub async fn pull_close( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, number)): Path<(String, String, i32)>, +) -> AppResult<Response> { + let (repository, _o, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let user = viewer.ok_or_else(AppError::unauthorized)?; + let pull = queries::get_pull(&state.pool, repository.id, number) + .await? + .ok_or_else(AppError::not_found)?; + if !(access.can_write() || pull.author_id == user.id) { + return Err(AppError::forbidden()); + } + if pull.state != "open" { + return Err(AppError::bad("pull is not open")); + } + queries::set_pull_state(&state.pool, pull.id, "closed", None).await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/pulls/{number}"))) +} + +// ── 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 + .into_iter() + .map(|a| ReleaseAssetView { + id: a.id, + filename: a.filename, + size_label: format_bytes(a.size_bytes), + content_type: a.content_type, + }) + .collect() +} + +fn release_branch_choices(state: &AppState, owner: &str, repo: &str, default: &str) -> Vec<String> { + let mut branches = git::open_bare(&state.config.repos_dir(), owner, repo) + .ok() + .and_then(|g| git::list_branches(&g).ok()) + .unwrap_or_default(); + if branches.is_empty() { + branches.push(default.to_string()); + } + branches +} + +pub async fn releases_list( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !repository.releases_enabled { + return Err(AppError::not_found()); + } + let mut releases = queries::list_releases(&state.pool, repository.id).await?; + if !access.can_write() { + releases.retain(|r| !r.is_draft); + } + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + Ok(ReleasesListTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + releases, + clone_http, + clone_ssh, + }) +} + +pub async fn release_new( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !repository.releases_enabled { + return Err(AppError::not_found()); + } + if !access.can_write() { + return Err(AppError::forbidden()); + } + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + let target = repository.default_branch.clone(); + let branches = release_branch_choices(&state, &owner, &repo, &target); + Ok(ReleaseNewTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + error: None, + clone_http, + clone_ssh, + tag_name: String::new(), + title: String::new(), + body: String::new(), + target, + is_prerelease: false, + is_draft: false, + branches, + }) +} + +#[derive(Deserialize)] +pub struct ReleaseCreateForm { + pub tag_name: String, + pub title: String, + pub body: Option<String>, + pub target: Option<String>, + pub is_prerelease: Option<String>, + pub is_draft: Option<String>, +} + +pub async fn release_create( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + Form(form): Form<ReleaseCreateForm>, +) -> AppResult<Response> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !repository.releases_enabled { + return Err(AppError::not_found()); + } + let user = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_write() { + return Err(AppError::forbidden()); + } + let tag = form.tag_name.trim().to_string(); + let title = form.title.trim().to_string(); + let body = form.body.clone().unwrap_or_default(); + let target = form + .target + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(repository.default_branch.as_str()) + .to_string(); + let is_prerelease = checkbox(&form.is_prerelease); + let is_draft = checkbox(&form.is_draft); + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + let branches = release_branch_choices(&state, &owner, &repo, &repository.default_branch); + + let redisplay = |err: String| { + ReleaseNewTemplate { + viewer: Some(user.clone()), + owner: owner_user.clone(), + repo: repository.clone(), + access, + error: Some(err), + clone_http: clone_http.clone(), + clone_ssh: clone_ssh.clone(), + tag_name: tag.clone(), + title: title.clone(), + body: body.clone(), + target: target.clone(), + is_prerelease, + is_draft, + branches: branches.clone(), + } + .into_response() + }; + + if tag.is_empty() || title.is_empty() { + return Ok(redisplay("tag and title required".into())); + } + if queries::get_release(&state.pool, repository.id, &tag) + .await? + .is_some() + { + return Ok(redisplay("a release already exists for this tag".into())); + } + + let bare = git::bare_path(&state.config.repos_dir(), &owner, &repo); + if let Ok(grepo) = git::open_bare(&state.config.repos_dir(), &owner, &repo) { + if !git::tag_exists(&grepo, &tag) { + if let Err(e) = git::create_tag_at(&bare, &tag, &target, &title) { + return Ok(redisplay(format!("could not create tag: {e}"))); + } + } + } else if let Err(e) = git::create_tag_at(&bare, &tag, &target, &title) { + return Ok(redisplay(format!("could not create tag: {e}"))); + } + + let release = queries::create_release( + &state.pool, + repository.id, + user.id, + &tag, + &title, + &body, + is_prerelease, + is_draft, + ) + .await?; + let action = if is_draft { "drafted" } else { "published" }; + queries::record_activity( + &state.pool, + Some(user.id), + Some(repository.id), + "release.create", + &format!("{action} release {tag}"), + serde_json::json!({ "tag": tag }), + ) + .await?; + Ok(redirect_see_other(&format!( + "/{owner}/{repo}/releases/{}", + release.tag_name + ))) +} + +pub async fn release_view( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, tag)): Path<(String, String, String)>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !repository.releases_enabled { + return Err(AppError::not_found()); + } + let release = queries::get_release(&state.pool, repository.id, &tag) + .await? + .ok_or_else(AppError::not_found)?; + if release.is_draft && !access.can_write() { + return Err(AppError::not_found()); + } + let assets = asset_views(queries::list_release_assets(&state.pool, release.id).await?); + let author = queries::get_user_by_id(&state.pool, release.author_id) + .await? + .ok_or_else(AppError::not_found)?; + let (tag_short, tag_target) = git::open_bare(&state.config.repos_dir(), &owner, &repo) + .ok() + .and_then(|g| { + git::list_tags(&g) + .ok()? + .into_iter() + .find(|t| t.name == tag) + .map(|t| (t.short_id, t.target)) + }) + .unwrap_or_else(|| (tag.clone(), String::new())); + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + Ok(ReleaseViewTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + author_avatar: avatar_url_for(&author), + body_html: render_markdown(&release.body), + release, + author, + assets, + tag_short, + tag_target, + clone_http, + clone_ssh, + }) +} + +pub async fn release_edit( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, tag)): Path<(String, String, String)>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !repository.releases_enabled { + return Err(AppError::not_found()); + } + if !access.can_write() { + return Err(AppError::forbidden()); + } + let _ = viewer.as_ref().ok_or_else(AppError::unauthorized)?; + let release = queries::get_release(&state.pool, repository.id, &tag) + .await? + .ok_or_else(AppError::not_found)?; + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + let target = git::open_bare(&state.config.repos_dir(), &owner, &repo) + .ok() + .and_then(|g| { + git::list_tags(&g) + .ok()? + .into_iter() + .find(|t| t.name == tag) + .map(|t| t.target) + }) + .unwrap_or_else(|| repository.default_branch.clone()); + let branches = release_branch_choices(&state, &owner, &repo, &repository.default_branch); + Ok(ReleaseEditTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + release, + error: None, + clone_http, + clone_ssh, + target, + branches, + }) +} + +#[derive(Deserialize)] +pub struct ReleaseUpdateForm { + pub tag_name: String, + pub title: String, + pub body: Option<String>, + pub target: Option<String>, + pub is_prerelease: Option<String>, + pub is_draft: Option<String>, +} + +pub async fn release_update( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, tag)): Path<(String, String, String)>, + Form(form): Form<ReleaseUpdateForm>, +) -> AppResult<Response> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !repository.releases_enabled { + return Err(AppError::not_found()); + } + let user = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_write() { + return Err(AppError::forbidden()); + } + let release = queries::get_release(&state.pool, repository.id, &tag) + .await? + .ok_or_else(AppError::not_found)?; + + let new_tag = form.tag_name.trim().to_string(); + let title = form.title.trim().to_string(); + let body = form.body.clone().unwrap_or_default(); + let is_prerelease = checkbox(&form.is_prerelease); + let is_draft = checkbox(&form.is_draft); + let target = form + .target + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + let branches = release_branch_choices(&state, &owner, &repo, &repository.default_branch); + + if new_tag.is_empty() || title.is_empty() { + let mut release = release; + release.tag_name = new_tag; + release.title = title; + release.body = body; + release.is_prerelease = is_prerelease; + release.is_draft = is_draft; + return Ok(ReleaseEditTemplate { + viewer: Some(user), + owner: owner_user, + repo: repository, + access, + release, + error: Some("tag and title required".into()), + clone_http, + clone_ssh, + target: target.unwrap_or_default(), + branches, + } + .into_response()); + } + + if new_tag != tag { + if queries::get_release(&state.pool, repository.id, &new_tag) + .await? + .is_some() + { + return Err(AppError::bad("a release already exists for that tag")); + } + if let Ok(grepo) = git::open_bare(&state.config.repos_dir(), &owner, &repo) { + if git::tag_exists(&grepo, &tag) { + git::rename_tag(&grepo, &tag, &new_tag) + .map_err(|e| AppError::bad(format!("rename tag failed: {e}")))?; + } + } + // Move on-disk assets directory if present + let old_dir = state + .config + .releases_dir() + .join(repository.id.to_string()) + .join(&tag); + let new_dir = state + .config + .releases_dir() + .join(repository.id.to_string()) + .join(&new_tag); + if old_dir.is_dir() && !new_dir.exists() { + let _ = std::fs::rename(&old_dir, &new_dir); + } + let old_prefix = format!("{}/{}/", repository.id, tag); + let new_prefix = format!("{}/{}/", repository.id, new_tag); + queries::rewrite_asset_paths_for_tag(&state.pool, release.id, &old_prefix, &new_prefix) + .await?; + } + + if let Some(ref tgt) = target { + let bare = git::bare_path(&state.config.repos_dir(), &owner, &repo); + let _ = git::retarget_tag(&bare, &new_tag, tgt, &title); + } + + let updated = queries::update_release( + &state.pool, + release.id, + &new_tag, + &title, + &body, + is_prerelease, + is_draft, + ) + .await?; + queries::record_activity( + &state.pool, + Some(user.id), + Some(repository.id), + "release.update", + &format!("updated release {}", updated.tag_name), + serde_json::json!({ "tag": updated.tag_name }), + ) + .await?; + Ok(redirect_see_other(&format!( + "/{owner}/{repo}/releases/{}", + updated.tag_name + ))) +} + +pub async fn release_delete( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, tag)): Path<(String, String, String)>, +) -> AppResult<Response> { + let (repository, _o, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !repository.releases_enabled { + return Err(AppError::not_found()); + } + let user = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_write() { + return Err(AppError::forbidden()); + } + let release = queries::get_release(&state.pool, repository.id, &tag) + .await? + .ok_or_else(AppError::not_found)?; + let assets = queries::list_release_assets(&state.pool, release.id).await?; + for a in &assets { + let path = state.config.releases_dir().join(&a.stored_path); + let _ = std::fs::remove_file(path); + } + let dir = state + .config + .releases_dir() + .join(repository.id.to_string()) + .join(&tag); + let _ = std::fs::remove_dir_all(dir); + queries::delete_release(&state.pool, release.id).await?; + queries::record_activity( + &state.pool, + Some(user.id), + Some(repository.id), + "release.delete", + &format!("deleted release {tag}"), + serde_json::json!({ "tag": tag }), + ) + .await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/releases"))) +} + +pub async fn release_upload( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, tag)): Path<(String, String, String)>, + mut multipart: Multipart, +) -> AppResult<Response> { + let (repository, _o, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let _user = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_write() { + return Err(AppError::forbidden()); + } + let release = queries::get_release(&state.pool, repository.id, &tag) + .await? + .ok_or_else(AppError::not_found)?; + + let dir = state + .config + .releases_dir() + .join(repository.id.to_string()) + .join(&tag); + std::fs::create_dir_all(&dir)?; + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| AppError::bad(e.to_string()))? + { + let name = field.name().unwrap_or("").to_string(); + if name != "asset" && name != "file" { + let _ = field.bytes().await; + continue; + } + let filename = field + .file_name() + .unwrap_or("asset.bin") + .replace(['/', '\\'], "_"); + if filename.is_empty() { + continue; + } + let content_type = field + .content_type() + .unwrap_or("application/octet-stream") + .to_string(); + let data = field + .bytes() + .await + .map_err(|e| AppError::bad(e.to_string()))?; + let stored_name = format!("{}-{}", Uuid::new_v4(), filename); + let path = dir.join(&stored_name); + std::fs::write(&path, &data)?; + let rel = format!("{}/{}/{}", repository.id, tag, stored_name); + queries::add_release_asset( + &state.pool, + release.id, + &filename, + &rel, + data.len() as i64, + &content_type, + ) + .await?; + } + + Ok(redirect_see_other(&format!( + "/{owner}/{repo}/releases/{tag}" + ))) +} + +pub async fn asset_download( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, id)): Path<(String, String, Uuid)>, +) -> AppResult<Response> { + let (_repository, _o, _viewer, _access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let asset = queries::get_asset(&state.pool, id) + .await? + .ok_or_else(AppError::not_found)?; + let path = state.config.releases_dir().join(&asset.stored_path); + if !path.is_file() { + // also try absolute stored path + let alt = PathBuf::from(&asset.stored_path); + if alt.is_file() { + return serve_file(&alt, &asset.filename, &asset.content_type); + } + return Err(AppError::not_found()); + } + serve_file(&path, &asset.filename, &asset.content_type) +} + +pub async fn asset_delete( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, tag, id)): Path<(String, String, String, Uuid)>, +) -> AppResult<Response> { + let (repository, _o, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let _ = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_write() { + return Err(AppError::forbidden()); + } + let release = queries::get_release(&state.pool, repository.id, &tag) + .await? + .ok_or_else(AppError::not_found)?; + let asset = queries::delete_release_asset(&state.pool, id) + .await? + .ok_or_else(AppError::not_found)?; + if asset.release_id != release.id { + return Err(AppError::not_found()); + } + let path = state.config.releases_dir().join(&asset.stored_path); + let _ = std::fs::remove_file(path); + Ok(redirect_see_other(&format!( + "/{owner}/{repo}/releases/{tag}" + ))) +} + +fn serve_file(path: &std::path::Path, filename: &str, content_type: &str) -> AppResult<Response> { + let data = std::fs::read(path)?; + let disp = format!("attachment; filename=\"{}\"", filename.replace('"', "")); + Ok(Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, content_type) + .header(CONTENT_DISPOSITION, disp) + .body(Body::from(data)) + .unwrap()) +} + +// ── repo settings ──────────────────────────────────────────────────────────── + +pub async fn repo_settings( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !access.can_admin() { + return Err(AppError::forbidden()); + } + let collaborators = queries::list_collaborators(&state.pool, repository.id).await?; + let collab_views: Vec<CollaboratorView> = collaborators + .into_iter() + .map(|(c, u)| { + let avatar_url = avatar_url_for(&u); + CollaboratorView { + user: u, + role: c.role, + avatar_url, + } + }) + .collect(); + let mut branches = git::open_bare(&state.config.repos_dir(), &owner, &repo) + .ok() + .and_then(|g| git::list_branches(&g).ok()) + .unwrap_or_default(); + if !branches.is_empty() && !branches.iter().any(|b| b == &repository.default_branch) { + branches.insert(0, repository.default_branch.clone()); + } + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + let branch_rules = queries::list_branch_rules(&state.pool, repository.id).await?; + let is_site_admin = viewer.as_ref().map(|u| u.is_site_admin).unwrap_or(false); + Ok(RepoSettingsTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + collaborators: collab_views, + branches, + clone_http, + clone_ssh, + branch_rules, + is_site_admin, + error: None, + }) +} + +#[derive(Deserialize)] +pub struct RepoSettingsForm { + pub description: Option<String>, + pub visibility: Option<String>, + pub default_branch: Option<String>, + pub issues_enabled: Option<String>, + pub pulls_enabled: Option<String>, + pub releases_enabled: Option<String>, + pub allow_merge: Option<String>, + pub allow_squash: Option<String>, + pub allow_rebase: Option<String>, + pub default_merge_style: Option<String>, + pub protect_default_branch: Option<String>, + pub protect_block_force_push: Option<String>, +} + +pub async fn repo_settings_save( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + Form(form): Form<RepoSettingsForm>, +) -> AppResult<Response> { + let (repository, _o, _viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !access.can_admin() { + return Err(AppError::forbidden()); + } + let visibility = match form.visibility.as_deref() { + Some("private") => "private", + _ => "public", + }; + let merge_style = match form.default_merge_style.as_deref() { + Some("squash") => "squash", + Some("rebase") => "rebase", + _ => "merge", + }; + let default_branch = form + .default_branch + .as_deref() + .unwrap_or(&repository.default_branch) + .trim() + .to_string(); + if default_branch.is_empty() { + return Err(AppError::bad("default branch required")); + } + queries::update_repo_settings( + &state.pool, + repository.id, + form.description.as_deref().unwrap_or(""), + visibility, + &default_branch, + checkbox(&form.issues_enabled), + checkbox(&form.pulls_enabled), + checkbox(&form.releases_enabled), + checkbox(&form.allow_merge), + checkbox(&form.allow_squash), + checkbox(&form.allow_rebase), + merge_style, + checkbox(&form.protect_default_branch), + checkbox(&form.protect_block_force_push), + ) + .await?; + let bare = git::bare_path(&state.config.repos_dir(), &owner, &repo); + if let Ok(g) = git::open_bare(&state.config.repos_dir(), &owner, &repo) { + if git::resolve_ref(&g, &default_branch).is_ok() { + let _ = git::set_head_branch(&g, &default_branch); + } + } + git::sync_branch_protection( + &bare, + &default_branch, + checkbox(&form.protect_default_branch), + checkbox(&form.protect_block_force_push), + )?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/settings"))) +} + +#[derive(Deserialize)] +pub struct CollabAddForm { + pub username: String, + pub role: Option<String>, +} + +pub async fn collab_add( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + Form(form): Form<CollabAddForm>, +) -> AppResult<Response> { + let (repository, _o, _viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !access.can_admin() { + return Err(AppError::forbidden()); + } + let username = form.username.trim(); + let role = match form.role.as_deref() { + Some("admin") => "admin", + Some("read") => "read", + _ => "write", + }; + let target = queries::get_user_by_username(&state.pool, username) + .await? + .ok_or_else(|| AppError::bad("user not found"))?; + if target.id == repository.owner_id { + return Err(AppError::bad("owner is already the owner")); + } + queries::add_collaborator(&state.pool, repository.id, target.id, role).await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/settings"))) +} + +pub async fn collab_remove( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, user_id)): Path<(String, String, Uuid)>, +) -> AppResult<Response> { + let (repository, _o, _viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !access.can_admin() { + return Err(AppError::forbidden()); + } + queries::remove_collaborator(&state.pool, repository.id, user_id).await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/settings"))) +} + +#[derive(Deserialize)] +pub struct ArchiveForm { + pub archived: Option<String>, +} + +pub async fn repo_archive( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + Form(form): Form<ArchiveForm>, +) -> AppResult<Response> { + let (repository, _o, _viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !access.can_owner() { + return Err(AppError::forbidden()); + } + let archived = match form.archived.as_deref() { + Some("false") | Some("0") | Some("unarchive") => false, + _ => !repository.archived, + }; + queries::set_archived(&state.pool, repository.id, archived).await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/settings"))) +} + +#[derive(Deserialize)] +pub struct DeleteForm { + pub confirm: Option<String>, +} + +pub async fn repo_delete( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + Form(form): Form<DeleteForm>, +) -> AppResult<Response> { + let (repository, _owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let user = viewer.ok_or_else(AppError::unauthorized)?; + let is_admin = user.is_site_admin; + if !access.can_owner() && !is_admin { + return Err(AppError::forbidden()); + } + let confirm = form.confirm.unwrap_or_default(); + if confirm != repository.name && confirm != format!("{owner}/{repo}") { + return Err(AppError::bad("type the repository name to confirm delete")); + } + queries::delete_repo(&state.pool, repository.id).await?; + let _ = git::remove_bare(&state.config.repos_dir(), &owner, &repo); + queries::record_activity( + &state.pool, + Some(user.id), + None, + "repo.delete", + "deleted repository", + serde_json::json!({}), + ) + .await?; + Ok(redirect_see_other(&format!("/{}", user.username))) +} + +#[derive(Deserialize)] +pub struct TransferForm { + pub new_owner: String, +} + +pub async fn repo_transfer( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + Form(form): Form<TransferForm>, +) -> AppResult<Response> { + let (repository, _o, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let _user = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_owner() { + return Err(AppError::forbidden()); + } + let new_name = form.new_owner.trim(); + let new_owner = queries::get_user_by_username(&state.pool, new_name) + .await? + .ok_or_else(|| AppError::bad("user not found"))?; + if new_owner.id == repository.owner_id { + return Err(AppError::bad("already owned by that user")); + } + if queries::get_repo(&state.pool, &new_owner.username, &repo) + .await? + .is_some() + { + return Err(AppError::bad("target already has a repo with that name")); + } + + let old_path = git::bare_path(&state.config.repos_dir(), &owner, &repo); + let new_path = git::bare_path(&state.config.repos_dir(), &new_owner.username, &repo); + if old_path.exists() { + if let Some(parent) = new_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::rename(&old_path, &new_path).map_err(|e| { + AppError::internal(format!("failed to move repository on disk: {e}")) + })?; + } + + queries::transfer_repo(&state.pool, repository.id, new_owner.id).await?; + Ok(redirect_see_other(&format!( + "/{}/{repo}", + new_owner.username + ))) +} + @@ -0,0 +1,809 @@ +//! Social, account, branch, raw, and admin helpers. + +use super::routes::{ + avatar_url_for, load_repo_context, redirect_see_other, require_login, AppError, AppResult, +}; +use crate::auth::{self, clear_session_cookie, hash_token, token_from_headers}; +use crate::db::queries; +use crate::git; +use crate::state::AppState; +use crate::web::templates::*; +use axum::extract::{Form, Path, State}; +use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, SET_COOKIE}; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::body::Body; +use serde::Deserialize; +use uuid::Uuid; + +fn checkbox(v: &Option<String>) -> bool { + matches!( + v.as_deref(), + Some("on") | Some("true") | Some("1") | Some("yes") + ) +} + +pub async fn repo_raw( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, rest)): Path<(String, String, String)>, +) -> AppResult<Response> { + let (_repository, _owner_user, _viewer, _access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo) + .map_err(|_| AppError::not_found())?; + // reuse split via rest: branch/path + let rest = rest.trim_matches('/'); + let parts: Vec<&str> = rest.split('/').collect(); + if parts.len() < 2 { + return Err(AppError::bad("missing path")); + } + // Prefer longest matching ref prefix + let (branch, path) = { + let mut found = None; + for i in (1..parts.len()).rev() { + let reference = parts[..i].join("/"); + if git::resolve_ref(&grepo, &reference).is_ok() { + found = Some((reference, parts[i..].join("/"))); + break; + } + } + found.unwrap_or_else(|| (parts[0].to_string(), parts[1..].join("/"))) + }; + if path.is_empty() { + return Err(AppError::bad("missing file path")); + } + let (data, _binary) = git::read_blob(&grepo, &branch, &path).map_err(|_| AppError::not_found())?; + let filename = path.rsplit('/').next().unwrap_or("file"); + let ct = mime_guess::from_path(filename) + .first_or_octet_stream() + .to_string(); + let disp = format!("attachment; filename=\"{filename}\""); + Ok(( + [ + (CONTENT_TYPE, HeaderValue::from_str(&ct).unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"))), + ( + CONTENT_DISPOSITION, + HeaderValue::from_str(&disp).unwrap_or_else(|_| HeaderValue::from_static("attachment")), + ), + ], + Body::from(data), + ) + .into_response()) +} + +pub async fn repo_star( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + let (repository, _, _, _) = load_repo_context(&state, &owner, &repo, &headers).await?; + queries::toggle_star(&state.pool, repository.id, user.id).await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}"))) +} + +pub async fn repo_watch( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + let (repository, _, _, _) = load_repo_context(&state, &owner, &repo, &headers).await?; + queries::toggle_watch(&state.pool, repository.id, user.id).await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}"))) +} + +pub async fn repo_fork( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + let (repository, owner_user, _, _) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if owner_user.id == user.id { + return Err(AppError::bad("cannot fork your own repository")); + } + if let Some(existing) = + queries::get_fork_of_user(&state.pool, repository.id, user.id).await? + { + return Ok(redirect_see_other(&format!( + "/{}/{}", + user.username, existing.name + ))); + } + let name = repository.name.clone(); + if queries::get_repo(&state.pool, &user.username, &name) + .await? + .is_some() + { + return Err(AppError::bad( + "you already have a repository with this name", + )); + } + let src = git::bare_path(&state.config.repos_dir(), &owner, &repo); + let dest = git::bare_path(&state.config.repos_dir(), &user.username, &name); + git::clone_bare(&src, &dest)?; + let forked = queries::create_fork( + &state.pool, + user.id, + &name, + &repository.description, + &repository.visibility, + repository.id, + ) + .await?; + let _ = forked; + Ok(redirect_see_other(&format!("/{}/{}", user.username, name))) +} + +#[derive(Deserialize)] +pub struct ReactForm { + pub emoji: String, +} + +pub async fn comment_react( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, comment_id)): Path<(String, String, Uuid)>, + Form(form): Form<ReactForm>, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + let (_repository, _, _, access) = load_repo_context(&state, &owner, &repo, &headers).await?; + if !access.can_read() { + return Err(AppError::forbidden()); + } + let allowed = [ + "+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes", + ]; + if !allowed.contains(&form.emoji.as_str()) { + return Err(AppError::bad("invalid reaction")); + } + queries::toggle_reaction(&state.pool, comment_id, user.id, &form.emoji).await?; + let referer = headers + .get(axum::http::header::REFERER) + .and_then(|v| v.to_str().ok()) + .unwrap_or("/"); + // Prefer relative path + let path = if let Ok(u) = url::Url::parse(referer) { + u.path().to_string() + } else if referer.starts_with('/') { + referer.to_string() + } else { + format!("/{owner}/{repo}") + }; + Ok(redirect_see_other(&path)) +} + +#[derive(Deserialize)] +pub struct BranchRuleForm { + pub pattern: String, + pub require_pr: Option<String>, + pub block_force_push: Option<String>, + pub allow_deletions: Option<String>, +} + +pub async fn branch_rule_add( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + Form(form): Form<BranchRuleForm>, +) -> AppResult<Response> { + let (_u, repository, access) = { + let (repository, _, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let _ = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_admin() { + return Err(AppError::forbidden()); + } + ((), repository, access) + }; + let _ = access; + let pattern = form.pattern.trim(); + if pattern.is_empty() { + return Err(AppError::bad("pattern required")); + } + queries::add_branch_rule( + &state.pool, + repository.id, + pattern, + checkbox(&form.require_pr), + checkbox(&form.block_force_push), + checkbox(&form.allow_deletions), + ) + .await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/settings"))) +} + +pub async fn branch_rule_delete( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, id)): Path<(String, String, Uuid)>, +) -> AppResult<Response> { + let (repository, _, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let _ = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_admin() { + return Err(AppError::forbidden()); + } + queries::delete_branch_rule(&state.pool, id, repository.id).await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/settings"))) +} + +#[derive(Deserialize)] +pub struct RenameBranchForm { + pub new_name: String, +} + +pub async fn branch_rename( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, branch)): Path<(String, String, String)>, + Form(form): Form<RenameBranchForm>, +) -> AppResult<Response> { + let (repository, _, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let _ = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_write() { + return Err(AppError::forbidden()); + } + if branch == repository.default_branch { + return Err(AppError::bad("cannot rename default branch here")); + } + let new_name = form.new_name.trim(); + if new_name.is_empty() || new_name.contains('/') { + return Err(AppError::bad("invalid branch name")); + } + let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)?; + git::rename_branch(&grepo, &branch, new_name)?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/branches"))) +} + +pub async fn branch_delete( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, branch)): Path<(String, String, String)>, +) -> AppResult<Response> { + let (repository, _, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let _ = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_write() { + return Err(AppError::forbidden()); + } + if branch == repository.default_branch { + return Err(AppError::bad("cannot delete default branch")); + } + let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)?; + git::delete_branch(&grepo, &branch)?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/branches"))) +} + +#[derive(Deserialize)] +pub struct CreateTagForm { + pub name: String, + pub target: Option<String>, + pub message: Option<String>, +} + +pub async fn tag_create( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + Form(form): Form<CreateTagForm>, +) -> AppResult<Response> { + let (repository, _, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let _ = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_write() { + return Err(AppError::forbidden()); + } + let name = form.name.trim(); + if name.is_empty() || name.contains("..") { + return Err(AppError::bad("invalid tag name")); + } + let target = form + .target + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(repository.default_branch.as_str()); + let message = form.message.unwrap_or_default(); + let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)?; + if git::tag_exists(&grepo, name) { + return Err(AppError::bad("tag already exists")); + } + let bare = git::bare_path(&state.config.repos_dir(), &owner, &repo); + git::create_tag_at(&bare, name, target, &message) + .map_err(|e| AppError::bad(format!("create tag failed: {e}")))?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/branches"))) +} + +#[derive(Deserialize)] +pub struct RenameTagForm { + pub new_name: String, +} + +pub async fn tag_rename( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, tag)): Path<(String, String, String)>, + Form(form): Form<RenameTagForm>, +) -> AppResult<Response> { + let (repository, _, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let _ = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_write() { + return Err(AppError::forbidden()); + } + let new_name = form.new_name.trim(); + if new_name.is_empty() || new_name.contains("..") { + return Err(AppError::bad("invalid tag name")); + } + let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)?; + git::rename_tag(&grepo, &tag, new_name) + .map_err(|e| AppError::bad(format!("rename tag failed: {e}")))?; + if let Some(release) = + queries::rename_release_tag(&state.pool, repository.id, &tag, new_name).await? + { + let old_dir = state + .config + .releases_dir() + .join(repository.id.to_string()) + .join(&tag); + let new_dir = state + .config + .releases_dir() + .join(repository.id.to_string()) + .join(new_name); + if old_dir.is_dir() && !new_dir.exists() { + let _ = std::fs::rename(&old_dir, &new_dir); + } + let old_prefix = format!("{}/{}/", repository.id, tag); + let new_prefix = format!("{}/{}/", repository.id, new_name); + queries::rewrite_asset_paths_for_tag(&state.pool, release.id, &old_prefix, &new_prefix) + .await?; + } + Ok(redirect_see_other(&format!("/{owner}/{repo}/branches"))) +} + +pub async fn tag_delete( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, tag)): Path<(String, String, String)>, +) -> AppResult<Response> { + let (_repository, _, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let _ = viewer.ok_or_else(AppError::unauthorized)?; + if !access.can_write() { + return Err(AppError::forbidden()); + } + let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)?; + git::delete_tag(&grepo, &tag).map_err(|e| AppError::bad(format!("delete tag failed: {e}")))?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/branches"))) +} + +// ── account settings ───────────────────────────────────────────────────────── + +pub async fn account_settings( + State(state): State<AppState>, + headers: HeaderMap, +) -> AppResult<impl IntoResponse> { + let user = require_login(&state.auth, &headers).await?; + let emails = queries::list_user_emails(&state.pool, user.id).await?; + let sessions_raw = queries::list_sessions(&state.pool, user.id).await?; + let current_hash = token_from_headers(&headers).map(|t| hash_token(&t)); + let mut current_session_id = None; + let sessions = sessions_raw + .into_iter() + .map(|s| { + let is_current = current_hash + .as_ref() + .map(|h| h == &s.token_hash) + .unwrap_or(false); + if is_current { + current_session_id = Some(s.id); + } + SessionView { + id: s.id, + created_at: s.created_at, + last_seen_at: s.last_seen_at, + user_agent: s.user_agent, + ip_address: s.ip_address, + is_current, + } + }) + .collect(); + Ok(AccountSettingsTemplate { + viewer: Some(user.clone()), + user, + emails, + sessions, + current_session_id, + error: None, + message: None, + }) +} + +fn mfa_page( + user: crate::db::models::User, + mfa: Option<crate::db::models::UserMfa>, + recovery_codes: Option<Vec<String>>, + error: Option<String>, + message: Option<String>, +) -> AppResult<MfaSettingsTemplate> { + let enabled = mfa.as_ref().is_some_and(|m| m.enabled); + let pending_secret = mfa + .as_ref() + .and_then(|m| m.pending_secret.clone()) + .filter(|s| !s.is_empty()); + let pending = !enabled && pending_secret.is_some(); + let (secret, qr_data_uri) = if let Some(ref sec) = pending_secret { + let uri = crate::mfa::otpauth_uri(&user.username, sec, "kitgit"); + let qr = crate::mfa::qr_svg_data_uri(&uri).ok(); + (Some(sec.clone()), qr) + } else { + (None, None) + }; + let recovery_remaining = mfa + .as_ref() + .map(|m| m.recovery_code_hashes.len()) + .unwrap_or(0); + Ok(MfaSettingsTemplate { + viewer: Some(user.clone()), + user, + enabled, + pending, + secret, + qr_data_uri, + recovery_codes, + recovery_remaining, + error, + message, + }) +} + +pub async fn mfa_settings( + State(state): State<AppState>, + headers: HeaderMap, +) -> AppResult<impl IntoResponse> { + let user = require_login(&state.auth, &headers).await?; + let mfa = queries::get_user_mfa(&state.pool, user.id).await?; + Ok(mfa_page(user, mfa, None, None, None)?) +} + +pub async fn mfa_enroll( + State(state): State<AppState>, + headers: HeaderMap, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + if queries::mfa_is_enabled(&state.pool, user.id).await? { + return Ok(redirect_see_other("/settings/mfa")); + } + let secret = crate::mfa::generate_totp_secret(); + queries::mfa_start_enroll(&state.pool, user.id, &secret).await?; + Ok(redirect_see_other("/settings/mfa")) +} + +#[derive(Deserialize)] +pub struct MfaCodeForm { + pub code: String, +} + +pub async fn mfa_confirm( + State(state): State<AppState>, + headers: HeaderMap, + Form(form): Form<MfaCodeForm>, +) -> AppResult<impl IntoResponse> { + let user = require_login(&state.auth, &headers).await?; + let mfa = queries::get_user_mfa(&state.pool, user.id) + .await? + .ok_or_else(|| AppError::bad("start enrollment first"))?; + let pending = mfa + .pending_secret + .as_deref() + .ok_or_else(|| AppError::bad("start enrollment first"))?; + if !crate::mfa::verify_totp(pending, &form.code) { + return Ok(mfa_page( + user, + Some(mfa), + None, + Some("invalid confirmation code".into()), + None, + )? + .into_response()); + } + let codes = crate::mfa::generate_recovery_codes(); + let hashes: Vec<String> = codes.iter().map(|c| crate::mfa::hash_recovery_code(c)).collect(); + let secret = pending.to_string(); + queries::mfa_confirm_enroll(&state.pool, user.id, &secret, &hashes).await?; + let mfa = queries::get_user_mfa(&state.pool, user.id).await?; + Ok(mfa_page( + user, + mfa, + Some(codes), + None, + Some("two-factor authentication enabled".into()), + )? + .into_response()) +} + +pub async fn mfa_disable( + State(state): State<AppState>, + headers: HeaderMap, + Form(form): Form<MfaCodeForm>, +) -> AppResult<impl IntoResponse> { + let user = require_login(&state.auth, &headers).await?; + let mfa = queries::get_user_mfa(&state.pool, user.id) + .await? + .ok_or_else(|| AppError::bad("MFA is not enabled"))?; + if !mfa.enabled { + return Ok(redirect_see_other("/settings/mfa").into_response()); + } + let secret = mfa + .totp_secret + .as_deref() + .ok_or_else(|| AppError::bad("MFA is not enabled"))?; + let ok = if crate::mfa::verify_totp(secret, &form.code) { + true + } else if let Some(idx) = crate::mfa::verify_recovery_code(&mfa.recovery_code_hashes, &form.code) + { + let mut hashes = mfa.recovery_code_hashes.clone(); + hashes.remove(idx); + queries::mfa_set_recovery_hashes(&state.pool, user.id, &hashes).await?; + true + } else { + false + }; + if !ok { + return Ok(mfa_page( + user, + Some(mfa), + None, + Some("invalid authentication code".into()), + None, + )? + .into_response()); + } + queries::mfa_disable(&state.pool, user.id).await?; + Ok(redirect_see_other("/settings/mfa").into_response()) +} + +#[derive(Deserialize)] +pub struct UsernameForm { + pub username: String, +} + +pub async fn account_change_username( + State(state): State<AppState>, + headers: HeaderMap, + Form(form): Form<UsernameForm>, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + let username = form.username.trim().to_lowercase(); + if !username + .chars() + .next() + .map(|c| c.is_ascii_alphanumeric()) + .unwrap_or(false) + || username.len() > 39 + || !username + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') + { + return Err(AppError::bad("invalid username")); + } + if username != user.username { + if queries::get_user_by_username(&state.pool, &username) + .await? + .is_some() + { + return Err(AppError::bad("username taken")); + } + // rename repos on disk + let old_root = state.config.repos_dir().join(&user.username); + let new_root = state.config.repos_dir().join(&username); + if old_root.exists() { + if new_root.exists() { + return Err(AppError::bad("cannot rename: target path exists")); + } + std::fs::rename(&old_root, &new_root)?; + } + queries::update_username(&state.pool, user.id, &username).await?; + } + Ok(redirect_see_other("/settings/account")) +} + +#[derive(Deserialize)] +pub struct PasswordForm { + pub current_password: String, + pub new_password: String, +} + +pub async fn account_change_password( + State(state): State<AppState>, + headers: HeaderMap, + Form(form): Form<PasswordForm>, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + if form.new_password.len() < 8 { + return Err(AppError::bad("password must be at least 8 characters")); + } + // Verify current password via Authentik (no session / MFA side effects). + auth::verify_password(&state.auth, &user.username, &form.current_password) + .await + .map_err(|_| AppError::bad("current password incorrect"))?; + let pk = auth::authentik_user_pk(&state.auth, &user.username) + .await + .map_err(|e| AppError::bad(crate::mfa::sanitize_user_error(&e.to_string())))?; + auth::authentik_set_password(&state.auth, pk, &form.new_password) + .await + .map_err(|e| AppError::bad(crate::mfa::sanitize_user_error(&e.to_string())))?; + Ok(redirect_see_other("/settings/account")) +} + +#[derive(Deserialize)] +pub struct PrivacyForm { + pub show_email: Option<String>, + pub vigilant_mode: Option<String>, +} + +pub async fn account_privacy( + State(state): State<AppState>, + headers: HeaderMap, + Form(form): Form<PrivacyForm>, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + queries::update_privacy( + &state.pool, + user.id, + checkbox(&form.show_email), + checkbox(&form.vigilant_mode), + ) + .await?; + Ok(redirect_see_other("/settings/account")) +} + +#[derive(Deserialize)] +pub struct EmailForm { + pub email: String, +} + +pub async fn account_add_email( + State(state): State<AppState>, + headers: HeaderMap, + Form(form): Form<EmailForm>, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + let email = form.email.trim(); + if !email.contains('@') { + return Err(AppError::bad("invalid email")); + } + queries::add_user_email(&state.pool, user.id, email).await?; + Ok(redirect_see_other("/settings/account")) +} + +pub async fn account_delete_email( + State(state): State<AppState>, + headers: HeaderMap, + Path(id): Path<Uuid>, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + queries::delete_user_email(&state.pool, id, user.id).await?; + Ok(redirect_see_other("/settings/account")) +} + +pub async fn account_revoke_session( + State(state): State<AppState>, + headers: HeaderMap, + Path(id): Path<Uuid>, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + queries::delete_session_by_id(&state.pool, id, user.id).await?; + Ok(redirect_see_other("/settings/account")) +} + +pub async fn account_revoke_others( + State(state): State<AppState>, + headers: HeaderMap, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + let Some(token) = token_from_headers(&headers) else { + return Err(AppError::unauthorized()); + }; + queries::delete_other_sessions(&state.pool, user.id, &hash_token(&token)).await?; + Ok(redirect_see_other("/settings/account")) +} + +pub async fn account_export( + State(state): State<AppState>, + headers: HeaderMap, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + let data = queries::export_user_data(&state.pool, user.id).await?; + let body = serde_json::to_vec_pretty(&data).map_err(|e| AppError::internal(e))?; + Ok(( + [ + (CONTENT_TYPE, HeaderValue::from_static("application/json")), + ( + CONTENT_DISPOSITION, + HeaderValue::from_static("attachment; filename=\"kitgit-export.json\""), + ), + ], + body, + ) + .into_response()) +} + +#[derive(Deserialize)] +pub struct DeleteAccountForm { + pub confirm: String, +} + +pub async fn account_delete( + State(state): State<AppState>, + headers: HeaderMap, + Form(form): Form<DeleteAccountForm>, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + if form.confirm != user.username { + return Err(AppError::bad("type your username to confirm")); + } + // Remove repos on disk + let root = state.config.repos_dir().join(&user.username); + if root.exists() { + let _ = std::fs::remove_dir_all(&root); + } + queries::delete_user(&state.pool, user.id).await?; + Ok(( + StatusCode::SEE_OTHER, + [ + ( + axum::http::header::LOCATION, + HeaderValue::from_static("/"), + ), + (SET_COOKIE, clear_session_cookie()), + ], + ) + .into_response()) +} + +#[derive(Deserialize)] +pub struct GpgForm { + pub name: String, + pub public_key: String, +} + +pub async fn gpg_add( + State(state): State<AppState>, + headers: HeaderMap, + Form(form): Form<GpgForm>, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + let key = form.public_key.trim(); + 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()); + queries::add_gpg_key(&state.pool, user.id, form.name.trim(), key, &fp).await?; + Ok(redirect_see_other("/settings/keys")) +} + +pub async fn gpg_delete( + State(state): State<AppState>, + headers: HeaderMap, + Path(id): Path<Uuid>, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + queries::delete_gpg_key(&state.pool, id, user.id).await?; + Ok(redirect_see_other("/settings/keys")) +} + +// silence unused import warning for avatar_url_for if not used +#[allow(dead_code)] +fn _use_avatar(u: &crate::db::models::User) -> String { + avatar_url_for(u) +} @@ -0,0 +1,524 @@ +use crate::db::models::{ + Access, BranchRule, CommitDay, GpgKey, Issue, PullRequest, Release, Repository, SshKey, User, + UserEmail, +}; +use askama::Template; +use askama_web::WebTemplate; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +// ── view models ────────────────────────────────────────────────────────────── + +pub struct ActivityRow { + pub kind: String, + /// Text before an optional issue/PR reference (e.g. `"commented on "`). + pub summary: String, + /// Linked phrase such as `"issue #1"` / `"pull #2"`. + pub ref_label: Option<String>, + pub ref_href: Option<String>, + pub actor_username: Option<String>, + pub actor_avatar: Option<String>, + pub repo_owner: Option<String>, + pub repo_name: Option<String>, + pub created_at: DateTime<Utc>, +} + +pub struct ReactionView { + pub emoji: String, + pub label: String, + pub count: i64, + pub mine: bool, +} + +pub struct CommentView { + pub id: Uuid, + pub author: User, + pub avatar_url: String, + pub body_html: String, + pub created_at: DateTime<Utc>, + pub reactions: Vec<ReactionView>, +} + +pub struct DiffFileView { + pub path: String, + pub anchor: String, + pub additions: u32, + pub deletions: u32, + pub html: String, + pub truncated: bool, + pub total_lines: usize, + pub binary: bool, +} + +pub struct TreeEntryView { + pub name: String, + pub path: String, + pub is_dir: bool, + pub mode: String, +} + +pub struct CommitView { + pub id: String, + pub short_id: String, + pub message: String, + pub author: String, + pub email: String, + pub time: i64, +} + +pub struct CollaboratorView { + pub user: User, + pub role: String, + pub avatar_url: String, +} + +pub struct LanguageStatView { + pub name: String, + pub percent: f64, + pub color: String, +} + +pub struct BranchRow { + pub name: String, + pub is_default: bool, + pub updated: String, + pub ahead: usize, + pub behind: usize, + pub pull_number: Option<i32>, +} + +pub struct TagRow { + pub name: String, + pub short_id: String, + pub target: String, + pub message: String, + pub updated: String, + pub has_release: bool, +} + +pub struct ReleaseAssetView { + pub id: Uuid, + pub filename: String, + pub size_label: String, + pub content_type: String, +} + +// ── page templates ─────────────────────────────────────────────────────────── + +#[derive(Template, WebTemplate)] +#[template(path = "home.html")] +pub struct HomeTemplate { + pub viewer: Option<User>, + pub motd: String, + pub my_repos: Vec<Repository>, + pub activities: Vec<ActivityRow>, +} + +pub struct ExploreRepo { + pub owner: String, + pub repo: Repository, +} + +#[derive(Template, WebTemplate)] +#[template(path = "explore.html")] +pub struct ExploreTemplate { + pub viewer: Option<User>, + pub repos: Vec<ExploreRepo>, + pub query: String, +} + +#[derive(Template, WebTemplate)] +#[template(path = "login.html")] +pub struct LoginTemplate { + pub viewer: Option<User>, + pub error: Option<String>, +} + +#[derive(Template, WebTemplate)] +#[template(path = "signup.html")] +pub struct SignupTemplate { + pub viewer: Option<User>, + pub error: Option<String>, +} + +#[derive(Template, WebTemplate)] +#[template(path = "error.html")] +pub struct ErrorTemplate { + pub viewer: Option<User>, + pub status: u16, + pub message: String, +} + +#[derive(Template, WebTemplate)] +#[template(path = "new_repo.html")] +pub struct NewRepoTemplate { + pub viewer: Option<User>, + pub error: Option<String>, +} + +#[derive(Template, WebTemplate)] +#[template(path = "profile_settings.html")] +pub struct ProfileSettingsTemplate { + pub viewer: Option<User>, + pub user: User, + pub avatar_url: String, + pub error: Option<String>, +} + +#[derive(Template, WebTemplate)] +#[template(path = "account_settings.html")] +pub struct AccountSettingsTemplate { + pub viewer: Option<User>, + pub user: User, + pub emails: Vec<UserEmail>, + pub sessions: Vec<SessionView>, + pub current_session_id: Option<Uuid>, + pub error: Option<String>, + pub message: Option<String>, +} + +#[derive(Template, WebTemplate)] +#[template(path = "mfa_challenge.html")] +pub struct MfaChallengeTemplate { + pub viewer: Option<User>, + pub error: Option<String>, +} + +#[derive(Template, WebTemplate)] +#[template(path = "mfa_settings.html")] +pub struct MfaSettingsTemplate { + pub viewer: Option<User>, + pub user: User, + pub enabled: bool, + pub pending: bool, + pub secret: Option<String>, + pub qr_data_uri: Option<String>, + pub recovery_codes: Option<Vec<String>>, + pub recovery_remaining: usize, + pub error: Option<String>, + pub message: Option<String>, +} + +pub struct SessionView { + pub id: Uuid, + pub created_at: DateTime<Utc>, + pub last_seen_at: DateTime<Utc>, + pub user_agent: String, + pub ip_address: String, + pub is_current: bool, +} + +#[derive(Template, WebTemplate)] +#[template(path = "keys_settings.html")] +pub struct KeysSettingsTemplate { + pub viewer: Option<User>, + pub keys: Vec<SshKey>, + pub gpg_keys: Vec<GpgKey>, + pub error: Option<String>, +} + +#[derive(Template, WebTemplate)] +#[template(path = "profile.html")] +pub struct ProfileTemplate { + pub viewer: Option<User>, + pub profile: User, + pub avatar_url: String, + pub repos: Vec<Repository>, + pub starred: Vec<ExploreRepo>, + pub watched_activity: Vec<ActivityRow>, + pub graph: Vec<CommitDay>, + pub has_activity: bool, + pub is_self: bool, +} + +#[derive(Template, WebTemplate)] +#[template(path = "repo_home.html")] +pub struct RepoHomeTemplate { + pub viewer: Option<User>, + pub owner: User, + pub repo: Repository, + pub access: Access, + pub owner_avatar: String, + pub clone_http: String, + pub clone_ssh: String, + pub branches: Vec<String>, + pub current_branch: String, + pub entries: Vec<TreeEntryView>, + pub readme_html: Option<String>, + pub languages: Vec<LanguageStatView>, + pub empty: bool, + pub latest_commit: Option<CommitView>, + pub starred: bool, + pub watching: bool, + pub forked_from: Option<(String, String)>, +} + +#[derive(Template, WebTemplate)] +#[template(path = "repo_tree.html")] +pub struct RepoTreeTemplate { + pub viewer: Option<User>, + pub owner: User, + pub repo: Repository, + pub access: Access, + pub branches: Vec<String>, + pub branch: String, + pub path: String, + pub breadcrumbs: Vec<(String, String)>, + pub entries: Vec<TreeEntryView>, + pub readme_html: Option<String>, + pub latest_commit: Option<CommitView>, + pub clone_http: String, + pub clone_ssh: String, +} + +#[derive(Template, WebTemplate)] +#[template(path = "repo_blob.html")] +pub struct RepoBlobTemplate { + pub viewer: Option<User>, + pub owner: User, + pub repo: Repository, + pub access: Access, + pub branches: Vec<String>, + pub branch: String, + pub path: String, + pub breadcrumbs: Vec<(String, String)>, + pub binary: bool, + pub size: usize, + pub content_html: Option<String>, + pub is_markdown: bool, + pub clone_http: String, + pub clone_ssh: String, +} + +#[derive(Template, WebTemplate)] +#[template(path = "repo_commits.html")] +pub struct RepoCommitsTemplate { + pub viewer: Option<User>, + pub owner: User, + pub repo: Repository, + pub access: Access, + pub branches: Vec<String>, + pub branch: String, + pub commits: Vec<CommitView>, + pub clone_http: String, + pub clone_ssh: String, +} + +#[derive(Template, WebTemplate)] +#[template(path = "repo_commit.html")] +pub struct RepoCommitTemplate { + pub viewer: Option<User>, + pub owner: User, + pub repo: Repository, + pub access: Access, + pub commit: CommitView, + pub message_html: String, + pub diff_html: String, + pub clone_http: String, + pub clone_ssh: String, +} + +#[derive(Template, WebTemplate)] +#[template(path = "repo_diff.html")] +pub struct RepoDiffTemplate { + pub viewer: Option<User>, + pub owner: User, + pub repo: Repository, + pub access: Access, + pub commit: CommitView, + pub diff_html: String, + pub clone_http: String, + pub clone_ssh: String, +} + +#[derive(Template, WebTemplate)] +#[template(path = "repo_branches.html")] +pub struct RepoBranchesTemplate { + pub viewer: Option<User>, + pub owner: User, + pub repo: Repository, + pub access: Access, + pub branches: Vec<BranchRow>, + pub tags: Vec<TagRow>, + pub clone_http: String, + pub clone_ssh: String, + pub error: Option<String>, +} + +#[derive(Template, WebTemplate)] +#[template(path = "issues_list.html")] +pub struct IssuesListTemplate { + pub viewer: Option<User>, + pub owner: User, + pub repo: Repository, + pub access: Access, + pub issues: Vec<Issue>, + pub state_filter: String, + pub clone_http: String, + pub clone_ssh: String, +} + +#[derive(Template, WebTemplate)] +#[template(path = "issue_new.html")] +pub struct IssueNewTemplate { + pub viewer: Option<User>, + pub owner: User, + pub repo: Repository, + pub access: Access, + pub error: Option<String>, + pub clone_http: String, + pub clone_ssh: String, +} + +#[derive(Template, WebTemplate)] +#[template(path = "issue_view.html")] +pub struct IssueViewTemplate { + pub viewer: Option<User>, + pub owner: User, + pub repo: Repository, + pub access: Access, + pub issue: Issue, + pub author: User, + pub author_avatar: String, + pub body_html: String, + pub comments: Vec<CommentView>, + pub clone_http: String, + pub clone_ssh: String, +} + +#[derive(Template, WebTemplate)] +#[template(path = "pulls_list.html")] +pub struct PullsListTemplate { + pub viewer: Option<User>, + pub owner: User, + pub repo: Repository, + pub access: Access, + pub pulls: Vec<PullRequest>, + pub state_filter: String, + pub clone_http: String, + pub clone_ssh: String, +} + +#[derive(Template, WebTemplate)] +#[template(path = "pull_new.html")] +pub struct PullNewTemplate { + pub viewer: Option<User>, + pub owner: User, + pub repo: Repository, + pub access: Access, + pub branches: Vec<String>, + pub error: Option<String>, + pub clone_http: String, + pub clone_ssh: String, + pub upstream: Option<(String, String)>, +} + +#[derive(Template, WebTemplate)] +#[template(path = "pull_view.html")] +pub struct PullViewTemplate { + pub viewer: Option<User>, + pub owner: User, + pub repo: Repository, + pub access: Access, + pub pull: PullRequest, + pub author: User, + pub author_avatar: String, + pub body_html: String, + pub comments: Vec<CommentView>, + pub commits: Vec<CommitView>, + pub diff_files: Vec<DiffFileView>, + pub tab: String, + pub conversation_count: usize, + pub can_merge: bool, + pub merge_styles: Vec<String>, + pub clone_http: String, + pub clone_ssh: String, +} + +#[derive(Template, WebTemplate)] +#[template(path = "releases_list.html")] +pub struct ReleasesListTemplate { + pub viewer: Option<User>, + pub owner: User, + pub repo: Repository, + pub access: Access, + pub releases: Vec<Release>, + pub clone_http: String, + pub clone_ssh: String, +} + +#[derive(Template, WebTemplate)] +#[template(path = "release_new.html")] +pub struct ReleaseNewTemplate { + pub viewer: Option<User>, + pub owner: User, + pub repo: Repository, + pub access: Access, + pub error: Option<String>, + pub clone_http: String, + pub clone_ssh: String, + pub tag_name: String, + pub title: String, + pub body: String, + pub target: String, + pub is_prerelease: bool, + pub is_draft: bool, + pub branches: Vec<String>, +} + +#[derive(Template, WebTemplate)] +#[template(path = "release_edit.html")] +pub struct ReleaseEditTemplate { + pub viewer: Option<User>, + pub owner: User, + pub repo: Repository, + pub access: Access, + pub release: Release, + pub error: Option<String>, + pub clone_http: String, + pub clone_ssh: String, + pub target: String, + pub branches: Vec<String>, +} + +#[derive(Template, WebTemplate)] +#[template(path = "release_view.html")] +pub struct ReleaseViewTemplate { + pub viewer: Option<User>, + pub owner: User, + pub repo: Repository, + pub access: Access, + pub release: Release, + pub author: User, + pub author_avatar: String, + pub body_html: String, + pub assets: Vec<ReleaseAssetView>, + pub tag_short: String, + pub tag_target: String, + pub clone_http: String, + pub clone_ssh: String, +} + +#[derive(Template, WebTemplate)] +#[template(path = "repo_settings.html")] +pub struct RepoSettingsTemplate { + pub viewer: Option<User>, + pub owner: User, + pub repo: Repository, + pub access: Access, + pub collaborators: Vec<CollaboratorView>, + pub branches: Vec<String>, + pub branch_rules: Vec<BranchRule>, + pub clone_http: String, + pub clone_ssh: String, + pub is_site_admin: bool, + pub error: Option<String>, +} + +#[derive(Template, WebTemplate)] +#[template(path = "admin.html")] +pub struct AdminTemplate { + pub viewer: Option<User>, + pub users: Vec<User>, + pub motd: String, +} @@ -0,0 +1,1416 @@ +/* kitgit — brand theme + ink on paper. no chrome. */ + +:root { + --kg-bg: #ffffff; + --kg-bg-raised: #f7f7f7; + --kg-fg: #0a0a0a; + --kg-muted: #6b6b6b; + --kg-faint: #a3a3a3; + --kg-line: #e5e5e5; + --kg-line-strong: #0a0a0a; + --kg-link: #0a0a0a; + --kg-focus: #0a0a0a; + --kg-danger: #c1121f; + --kg-danger-bg: #fff5f5; + --kg-invert-bg: #0a0a0a; + --kg-invert-fg: #ffffff; + --kg-invert-muted: #a3a3a3; + --kg-font: "JetBrainsMono Nerd Font", "JetBrainsMono NF", "JetBrains Mono", + "FiraCode Nerd Font", "Fira Code", ui-monospace, "Cascadia Code", "SF Mono", + Menlo, Consolas, monospace; + --kg-font-size: 14px; + --kg-line-height: 1.55; + --kg-tracking-tight: -0.02em; + --kg-tracking-brand: 0.08em; + --kg-space-1: 0.25rem; + --kg-space-2: 0.5rem; + --kg-space-3: 1rem; + --kg-space-4: 1.5rem; + --kg-space-5: 2.5rem; + --kg-space-6: 4rem; + --kg-max: 75rem; + --kg-radius: 0; + color-scheme: light; +} + +html.kg-dark { + --kg-bg: #0a0a0a; + --kg-bg-raised: #141414; + --kg-fg: #f5f5f5; + --kg-muted: #8a8a8a; + --kg-faint: #5c5c5c; + --kg-line: #262626; + --kg-line-strong: #f5f5f5; + --kg-link: #f5f5f5; + --kg-danger: #ff4d5a; + --kg-danger-bg: #2a1214; + --kg-invert-bg: #f5f5f5; + --kg-invert-fg: #0a0a0a; + --kg-invert-muted: #525252; + color-scheme: dark; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + font-size: var(--kg-font-size); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +body { + margin: 0; + min-height: 100vh; + font-family: var(--kg-font); + line-height: var(--kg-line-height); + color: var(--kg-fg); + background: var(--kg-bg); +} + +a { + color: var(--kg-fg); +} + +a:hover { + text-decoration-thickness: 2px; +} + +.kg-shell { + width: min(100% - 2rem, var(--kg-max)); + margin-inline: auto; +} + +.kg-mark { + display: inline-flex; + align-items: baseline; + gap: 0.4em; + font-weight: 700; + font-size: 1.125rem; + letter-spacing: var(--kg-tracking-brand); + text-decoration: none; + color: var(--kg-fg); + line-height: 1; +} + +.kg-mark__glyph { + font-weight: 700; + letter-spacing: 0; + font-size: 1.15em; + line-height: 1; +} + +.kg-mark__name { + letter-spacing: var(--kg-tracking-brand); +} + +.kg-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--kg-space-3); + padding: var(--kg-space-3) 0; + border-bottom: 1px solid var(--kg-line); + flex-wrap: wrap; +} + +.kg-bar nav { + display: flex; + gap: var(--kg-space-3); + flex-wrap: wrap; +} + +.kg-bar nav a { + color: var(--kg-muted); + text-decoration: none; + font-size: 0.8125rem; +} + +.kg-bar nav a:hover, +.kg-bar nav a[aria-current="page"] { + color: var(--kg-fg); +} + +.kg-userchip { + display: inline-flex; + align-items: center; + gap: 0.5em; + text-decoration: none; + color: var(--kg-fg); + font-size: 0.8125rem; +} + +.kg-avatar, +.kg-avatar--sm, +.kg-avatar--lg { + display: inline-block; + object-fit: cover; + border: 1px solid var(--kg-line); + background: var(--kg-bg-raised); + border-radius: 0; +} + +.kg-avatar { + width: 2.5rem; + height: 2.5rem; +} + +.kg-avatar--sm { + width: 1.25rem; + height: 1.25rem; +} + +.kg-avatar--lg { + width: 4.5rem; + height: 4.5rem; +} + +.kg-footer { + padding: var(--kg-space-4) 0 var(--kg-space-6); + color: var(--kg-faint); + font-size: 0.75rem; + letter-spacing: 0.06em; +} + +.kg-display { + font-weight: 700; + font-size: clamp(1.5rem, 3vw, 2rem); + line-height: 1.2; + letter-spacing: var(--kg-tracking-tight); + margin: 0; +} + +.kg-title { + font-weight: 700; + font-size: 1.125rem; + margin: 0; +} + +.kg-muted { + color: var(--kg-muted); +} + +.kg-faint { + color: var(--kg-faint); +} + +.kg-kicker { + display: block; + font-size: 0.75rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--kg-muted); + margin: 0 0 var(--kg-space-3); +} + +.kg-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5em; + padding: 0.55em 1em; + font-family: var(--kg-font); + font-size: 0.875rem; + border: 1px solid var(--kg-line-strong); + background: var(--kg-invert-bg); + color: var(--kg-invert-fg); + text-decoration: none; + cursor: pointer; +} + +.kg-btn:hover { + opacity: 0.85; +} + +.kg-btn--ghost { + background: transparent; + color: var(--kg-fg); +} + +.kg-btn--ghost:hover { + opacity: 1; + background: var(--kg-bg-raised); +} + +.kg-btn--danger { + background: var(--kg-danger); + color: #fff; + border-color: var(--kg-danger); +} + +.kg-btn--danger:hover { + opacity: 0.9; +} + +.kg-btn--danger-ghost { + background: transparent; + color: var(--kg-danger); + border-color: var(--kg-danger); +} + +.kg-btn--danger-ghost:hover { + background: var(--kg-danger-bg); +} + +.kg-actions { + display: flex; + flex-wrap: wrap; + gap: var(--kg-space-2); +} + +.kg-list { + list-style: none; + margin: 0; + padding: 0; + border: 1px solid var(--kg-line); +} + +.kg-list li { + display: flex; + justify-content: space-between; + gap: var(--kg-space-3); + padding: var(--kg-space-2) var(--kg-space-3); + border-bottom: 1px solid var(--kg-line); + font-size: 0.875rem; +} + +.kg-list li:last-child { + border-bottom: 0; +} + +.kg-list a { + text-decoration: none; +} + +.kg-list a:hover { + text-decoration: underline; + text-underline-offset: 0.2em; +} + +.kg-section { + padding: var(--kg-space-4) 0; +} + +.kg-flash { + padding: var(--kg-space-2) var(--kg-space-3); + border: 1px solid var(--kg-line-strong); + margin: var(--kg-space-3) 0; + font-size: 0.875rem; +} + +.kg-form { + display: flex; + flex-direction: column; + gap: var(--kg-space-3); + max-width: 28rem; +} + +.kg-form label { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.8125rem; + color: var(--kg-muted); +} + +.kg-form input, +.kg-form textarea, +.kg-form select { + font-family: var(--kg-font); + font-size: 0.875rem; + padding: 0.5em 0.65em; + border: 1px solid var(--kg-line-strong); + background: var(--kg-bg); + color: var(--kg-fg); + border-radius: 0; +} + +.kg-form textarea { + min-height: 6rem; + resize: vertical; +} + +.kg-form .row { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--kg-fg); +} + +.kg-tabs { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--kg-space-3); + border-bottom: 1px solid var(--kg-line); + margin: var(--kg-space-3) 0; + flex-wrap: wrap; +} + +.kg-tabs__links { + display: flex; + gap: var(--kg-space-3); + flex-wrap: wrap; + align-items: flex-end; +} + +.kg-tabs a { + text-decoration: none; + color: var(--kg-muted); + font-size: 0.8125rem; + padding-bottom: 0.5rem; + border-bottom: 2px solid transparent; + display: inline-flex; + align-items: center; + gap: 0.35em; +} + +.kg-tabs a[aria-current="page"] { + color: var(--kg-fg); + border-bottom-color: var(--kg-fg); +} + +.kg-icon { + display: inline-block; + width: 1em; + height: 1em; + vertical-align: -0.125em; + background-color: currentColor; + -webkit-mask: var(--icon) center / contain no-repeat; + mask: var(--icon) center / contain no-repeat; + flex-shrink: 0; +} + +.kg-motd { + color: var(--kg-muted); + white-space: pre-wrap; + margin: var(--kg-space-2) 0 0; +} + +.kg-activity { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35em 0.5em; +} + +.kg-dropdown { + position: relative; + margin-bottom: 0.35rem; +} + +.kg-dropdown > summary { + list-style: none; + cursor: pointer; +} + +.kg-dropdown > summary::-webkit-details-marker { + display: none; +} + +.kg-dropdown__panel { + position: absolute; + right: 0; + top: calc(100% + 0.35rem); + z-index: 20; + min-width: 18rem; + padding: var(--kg-space-3); + background: var(--kg-bg); + border: 1px solid var(--kg-line-strong); + display: flex; + flex-direction: column; + gap: var(--kg-space-2); +} + +.kg-dropdown__item { + display: inline-flex; + align-items: center; + gap: 0.5em; + text-decoration: none; + font-size: 0.8125rem; + color: var(--kg-fg); + padding: 0.35rem 0; +} + +.kg-clone-row { + display: grid; + grid-template-columns: 2.5rem 1fr auto; + gap: 0.35rem; + align-items: center; + font-size: 0.75rem; + color: var(--kg-muted); +} + +.kg-clone-row input { + 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.4em 0.5em; + min-width: 0; +} + +.kg-branchbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--kg-space-3); + flex-wrap: wrap; + border: 1px solid var(--kg-line); + padding: 0.55rem 0.75rem; + margin: var(--kg-space-3) 0; + background: var(--kg-bg-raised); +} + +.kg-branchbar__left { + display: flex; + align-items: center; + gap: var(--kg-space-3); + flex-wrap: wrap; + min-width: 0; + flex: 1 1 auto; +} + +.kg-branchbar__actions { + display: flex; + align-items: center; + gap: var(--kg-space-2); + flex-wrap: wrap; +} + +.kg-branchbar__branch { + display: inline-flex; + align-items: center; + gap: 0.4em; + text-decoration: none; + font-weight: 700; + color: var(--kg-fg); +} + +.kg-branchbar__commit { + display: flex; + align-items: center; + gap: var(--kg-space-2); + min-width: 0; + flex: 1; + justify-content: flex-end; +} + +.kg-branchbar__sha { + display: inline-flex; + align-items: center; + gap: 0.35em; + text-decoration: none; + color: var(--kg-muted); + font-size: 0.8125rem; + flex-shrink: 0; +} + +.kg-branchbar__msg { + color: var(--kg-fg); + text-decoration: none; + font-size: 0.8125rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 28rem; +} + +.kg-branchbar__msg:hover, +.kg-branchbar__sha:hover, +.kg-branchbar__branch:hover { + text-decoration: underline; + text-underline-offset: 0.15em; +} + +.kg-dropdown__panel--upload { + min-width: 20rem; +} + +.kg-upload { + display: flex; + flex-direction: column; + gap: var(--kg-space-3); + margin: 0; + padding: 0; + border: 0; +} + +.kg-upload__file, +.kg-upload__message { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.8125rem; + color: var(--kg-muted); +} + +.kg-upload input[type="file"] { + width: 100%; + max-width: 100%; + font-size: 0.75rem; +} + +.kg-upload input[type="text"] { + font-family: var(--kg-font); + font-size: 0.8125rem; + border: 1px solid var(--kg-line); + padding: 0.4em 0.55em; + background: var(--kg-bg-raised); + color: var(--kg-fg); + width: 100%; + box-sizing: border-box; +} + +.kg-upload .kg-btn { + align-self: flex-start; +} + +.kg-blobbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--kg-space-2); + padding: var(--kg-space-2) 0; + margin: var(--kg-space-2) 0 var(--kg-space-3); + border-bottom: 1px solid var(--kg-line); +} + +.kg-blobbar__actions { + display: flex; + flex-wrap: wrap; + gap: var(--kg-space-2); + align-items: center; +} + +.kg-danger-zone { + margin-top: var(--kg-space-5); + padding: var(--kg-space-3); + border: 1px solid var(--kg-danger); + background: var(--kg-danger-bg); +} + +.kg-danger-zone .kg-kicker { + color: var(--kg-danger); +} + +.kg-danger-zone .kg-form { + max-width: none; + gap: var(--kg-space-2); + margin: var(--kg-space-2) 0 0; +} + +.kg-danger-row { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--kg-space-2); +} + +.kg-danger-row label { + flex: 1 1 12rem; + margin: 0; + gap: 0.25rem; +} + +.kg-settings-section { + margin-top: var(--kg-space-5); + padding-top: var(--kg-space-3); + border-top: 1px solid var(--kg-line); +} + +.kg-settings-section > .kg-kicker { + margin-bottom: var(--kg-space-2); +} + +.kg-social { + display: flex; + flex-wrap: wrap; + gap: var(--kg-space-2); + margin: 0; + margin-left: auto; + align-items: center; + flex-shrink: 0; +} + +.kg-social form { + margin: 0; +} + +.kg-reactions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: var(--kg-space-2); +} + +.kg-reaction { + display: inline-flex; + align-items: center; + gap: 0.25em; + font-size: 0.75rem; + padding: 0.15em 0.45em; + border: 1px solid var(--kg-line); + background: var(--kg-bg); + color: var(--kg-fg); + cursor: pointer; + font-family: var(--kg-font); +} + +.kg-reaction[aria-pressed="true"] { + border-color: var(--kg-fg); + background: var(--kg-bg-raised); +} + +.kg-branch-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + margin: var(--kg-space-3) 0; +} + +.kg-branch-table th, +.kg-branch-table td { + text-align: left; + padding: var(--kg-space-2) var(--kg-space-3); + border-bottom: 1px solid var(--kg-line); + vertical-align: middle; +} + +.kg-branch-table th { + color: var(--kg-muted); + font-weight: 500; + font-size: 0.75rem; +} + +.kg-branch-actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.kg-subtitle { + font-family: var(--kg-font); + font-size: 1.1rem; + letter-spacing: var(--kg-tracking-tight); + margin: var(--kg-space-4) 0 var(--kg-space-2); +} + +.kg-check { + display: flex !important; + flex-direction: row !important; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; +} + +.kg-check input { + width: auto; +} + +.kg-tag-create { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); + gap: var(--kg-space-3); + align-items: end; + margin: var(--kg-space-3) 0 var(--kg-space-4); + padding: var(--kg-space-3); + border: 1px solid var(--kg-line); +} + +.kg-tag-create .kg-actions { + margin: 0; +} + +.kg-release-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--kg-space-4); + flex-wrap: wrap; + margin-bottom: var(--kg-space-2); +} + +.kg-release-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(14rem, 18rem); + gap: var(--kg-space-5); + margin-top: var(--kg-space-4); + align-items: start; +} + +@media (max-width: 800px) { + .kg-release-layout { + grid-template-columns: 1fr; + } +} + +.kg-release-notes { + margin-top: var(--kg-space-2); +} + +.kg-release-aside { + border: 1px solid var(--kg-line); + padding: var(--kg-space-3); +} + +.kg-asset-list { + list-style: none; + padding: 0; + margin: var(--kg-space-2) 0; +} + +.kg-asset-list li { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0; + border-bottom: 1px solid var(--kg-line); + font-size: 0.875rem; +} + +.kg-asset-list li:last-child { + border-bottom: none; +} + +.kg-asset-list a { + flex: 1; + min-width: 0; + display: inline-flex; + align-items: center; + gap: 0.35rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.kg-asset-source { + margin: var(--kg-space-3) 0; +} + +.kg-asset-upload { + margin-top: var(--kg-space-3); + padding-top: var(--kg-space-3); + border-top: 1px solid var(--kg-line); +} + +.kg-release-list { + list-style: none; + padding: 0; + margin: var(--kg-space-4) 0 0; +} + +.kg-release-list__item { + padding: var(--kg-space-3) 0; + border-bottom: 1px solid var(--kg-line); +} + +.kg-release-list__title { + font-size: 1.05rem; + font-weight: 600; +} + +.kg-badge--pre { + color: var(--kg-muted); + border-style: dashed; +} + +.kg-contrib-empty { + color: var(--kg-muted); + font-size: 0.875rem; +} + +.kg-diff { + border: 1px solid var(--kg-line); + overflow-x: auto; + font-size: 0.8125rem; + margin: 0; + padding: 0; + line-height: 1.45; + background: var(--kg-bg); +} + +.kg-diff .kg-diff__add { + display: block; + background: color-mix(in srgb, #1a7f37 14%, var(--kg-bg)); + color: color-mix(in srgb, #1a7f37 70%, var(--kg-fg)); +} + +.kg-diff .kg-diff__del { + display: block; + background: color-mix(in srgb, #cf222e 12%, var(--kg-bg)); + color: color-mix(in srgb, #cf222e 65%, var(--kg-fg)); +} + +.kg-diff .kg-diff__hunk { + display: block; + background: var(--kg-bg-raised); + color: var(--kg-muted); +} + +.kg-diff .kg-diff__meta { + display: block; + color: var(--kg-muted); +} + +.kg-diff__truncated { + margin: 0; + padding: var(--kg-space-3); + border-top: 1px solid var(--kg-line); + background: var(--kg-bg-raised); + color: var(--kg-muted); + font-size: 0.8125rem; +} + +.kg-pr-tabs { + display: flex; + flex-wrap: wrap; + gap: var(--kg-space-2); + border-bottom: 1px solid var(--kg-line); + margin: var(--kg-space-4) 0 var(--kg-space-4); + padding-bottom: 0; +} + +.kg-pr-tabs a { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.55rem 0.15rem; + margin-bottom: -1px; + border-bottom: 2px solid transparent; + color: var(--kg-muted); + text-decoration: none; + font-size: 0.875rem; +} + +.kg-pr-tabs a:hover { + color: var(--kg-fg); +} + +.kg-pr-tabs a[aria-current="page"] { + color: var(--kg-fg); + border-bottom-color: var(--kg-fg); +} + +.kg-pr-tabs__count { + display: inline-block; + min-width: 1.25rem; + padding: 0 0.35rem; + border: 1px solid var(--kg-line); + background: var(--kg-bg-raised); + font-size: 0.75rem; + text-align: center; +} + +.kg-pr-files { + display: grid; + grid-template-columns: minmax(12rem, 16rem) minmax(0, 1fr); + gap: var(--kg-space-4); + align-items: start; +} + +.kg-pr-files__nav { + position: sticky; + top: var(--kg-space-3); + border: 1px solid var(--kg-line); + background: var(--kg-bg); + max-height: calc(100vh - 4rem); + overflow: auto; +} + +.kg-pr-files__nav-title { + margin: 0; + padding: var(--kg-space-2) var(--kg-space-3); + border-bottom: 1px solid var(--kg-line); + font-size: 0.75rem; + letter-spacing: 0.06em; + text-transform: lowercase; + color: var(--kg-muted); +} + +.kg-pr-files__nav ul { + list-style: none; + margin: 0; + padding: 0; +} + +.kg-pr-files__nav a { + display: flex; + justify-content: space-between; + gap: var(--kg-space-2); + padding: 0.55rem var(--kg-space-3); + border-bottom: 1px solid var(--kg-line); + color: var(--kg-fg); + text-decoration: none; + font-size: 0.8125rem; +} + +.kg-pr-files__nav a:hover { + background: var(--kg-bg-raised); +} + +.kg-pr-files__path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.kg-pr-files__stat { + flex-shrink: 0; + font-variant-numeric: tabular-nums; + font-size: 0.75rem; +} + +.kg-pr-files__add { + color: #1a7f37; +} + +.kg-pr-files__del { + color: #cf222e; +} + +.kg-pr-files__panels { + display: flex; + flex-direction: column; + gap: var(--kg-space-4); + min-width: 0; +} + +.kg-diff-file { + border: 1px solid var(--kg-line); + background: var(--kg-bg); +} + +.kg-diff-file__head { + display: flex; + justify-content: space-between; + gap: var(--kg-space-2); + align-items: baseline; + padding: var(--kg-space-2) var(--kg-space-3); + border-bottom: 1px solid var(--kg-line); + background: var(--kg-bg-raised); + font-size: 0.8125rem; +} + +.kg-diff-file__head strong { + font-weight: 500; + word-break: break-all; +} + +@media (max-width: 800px) { + .kg-pr-files { + grid-template-columns: 1fr; + } + + .kg-pr-files__nav { + position: static; + max-height: 12rem; + } +} + +.kg-landing { + padding: var(--kg-space-6) 0 var(--kg-space-5); + min-height: 60vh; + display: flex; + flex-direction: column; + justify-content: center; + gap: var(--kg-space-3); +} + +.kg-landing__brand { + display: inline-flex; + align-items: baseline; + gap: 0.45em; + margin: 0; + font-weight: 700; + font-size: clamp(2rem, 5vw, 3rem); + letter-spacing: var(--kg-tracking-brand); + line-height: 1; +} + +.kg-landing__brand .kg-mark__glyph { + font-size: 1.05em; +} + +.kg-explore-search { + display: flex; + gap: var(--kg-space-2); + flex-wrap: wrap; + margin-top: var(--kg-space-3); + max-width: 36rem; +} + +.kg-explore-search input { + flex: 1; + min-width: 12rem; + font-family: var(--kg-font); + font-size: 0.875rem; + border: 1px solid var(--kg-line-strong); + padding: 0.55em 0.75em; + background: var(--kg-bg); + color: var(--kg-fg); +} + +.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-auth { + max-width: 22rem; +} + +.kg-auth .kg-landing__brand { + font-size: 1.5rem; + margin-bottom: var(--kg-space-3); +} + +.kg-bar nav a { + display: inline-flex; + align-items: center; + gap: 0.35em; +} + +.kg-meta { + color: var(--kg-muted); + font-size: 0.75rem; +} + +.kg-langbar { + display: flex; + height: 0.5rem; + width: 100%; + border: 1px solid var(--kg-line); + margin: var(--kg-space-2) 0; + overflow: hidden; +} + +.kg-langbar span { + display: block; + height: 100%; + min-width: 0; +} + +.kg-langlist { + display: flex; + flex-wrap: wrap; + gap: var(--kg-space-2) var(--kg-space-3); + font-size: 0.75rem; + color: var(--kg-muted); + list-style: none; + padding: 0; + margin: 0 0 var(--kg-space-3); +} + +.kg-langlist li { + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: var(--kg-fg); +} + +.kg-langdot { + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; + flex-shrink: 0; + border: 1px solid color-mix(in srgb, var(--kg-fg) 12%, transparent); +} + +.kg-tree { + list-style: none; + margin: 0; + padding: 0; + border: 1px solid var(--kg-line); +} + +.kg-tree li { + display: grid; + grid-template-columns: 1.5rem 1fr auto auto; + align-items: center; + gap: var(--kg-space-2); + padding: 0.35rem var(--kg-space-3); + border-bottom: 1px solid var(--kg-line); + font-size: 0.875rem; +} + +.kg-tree li:last-child { + border-bottom: 0; +} + +.kg-tree a { + text-decoration: none; +} + +.kg-tree__dl { + padding: 0.15rem 0.35rem; + opacity: 0.55; +} + +.kg-tree li:hover .kg-tree__dl, +.kg-tree__dl:focus-visible { + opacity: 1; +} + +.kg-codeview, +.kg-blob { + display: flex; + align-items: flex-start; + border: 1px solid var(--kg-line); + overflow-x: auto; + font-family: var(--kg-font); + font-size: 0.8125rem; + line-height: 1.5; + margin-top: var(--kg-space-3); + tab-size: 4; +} + +.kg-blob__nums { + flex: 0 0 auto; + margin: 0; + padding: 0.75rem 0.75rem 0.75rem 1rem; + min-width: 3rem; + color: var(--kg-faint); + text-align: right; + user-select: none; + white-space: pre; + border-right: 1px solid var(--kg-line); + font-family: inherit; + font-size: inherit; + font-weight: 400; + font-variant-numeric: tabular-nums; + line-height: inherit; + background: var(--kg-bg); +} + +.kg-blob__code { + flex: 1 1 auto; + margin: 0; + padding: 0.75rem 1rem 0.75rem 0.75rem; + min-width: 0; + overflow-x: auto; + white-space: pre; + font-family: inherit; + font-size: inherit; + font-weight: 400; + line-height: inherit; + background: transparent; + border: 0; + color: inherit; +} + +.kg-blob__code .kg-highlight, +.kg-blob__code code { + display: block; + margin: 0; + padding: 0; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + white-space: inherit; + background: transparent; + color: inherit; +} + +/* syntect ClassStyle::Spaced token colors */ +.kg-codeview .comment, +.kg-codeview .punctuation.definition.comment { color: #6b6b6b; font-style: italic; } +.kg-codeview .string, +.kg-codeview .string.quoted, +.kg-codeview .constant.character.escape { color: #0b6e4f; } +.kg-codeview .constant.numeric, +.kg-codeview .constant.language, +.kg-codeview .constant.character { color: #8a4b08; } +.kg-codeview .keyword, +.kg-codeview .keyword.control, +.kg-codeview .keyword.operator, +.kg-codeview .storage, +.kg-codeview .storage.type, +.kg-codeview .storage.modifier { color: #0a0a0a; font-weight: 700; } +.kg-codeview .entity.name.function, +.kg-codeview .support.function { color: #1a4a8a; } +.kg-codeview .entity.name.class, +.kg-codeview .entity.name.struct, +.kg-codeview .entity.name.type, +.kg-codeview .entity.name.namespace, +.kg-codeview .support.type, +.kg-codeview .support.class { color: #5b2d8e; } +.kg-codeview .variable, +.kg-codeview .variable.parameter, +.kg-codeview .variable.other { color: #0a0a0a; } +.kg-codeview .meta.annotation, +.kg-codeview .entity.name.tag { color: #8a1c1c; } +.kg-codeview .entity.other.attribute-name { color: #8a4b08; } +.kg-codeview .punctuation { color: #3d3d3d; } + +html.kg-dark .kg-codeview .comment, +html.kg-dark .kg-codeview .punctuation.definition.comment { color: #8a8a8a; } +html.kg-dark .kg-codeview .string, +html.kg-dark .kg-codeview .string.quoted, +html.kg-dark .kg-codeview .constant.character.escape { color: #5ec49a; } +html.kg-dark .kg-codeview .constant.numeric, +html.kg-dark .kg-codeview .constant.language, +html.kg-dark .kg-codeview .constant.character { color: #e0a35a; } +html.kg-dark .kg-codeview .keyword, +html.kg-dark .kg-codeview .keyword.control, +html.kg-dark .kg-codeview .keyword.operator, +html.kg-dark .kg-codeview .storage, +html.kg-dark .kg-codeview .storage.type, +html.kg-dark .kg-codeview .storage.modifier { color: #f5f5f5; font-weight: 700; } +html.kg-dark .kg-codeview .entity.name.function, +html.kg-dark .kg-codeview .support.function { color: #7eb0f0; } +html.kg-dark .kg-codeview .entity.name.class, +html.kg-dark .kg-codeview .entity.name.struct, +html.kg-dark .kg-codeview .entity.name.type, +html.kg-dark .kg-codeview .entity.name.namespace, +html.kg-dark .kg-codeview .support.type, +html.kg-dark .kg-codeview .support.class { color: #c9a0ff; } +html.kg-dark .kg-codeview .variable, +html.kg-dark .kg-codeview .variable.parameter, +html.kg-dark .kg-codeview .variable.other { color: #f5f5f5; } +html.kg-dark .kg-codeview .meta.annotation, +html.kg-dark .kg-codeview .entity.name.tag { color: #ff8a8a; } +html.kg-dark .kg-codeview .entity.other.attribute-name { color: #e0a35a; } +html.kg-dark .kg-codeview .punctuation { color: #b0b0b0; } + +.kg-readme { + border: 1px solid var(--kg-line); + padding: var(--kg-space-4); + margin-top: var(--kg-space-4); +} + +.kg-readme h1, +.kg-readme h2, +.kg-readme h3 { + font-family: var(--kg-font); + letter-spacing: var(--kg-tracking-tight); +} + +.kg-readme pre { + background: var(--kg-bg-raised); + padding: var(--kg-space-3); + overflow-x: auto; + border: 1px solid var(--kg-line); +} + +.kg-readme code { + font-family: var(--kg-font); + font-size: 0.875em; +} + +.kg-readme table { + border-collapse: collapse; + width: 100%; + font-size: 0.875rem; +} + +.kg-readme th, +.kg-readme td { + border: 1px solid var(--kg-line); + padding: 0.35rem 0.5rem; + text-align: left; +} + +.kg-graph { + display: grid; + grid-template-columns: repeat(53, 1fr); + gap: 2px; + margin: var(--kg-space-3) 0; +} + +.kg-graph i { + aspect-ratio: 1; + background: var(--kg-line); + display: block; +} + +.kg-graph i[data-n="1"] { + background: color-mix(in srgb, var(--kg-fg) 25%, var(--kg-bg)); +} +.kg-graph i[data-n="2"] { + background: color-mix(in srgb, var(--kg-fg) 45%, var(--kg-bg)); +} +.kg-graph i[data-n="3"] { + background: color-mix(in srgb, var(--kg-fg) 70%, var(--kg-bg)); +} +.kg-graph i[data-n="4"] { + background: var(--kg-fg); +} + +.kg-profile-head { + display: flex; + gap: var(--kg-space-4); + align-items: flex-start; + margin-bottom: var(--kg-space-4); + flex-wrap: wrap; +} + +.kg-profile-head__main { + flex: 1 1 12rem; + min-width: 0; +} + +.kg-comment { + border: 1px solid var(--kg-line); + padding: var(--kg-space-3); + margin: var(--kg-space-2) 0; +} + +.kg-comment header { + display: flex; + justify-content: space-between; + gap: var(--kg-space-2); + margin-bottom: var(--kg-space-2); + font-size: 0.75rem; + color: var(--kg-muted); +} + +.kg-badge { + display: inline-block; + border: 1px solid var(--kg-line-strong); + padding: 0.1em 0.4em; + font-size: 0.7rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.kg-badge--open { + background: var(--kg-fg); + color: var(--kg-bg); +} + +.kg-badge--closed { + background: transparent; + color: var(--kg-muted); +} + +.kg-clone { + display: block; + padding: var(--kg-space-2) var(--kg-space-3); + background: var(--kg-invert-bg); + color: var(--kg-invert-fg); + font-size: 0.75rem; + overflow-x: auto; + margin: var(--kg-space-3) 0; +} + +@media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + } +} @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><line x1="3" x2="9" y1="12" y2="12"/><line x1="15" x2="21" y1="12" y2="12"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M18 9v2c0 .6-.4 1-1 1H7c-.6 0-1-.4-1-1V9"/><path d="M12 12v3"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M6 9v12"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" x2="3" y1="12" y2="12"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="m17 8-5-5-5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/></svg> @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> @@ -0,0 +1,153 @@ +{% extends "layout.html" %} + +{% block title %}account settings — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <span class="kg-kicker">settings</span> + <h1 class="kg-title">Account</h1> + <nav class="kg-tabs" aria-label="Settings"> + <a href="/settings/profile">profile</a> + <a href="/settings/account" aria-current="page">account</a> + <a href="/settings/mfa">mfa</a> + <a href="/settings/keys">ssh / gpg</a> + </nav> + {% if let Some(err) = error %} + <div class="kg-flash">{{ err }}</div> + {% endif %} + {% if let Some(msg) = message %} + <div class="kg-flash">{{ msg }}</div> + {% endif %} + + <div class="kg-settings-section" style="margin-top:1rem;border-top:0;padding-top:0"> + <span class="kg-kicker">username</span> + <form class="kg-form" method="post" action="/settings/account/username"> + <label> + username + <input type="text" name="username" value="{{ user.username }}" required pattern="[a-z0-9][a-z0-9_-]{0,38}" /> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">change username</button> + </div> + </form> + </div> + + <div class="kg-settings-section"> + <span class="kg-kicker">password</span> + <p class="kg-muted">Change your account password.</p> + <form class="kg-form" method="post" action="/settings/account/password"> + <label> + current password + <input type="password" name="current_password" required autocomplete="current-password" /> + </label> + <label> + new password + <input type="password" name="new_password" required minlength="8" autocomplete="new-password" /> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">change password</button> + </div> + </form> + </div> + + <div class="kg-settings-section"> + <span class="kg-kicker">emails</span> + <p class="kg-muted">Emails are hidden on your profile unless you opt in. Extra addresses help match commit authors.</p> + <form class="kg-form" method="post" action="/settings/account/privacy"> + <label class="row"> + <input type="checkbox" name="show_email"{% if user.show_email %} checked{% endif %} /> + show primary email on profile + </label> + <label class="row"> + <input type="checkbox" name="vigilant_mode"{% if user.vigilant_mode %} checked{% endif %} /> + vigilant mode (flag unsigned commits) + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">save privacy</button> + </div> + </form> + {% if !emails.is_empty() %} + <ul class="kg-list" style="margin-top:1rem"> + {% for e in emails %} + <li> + <span> + {{ e.email }} + {% if e.is_primary %}<span class="kg-badge">primary</span>{% endif %} + {% if e.verified %}<span class="kg-badge">verified</span>{% endif %} + </span> + {% if !e.is_primary %} + <form method="post" action="/settings/account/emails/{{ e.id }}/delete"> + <button class="kg-btn kg-btn--danger-ghost" type="submit">remove</button> + </form> + {% endif %} + </li> + {% endfor %} + </ul> + {% endif %} + <form class="kg-form" method="post" action="/settings/account/emails" style="margin-top:1rem"> + <label> + add email + <input type="email" name="email" required /> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">add</button> + </div> + </form> + </div> + + <div class="kg-settings-section"> + <span class="kg-kicker">two-factor authentication</span> + <p class="kg-muted">Add an authenticator app for an extra sign-in step.</p> + <div class="kg-actions"> + <a class="kg-btn kg-btn--ghost" href="/settings/mfa">manage MFA</a> + </div> + </div> + + <div class="kg-settings-section"> + <span class="kg-kicker">sessions</span> + {% if sessions.is_empty() %} + <p class="kg-muted">No active sessions.</p> + {% else %} + <ul class="kg-list"> + {% for s in sessions %} + <li> + <span> + {% if s.is_current %}<span class="kg-badge">current</span>{% endif %} + <span class="kg-meta">{{ s.ip_address }}{% if !s.user_agent.is_empty() %} · {{ s.user_agent }}{% endif %}</span> + <span class="kg-meta">last seen {{ s.last_seen_at }}</span> + </span> + {% if !s.is_current %} + <form method="post" action="/settings/account/sessions/{{ s.id }}/revoke"> + <button class="kg-btn kg-btn--danger-ghost" type="submit">revoke</button> + </form> + {% endif %} + </li> + {% endfor %} + </ul> + {% endif %} + <form method="post" action="/settings/account/sessions/revoke-others" style="margin-top:1rem"> + <button class="kg-btn kg-btn--ghost" type="submit">revoke other sessions</button> + </form> + </div> + + <div class="kg-settings-section"> + <span class="kg-kicker">GDPR</span> + <div class="kg-actions"> + <a class="kg-btn kg-btn--ghost" href="/settings/account/export">export my data</a> + </div> + </div> + + <div class="kg-danger-zone"> + <span class="kg-kicker">delete account</span> + <form class="kg-form" method="post" action="/settings/account/delete"> + <div class="kg-danger-row"> + <label> + type <code>{{ user.username }}</code> to delete + <input type="text" name="confirm" required autocomplete="off" /> + </label> + <button class="kg-btn kg-btn--danger" type="submit">delete account</button> + </div> + </form> + </div> +</section> +{% endblock %} @@ -0,0 +1,44 @@ +{% extends "layout.html" %} +{% block title %}admin — kitgit{% endblock %} +{% block content %} +<section class="kg-section"> + <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"> + <label> + message of the day + <textarea name="motd" rows="3" placeholder="Shown on the frontpage">{{ motd }}</textarea> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">save MOTD</button> + </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> + + <ul class="kg-list" style="margin-top:1.5rem"> + {% 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> + </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> + {% else %} + <input type="hidden" name="is_site_admin" value="true" /> + <button class="kg-btn" type="submit">make admin</button> + {% endif %} + </form> + </li> + {% else %} + <li><span class="kg-muted">no users yet — create an account or log in first.</span></li> + {% endfor %} + </ul> +</section> +{% endblock %} @@ -0,0 +1,12 @@ +{% extends "layout.html" %} + +{% block title %}{{ status }} — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <span class="kg-kicker">error</span> + <h1 class="kg-display">{{ status }}</h1> + <p class="kg-muted">{{ message }}</p> + <p><a class="kg-btn kg-btn--ghost" href="/">home</a></p> +</section> +{% endblock %} @@ -0,0 +1,38 @@ +{% extends "layout.html" %} + +{% block title %}explore — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <span class="kg-kicker">explore</span> + <h1 class="kg-display">public repositories</h1> + <form class="kg-explore-search" method="get" action="/explore" role="search"> + <label class="kg-sr-only" for="q">Search</label> + <input id="q" type="search" name="q" value="{{ query }}" placeholder="Search name, description, owner…" /> + <button class="kg-btn" type="submit">search</button> + </form> + + {% if repos.is_empty() %} + <p class="kg-muted" style="margin-top:1.5rem"> + {% if !query.is_empty() %}No public repositories match “{{ query }}”.{% else %}No public repositories yet.{% endif %} + </p> + {% else %} + <ul class="kg-list" style="margin-top:1.5rem"> + {% for item in repos %} + <li> + <div> + <a href="/{{ item.owner }}/{{ item.repo.name }}"> + <span class="kg-icon" style="--icon:url('/static/icons/code.svg')"></span> + {{ item.owner }}/{{ item.repo.name }} + </a> + {% if !item.repo.description.is_empty() %} + <p class="kg-meta" style="margin:0.25rem 0 0">{{ item.repo.description }}</p> + {% endif %} + </div> + <span class="kg-meta">{{ item.repo.updated_at }}</span> + </li> + {% endfor %} + </ul> + {% endif %} +</section> +{% endblock %} @@ -0,0 +1,81 @@ +{% extends "layout.html" %} + +{% block title %}kitgit{% endblock %} + +{% block content %} +{% if let Some(u) = viewer %} +<section class="kg-section"> + <h1 class="kg-display">kitgit</h1> + {% if !motd.is_empty() %} + <p class="kg-motd">{{ motd }}</p> + {% endif %} +</section> + +<section class="kg-section"> + <span class="kg-kicker">your repositories</span> + {% if my_repos.is_empty() %} + <p class="kg-muted">No repositories yet. <a href="/new">Create one</a>.</p> + {% else %} + <ul class="kg-list"> + {% for r in my_repos %} + <li> + <a href="/{{ u.username }}/{{ r.name }}"> + <span class="kg-icon" style="--icon:url('/static/icons/code.svg')"></span> + {{ u.username }}/{{ r.name }} + </a> + <span class="kg-meta">{% if r.visibility == "private" %}private{% else %}public{% endif %}</span> + </li> + {% endfor %} + </ul> + {% endif %} +</section> + +<section class="kg-section"> + <span class="kg-kicker">your activity</span> + {% if activities.is_empty() %} + <p class="kg-muted">No activity yet.</p> + {% else %} + <ul class="kg-list"> + {% for a in activities %} + <li> + <span class="kg-activity"> + {% if let Some(avatar) = a.actor_avatar %} + <img class="kg-avatar kg-avatar--sm" src="{{ avatar }}" alt="" /> + {% endif %} + {% if let Some(name) = a.actor_username %} + <a href="/{{ name }}">{{ name }}</a> + {% endif %} + <span> + {{ a.summary }}{% if let Some(label) = a.ref_label %}{% if let Some(href) = a.ref_href %}<a href="{{ href }}">{{ label }}</a>{% else %}{{ label }}{% endif %}{% endif %} + </span> + {% if let Some(owner) = a.repo_owner %} + {% if let Some(rname) = a.repo_name %} + <a href="/{{ owner }}/{{ rname }}">{{ owner }}/{{ rname }}</a> + {% endif %} + {% endif %} + </span> + <span class="kg-meta">{{ a.created_at }}</span> + </li> + {% endfor %} + </ul> + {% endif %} +</section> +{% else %} +<section class="kg-landing"> + <p class="kg-landing__brand"> + <span class="kg-mark__glyph" aria-hidden="true">狐</span> + <span class="kg-mark__name">kitgit</span> + </p> + {% if !motd.is_empty() %} + <p class="kg-motd">{{ motd }}</p> + {% else %} + <p class="kg-muted">Self-hosted git for your team.</p> + {% endif %} + <div class="kg-actions kg-landing__cta"> + <a class="kg-btn" href="/auth/login">log in</a> + <a class="kg-btn kg-btn--ghost" href="/auth/signup">sign up</a> + <a class="kg-btn kg-btn--ghost" href="/explore">explore</a> + </div> +</section> +{% endif %} +{% endblock %} @@ -0,0 +1,29 @@ +{% extends "layout.html" %} + +{% block title %}new issue · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <h1 class="kg-title">New issue</h1> + {% let tab = "issues" %} + {% let archive_ref = repo.default_branch %} + {% include "partials/repo_tabs.html" %} + {% if let Some(err) = error %} + <div class="kg-flash">{{ err }}</div> + {% endif %} + <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/issues"> + <label> + title + <input type="text" name="title" required autofocus /> + </label> + <label> + body + <textarea name="body" rows="8"></textarea> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">create</button> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/issues">cancel</a> + </div> + </form> +</section> +{% endblock %} @@ -0,0 +1,57 @@ +{% extends "layout.html" %} + +{% block title %}#{{ issue.number }} {{ issue.title }} — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <h1 class="kg-title"> + <span class="kg-badge {% if issue.state == "open" %}kg-badge--open{% else %}kg-badge--closed{% endif %}">{{ issue.state }}</span> + #{{ issue.number }} {{ issue.title }} + </h1> + {% let tab = "issues" %} + {% let archive_ref = repo.default_branch %} + {% include "partials/repo_tabs.html" %} + + <article class="kg-comment"> + <header> + <span> + <img class="kg-avatar kg-avatar--sm" src="{{ author_avatar }}" alt="" /> + <a href="/{{ author.username }}">{{ author.username }}</a> + </span> + <span>{{ issue.created_at }}</span> + </header> + <div class="kg-readme">{{ body_html|safe }}</div> + </article> + + {% for c in comments %} + <article class="kg-comment"> + <header> + <span> + <img class="kg-avatar kg-avatar--sm" src="{{ c.avatar_url }}" alt="" /> + <a href="/{{ c.author.username }}">{{ c.author.username }}</a> + </span> + <span>{{ c.created_at }}</span> + </header> + <div class="kg-readme">{{ c.body_html|safe }}</div> + {% include "partials/reactions.html" %} + </article> + {% endfor %} + + {% if viewer.is_some() %} + <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/issues/{{ issue.number }}"> + <label> + comment + <textarea name="body" rows="4" required></textarea> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">comment</button> + {% if issue.state == "open" %} + <button class="kg-btn kg-btn--ghost" formaction="/{{ owner.username }}/{{ repo.name }}/issues/{{ issue.number }}/close" formmethod="post" type="submit" formnovalidate>close</button> + {% else %} + <button class="kg-btn kg-btn--ghost" formaction="/{{ owner.username }}/{{ repo.name }}/issues/{{ issue.number }}/reopen" formmethod="post" type="submit" formnovalidate>reopen</button> + {% endif %} + </div> + </form> + {% endif %} +</section> +{% endblock %} @@ -0,0 +1,39 @@ +{% extends "layout.html" %} + +{% block title %}issues · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <h1 class="kg-title"> + <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a> + · issues + </h1> + {% let tab = "issues" %} + {% let archive_ref = repo.default_branch %} + {% include "partials/repo_tabs.html" %} + + <div class="kg-actions"> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/issues?state=open"{% if state_filter == "open" %} aria-current="page"{% endif %}>open</a> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/issues?state=closed"{% if state_filter == "closed" %} aria-current="page"{% endif %}>closed</a> + {% if viewer.is_some() %} + <a class="kg-btn" href="/{{ owner.username }}/{{ repo.name }}/issues/new">new issue</a> + {% endif %} + </div> + + {% if issues.is_empty() %} + <p class="kg-muted">No {{ state_filter }} issues.</p> + {% else %} + <ul class="kg-list"> + {% for issue in issues %} + <li> + <span> + <span class="kg-badge {% if issue.state == "open" %}kg-badge--open{% else %}kg-badge--closed{% endif %}">{{ issue.state }}</span> + <a href="/{{ owner.username }}/{{ repo.name }}/issues/{{ issue.number }}">#{{ issue.number }} {{ issue.title }}</a> + </span> + <span class="kg-meta">{{ issue.updated_at }}</span> + </li> + {% endfor %} + </ul> + {% endif %} +</section> +{% endblock %} @@ -0,0 +1,88 @@ +{% extends "layout.html" %} + +{% block title %}ssh / gpg keys — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <span class="kg-kicker">settings</span> + <h1 class="kg-title">SSH & GPG keys</h1> + <nav class="kg-tabs" aria-label="Settings"> + <a href="/settings/profile">profile</a> + <a href="/settings/account">account</a> + <a href="/settings/mfa">mfa</a> + <a href="/settings/keys" aria-current="page">ssh / gpg</a> + </nav> + {% if let Some(err) = error %} + <div class="kg-flash">{{ err }}</div> + {% endif %} + + <span class="kg-kicker" style="margin-top:1.5rem">SSH keys</span> + {% if !keys.is_empty() %} + <ul class="kg-list"> + {% for key in keys %} + <li> + <span> + <strong>{{ key.name }}</strong> + <span class="kg-meta">{{ key.fingerprint }}</span> + </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> + {% else %} + <p class="kg-muted">No SSH keys yet.</p> + {% endif %} + + <h2 class="kg-title" style="margin-top:2rem">Add SSH key</h2> + <form class="kg-form" method="post" action="/settings/keys"> + <label> + name + <input type="text" name="name" required /> + </label> + <label> + public key + <textarea name="public_key" rows="4" required placeholder="ssh-ed25519 AAAA…"></textarea> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">add</button> + </div> + </form> + + <span class="kg-kicker" style="margin-top:2.5rem">GPG keys</span> + <p class="kg-muted">Used for commit verification. Vigilant mode flags unsigned commits on your profile.</p> + {% if !gpg_keys.is_empty() %} + <ul class="kg-list"> + {% for key in gpg_keys %} + <li> + <span> + <strong>{{ key.name }}</strong> + <span class="kg-meta">{{ key.fingerprint }}</span> + </span> + <form method="post" action="/settings/gpg/{{ key.id }}/delete"> + <button class="kg-btn kg-btn--danger-ghost" type="submit">delete</button> + </form> + </li> + {% endfor %} + </ul> + {% else %} + <p class="kg-muted">No GPG keys yet.</p> + {% endif %} + + <h2 class="kg-title" style="margin-top:2rem">Add GPG key</h2> + <form class="kg-form" method="post" action="/settings/gpg"> + <label> + name + <input type="text" name="name" required value="gpg" /> + </label> + <label> + public key + <textarea name="public_key" rows="6" required placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----"></textarea> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">add</button> + </div> + </form> +</section> +{% endblock %} @@ -0,0 +1,53 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>{% block title %}kitgit{% endblock %}</title> + <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" /> + <link rel="stylesheet" href="/static/brand.css" /> + {% block head %}{% endblock %} +</head> +<body> + <div class="kg-shell"> + <header class="kg-bar"> + <a class="kg-mark" href="/"> + <span class="kg-mark__glyph" aria-hidden="true">狐</span> + <span class="kg-mark__name">kitgit</span> + </a> + <nav aria-label="Primary"> + <a href="/explore"><span class="kg-icon" style="--icon:url('/static/icons/code.svg')"></span> explore</a> + {% if let Some(u) = viewer %} + <a href="/new"><span class="kg-icon" style="--icon:url('/static/icons/plus.svg')"></span> new</a> + {% if u.is_site_admin %}<a href="/admin"><span class="kg-icon" style="--icon:url('/static/icons/settings.svg')"></span> admin</a>{% endif %} + <a href="/settings/profile"><span class="kg-icon" style="--icon:url('/static/icons/user.svg')"></span> settings</a> + <a class="kg-userchip" href="/{{ u.username }}"> + <img class="kg-avatar kg-avatar--sm" src="/avatars/{{ u.id }}?v={{ u.updated_at.timestamp() }}" alt="" /> + {{ u.username }} + </a> + <a href="/auth/logout"><span class="kg-icon" style="--icon:url('/static/icons/log-out.svg')"></span> logout</a> + {% else %} + <a href="/auth/login"><span class="kg-icon" style="--icon:url('/static/icons/log-in.svg')"></span> login</a> + <a href="/auth/signup"><span class="kg-icon" style="--icon:url('/static/icons/plus.svg')"></span> signup</a> + {% endif %} + </nav> + </header> + <main> + {% block content %}{% endblock %} + </main> + <footer class="kg-footer">kitgit</footer> + </div> + <script> + document.addEventListener('click', (e) => { + const btn = e.target.closest('[data-copy]'); + if (!btn) return; + const sel = btn.getAttribute('data-copy'); + const el = sel ? document.querySelector(sel) : null; + const text = el ? (el.value || el.textContent) : btn.getAttribute('data-copy-text'); + if (text) navigator.clipboard.writeText(text.trim()); + }); + </script> +</body> +</html> @@ -0,0 +1,32 @@ +{% extends "layout.html" %} + +{% block title %}log in — kitgit{% endblock %} + +{% block content %} +<section class="kg-section kg-auth"> + <p class="kg-landing__brand"> + <span class="kg-mark__glyph" aria-hidden="true">狐</span> + <span class="kg-mark__name">kitgit</span> + </p> + <h1 class="kg-title">log in</h1> + {% if let Some(err) = error %} + <div class="kg-flash">{{ err }}</div> + {% endif %} + <form class="kg-form" method="post" action="/auth/login" autocomplete="on"> + <label> + username + <input type="text" name="username" required autofocus autocomplete="username" /> + </label> + <label> + password + <input type="password" name="password" required autocomplete="current-password" /> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">log in</button> + </div> + </form> + <p class="kg-meta" style="margin-top:1.25rem"> + No account? <a href="/auth/signup">Sign up</a> + </p> +</section> +{% endblock %} @@ -0,0 +1,27 @@ +{% extends "layout.html" %} + +{% block title %}verify — kitgit{% endblock %} + +{% block content %} +<section class="kg-section kg-auth"> + <p class="kg-landing__brand"> + <span class="kg-mark__glyph" aria-hidden="true">狐</span> + <span class="kg-mark__name">kitgit</span> + </p> + <h1 class="kg-title">two-factor authentication</h1> + <p class="kg-muted">Enter a code from your authenticator app, or a recovery code.</p> + {% if let Some(err) = error %} + <div class="kg-flash">{{ err }}</div> + {% endif %} + <form class="kg-form" method="post" action="/auth/mfa" autocomplete="one-time-code"> + <label> + authentication code + <input type="text" name="code" required autofocus inputmode="numeric" autocomplete="one-time-code" /> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">verify</button> + <a class="kg-btn kg-btn--ghost" href="/auth/login">back</a> + </div> + </form> +</section> +{% endblock %} @@ -0,0 +1,78 @@ +{% extends "layout.html" %} + +{% block title %}MFA settings — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <span class="kg-kicker">settings</span> + <h1 class="kg-title">Two-factor authentication</h1> + <nav class="kg-tabs" aria-label="Settings"> + <a href="/settings/profile">profile</a> + <a href="/settings/account">account</a> + <a href="/settings/mfa" aria-current="page">mfa</a> + <a href="/settings/keys">ssh / gpg</a> + </nav> + + {% if let Some(err) = error %} + <div class="kg-flash">{{ err }}</div> + {% endif %} + {% if let Some(msg) = message %} + <div class="kg-flash">{{ msg }}</div> + {% endif %} + + <div class="kg-settings-section" style="border-top:0;padding-top:0;margin-top:var(--kg-space-4)"> + {% if enabled %} + <p class="kg-muted">Authenticator app MFA is enabled for your account.</p> + <p class="kg-meta">{{ recovery_remaining }} recovery code(s) remaining.</p> + <form class="kg-form" method="post" action="/settings/mfa/disable" style="margin-top:1rem"> + <label> + confirm with authenticator or recovery code + <input type="text" name="code" required autocomplete="one-time-code" /> + </label> + <div class="kg-actions"> + <button class="kg-btn kg-btn--danger" type="submit">disable MFA</button> + <a class="kg-btn kg-btn--ghost" href="/settings/account">back to account</a> + </div> + </form> + {% else if pending %} + <p class="kg-muted">Scan this QR code with your authenticator app, then enter a code to confirm.</p> + {% if let Some(qr) = qr_data_uri %} + <p><img src="{{ qr }}" alt="MFA QR code" width="180" height="180" style="image-rendering:pixelated;background:#fff;padding:0.5rem" /></p> + {% endif %} + {% if let Some(sec) = secret %} + <p class="kg-meta">Or enter this secret manually: <code>{{ sec }}</code></p> + {% endif %} + <form class="kg-form" method="post" action="/settings/mfa/confirm" style="margin-top:1rem"> + <label> + confirmation code + <input type="text" name="code" required autofocus inputmode="numeric" autocomplete="one-time-code" /> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">confirm and enable</button> + <a class="kg-btn kg-btn--ghost" href="/settings/account">cancel</a> + </div> + </form> + {% else %} + <p class="kg-muted">Protect your account with a time-based one-time password (TOTP) from an authenticator app.</p> + <form method="post" action="/settings/mfa/enroll"> + <div class="kg-actions"> + <button class="kg-btn" type="submit">set up authenticator</button> + <a class="kg-btn kg-btn--ghost" href="/settings/account">back to account</a> + </div> + </form> + {% endif %} + </div> + + {% if let Some(codes) = recovery_codes %} + <div class="kg-settings-section"> + <span class="kg-kicker">recovery codes</span> + <p class="kg-muted">Save these codes somewhere safe. Each can be used once if you lose your authenticator. They will not be shown again.</p> + <ul class="kg-list"> + {% for c in codes %} + <li><code>{{ c }}</code></li> + {% endfor %} + </ul> + </div> + {% endif %} +</section> +{% endblock %} @@ -0,0 +1,33 @@ +{% extends "layout.html" %} + +{% block title %}new repository — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <span class="kg-kicker">new</span> + <h1 class="kg-title">New repository</h1> + {% if let Some(err) = error %} + <div class="kg-flash">{{ err }}</div> + {% endif %} + <form class="kg-form" method="post" action="/new"> + <label> + name + <input type="text" name="name" required maxlength="100" pattern="[A-Za-z0-9._-]+" autofocus /> + </label> + <label> + description + <textarea name="description" rows="3"></textarea> + </label> + <label> + visibility + <select name="visibility"> + <option value="public">public</option> + <option value="private">private</option> + </select> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">create</button> + </div> + </form> +</section> +{% endblock %} @@ -0,0 +1,58 @@ +{# Expects: owner, repo, branch, latest_commit: Option<CommitView>, access (optional for upload), upload_path (optional) #} +<div class="kg-branchbar"> + <div class="kg-branchbar__left"> + <a class="kg-branchbar__branch" href="/{{ owner.username }}/{{ repo.name }}/branches"> + <span class="kg-icon" style="--icon:url('/static/icons/git-branch.svg')"></span> + {{ branch }} + </a> + {% if let Some(c) = latest_commit %} + <div class="kg-branchbar__commit"> + <a class="kg-branchbar__sha" href="/{{ owner.username }}/{{ repo.name }}/commits" title="View commits"> + <span class="kg-icon" style="--icon:url('/static/icons/git-commit.svg')"></span> + {{ c.short_id }} + </a> + <a class="kg-branchbar__msg" href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}" title="View commit">{{ c.message }}</a> + </div> + {% endif %} + </div> + <div class="kg-branchbar__actions"> + {% if upload_path != "" %} + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/archive.zip?ref={{ branch }}&path={{ upload_path }}" title="Download this folder as ZIP"> + <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span> + download folder + </a> + {% else %} + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/archive.zip?ref={{ branch }}" title="Download repository as ZIP"> + <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span> + download + </a> + {% endif %} + {% if access.can_write() %} + <details class="kg-dropdown"> + <summary class="kg-btn kg-btn--ghost"> + <span class="kg-icon" style="--icon:url('/static/icons/upload.svg')"></span> + upload + <span class="kg-icon" style="--icon:url('/static/icons/chevron-down.svg')"></span> + </summary> + <div class="kg-dropdown__panel kg-dropdown__panel--upload"> + <form class="kg-upload" method="post" action="/{{ owner.username }}/{{ repo.name }}/upload" enctype="multipart/form-data"> + <input type="hidden" name="branch" value="{{ branch }}" /> + <input type="hidden" name="path" value="{{ upload_path }}" /> + <label class="kg-upload__file"> + <span class="kg-meta">File</span> + <input type="file" name="file" required /> + </label> + <label class="kg-upload__message"> + <span class="kg-meta">Commit message</span> + <input type="text" name="message" placeholder="Commit message" value="Upload file" /> + </label> + <button class="kg-btn" type="submit"> + <span class="kg-icon" style="--icon:url('/static/icons/upload.svg')"></span> + commit file + </button> + </form> + </div> + </details> + {% endif %} + </div> +</div> @@ -0,0 +1,39 @@ +{# KaTeX for README / markdown — uses KaTeX's bundled Computer Modern fonts #} +<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" crossorigin="anonymous"> +<style> + .katex-display { + margin: 1rem 0; + overflow-x: auto; + overflow-y: hidden; + } + /* Size bump only — font-face families come from katex.min.css (Computer Modern). */ + .kg-readme .katex, + .kg-md .katex { + font-size: 1.1em; + } +</style> +<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" crossorigin="anonymous"></script> +<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js" crossorigin="anonymous" + onload="window.__kitgitRenderKatex && window.__kitgitRenderKatex()"></script> +<script> +window.__kitgitRenderKatex = function () { + if (typeof renderMathInElement !== "function") return; + var roots = document.querySelectorAll(".kg-readme, .kg-md"); + if (!roots.length) roots = [document.body]; + var opts = { + delimiters: [ + { left: "$$", right: "$$", display: true }, + { left: "\\[", right: "\\]", display: true }, + { left: "$", right: "$", display: false }, + { left: "\\(", right: "\\)", display: false } + ], + throwOnError: false, + strict: "ignore", + ignoredTags: ["script", "noscript", "style", "textarea", "pre", "code", "option"] + }; + roots.forEach(function (el) { renderMathInElement(el, opts); }); +}; +document.addEventListener("DOMContentLoaded", function () { + if (typeof renderMathInElement === "function") window.__kitgitRenderKatex(); +}); +</script> @@ -0,0 +1,44 @@ +{# Expects: c (CommentView), owner, repo, viewer #} +{% if viewer.is_some() || !c.reactions.is_empty() %} +<div class="kg-reactions"> + {% for r in c.reactions %} + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/comments/{{ c.id }}/react" style="display:inline"> + <input type="hidden" name="emoji" value="{{ r.emoji }}" /> + <button class="kg-reaction" type="submit" aria-pressed="{% if r.mine %}true{% else %}false{% endif %}" title="{{ r.emoji }}"> + {{ r.label }} {{ r.count }} + </button> + </form> + {% endfor %} + {% if viewer.is_some() %} + <details class="kg-dropdown"> + <summary class="kg-reaction" title="Add reaction">+</summary> + <div class="kg-dropdown__panel"> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/comments/{{ c.id }}/react"> + <input type="hidden" name="emoji" value="+1" /> + <button class="kg-dropdown__item" type="submit">+1</button> + </form> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/comments/{{ c.id }}/react"> + <input type="hidden" name="emoji" value="-1" /> + <button class="kg-dropdown__item" type="submit">-1</button> + </form> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/comments/{{ c.id }}/react"> + <input type="hidden" name="emoji" value="heart" /> + <button class="kg-dropdown__item" type="submit">heart</button> + </form> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/comments/{{ c.id }}/react"> + <input type="hidden" name="emoji" value="laugh" /> + <button class="kg-dropdown__item" type="submit">laugh</button> + </form> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/comments/{{ c.id }}/react"> + <input type="hidden" name="emoji" value="rocket" /> + <button class="kg-dropdown__item" type="submit">rocket</button> + </form> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/comments/{{ c.id }}/react"> + <input type="hidden" name="emoji" value="eyes" /> + <button class="kg-dropdown__item" type="submit">eyes</button> + </form> + </div> + </details> + {% endif %} +</div> +{% endif %} @@ -0,0 +1,61 @@ +{# Expects: owner, repo, access, tab, clone_http, clone_ssh, archive_ref #} +<nav class="kg-tabs" aria-label="Repository"> + <div class="kg-tabs__links"> + <a href="/{{ owner.username }}/{{ repo.name }}"{% if tab == "code" %} aria-current="page"{% endif %}> + <span class="kg-icon" style="--icon:url('/static/icons/code.svg')"></span> code + </a> + {% if repo.issues_enabled %} + <a href="/{{ owner.username }}/{{ repo.name }}/issues"{% if tab == "issues" %} aria-current="page"{% endif %}> + <span class="kg-icon" style="--icon:url('/static/icons/circle.svg')"></span> issues + </a> + {% endif %} + {% if repo.pulls_enabled %} + <a href="/{{ owner.username }}/{{ repo.name }}/pulls"{% if tab == "pulls" %} aria-current="page"{% endif %}> + <span class="kg-icon" style="--icon:url('/static/icons/git-pull-request.svg')"></span> pulls + </a> + {% endif %} + {% if repo.releases_enabled %} + <a href="/{{ owner.username }}/{{ repo.name }}/releases"{% if tab == "releases" %} aria-current="page"{% endif %}> + <span class="kg-icon" style="--icon:url('/static/icons/package.svg')"></span> releases + </a> + {% endif %} + <a href="/{{ owner.username }}/{{ repo.name }}/commits"{% if tab == "commits" %} aria-current="page"{% endif %}> + <span class="kg-icon" style="--icon:url('/static/icons/history.svg')"></span> commits + </a> + <a href="/{{ owner.username }}/{{ repo.name }}/branches"{% if tab == "branches" %} aria-current="page"{% endif %}> + <span class="kg-icon" style="--icon:url('/static/icons/git-branch.svg')"></span> branches + </a> + {% if access.can_admin() %} + <a href="/{{ owner.username }}/{{ repo.name }}/settings"{% if tab == "settings" %} aria-current="page"{% endif %}> + <span class="kg-icon" style="--icon:url('/static/icons/settings.svg')"></span> settings + </a> + {% endif %} + </div> + <details class="kg-dropdown"> + <summary class="kg-btn kg-btn--ghost"> + <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span> + clone + <span class="kg-icon" style="--icon:url('/static/icons/chevron-down.svg')"></span> + </summary> + <div class="kg-dropdown__panel"> + <a class="kg-dropdown__item" href="/{{ owner.username }}/{{ repo.name }}/archive.zip?ref={{ archive_ref }}"> + <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span> + Download ZIP + </a> + <div class="kg-clone-row"> + <label for="clone-http">HTTP</label> + <input id="clone-http" type="text" readonly value="{{ clone_http }}" /> + <button type="button" class="kg-btn kg-btn--ghost" data-copy="#clone-http" title="Copy"> + <span class="kg-icon" style="--icon:url('/static/icons/copy.svg')"></span> + </button> + </div> + <div class="kg-clone-row"> + <label for="clone-ssh">SSH</label> + <input id="clone-ssh" type="text" readonly value="{{ clone_ssh }}" /> + <button type="button" class="kg-btn kg-btn--ghost" data-copy="#clone-ssh" title="Copy"> + <span class="kg-icon" style="--icon:url('/static/icons/copy.svg')"></span> + </button> + </div> + </div> + </details> +</nav> @@ -0,0 +1,121 @@ +{% extends "layout.html" %} + +{% block title %}{{ profile.username }} — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <div class="kg-profile-head"> + <img class="kg-avatar kg-avatar--lg" src="{{ avatar_url }}" alt="" /> + <div> + {% if !profile.display_name.is_empty() %} + <h1 class="kg-display">{{ profile.display_name }}</h1> + <p class="kg-muted">@{{ profile.username }}</p> + {% else %} + <h1 class="kg-display">{{ profile.username }}</h1> + {% endif %} + {% if profile.show_email && !profile.email.is_empty() %} + <p class="kg-meta">{{ profile.email }}</p> + {% endif %} + {% if !profile.bio.is_empty() %} + <p>{{ profile.bio }}</p> + {% endif %} + {% if is_self %} + <p><a class="kg-btn kg-btn--ghost" href="/settings/profile">edit profile</a></p> + {% endif %} + </div> + </div> + + <span class="kg-kicker">contributions</span> + {% if !has_activity %} + <p class="kg-contrib-empty">No recorded git activity yet.</p> + {% endif %} + <div class="kg-graph" aria-label="Commit graph"> + {% for day in graph %} + {% if day.count == 0 %} + <i title="{{ day.day }}: 0"></i> + {% else if day.count == 1 %} + <i data-n="1" title="{{ day.day }}: {{ day.count }}"></i> + {% else if day.count < 4 %} + <i data-n="2" title="{{ day.day }}: {{ day.count }}"></i> + {% else if day.count < 7 %} + <i data-n="3" title="{{ day.day }}: {{ day.count }}"></i> + {% else %} + <i data-n="4" title="{{ day.day }}: {{ day.count }}"></i> + {% endif %} + {% endfor %} + </div> + + <span class="kg-kicker">repositories</span> + {% if repos.is_empty() %} + <p class="kg-muted">No repositories.</p> + {% else %} + <ul class="kg-list"> + {% for r in repos %} + <li> + <span> + <a href="/{{ profile.username }}/{{ r.name }}">{{ r.name }}</a> + {% if r.visibility == "private" %}<span class="kg-badge">private</span>{% endif %} + {% if r.archived %}<span class="kg-badge kg-badge--closed">archived</span>{% endif %} + {% if !r.description.is_empty() %} + <span class="kg-muted"> — {{ r.description }}</span> + {% endif %} + </span> + <span class="kg-meta">{{ r.updated_at }}</span> + </li> + {% endfor %} + </ul> + {% endif %} + + <span class="kg-kicker">starred</span> + {% if starred.is_empty() %} + <p class="kg-muted">No public starred repositories.</p> + {% else %} + <ul class="kg-list"> + {% for s in starred %} + <li> + <span> + <a href="/{{ s.owner }}/{{ s.repo.name }}">{{ s.owner }}/{{ s.repo.name }}</a> + {% if s.repo.archived %}<span class="kg-badge kg-badge--closed">archived</span>{% endif %} + {% if !s.repo.description.is_empty() %} + <span class="kg-muted"> — {{ s.repo.description }}</span> + {% endif %} + </span> + <span class="kg-meta"> + <span class="kg-icon" style="--icon:url('/static/icons/star.svg')"></span> + {{ s.repo.stars_count }} + </span> + </li> + {% endfor %} + </ul> + {% endif %} + + <span class="kg-kicker">watched activity</span> + {% if watched_activity.is_empty() %} + <p class="kg-muted">No recent activity from watched public repositories.</p> + {% else %} + <ul class="kg-list"> + {% for a in watched_activity %} + <li> + <span class="kg-activity"> + {% if let Some(avatar) = a.actor_avatar %} + <img class="kg-avatar kg-avatar--sm" src="{{ avatar }}" alt="" /> + {% endif %} + {% if let Some(name) = a.actor_username %} + <a href="/{{ name }}">{{ name }}</a> + {% endif %} + <span> + {{ a.summary }}{% if let Some(label) = a.ref_label %}{% if let Some(href) = a.ref_href %}<a href="{{ href }}">{{ label }}</a>{% else %}{{ label }}{% endif %}{% endif %} + </span> + {% if let Some(owner) = a.repo_owner %} + {% if let Some(rname) = a.repo_name %} + <a href="/{{ owner }}/{{ rname }}">{{ owner }}/{{ rname }}</a> + {% endif %} + {% endif %} + </span> + <span class="kg-meta">{{ a.created_at }}</span> + </li> + {% endfor %} + </ul> + {% endif %} +</section> +{% endblock %} @@ -0,0 +1,45 @@ +{% extends "layout.html" %} + +{% block title %}profile settings — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <span class="kg-kicker">settings</span> + <h1 class="kg-title">Profile</h1> + <nav class="kg-tabs" aria-label="Settings"> + <a href="/settings/profile" aria-current="page">profile</a> + <a href="/settings/account">account</a> + <a href="/settings/mfa">mfa</a> + <a href="/settings/keys">ssh / gpg</a> + </nav> + {% if let Some(err) = error %} + <div class="kg-flash">{{ err }}</div> + {% endif %} + <div class="kg-profile-head"> + <img class="kg-avatar kg-avatar--lg" src="{{ avatar_url }}" alt="" /> + <div> + <p class="kg-title">{{ user.username }}</p> + {% if user.show_email && !user.email.is_empty() %} + <p class="kg-muted">{{ user.email }}</p> + {% endif %} + </div> + </div> + <form class="kg-form" method="post" action="/settings/profile" enctype="multipart/form-data"> + <label> + display name + <input type="text" name="display_name" value="{{ user.display_name }}" /> + </label> + <label> + bio + <textarea name="bio" rows="4">{{ user.bio }}</textarea> + </label> + <label> + avatar + <input type="file" name="avatar" accept="image/*" /> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">save</button> + </div> + </form> +</section> +{% endblock %} @@ -0,0 +1,45 @@ +{% extends "layout.html" %} + +{% block title %}new pull · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <h1 class="kg-title">New pull request</h1> + {% let tab = "pulls" %} + {% let archive_ref = repo.default_branch %} + {% include "partials/repo_tabs.html" %} + {% if let Some(err) = error %} + <div class="kg-flash">{{ err }}</div> + {% endif %} + <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/pulls"> + <label> + title + <input type="text" name="title" required autofocus /> + </label> + <label> + source branch + <select name="source_branch" required> + {% for b in branches %} + <option value="{{ b }}">{{ b }}</option> + {% endfor %} + </select> + </label> + <label> + target branch + <select name="target_branch" required> + {% for b in branches %} + <option value="{{ b }}"{% if b.as_str() == repo.default_branch.as_str() %} selected{% endif %}>{{ b }}</option> + {% endfor %} + </select> + </label> + <label> + body + <textarea name="body" rows="8"></textarea> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">create</button> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/pulls">cancel</a> + </div> + </form> +</section> +{% endblock %} @@ -0,0 +1,160 @@ +{% extends "layout.html" %} + +{% block title %}#{{ pull.number }} {{ pull.title }} — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <h1 class="kg-title"> + <span class="kg-badge {% if pull.state == "open" %}kg-badge--open{% else %}kg-badge--closed{% endif %}">{{ pull.state }}</span> + #{{ pull.number }} {{ pull.title }} + </h1> + <p class="kg-meta">{{ pull.source_branch }} → {{ pull.target_branch }}</p> + {% let pr_tab = tab %} + {% let tab = "pulls" %} + {% let archive_ref = repo.default_branch %} + {% include "partials/repo_tabs.html" %} + + <nav class="kg-pr-tabs" aria-label="Pull request"> + <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}?tab=conversation"{% if pr_tab == "conversation" %} aria-current="page"{% endif %}> + <span class="kg-icon" style="--icon:url('/static/icons/circle.svg')"></span> + conversation + <span class="kg-pr-tabs__count">{{ conversation_count }}</span> + </a> + <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}?tab=commits"{% if pr_tab == "commits" %} aria-current="page"{% endif %}> + <span class="kg-icon" style="--icon:url('/static/icons/history.svg')"></span> + commits + <span class="kg-pr-tabs__count">{{ commits.len() }}</span> + </a> + <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}?tab=files"{% if pr_tab == "files" %} aria-current="page"{% endif %}> + <span class="kg-icon" style="--icon:url('/static/icons/file.svg')"></span> + files + <span class="kg-pr-tabs__count">{{ diff_files.len() }}</span> + </a> + </nav> + + {% if pr_tab == "conversation" %} + <article class="kg-comment"> + <header> + <span> + <img class="kg-avatar kg-avatar--sm" src="{{ author_avatar }}" alt="" /> + <a href="/{{ author.username }}">{{ author.username }}</a> + </span> + <span>{{ pull.created_at }}</span> + </header> + <div class="kg-readme">{{ body_html|safe }}</div> + </article> + + {% for c in comments %} + <article class="kg-comment"> + <header> + <span> + <img class="kg-avatar kg-avatar--sm" src="{{ c.avatar_url }}" alt="" /> + <a href="/{{ c.author.username }}">{{ c.author.username }}</a> + </span> + <span>{{ c.created_at }}</span> + </header> + <div class="kg-readme">{{ c.body_html|safe }}</div> + {% include "partials/reactions.html" %} + </article> + {% endfor %} + + {% if viewer.is_some() %} + <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}"> + <label> + comment + <textarea name="body" rows="4" required></textarea> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">comment</button> + {% if pull.state == "open" %} + <button class="kg-btn kg-btn--ghost" formaction="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}/close" formmethod="post" type="submit" formnovalidate>close</button> + {% endif %} + </div> + </form> + {% endif %} + + {% if can_merge && !merge_styles.is_empty() %} + <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}/merge"> + <label> + merge style + <select name="style"> + {% for style in merge_styles %} + <option value="{{ style }}"{% if style.as_str() == repo.default_merge_style.as_str() %} selected{% endif %}>{{ style }}</option> + {% endfor %} + </select> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">merge</button> + </div> + </form> + {% endif %} + {% else if pr_tab == "commits" %} + {% if commits.is_empty() %} + <p class="kg-muted">No commits on this pull request.</p> + {% else %} + <ul class="kg-list"> + {% for c in commits %} + <li> + <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> + </span> + <span class="kg-meta">{{ c.author }}</span> + </li> + {% endfor %} + </ul> + {% endif %} + {% else %} + {% if diff_files.is_empty() %} + <p class="kg-muted">No file changes.</p> + {% else %} + <div class="kg-pr-files"> + <aside class="kg-pr-files__nav" aria-label="Changed files"> + <p class="kg-pr-files__nav-title">{{ diff_files.len() }} files</p> + <ul> + {% for f in diff_files %} + <li> + <a href="#{{ f.anchor }}"> + <span class="kg-pr-files__path">{{ f.path }}</span> + <span class="kg-pr-files__stat"> + {% if f.binary %} + binary + {% else %} + <span class="kg-pr-files__add">+{{ f.additions }}</span> + <span class="kg-pr-files__del">−{{ f.deletions }}</span> + {% endif %} + </span> + </a> + </li> + {% endfor %} + </ul> + </aside> + <div class="kg-pr-files__panels"> + {% for f in diff_files %} + <section class="kg-diff-file" id="{{ f.anchor }}"> + <header class="kg-diff-file__head"> + <strong>{{ f.path }}</strong> + <span class="kg-meta"> + {% if f.binary %} + binary + {% else %} + <span class="kg-pr-files__add">+{{ f.additions }}</span> + <span class="kg-pr-files__del">−{{ f.deletions }}</span> + {% endif %} + </span> + </header> + {{ f.html|safe }} + {% if f.truncated %} + <p class="kg-diff__truncated"> + Large diffs are not rendered by default. + Showing the first 50 of {{ f.total_lines }} lines. + </p> + {% endif %} + </section> + {% endfor %} + </div> + </div> + {% endif %} + {% endif %} +</section> +{% endblock %} @@ -0,0 +1,41 @@ +{% extends "layout.html" %} + +{% block title %}pulls · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <h1 class="kg-title"> + <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a> + · pulls + </h1> + {% let tab = "pulls" %} + {% let archive_ref = repo.default_branch %} + {% include "partials/repo_tabs.html" %} + + <div class="kg-actions"> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/pulls?state=open"{% if state_filter == "open" %} aria-current="page"{% endif %}>open</a> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/pulls?state=closed"{% if state_filter == "closed" %} aria-current="page"{% endif %}>closed</a> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/pulls?state=merged"{% if state_filter == "merged" %} aria-current="page"{% endif %}>merged</a> + {% if viewer.is_some() %} + <a class="kg-btn" href="/{{ owner.username }}/{{ repo.name }}/pulls/new">new pull</a> + {% endif %} + </div> + + {% if pulls.is_empty() %} + <p class="kg-muted">No {{ state_filter }} pulls.</p> + {% else %} + <ul class="kg-list"> + {% for pull in pulls %} + <li> + <span> + <span class="kg-badge {% if pull.state == "open" %}kg-badge--open{% else %}kg-badge--closed{% endif %}">{{ pull.state }}</span> + <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ pull.number }}">#{{ pull.number }} {{ pull.title }}</a> + <span class="kg-meta">{{ pull.source_branch }} → {{ pull.target_branch }}</span> + </span> + <span class="kg-meta">{{ pull.updated_at }}</span> + </li> + {% endfor %} + </ul> + {% endif %} +</section> +{% endblock %} @@ -0,0 +1,50 @@ +{% extends "layout.html" %} + +{% block title %}edit {{ release.tag_name }} · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <h1 class="kg-title">Edit release</h1> + {% let tab = "releases" %} + {% let archive_ref = release.tag_name.clone() %} + {% include "partials/repo_tabs.html" %} + {% if let Some(err) = error %} + <div class="kg-flash">{{ err }}</div> + {% endif %} + <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/releases/{{ release.tag_name }}/edit"> + <label> + tag + <input type="text" name="tag_name" required value="{{ release.tag_name }}" /> + </label> + <label> + title + <input type="text" name="title" required value="{{ release.title }}" /> + </label> + <label> + target (retarget tag) + <input type="text" name="target" list="release-targets" value="{{ target }}" placeholder="branch, tag, or commit SHA" /> + <datalist id="release-targets"> + {% for b in branches %} + <option value="{{ b }}"></option> + {% endfor %} + </datalist> + </label> + <label> + notes + <textarea name="body" rows="12" placeholder="Markdown supported">{{ release.body }}</textarea> + </label> + <label class="kg-check"> + <input type="checkbox" name="is_prerelease" value="on"{% if release.is_prerelease %} checked{% endif %} /> + This is a pre-release + </label> + <label class="kg-check"> + <input type="checkbox" name="is_draft" value="on"{% if release.is_draft %} checked{% endif %} /> + Save as draft + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">save changes</button> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/releases/{{ release.tag_name }}">cancel</a> + </div> + </form> +</section> +{% endblock %} @@ -0,0 +1,50 @@ +{% extends "layout.html" %} + +{% block title %}new release · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <h1 class="kg-title">New release</h1> + {% let tab = "releases" %} + {% let archive_ref = repo.default_branch %} + {% include "partials/repo_tabs.html" %} + {% if let Some(err) = error %} + <div class="kg-flash">{{ err }}</div> + {% endif %} + <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/releases"> + <label> + tag + <input type="text" name="tag_name" required placeholder="v1.0.0" autofocus value="{{ tag_name }}" /> + </label> + <label> + target + <input type="text" name="target" list="release-targets" value="{{ target }}" placeholder="branch, tag, or commit SHA" /> + <datalist id="release-targets"> + {% for b in branches %} + <option value="{{ b }}"></option> + {% endfor %} + </datalist> + </label> + <label> + title + <input type="text" name="title" required value="{{ title }}" /> + </label> + <label> + notes + <textarea name="body" rows="10" placeholder="Markdown supported">{{ body }}</textarea> + </label> + <label class="kg-check"> + <input type="checkbox" name="is_prerelease" value="on"{% if is_prerelease %} checked{% endif %} /> + This is a pre-release + </label> + <label class="kg-check"> + <input type="checkbox" name="is_draft" value="on"{% if is_draft %} checked{% endif %} /> + Save as draft + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">publish release</button> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/releases">cancel</a> + </div> + </form> +</section> +{% endblock %} @@ -0,0 +1,104 @@ +{% extends "layout.html" %} + +{% block title %}{{ release.tag_name }} · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <div class="kg-release-head"> + <div> + <h1 class="kg-title"> + {% if release.is_draft %}<span class="kg-badge">draft</span>{% endif %} + {% if release.is_prerelease %}<span class="kg-badge kg-badge--pre">pre-release</span>{% endif %} + {{ release.title }} + </h1> + <p class="kg-meta"> + <img class="kg-avatar kg-avatar--sm" src="{{ author_avatar }}" alt="" /> + <code>{{ release.tag_name }}</code> + · by <a href="/{{ author.username }}">{{ author.username }}</a> + · {{ release.created_at }} + {% if !tag_target.is_empty() %} + · <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ tag_target }}"><code>{{ tag_short }}</code></a> + {% endif %} + · <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ release.tag_name }}">browse files</a> + </p> + </div> + {% if access.can_write() %} + <div class="kg-actions"> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/releases/{{ release.tag_name }}/edit"> + <span class="kg-icon" style="--icon:url('/static/icons/pencil.svg')"></span> + edit + </a> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/releases/{{ release.tag_name }}/delete" onsubmit="return confirm('Delete release {{ release.tag_name }}? Assets will be removed. The git tag is kept.')"> + <button class="kg-btn kg-btn--danger-ghost" type="submit"> + <span class="kg-icon" style="--icon:url('/static/icons/trash.svg')"></span> + delete + </button> + </form> + </div> + {% endif %} + </div> + + {% let tab = "releases" %} + {% let archive_ref = release.tag_name.clone() %} + {% include "partials/repo_tabs.html" %} + + <div class="kg-release-layout"> + <div class="kg-release-main"> + <span class="kg-kicker">release notes</span> + {% if release.body.is_empty() %} + <p class="kg-muted">No release notes.</p> + {% else %} + <article class="kg-readme kg-release-notes">{{ body_html|safe }}</article> + {% endif %} + </div> + + <aside class="kg-release-aside"> + <span class="kg-kicker">assets</span> + {% if assets.is_empty() %} + <p class="kg-muted">No assets yet.</p> + {% else %} + <ul class="kg-asset-list"> + {% for a in assets %} + <li> + <a href="/{{ owner.username }}/{{ repo.name }}/assets/{{ a.id }}"> + <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span> + {{ a.filename }} + </a> + <span class="kg-meta">{{ a.size_label }}</span> + {% if access.can_write() %} + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/releases/{{ release.tag_name }}/assets/{{ a.id }}/delete" onsubmit="return confirm('Delete {{ a.filename }}?')" style="display:inline"> + <button class="kg-btn kg-btn--danger-ghost" type="submit" title="Delete asset"> + <span class="kg-icon" style="--icon:url('/static/icons/trash.svg')"></span> + </button> + </form> + {% endif %} + </li> + {% endfor %} + </ul> + {% endif %} + + <div class="kg-asset-source"> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/archive.zip?ref={{ release.tag_name }}"> + <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span> + Source code (zip) + </a> + </div> + + {% if access.can_write() %} + <form class="kg-form kg-asset-upload" method="post" action="/{{ owner.username }}/{{ repo.name }}/releases/{{ release.tag_name }}/upload" enctype="multipart/form-data"> + <label> + upload asset + <input type="file" name="asset" required /> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit"> + <span class="kg-icon" style="--icon:url('/static/icons/upload.svg')"></span> + upload + </button> + </div> + </form> + {% endif %} + </aside> + </div> +</section> +{% endblock %} @@ -0,0 +1,46 @@ +{% extends "layout.html" %} + +{% block title %}releases · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <h1 class="kg-title"> + <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a> + · releases + </h1> + {% let tab = "releases" %} + {% let archive_ref = repo.default_branch %} + {% include "partials/repo_tabs.html" %} + + {% if access.can_write() %} + <div class="kg-actions"> + <a class="kg-btn" href="/{{ owner.username }}/{{ repo.name }}/releases/new"> + <span class="kg-icon" style="--icon:url('/static/icons/plus.svg')"></span> + new release + </a> + </div> + {% endif %} + + {% if releases.is_empty() %} + <p class="kg-muted">No releases.</p> + {% else %} + <ul class="kg-release-list"> + {% for r in releases %} + <li class="kg-release-list__item"> + <div> + <a class="kg-release-list__title" href="/{{ owner.username }}/{{ repo.name }}/releases/{{ r.tag_name }}"> + {{ r.title }} + </a> + <p class="kg-meta"> + <code>{{ r.tag_name }}</code> + · {{ r.created_at }} + {% if r.is_draft %}<span class="kg-badge">draft</span>{% endif %} + {% if r.is_prerelease %}<span class="kg-badge kg-badge--pre">pre-release</span>{% endif %} + </p> + </div> + </li> + {% endfor %} + </ul> + {% endif %} +</section> +{% endblock %} @@ -0,0 +1,53 @@ +{% extends "layout.html" %} + +{% block title %}{{ path }} · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %} + +{% block head %} +{% if is_markdown %} +{% include "partials/katex.html" %} +{% endif %} +{% endblock %} + +{% block content %} +<section class="kg-section"> + <h1 class="kg-title"> + <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a> + </h1> + {% let tab = "code" %} + {% let archive_ref = branch %} + {% include "partials/repo_tabs.html" %} + + <p class="kg-meta kg-breadcrumbs"> + <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ branch }}">{{ branch }}</a> + {% for (name, full) in breadcrumbs %} + / + {% if loop.last %} + {{ name }} + {% else %} + <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ branch }}/{{ full }}">{{ name }}</a> + {% endif %} + {% endfor %} + · {{ size }} bytes + </p> + + <div class="kg-blobbar"> + <span class="kg-meta">{{ path }}</span> + <div class="kg-blobbar__actions"> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/raw/{{ branch }}/{{ path }}"> + <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span> + Download raw + </a> + </div> + </div> + + {% if binary %} + <p class="kg-muted">Binary file.</p> + {% else if let Some(html) = content_html %} + {% if is_markdown %} + <article class="kg-readme kg-md">{{ html|safe }}</article> + {% else %} + {{ html|safe }} + {% endif %} + {% endif %} +</section> +{% endblock %} @@ -0,0 +1,162 @@ +{% extends "layout.html" %} + +{% block title %}branches · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <h1 class="kg-title"> + <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a> + · branches & tags + </h1> + {% let tab = "branches" %} + {% let archive_ref = repo.default_branch %} + {% include "partials/repo_tabs.html" %} + + {% if let Some(err) = error %} + <div class="kg-flash">{{ err }}</div> + {% endif %} + + <h2 class="kg-subtitle">Branches</h2> + {% if branches.is_empty() %} + <p class="kg-muted">No branches.</p> + {% else %} + <table class="kg-branch-table"> + <thead> + <tr> + <th>branch</th> + <th>updated</th> + <th>vs {{ repo.default_branch }}</th> + <th>pull</th> + <th></th> + </tr> + </thead> + <tbody> + {% for b in branches %} + <tr> + <td> + <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ b.name }}"> + <span class="kg-icon" style="--icon:url('/static/icons/git-branch.svg')"></span> + {{ b.name }} + </a> + {% if b.is_default %}<span class="kg-badge">default</span>{% endif %} + </td> + <td class="kg-meta">{{ b.updated }}</td> + <td class="kg-meta"> + {% if b.is_default %} + — + {% else %} + {% if b.ahead > 0 %}<span>{{ b.ahead }} ahead</span>{% endif %} + {% if b.ahead > 0 && b.behind > 0 %} · {% endif %} + {% if b.behind > 0 %}<span>{{ b.behind }} behind</span>{% endif %} + {% if b.ahead == 0 && b.behind == 0 %}even{% endif %} + {% endif %} + </td> + <td> + {% if let Some(n) = b.pull_number %} + <a href="/{{ owner.username }}/{{ repo.name }}/pulls/{{ n }}">#{{ n }}</a> + {% else %} + <span class="kg-meta">—</span> + {% endif %} + </td> + <td> + {% if access.can_write() && !b.is_default %} + <div class="kg-branch-actions"> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/branches/{{ b.name }}/rename" style="display:inline-flex;gap:0.35rem"> + <input type="text" name="new_name" placeholder="rename" required style="width:7rem;font:inherit;border:1px solid var(--kg-line);padding:0.25em 0.4em" /> + <button class="kg-btn kg-btn--ghost" type="submit" title="Rename"> + <span class="kg-icon" style="--icon:url('/static/icons/pencil.svg')"></span> + </button> + </form> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/branches/{{ b.name }}/delete" style="display:inline" onsubmit="return confirm('Delete branch {{ b.name }}?')"> + <button class="kg-btn kg-btn--danger-ghost" type="submit" title="Delete"> + <span class="kg-icon" style="--icon:url('/static/icons/trash.svg')"></span> + </button> + </form> + </div> + {% endif %} + </td> + </tr> + {% endfor %} + </tbody> + </table> + {% endif %} + + <h2 class="kg-subtitle" style="margin-top:2.5rem">Tags</h2> + + {% if access.can_write() %} + <form class="kg-form kg-tag-create" method="post" action="/{{ owner.username }}/{{ repo.name }}/tags"> + <label> + name + <input type="text" name="name" required placeholder="v1.0.0" /> + </label> + <label> + target + <input type="text" name="target" placeholder="{{ repo.default_branch }}" value="{{ repo.default_branch }}" /> + </label> + <label> + message + <input type="text" name="message" placeholder="optional annotated message" /> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit"> + <span class="kg-icon" style="--icon:url('/static/icons/plus.svg')"></span> + create tag + </button> + </div> + </form> + {% endif %} + + {% if tags.is_empty() %} + <p class="kg-muted">No tags.</p> + {% else %} + <table class="kg-branch-table"> + <thead> + <tr> + <th>tag</th> + <th>commit</th> + <th>message</th> + <th>updated</th> + <th></th> + </tr> + </thead> + <tbody> + {% for t in tags %} + <tr> + <td> + <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ t.name }}"> + <span class="kg-icon" style="--icon:url('/static/icons/package.svg')"></span> + {{ t.name }} + </a> + {% if t.has_release %} + <a class="kg-badge kg-badge--open" href="/{{ owner.username }}/{{ repo.name }}/releases/{{ t.name }}">release</a> + {% endif %} + </td> + <td> + <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ t.target }}"><code>{{ t.short_id }}</code></a> + </td> + <td class="kg-meta">{{ t.message }}</td> + <td class="kg-meta">{{ t.updated }}</td> + <td> + {% if access.can_write() %} + <div class="kg-branch-actions"> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/tags/{{ t.name }}/rename" style="display:inline-flex;gap:0.35rem"> + <input type="text" name="new_name" placeholder="rename" required style="width:7rem;font:inherit;border:1px solid var(--kg-line);padding:0.25em 0.4em" /> + <button class="kg-btn kg-btn--ghost" type="submit" title="Rename"> + <span class="kg-icon" style="--icon:url('/static/icons/pencil.svg')"></span> + </button> + </form> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/tags/{{ t.name }}/delete" style="display:inline" onsubmit="return confirm('Delete tag {{ t.name }}?')"> + <button class="kg-btn kg-btn--danger-ghost" type="submit" title="Delete"> + <span class="kg-icon" style="--icon:url('/static/icons/trash.svg')"></span> + </button> + </form> + </div> + {% endif %} + </td> + </tr> + {% endfor %} + </tbody> + </table> + {% endif %} +</section> +{% endblock %} @@ -0,0 +1,22 @@ +{% extends "layout.html" %} + +{% block title %}{{ commit.short_id }} · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <h1 class="kg-title"> + <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a> + · commit + </h1> + {% let tab = "commits" %} + {% let archive_ref = commit.id %} + {% include "partials/repo_tabs.html" %} + + <p><code>{{ commit.id }}</code></p> + <p class="kg-title">{{ message_html|safe }}</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> + {{ diff_html|safe }} +</section> +{% endblock %} @@ -0,0 +1,42 @@ +{% extends "layout.html" %} + +{% block title %}commits · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <h1 class="kg-title"> + <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a> + · commits + </h1> + {% let tab = "commits" %} + {% let archive_ref = branch %} + {% include "partials/repo_tabs.html" %} + + <p class="kg-meta"> + <a href="/{{ owner.username }}/{{ repo.name }}/branches"> + <span class="kg-icon" style="--icon:url('/static/icons/git-branch.svg')"></span> + {{ branch }} + </a> + </p> + + {% if commits.is_empty() %} + <p class="kg-muted">No commits.</p> + {% else %} + <ul class="kg-list"> + {% for c in commits %} + <li> + <span> + <a href="/{{ owner.username }}/{{ repo.name }}/commits" title="View commits"> + <span class="kg-icon" style="--icon:url('/static/icons/git-commit.svg')"></span> + <code>{{ c.short_id }}</code> + </a> + <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}">{{ c.message }}</a> + <span class="kg-muted"> — {{ c.author }}</span> + </span> + <span class="kg-meta">{{ c.time }}</span> + </li> + {% endfor %} + </ul> + {% endif %} +</section> +{% endblock %} @@ -0,0 +1,28 @@ +{% extends "layout.html" %} + +{% block title %}diff {{ commit.short_id }} · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <h1 class="kg-title"> + <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a> + · diff + </h1> + {% let tab = "commits" %} + {% let archive_ref = commit.id %} + {% include "partials/repo_tabs.html" %} + + <p class="kg-meta"> + <a href="/{{ owner.username }}/{{ repo.name }}/commits" title="View commits"> + <span class="kg-icon" style="--icon:url('/static/icons/git-commit.svg')"></span> + {{ commit.short_id }} + </a> + · {{ commit.author }} + </p> + <p class="kg-title"> + <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ commit.id }}">{{ commit.message }}</a> + </p> + + {{ diff_html|safe }} +</section> +{% endblock %} @@ -0,0 +1,124 @@ +{% extends "layout.html" %} + +{% block title %}{{ owner.username }}/{{ repo.name }} — kitgit{% endblock %} + +{% block head %} +{% if readme_html.is_some() %} +{% include "partials/katex.html" %} +{% endif %} +{% endblock %} + +{% block content %} +<section class="kg-section"> + <div class="kg-profile-head"> + <img class="kg-avatar" src="{{ owner_avatar }}" alt="" /> + <div class="kg-profile-head__main"> + <h1 class="kg-title"> + <a href="/{{ owner.username }}">{{ owner.username }}</a> + / + <strong>{{ repo.name }}</strong> + {% if repo.visibility == "private" %}<span class="kg-badge">private</span>{% endif %} + {% if repo.archived %}<span class="kg-badge kg-badge--closed">archived</span>{% endif %} + {% if forked_from.is_some() %}<span class="kg-badge">fork</span>{% endif %} + </h1> + {% if let Some((fo, fn_)) = forked_from %} + <p class="kg-meta">forked from <a href="/{{ fo }}/{{ fn_ }}">{{ fo }}/{{ fn_ }}</a></p> + {% endif %} + {% if !repo.description.is_empty() %} + <p class="kg-muted">{{ repo.description }}</p> + {% endif %} + </div> + <div class="kg-social"> + {% if viewer.is_some() %} + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/watch"> + <button class="kg-btn kg-btn--ghost" type="submit"> + <span class="kg-icon" style="--icon:url('/static/icons/eye.svg')"></span> + {% if watching %}unwatch{% else %}watch{% endif %} + <span class="kg-meta">{{ repo.watches_count }}</span> + </button> + </form> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/fork"> + <button class="kg-btn kg-btn--ghost" type="submit"> + <span class="kg-icon" style="--icon:url('/static/icons/git-fork.svg')"></span> + fork + <span class="kg-meta">{{ repo.forks_count }}</span> + </button> + </form> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/star"> + <button class="kg-btn kg-btn--ghost" type="submit"> + <span class="kg-icon" style="--icon:url('/static/icons/star.svg')"></span> + {% if starred %}unstar{% else %}star{% endif %} + <span class="kg-meta">{{ repo.stars_count }}</span> + </button> + </form> + {% else %} + <span class="kg-meta"> + <span class="kg-icon" style="--icon:url('/static/icons/eye.svg')"></span> {{ repo.watches_count }} + · + <span class="kg-icon" style="--icon:url('/static/icons/git-fork.svg')"></span> {{ repo.forks_count }} + · + <span class="kg-icon" style="--icon:url('/static/icons/star.svg')"></span> {{ repo.stars_count }} + </span> + {% endif %} + </div> + </div> + + {% let tab = "code" %} + {% let archive_ref = current_branch %} + {% include "partials/repo_tabs.html" %} + + {% if empty %} + <p class="kg-muted">Empty repository. Push to get started.</p> + <div class="kg-clone-empty"> + <code class="kg-clone">git clone {{ clone_http }}</code> + <code class="kg-clone">git clone {{ clone_ssh }}</code> + </div> + {% else %} + {% let branch = current_branch %} + {% let upload_path = "" %} + {% include "partials/branch_bar.html" %} + + {% if !languages.is_empty() %} + <div class="kg-langbar" role="img" aria-label="Languages"> + {% for lang in languages %} + <span style="width: {{ lang.percent }}%; background: {{ lang.color }}" title="{{ lang.name }} {{ lang.percent }}%"></span> + {% endfor %} + </div> + <ul class="kg-langlist"> + {% for lang in languages %} + <li> + <span class="kg-langdot" style="background: {{ lang.color }}" aria-hidden="true"></span> + {{ lang.name }} <span class="kg-meta">{{ lang.percent }}%</span> + </li> + {% endfor %} + </ul> + {% endif %} + + <ul class="kg-tree"> + {% for e in entries %} + <li> + {% if e.is_dir %} + <span class="kg-icon" style="--icon:url('/static/icons/folder.svg')"></span> + <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ current_branch }}/{{ e.path }}">{{ e.name }}</a> + <span class="kg-meta">{{ e.mode }}</span> + <a class="kg-tree__dl kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/archive.zip?ref={{ current_branch }}&path={{ e.path }}" title="Download {{ e.name }} as ZIP"> + <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span> + <span class="kg-sr-only">Download</span> + </a> + {% else %} + <span class="kg-icon" style="--icon:url('/static/icons/file.svg')"></span> + <a href="/{{ owner.username }}/{{ repo.name }}/blob/{{ current_branch }}/{{ e.path }}">{{ e.name }}</a> + <span class="kg-meta">{{ e.mode }}</span> + {% endif %} + </li> + {% endfor %} + </ul> + + {% if let Some(html) = readme_html %} + <article class="kg-readme"> + {{ html|safe }} + </article> + {% endif %} + {% endif %} +</section> +{% endblock %} @@ -0,0 +1,224 @@ +{% extends "layout.html" %} + +{% block title %}settings · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <h1 class="kg-title"> + <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a> + · settings + </h1> + {% let tab = "settings" %} + {% let archive_ref = repo.default_branch %} + {% include "partials/repo_tabs.html" %} + + {% if let Some(err) = error %} + <div class="kg-flash">{{ err }}</div> + {% endif %} + + <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings" style="max-width:none"> + <div class="kg-settings-section" style="margin-top:1rem;border-top:0;padding-top:0"> + <span class="kg-kicker">general</span> + <label> + description + <textarea name="description" rows="3">{{ repo.description }}</textarea> + </label> + <label> + visibility + <select name="visibility"> + <option value="public"{% if repo.visibility == "public" %} selected{% endif %}>public</option> + <option value="private"{% if repo.visibility == "private" %} selected{% endif %}>private</option> + </select> + </label> + <label> + default branch + {% if branches.is_empty() %} + <input type="text" name="default_branch" value="{{ repo.default_branch }}" required /> + {% else %} + <select name="default_branch"> + {% for b in branches %} + <option value="{{ b }}"{% if b.as_str() == repo.default_branch.as_str() %} selected{% endif %}>{{ b }}</option> + {% endfor %} + </select> + {% endif %} + </label> + </div> + + <div class="kg-settings-section"> + <span class="kg-kicker">features</span> + <label class="row"> + <input type="checkbox" name="issues_enabled"{% if repo.issues_enabled %} checked{% endif %} /> + issues enabled + </label> + <label class="row"> + <input type="checkbox" name="pulls_enabled"{% if repo.pulls_enabled %} checked{% endif %} /> + pulls enabled + </label> + <label class="row"> + <input type="checkbox" name="releases_enabled"{% if repo.releases_enabled %} checked{% endif %} /> + releases enabled + </label> + </div> + + <div class="kg-settings-section"> + <span class="kg-kicker">pull requests</span> + <label class="row"> + <input type="checkbox" name="allow_merge"{% if repo.allow_merge %} checked{% endif %} /> + allow merge + </label> + <label class="row"> + <input type="checkbox" name="allow_squash"{% if repo.allow_squash %} checked{% endif %} /> + allow squash + </label> + <label class="row"> + <input type="checkbox" name="allow_rebase"{% if repo.allow_rebase %} checked{% endif %} /> + allow rebase + </label> + <label> + default merge style + <select name="default_merge_style"> + <option value="merge"{% if repo.default_merge_style == "merge" %} selected{% endif %}>merge</option> + <option value="squash"{% if repo.default_merge_style == "squash" %} selected{% endif %}>squash</option> + <option value="rebase"{% if repo.default_merge_style == "rebase" %} selected{% endif %}>rebase</option> + </select> + </label> + </div> + + <div class="kg-settings-section"> + <span class="kg-kicker">branch protection</span> + <label class="row"> + <input type="checkbox" name="protect_default_branch"{% if repo.protect_default_branch %} checked{% endif %} /> + protect default branch + </label> + <label class="row"> + <input type="checkbox" name="protect_block_force_push"{% if repo.protect_block_force_push %} checked{% endif %} /> + block force push + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">save settings</button> + </div> + </div> + </form> + + <div class="kg-settings-section"> + <span class="kg-kicker">branch rules</span> + <p class="kg-muted">Pattern-based rules (e.g. <code>main</code>, <code>release/*</code>).</p> + {% if branch_rules.is_empty() %} + <p class="kg-muted">No branch rules yet.</p> + {% else %} + <ul class="kg-list"> + {% for rule in branch_rules %} + <li> + <span> + <code>{{ rule.pattern }}</code> + {% if rule.require_pr %}<span class="kg-badge">require PR</span>{% endif %} + {% if rule.block_force_push %}<span class="kg-badge">no force</span>{% endif %} + {% if rule.allow_deletions %}<span class="kg-badge">deletions ok</span>{% endif %} + </span> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/branch-rules/{{ rule.id }}/delete"> + <button class="kg-btn kg-btn--danger-ghost" type="submit">delete</button> + </form> + </li> + {% endfor %} + </ul> + {% endif %} + <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/branch-rules" style="margin-top:1rem"> + <label> + pattern + <input type="text" name="pattern" required placeholder="main" /> + </label> + <label class="row"> + <input type="checkbox" name="require_pr" /> + require pull request + </label> + <label class="row"> + <input type="checkbox" name="block_force_push" checked /> + block force push + </label> + <label class="row"> + <input type="checkbox" name="allow_deletions" /> + allow deletions + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">add rule</button> + </div> + </form> + </div> + + <div class="kg-settings-section"> + <span class="kg-kicker">collaborators</span> + {% if collaborators.is_empty() %} + <p class="kg-muted">No collaborators.</p> + {% else %} + <ul class="kg-list"> + {% for c in collaborators %} + <li> + <span> + <img class="kg-avatar kg-avatar--sm" src="{{ c.avatar_url }}" alt="" /> + <a href="/{{ c.user.username }}">{{ c.user.username }}</a> + <span class="kg-badge">{{ c.role }}</span> + </span> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/collaborators/{{ c.user.id }}/remove"> + <button class="kg-btn kg-btn--ghost" type="submit">remove</button> + </form> + </li> + {% endfor %} + </ul> + {% endif %} + + <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/collaborators" style="margin-top:1rem"> + <label> + username + <input type="text" name="username" required /> + </label> + <label> + role + <select name="role"> + <option value="read">read</option> + <option value="write" selected>write</option> + <option value="admin">admin</option> + </select> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">add collaborator</button> + </div> + </form> + </div> + + {% if access.can_owner() || is_site_admin %} + <div class="kg-danger-zone"> + <span class="kg-kicker">danger zone</span> + + {% if access.can_owner() %} + <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/danger/archive"> + <input type="hidden" name="archived" value="{% if repo.archived %}unarchive{% else %}archive{% endif %}" /> + <div class="kg-danger-row"> + <p class="kg-muted" style="margin:0;flex:1">{% if repo.archived %}Unarchive this repository.{% else %}Archive — read-only for everyone.{% endif %}</p> + <button class="kg-btn kg-btn--danger-ghost" type="submit">{% if repo.archived %}unarchive{% else %}archive{% endif %}</button> + </div> + </form> + + <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/danger/transfer"> + <div class="kg-danger-row"> + <label> + transfer to username + <input type="text" name="new_owner" required /> + </label> + <button class="kg-btn kg-btn--danger-ghost" type="submit">transfer</button> + </div> + </form> + {% endif %} + + <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/danger/delete"> + <div class="kg-danger-row"> + <label> + type <code>{{ repo.name }}</code> to delete + <input type="text" name="confirm" required autocomplete="off" /> + </label> + <button class="kg-btn kg-btn--danger" type="submit">delete repository</button> + </div> + </form> + </div> + {% endif %} +</section> +{% endblock %} @@ -0,0 +1,50 @@ +{% extends "layout.html" %} + +{% block title %}{{ path }} · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <h1 class="kg-title"> + <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a> + </h1> + {% let tab = "code" %} + {% let archive_ref = branch %} + {% include "partials/repo_tabs.html" %} + + <p class="kg-meta kg-breadcrumbs"> + <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ branch }}">{{ branch }}</a> + {% for (name, full) in breadcrumbs %} + / <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ branch }}/{{ full }}">{{ name }}</a> + {% endfor %} + </p> + + {% let upload_path = path %} + {% include "partials/branch_bar.html" %} + + <ul class="kg-tree"> + {% for e in entries %} + <li> + {% if e.is_dir %} + <span class="kg-icon" style="--icon:url('/static/icons/folder.svg')"></span> + <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ branch }}/{{ e.path }}">{{ e.name }}</a> + <span class="kg-meta">{{ e.mode }}</span> + <a class="kg-tree__dl kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/archive.zip?ref={{ branch }}&path={{ e.path }}" title="Download {{ e.name }} as ZIP"> + <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span> + <span class="kg-sr-only">Download</span> + </a> + {% else %} + <span class="kg-icon" style="--icon:url('/static/icons/file.svg')"></span> + <a href="/{{ owner.username }}/{{ repo.name }}/blob/{{ branch }}/{{ e.path }}">{{ e.name }}</a> + <span class="kg-meta">{{ e.mode }}</span> + {% endif %} + </li> + {% endfor %} + </ul> + + {% if let Some(html) = readme_html %} + <article class="kg-readme"> + {{ html|safe }} + </article> + {% endif %} +</section> +{% endblock %} @@ -0,0 +1,40 @@ +{% extends "layout.html" %} + +{% block title %}sign up — kitgit{% endblock %} + +{% block content %} +<section class="kg-section kg-auth"> + <p class="kg-landing__brand"> + <span class="kg-mark__glyph" aria-hidden="true">狐</span> + <span class="kg-mark__name">kitgit</span> + </p> + <h1 class="kg-title">sign up</h1> + {% if let Some(err) = error %} + <div class="kg-flash">{{ err }}</div> + {% endif %} + <form class="kg-form" method="post" action="/auth/signup" autocomplete="on"> + <label> + username + <input type="text" name="username" required autofocus autocomplete="username" pattern="[A-Za-z0-9._\-]+" minlength="2" /> + </label> + <label> + display name + <input type="text" name="display_name" autocomplete="name" /> + </label> + <label> + email + <input type="email" name="email" required autocomplete="email" /> + </label> + <label> + password + <input type="password" name="password" required minlength="8" autocomplete="new-password" /> + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">create account</button> + </div> + </form> + <p class="kg-meta" style="margin-top:1.25rem"> + Already have an account? <a href="/auth/login">Log in</a> + </p> +</section> +{% endblock %}