diff --git a/README.md b/README.md index ca6aff4..3e9de7d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Git Logs +# OctoFlow -Rewrite of the original Github webhook logger (Git Logs). +Rewrite of the original Github webhook logger (formally Git Logs). --- @@ -53,4 +53,4 @@ You should ideally make this 2 systemd services in production. ## License -This project is licensed under the MIT License +This project is licensed under the GNU Afferno License. diff --git a/bot/Cargo.lock b/bot/Cargo.lock index 5383d11..b456402 100644 --- a/bot/Cargo.lock +++ b/bot/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -107,6 +107,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayvec" version = "0.7.4" @@ -124,7 +133,7 @@ checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -157,6 +166,12 @@ dependencies = [ "rustc-demangle", ] +[[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" @@ -196,11 +211,11 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.5.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -221,7 +236,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -235,6 +250,7 @@ dependencies = [ "futures-util", "indexmap", "log", + "octocrab", "once_cell", "poise", "rand", @@ -247,6 +263,7 @@ dependencies = [ "serenity", "sqlx", "tokio", + "urlencoding", ] [[package]] @@ -284,17 +301,17 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.6.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "camino" -version = "1.1.6" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59e92b5a388f549b863a7bea62612c09f24c8393560709a54558a9abdfb3b9c" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -306,6 +323,16 @@ dependencies = [ "serde", ] +[[package]] +name = "cargo-platform" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "cargo_metadata" version = "0.14.2" @@ -313,10 +340,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" dependencies = [ "camino", - "cargo-platform", + "cargo-platform 0.1.8", + "semver", + "serde", + "serde_json", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform 0.3.3", "semver", "serde", "serde_json", + "thiserror 2.0.18", ] [[package]] @@ -377,11 +418,21 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" @@ -440,6 +491,18 @@ version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" +[[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", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -450,6 +513,33 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "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.117", +] + [[package]] name = "darling" version = "0.20.8" @@ -471,7 +561,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -482,7 +572,7 @@ checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f" dependencies = [ "darling_core", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -518,12 +608,12 @@ dependencies = [ [[package]] name = "deranged" -version = "0.3.11" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -555,6 +645,44 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[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", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.11.0" @@ -564,6 +692,27 @@ 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", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "env_filter" version = "0.1.0" @@ -650,6 +799,22 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "finl_unicode" version = "1.2.0" @@ -759,7 +924,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -815,17 +980,20 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] name = "getrandom" -version = "0.2.14" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -840,6 +1008,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -946,9 +1125,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.8.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "humantime" @@ -958,9 +1137,9 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "hyper" -version = "1.3.1" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe575dd17d0862a9a33781c8c4696a55c320909004a67a00fb286ba8b1bc496d" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" dependencies = [ "bytes", "futures-channel", @@ -988,26 +1167,57 @@ dependencies = [ "rustls 0.22.3", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.25.0", + "tower-service", +] + +[[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", + "log", + "rustls 0.23.31", + "rustls-native-certs 0.8.3", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", "tower-service", ] [[package]] name = "hyper-util" -version = "0.1.3" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ "bytes", "futures-channel", + "futures-core", "futures-util", "http", "http-body", "hyper", + "libc", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio", - "tower", "tower-service", "tracing", ] @@ -1092,6 +1302,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +dependencies = [ + "base64 0.22.0", + "ed25519-dalek", + "getrandom", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand", + "rsa", + "serde", + "serde_json", + "sha2", + "signature", + "simple_asn1", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -1103,9 +1336,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.153" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" @@ -1268,9 +1501,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -1321,6 +1554,47 @@ dependencies = [ "memchr", ] +[[package]] +name = "octocrab" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce7ace5d83b077dd50ff01214a81feea17e258b8f677590c2286add76dc8238e" +dependencies = [ + "arc-swap", + "async-trait", + "base64 0.22.0", + "bytes", + "cargo_metadata 0.23.1", + "cfg-if", + "chrono", + "futures", + "futures-util", + "getrandom", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls 0.27.9", + "hyper-timeout", + "hyper-util", + "jsonwebtoken", + "once_cell", + "percent-encoding", + "pin-project", + "secrecy 0.10.3", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "snafu", + "tokio", + "tower", + "tower-http", + "tracing", + "url", + "web-time", +] + [[package]] name = "once_cell" version = "1.19.0" @@ -1333,6 +1607,36 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[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 = "parking" version = "2.2.0" @@ -1368,6 +1672,16 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.0", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1400,7 +1714,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -1466,7 +1780,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -1481,11 +1795,20 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[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.81" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -1496,7 +1819,7 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.11.1", "memchr", "unicase", ] @@ -1592,7 +1915,7 @@ dependencies = [ "http-body", "http-body-util", "hyper", - "hyper-rustls", + "hyper-rustls 0.26.0", "hyper-util", "ipnet", "js-sys", @@ -1603,15 +1926,15 @@ dependencies = [ "percent-encoding", "pin-project-lite", "rustls 0.22.3", - "rustls-native-certs", + "rustls-native-certs 0.7.1", "rustls-pemfile 2.1.2", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 0.1.2", "tokio", - "tokio-rustls", + "tokio-rustls 0.25.0", "tokio-util", "tower-service", "url", @@ -1623,6 +1946,16 @@ dependencies = [ "winreg", ] +[[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.8" @@ -1664,13 +1997,22 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65e04861e65f21776e67888bfbea442b3642beaa0138fdb1dd7a84a52dffdb89" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -1702,17 +2044,44 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls" +version = "0.23.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle", + "zeroize", +] + [[package]] name = "rustls-native-certs" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a88d6d420651b496bdd98684116959239430022a115c1240e6c3993be0b15fba" dependencies = [ - "openssl-probe", + "openssl-probe 0.1.5", "rustls-pemfile 2.1.2", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 2.10.0", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", ] [[package]] @@ -1736,9 +2105,12 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.4.1" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecd36cc4259e3e4514335c4a138c6b43171a8d61d8f5c9348f9fc7529416f247" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] [[package]] name = "rustls-webpki" @@ -1761,6 +2133,17 @@ dependencies = [ "untrusted", ] +[[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.15" @@ -1807,6 +2190,20 @@ dependencies = [ "untrusted", ] +[[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 = "secrecy" version = "0.8.0" @@ -1817,6 +2214,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "2.10.0" @@ -1824,7 +2230,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "770452e37cad93e0a50d5abc3990d2bc351c36d0328f86cefec2f2fb206eaef6" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -1832,9 +2251,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.10.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f3cc463c0ef97e11c3461a9d3787412d30e8e7eb907c79180c4a57bf7c04ef" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -1842,18 +2261,29 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.22" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", + "serde_core", ] [[package]] name = "serde" -version = "1.0.198" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9846a40c979031340571da2545a4e5b7c4163bdae79b301d5f86d03979451fcc" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] @@ -1869,24 +2299,37 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.198" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e88edab869b01783ba905e7d0153f9fc1a6505a96e4ad3018011eedb838566d9" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] name = "serde_json" -version = "1.0.116" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", - "ryu", + "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]] @@ -1897,7 +2340,7 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -1933,7 +2376,7 @@ dependencies = [ "arrayvec", "async-trait", "base64 0.21.7", - "bitflags 2.5.0", + "bitflags 2.11.1", "bool_to_bitflags", "bytes", "chrono", @@ -1947,7 +2390,7 @@ dependencies = [ "parking_lot", "percent-encoding", "reqwest", - "secrecy", + "secrecy 0.8.0", "serde", "serde_cow", "serde_json", @@ -2004,6 +2447,18 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "skeptic" version = "0.13.7" @@ -2011,7 +2466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8" dependencies = [ "bytecount", - "cargo_metadata", + "cargo_metadata 0.14.2", "error-chain", "glob", "pulldown-cmark", @@ -2046,6 +2501,27 @@ dependencies = [ "serde", ] +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "socket2" version = "0.5.6" @@ -2056,6 +2532,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spin" version = "0.5.2" @@ -2141,7 +2627,7 @@ dependencies = [ "sha2", "smallvec", "sqlformat", - "thiserror", + "thiserror 1.0.58", "tokio", "tokio-stream", "tracing", @@ -2160,7 +2646,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -2183,7 +2669,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.59", + "syn 2.0.117", "tempfile", "tokio", "url", @@ -2198,7 +2684,7 @@ dependencies = [ "atoi", "base64 0.22.0", "bigdecimal", - "bitflags 2.5.0", + "bitflags 2.11.1", "byteorder", "bytes", "chrono", @@ -2228,7 +2714,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.58", "tracing", "uuid", "whoami", @@ -2243,7 +2729,7 @@ dependencies = [ "atoi", "base64 0.22.0", "bigdecimal", - "bitflags 2.5.0", + "bitflags 2.11.1", "byteorder", "chrono", "crc", @@ -2270,7 +2756,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.58", "tracing", "uuid", "whoami", @@ -2337,7 +2823,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -2359,9 +2845,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.59" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a6531ffc7b071655e4ce2e04bd464c4830bb585a61cabb96cf808f05172615a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -2374,6 +2860,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "tagptr" version = "0.2.0" @@ -2398,7 +2890,16 @@ version = "1.0.58" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.58", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -2409,35 +2910,46 @@ checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] name = "time" -version = "0.3.36" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.18" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -2483,7 +2995,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.5.6", "tokio-macros", "windows-sys 0.48.0", ] @@ -2496,7 +3008,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -2510,6 +3022,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.31", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.15" @@ -2532,7 +3054,7 @@ dependencies = [ "rustls 0.22.3", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.25.0", "tungstenite", "webpki-roots 0.26.1", ] @@ -2553,31 +3075,51 @@ dependencies = [ [[package]] name = "tower" -version = "0.4.13" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "pin-project", "pin-project-lite", + "sync_wrapper 1.0.2", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + [[package]] name = "tower-layer" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-service" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" @@ -2599,7 +3141,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -2639,7 +3181,7 @@ dependencies = [ "rustls 0.22.3", "rustls-pki-types", "sha1", - "thiserror", + "thiserror 1.0.58", "url", "utf-8", ] @@ -2663,7 +3205,7 @@ dependencies = [ "mini-moka", "nonmax", "parking_lot", - "secrecy", + "secrecy 0.8.0", "serde_json", "time", "typesize-derive", @@ -2678,7 +3220,7 @@ checksum = "905e88c2a4cc27686bd57e495121d451f027e441388a67f773be729ad4be1ea8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -2741,6 +3283,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -2823,7 +3371,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2857,7 +3405,7 @@ checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2891,6 +3439,17 @@ dependencies = [ "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", + "serde", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.25.4" @@ -2956,6 +3515,12 @@ dependencies = [ "windows-targets 0.52.5", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.48.0" @@ -2974,6 +3539,15 @@ dependencies = [ "windows-targets 0.52.5", ] +[[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" @@ -3122,7 +3696,7 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.117", ] [[package]] @@ -3130,3 +3704,9 @@ name = "zeroize" version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/bot/Cargo.toml b/bot/Cargo.toml index e48b52f..992bf84 100644 --- a/bot/Cargo.toml +++ b/bot/Cargo.toml @@ -22,6 +22,8 @@ data-encoding = "2.3" indexmap = { version = "2", features = ["serde"] } serde_yaml = "0.9" once_cell = "1.17" +octocrab = "0.50.0" +urlencoding = "2.1.3" [dependencies.tokio] version = "1" diff --git a/bot/src/commands/delhook.rs b/bot/src/commands/delhook.rs new file mode 100644 index 0000000..44aae65 --- /dev/null +++ b/bot/src/commands/delhook.rs @@ -0,0 +1,29 @@ +use crate::{Context, Error}; + +/// Deletes a webhook +#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] +pub async fn delhook( + ctx: Context<'_>, + #[description = "The webhook ID"] + #[autocomplete = "super::autocomplete_webhooks"] + id: String, +) -> Result<(), Error> { + let data = ctx.data(); + + let guild = sqlx::query!( + "SELECT COUNT(1) FROM guilds WHERE id = $1", + &ctx.guild_id().unwrap().to_string() + ).fetch_one(&data.pool).await?; + + if guild.count.unwrap_or_default() == 0 { + return Err("You don't have any webhooks in this guild! Use ``/newhook`` (or ``git!newhook``) to create one".into()); + } + + sqlx::query!( + "DELETE FROM webhooks WHERE id = $1 AND guild_id = $2", + &id, &ctx.guild_id().unwrap().to_string() + ).execute(&data.pool).await?; + + ctx.say("Webhook deleted if it exists!").await?; + Ok(()) +} diff --git a/bot/src/commands/delrepo.rs b/bot/src/commands/delrepo.rs new file mode 100644 index 0000000..41f842c --- /dev/null +++ b/bot/src/commands/delrepo.rs @@ -0,0 +1,20 @@ +use crate::{Context, Error}; + +/// Deletes a repository +#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] +pub async fn delrepo( + ctx: Context<'_>, + #[description = "The repo ID"] + #[autocomplete = "super::autocomplete_repos"] + id: String, +) -> Result<(), Error> { + let data = ctx.data(); + + sqlx::query!( + "DELETE FROM repos WHERE id = $1 AND guild_id = $2", + &id, &ctx.guild_id().unwrap().to_string() + ).execute(&data.pool).await?; + + ctx.say("Repo deleted!").await?; + Ok(()) +} diff --git a/bot/src/commands/edithook.rs b/bot/src/commands/edithook.rs new file mode 100644 index 0000000..e530ebc --- /dev/null +++ b/bot/src/commands/edithook.rs @@ -0,0 +1,127 @@ +use crate::{Context, Error}; + +/// Edits a webhook +#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] +pub async fn edithook( + ctx: Context<'_>, + #[description = "The webhook ID"] + #[autocomplete = "super::autocomplete_webhooks"] + id: String, + #[description = "The comment for the webhook"] comment: Option, + #[description = "Is the webhook broken?"] broken: Option, + #[description = "The new secret for the webhook"] webhook_secret: Option, + #[description = "Provider: github or gitlab"] provider: Option, +) -> Result<(), Error> { + let data = ctx.data(); + + // Validate provider if provided + if let Some(ref p) = provider { + let p_lower = p.to_lowercase(); + if p_lower != "github" && p_lower != "gitlab" { + ctx.say("Invalid provider! Use `github` or `gitlab`").await?; + return Ok(()); + } + } + + // Validate secret isn't too short if provided + if let Some(ref s) = webhook_secret { + if s.len() < 16 { + ctx.say("Webhook secret must be at least 16 characters long for security!").await?; + return Ok(()); + } + } + + // Check if the guild exists on our DB + let guild = sqlx::query!( + "SELECT COUNT(1) FROM guilds WHERE id = $1", + &ctx.guild_id().unwrap().to_string() + ) + .fetch_one(&data.pool) + .await?; + + if guild.count.unwrap_or_default() == 0 { + // If it doesn't, create it + sqlx::query!( + "INSERT INTO guilds (id) VALUES ($1)", + &ctx.guild_id().unwrap().to_string() + ) + .execute(&data.pool) + .await?; + } + + // Check webhook for existence + let webhook_count = sqlx::query!( + "SELECT COUNT(1) FROM webhooks WHERE guild_id = $1 AND id = $2", + &ctx.guild_id().unwrap().to_string(), + &id + ) + .fetch_one(&data.pool) + .await?; + + if webhook_count.count.unwrap_or_default() == 0 { + ctx.say("This webhook does not exist!").await?; + return Ok(()); + } + + let mut tx = data.pool.begin().await?; + + if let Some(comment) = comment { + sqlx::query!( + "UPDATE webhooks SET comment = $1 WHERE id = $2 AND guild_id = $3", + comment, + &id, + &ctx.guild_id().unwrap().to_string() + ) + .execute(&mut *tx) + .await?; + } + + if let Some(broken) = broken { + sqlx::query!( + "UPDATE webhooks SET broken = $1 WHERE id = $2 AND guild_id = $3", + broken, + &id, + &ctx.guild_id().unwrap().to_string() + ) + .execute(&mut *tx) + .await?; + } + + if let Some(webhook_secret) = webhook_secret { + sqlx::query!( + "UPDATE webhooks SET secret = $1 WHERE id = $2 AND guild_id = $3", + webhook_secret, + &id, + &ctx.guild_id().unwrap().to_string() + ) + .execute(&mut *tx) + .await?; + } + + if let Some(provider) = provider { + sqlx::query!( + "UPDATE webhooks SET provider = $1 WHERE id = $2 AND guild_id = $3", + provider.to_lowercase(), + &id, + &ctx.guild_id().unwrap().to_string() + ) + .execute(&mut *tx) + .await?; + } + + // Update last_updated_at and last_updated_by regardless + sqlx::query!( + "UPDATE webhooks SET last_updated_at = NOW(), last_updated_by = $1 WHERE id = $2 AND guild_id = $3", + ctx.author().id.to_string(), + &id, + &ctx.guild_id().unwrap().to_string() + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + ctx.say("Webhook updated successfully!").await?; + + Ok(()) +} diff --git a/bot/src/commands/editrepo.rs b/bot/src/commands/editrepo.rs new file mode 100644 index 0000000..47abea7 --- /dev/null +++ b/bot/src/commands/editrepo.rs @@ -0,0 +1,51 @@ +use crate::{Context, Error}; + +/// Edits a repository's name +#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] +pub async fn editrepo( + ctx: Context<'_>, + #[description = "The repo ID"] + #[autocomplete = "super::autocomplete_repos"] + id: String, + #[description = "The new repo owner or organization"] owner: String, + #[description = "The new repo name"] name: String, +) -> Result<(), Error> { + let data = ctx.data(); + let repo_name = (owner+"/"+&name).to_lowercase(); + + let repo = sqlx::query!( + "SELECT COUNT(1) FROM repos WHERE id = $1 AND guild_id = $2", + &id, &ctx.guild_id().unwrap().to_string() + ).fetch_one(&data.pool).await?; + + if repo.count.unwrap_or_default() == 0 { + return Err("That repo doesn't exist!".into()); + } + + let provider_query = sqlx::query!( + "SELECT webhooks.provider FROM repos JOIN webhooks ON repos.webhook_id = webhooks.id WHERE repos.id = $1 AND repos.guild_id = $2", + &id, &ctx.guild_id().unwrap().to_string() + ).fetch_one(&data.pool).await?; + + let provider = provider_query.provider.unwrap_or_else(|| "github".to_string()); + let client = reqwest::Client::new(); + let exists = if provider == "gitlab" { + let url = format!("https://gitlab.com/api/v4/projects/{}", urlencoding::encode(&repo_name)); + client.get(&url).send().await.map(|r| r.status().is_success()).unwrap_or(false) + } else { + let url = format!("https://api.github.com/repos/{}", repo_name); + client.get(&url).header("User-Agent", "OctoFlow-Discord-Bot").send().await.map(|r| r.status().is_success()).unwrap_or(false) + }; + + if !exists { + return Err("That repository could not be found! Make sure it exists and is public.".into()); + } + + sqlx::query!( + "UPDATE repos SET repo_name = $1, last_updated_by = $2 WHERE id = $3 AND guild_id = $4", + &repo_name, ctx.author().id.to_string(), &id, &ctx.guild_id().unwrap().to_string() + ).execute(&data.pool).await?; + + ctx.say("Repo name updated successfully!").await?; + Ok(()) +} diff --git a/bot/src/commands/list.rs b/bot/src/commands/list.rs new file mode 100644 index 0000000..3106752 --- /dev/null +++ b/bot/src/commands/list.rs @@ -0,0 +1,78 @@ +use log::error; +use poise::{serenity_prelude::CreateEmbed, CreateReply}; + +use crate::{Context, Error, config}; + +/// Lists all webhooks in a guild with their respective repos and channel IDs +#[poise::command(slash_command, prefix_command, guild_only, required_permissions = "MANAGE_GUILD")] +pub async fn list( + ctx: Context<'_>, +) -> Result<(), Error> { + let data = ctx.data(); + + // Check if the guild exists on our DB + let guild = sqlx::query!( + "SELECT COUNT(1) FROM guilds WHERE id = $1", + &ctx.guild_id().unwrap().to_string() + ) + .fetch_one(&data.pool) + .await?; + + if guild.count.unwrap_or_default() == 0 { + // If it doesn't, create it + sqlx::query!( + "INSERT INTO guilds (id) VALUES ($1)", + &ctx.guild_id().unwrap().to_string() + ) + .execute(&data.pool) + .await?; + + ctx.say("This guild doesn't have any webhooks yet. Get started with ``/newhook`` (or ``git!newhook``)").await?; + } else { + // Get all webhooks + let webhooks = sqlx::query!( + "SELECT id, broken, comment, created_at, COALESCE(provider, 'github') as provider FROM webhooks WHERE guild_id = $1", + &ctx.guild_id().unwrap().to_string() + ) + .fetch_all(&data.pool) + .await; + + match webhooks { + Ok(webhooks) => { + if webhooks.is_empty() { + ctx.say("This guild doesn't have any webhooks yet. Get started with ``/newhook`` (or ``git!newhook``)").await?; + return Ok(()); + } + + let mut cr = CreateReply::default() + .content("Here are all the webhooks in this guild:"); + + let api_url = config::CONFIG.api_url[0].clone(); + + for webhook in webhooks { + let webhook_id = webhook.id; + let provider = webhook.provider.unwrap_or_else(|| "github".to_string()); + let provider_label = if provider == "gitlab" { "GitLab" } else { "GitHub" }; + + cr = cr.embed( + CreateEmbed::new() + .title(format!("Webhook \"{}\"", webhook.comment)) + .field("Webhook ID", webhook_id.clone(), false) + .field("Hook URL", format!("`{}/kittycat?id={}`", api_url, webhook_id), false) + .field("Provider", provider_label.to_string(), true) + .field("Marked as Broken", format!("{}", webhook.broken), true) + .field("Created at", webhook.created_at.to_string(), true) + ); + }; + + ctx.send(cr).await?; + }, + Err(e) => { + error!("Error fetching webhooks: {:?}", e); + ctx.say("This guild doesn't have any webhooks yet. Get started with ``/newhook`` (or ``git!newhook``)").await?; + } + } + } + + Ok(()) +} diff --git a/bot/src/commands/mod.rs b/bot/src/commands/mod.rs new file mode 100644 index 0000000..71f3f5e --- /dev/null +++ b/bot/src/commands/mod.rs @@ -0,0 +1,85 @@ +pub mod list; +pub mod newhook; +pub mod edithook; +pub mod newrepo; +pub mod editrepo; +pub mod delhook; +pub mod delrepo; +pub mod setrepochannel; +pub mod resetsecret; + +use crate::{Context, Error}; + +pub(crate) async fn autocomplete_webhooks<'a>( + ctx: Context<'a>, + partial: &'a str, +) -> impl Iterator + 'a { + let data = ctx.data(); + let guild_id = match ctx.guild_id() { + Some(id) => id.to_string(), + None => return vec![].into_iter(), + }; + + struct WebhookChoice { + id: String, + comment: String, + } + + let webhooks = match sqlx::query_as!( + WebhookChoice, + "SELECT id, comment FROM webhooks WHERE guild_id = $1", + guild_id + ) + .fetch_all(&data.pool) + .await { + Ok(v) => v, + Err(_) => return vec![].into_iter(), + }; + + webhooks + .into_iter() + .filter(move |w| { + w.comment.to_lowercase().contains(&partial.to_lowercase()) + || w.id.to_lowercase().contains(&partial.to_lowercase()) + }) + .map(|w| w.id) + .collect::>() + .into_iter() +} + +pub(crate) async fn autocomplete_repos<'a>( + ctx: Context<'a>, + partial: &'a str, +) -> impl Iterator + 'a { + let data = ctx.data(); + let guild_id = match ctx.guild_id() { + Some(id) => id.to_string(), + None => return vec![].into_iter(), + }; + + struct RepoChoice { + id: String, + repo_name: String, + } + + let repos = match sqlx::query_as!( + RepoChoice, + "SELECT id, repo_name FROM repos WHERE guild_id = $1", + guild_id + ) + .fetch_all(&data.pool) + .await { + Ok(v) => v, + Err(_) => return vec![].into_iter(), + }; + + repos + .into_iter() + .filter(move |r| { + r.repo_name.to_lowercase().contains(&partial.to_lowercase()) + || r.id.to_lowercase().contains(&partial.to_lowercase()) + }) + .map(|r| r.id) + .collect::>() + .into_iter() +} diff --git a/bot/src/commands/newhook.rs b/bot/src/commands/newhook.rs new file mode 100644 index 0000000..559852a --- /dev/null +++ b/bot/src/commands/newhook.rs @@ -0,0 +1,144 @@ +use poise::serenity_prelude::CreateMessage; +use rand::distributions::{Alphanumeric, DistString}; + +use crate::{Context, Error, config}; + +/// Creates a new webhook in a guild for sending GitHub/GitLab notifications +#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] +pub async fn newhook( + ctx: Context<'_>, + #[description = "The comment for the webhook"] comment: String, + #[description = "Provider: github or gitlab"] provider: Option, + #[description = "Is the webhook broken?"] broken: Option, +) -> Result<(), Error> { + let data = ctx.data(); + + let provider = provider.unwrap_or_else(|| "github".to_string()).to_lowercase(); + + if provider != "github" && provider != "gitlab" { + ctx.say("Invalid provider! Use `github` or `gitlab`").await?; + return Ok(()); + } + + // Check if the guild exists on our DB + let guild = sqlx::query!( + "SELECT COUNT(1) FROM guilds WHERE id = $1", + &ctx.guild_id().unwrap().to_string() + ) + .fetch_one(&data.pool) + .await?; + + if guild.count.unwrap_or_default() == 0 { + // If it doesn't, create it + sqlx::query!( + "INSERT INTO guilds (id) VALUES ($1)", + &ctx.guild_id().unwrap().to_string() + ) + .execute(&data.pool) + .await?; + } + + // Check webhook count + let webhook_count = sqlx::query!( + "SELECT COUNT(1) FROM webhooks WHERE guild_id = $1", + &ctx.guild_id().unwrap().to_string() + ) + .fetch_one(&data.pool) + .await?; + + if webhook_count.count.unwrap_or_default() >= 5 { + ctx.say("You can't have more than 5 webhooks per guild").await?; + return Ok(()); + } + + // Create the webhook + let id = Alphanumeric.sample_string(&mut rand::thread_rng(), 32); + + let webh_secret = Alphanumeric.sample_string(&mut rand::thread_rng(), 256); + + // Create a new dm channel with the user if not slash command + let dm_channel = ctx.author().create_dm_channel(ctx.http()).await; + + let dm = match dm_channel { + Ok(dm) => dm, + Err(_) => { + ctx.say("I couldn't create a DM channel with you, please enable DMs from server members").await?; + return Ok(()); + } + }; + + sqlx::query!( + "INSERT INTO webhooks (id, guild_id, comment, secret, broken, provider, created_by, last_updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + id, + &ctx.guild_id().unwrap().to_string(), + comment, + webh_secret, + broken.unwrap_or(false), + provider, + ctx.author().id.to_string(), + ctx.author().id.to_string(), + ) + .execute(&data.pool) + .await?; + + ctx.say("Webhook created! Trying to DM you the credentials...").await?; + + let backup_domains = if config::CONFIG.api_url.len() > 1 { + format!("\n**Backup domains:** {}", config::CONFIG.api_url[1..].join(", ")) + } else { + String::new() + }; + + let dm_content = if provider == "gitlab" { + format!( + "\ +**GitLab Webhook Setup** ๐ŸฆŠ + +1. Go to your GitLab project โ†’ Settings โ†’ Webhooks +2. Set the **URL** to: `{api_url}/kittycat?id={id}` +3. Set the **Secret token** to: `{webh_secret}` +4. Select the events you want to receive +5. Click **Add webhook** + +When creating repositories with the bot, use `{id}` as the webhook ID. +{backup_domains} +โš ๏ธ **The above URL and secret is unique โ€” do not share it with others** +๐Ÿ—‘๏ธ **Delete this message after you're done!**", + api_url=config::CONFIG.api_url[0], + backup_domains=backup_domains, + id=id, + webh_secret=webh_secret + ) + } else { + format!( + "\ +**GitHub Webhook Setup** ๐Ÿ™ + +1. Go to your repo/org โ†’ Settings โ†’ Webhooks โ†’ Add webhook +2. Set the **Payload URL** to: `{api_url}/kittycat?id={id}` +3. Set the **Content type** to `application/json` +4. Set the **Secret** to: `{webh_secret}` +5. Select the events you want to receive +6. Click **Add webhook** + +When creating repositories with the bot, use `{id}` as the webhook ID. +{backup_domains} +โš ๏ธ **The above URL and secret is unique โ€” do not share it with others** +๐Ÿ—‘๏ธ **Delete this message after you're done!**", + api_url=config::CONFIG.api_url[0], + backup_domains=backup_domains, + id=id, + webh_secret=webh_secret + ) + }; + + dm.id.send_message( + &ctx, + CreateMessage::new() + .content(dm_content) + ).await?; + + ctx.say("Webhook created! Check your DMs for the webhook information.").await?; + + Ok(()) +} diff --git a/bot/src/commands/newrepo.rs b/bot/src/commands/newrepo.rs new file mode 100644 index 0000000..328dbdb --- /dev/null +++ b/bot/src/commands/newrepo.rs @@ -0,0 +1,136 @@ +use poise::serenity_prelude::ChannelId; +use rand::distributions::{Alphanumeric, DistString}; + +use crate::{Context, Error}; + +/// Creates a new repository for a webhook +#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] +pub async fn newrepo( + ctx: Context<'_>, + #[description = "The webhook ID to use"] + #[autocomplete = "super::autocomplete_webhooks"] + webhook_id: String, + #[description = "The repo owner or organization"] owner: String, + #[description = "The repo name"] name: String, + #[description = "The channel to send to"] channel: ChannelId, +) -> Result<(), Error> { + let data = ctx.data(); + + // Check if the guild exists on our DB + let guild = sqlx::query!( + "SELECT COUNT(1) FROM guilds WHERE id = $1", + &ctx.guild_id().unwrap().to_string() + ) + .fetch_one(&data.pool) + .await?; + + if guild.count.unwrap_or_default() == 0 { + // If it doesn't, return a error + return Err("You don't have any webhooks in this guild! Use ``/newhook`` (or ``git!newhook``) to create one".into()); + } + + // Check webhook count + let webhook_count = sqlx::query!( + "SELECT COUNT(1) FROM webhooks WHERE guild_id = $1", + &ctx.guild_id().unwrap().to_string() + ) + .fetch_one(&data.pool) + .await?; + + let count = webhook_count.count.unwrap_or_default(); + + if count == 0 { + Err("You don't have any webhooks in this guild! Use ``/newhook`` (or ``git!newhook``) to create one".into()) + } else { + // Check if the webhook exists + let webhook = sqlx::query!( + "SELECT COUNT(1) FROM webhooks WHERE id = $1 AND guild_id = $2", + &webhook_id, + &ctx.guild_id().unwrap().to_string() + ) + .fetch_one(&data.pool) + .await?; + + if webhook.count.unwrap_or_default() == 0 { + return Err("That webhook doesn't exist! Use ``/newhook`` (or ``git!newhook``) to create one".into()); + } + + let repo_name = (owner+"/"+&name).to_lowercase(); + + // Get provider to validate repo + let provider_query = sqlx::query!( + "SELECT provider FROM webhooks WHERE id = $1 AND guild_id = $2", + &webhook_id, + &ctx.guild_id().unwrap().to_string() + ) + .fetch_one(&data.pool) + .await?; + + let provider = provider_query.provider.unwrap_or_else(|| "github".to_string()); + + // Validate repository exists + let client = reqwest::Client::new(); + let exists = if provider == "gitlab" { + // GitLab API check + let url = format!("https://gitlab.com/api/v4/projects/{}", urlencoding::encode(&repo_name)); + let res = client.get(&url).send().await; + + if let Ok(response) = res { + response.status().is_success() + } else { + false + } + } else { + // GitHub API check + let url = format!("https://api.github.com/repos/{}", repo_name); + let res = client.get(&url) + .header("User-Agent", "OctoFlow-Discord-Bot") + .send().await; + + if let Ok(response) = res { + response.status().is_success() + } else { + false + } + }; + + if !exists { + return Err("That repository could not be found! Make sure it exists and is public (or use your custom GitLab URL if self-hosted, though validation only works for public repos on github.com/gitlab.com currently).".into()); + } + + // Check if the repo exists + let repo = sqlx::query!( + "SELECT COUNT(1) FROM repos WHERE lower(repo_name) = $1 AND webhook_id = $2", + &repo_name, + &webhook_id + ) + .fetch_one(&data.pool) + .await?; + + if repo.count.unwrap_or_default() == 0 { + // If it doesn't, create it + let id = Alphanumeric.sample_string(&mut rand::thread_rng(), 32); + + sqlx::query!( + "INSERT INTO repos (id, webhook_id, repo_name, channel_id, guild_id, created_by, last_updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7)", + id, + &webhook_id, + &repo_name, + channel.to_string(), + &ctx.guild_id().unwrap().to_string(), + ctx.author().id.to_string(), + ctx.author().id.to_string(), + ) + .execute(&data.pool) + .await?; + + ctx.say( + format!("Repository created with ID of ``{id}``!", id=id) + ).await?; + + Ok(()) + } else { + Err("That repo already exists! Use ``/delrepo`` (or ``git!delrepo``) to delete it".into()) + } + } +} diff --git a/bot/src/commands/resetsecret.rs b/bot/src/commands/resetsecret.rs new file mode 100644 index 0000000..06ee691 --- /dev/null +++ b/bot/src/commands/resetsecret.rs @@ -0,0 +1,79 @@ +use poise::serenity_prelude::CreateMessage; +use rand::distributions::{Alphanumeric, DistString}; + +use crate::{Context, Error}; + +/// Resets a webhook secret. DMs must be open +#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] +pub async fn resetsecret( + ctx: Context<'_>, + #[description = "The webhook ID"] + #[autocomplete = "super::autocomplete_webhooks"] + id: String, +) -> Result<(), Error> { + let data = ctx.data(); + + let guild = sqlx::query!( + "SELECT COUNT(1) FROM guilds WHERE id = $1", + &ctx.guild_id().unwrap().to_string() + ) + .fetch_one(&data.pool) + .await?; + + if guild.count.unwrap_or_default() == 0 { + return Err("You don't have any webhooks in this guild! Use ``/newhook`` (or ``git!newhook``) to create one".into()); + } + + let webhook = sqlx::query!( + "SELECT COUNT(1) FROM webhooks WHERE id = $1 AND guild_id = $2", + &id, + &ctx.guild_id().unwrap().to_string() + ) + .fetch_one(&data.pool) + .await?; + + if webhook.count.unwrap_or_default() == 0 { + return Err("That webhook doesn't exist! Use ``/newhook`` (or ``git!newhook``) to create one".into()); + } + + let webh_secret = Alphanumeric.sample_string(&mut rand::thread_rng(), 256); + + let dm_channel = ctx.author().create_dm_channel(ctx.http()).await; + + let dm = match dm_channel { + Ok(dm) => dm, + Err(_) => { + ctx.say("I couldn't create a DM channel with you, please enable DMs from server members").await?; + return Ok(()); + } + }; + + sqlx::query!( + "UPDATE webhooks SET secret = $1, last_updated_by = $2 WHERE id = $3 AND guild_id = $4", + webh_secret, + ctx.author().id.to_string(), + &id, + &ctx.guild_id().unwrap().to_string() + ) + .execute(&data.pool) + .await?; + + dm.id.send_message( + &ctx, + CreateMessage::new() + .content( + format!( + "Your new webhook secret is `{webh_secret}`. + +Update this webhooks information in your GitHub/GitLab settings now. Your webhook will not accept messages unless you do so! + +**Delete this message after you're done!**", + webh_secret=webh_secret + ) + ) + ).await?; + + ctx.say("Webhook secret updated! Check your DMs for the webhook information.").await?; + + Ok(()) +} diff --git a/bot/src/commands/setrepochannel.rs b/bot/src/commands/setrepochannel.rs new file mode 100644 index 0000000..930e89d --- /dev/null +++ b/bot/src/commands/setrepochannel.rs @@ -0,0 +1,32 @@ +use poise::serenity_prelude::ChannelId; + +use crate::{Context, Error}; + +/// Updates the channel for a repository +#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] +pub async fn setrepochannel( + ctx: Context<'_>, + #[description = "The repo ID"] + #[autocomplete = "super::autocomplete_repos"] + id: String, + #[description = "The new channel ID"] channel: ChannelId, +) -> Result<(), Error> { + let data = ctx.data(); + + let repo = sqlx::query!( + "SELECT COUNT(1) FROM repos WHERE id = $1 AND guild_id = $2", + &id, &ctx.guild_id().unwrap().to_string() + ).fetch_one(&data.pool).await?; + + if repo.count.unwrap_or_default() == 0 { + return Err("That repo doesn't exist! Use ``/newrepo`` (or ``git!newrepo``) to create one".into()); + } + + sqlx::query!( + "UPDATE repos SET channel_id = $1, last_updated_by = $2 WHERE id = $3 AND guild_id = $4", + channel.to_string(), ctx.author().id.to_string(), &id, &ctx.guild_id().unwrap().to_string() + ).execute(&data.pool).await?; + + ctx.say("Channel updated!").await?; + Ok(()) +} diff --git a/bot/src/core.rs b/bot/src/core.rs deleted file mode 100644 index 8ac1c0c..0000000 --- a/bot/src/core.rs +++ /dev/null @@ -1,527 +0,0 @@ -use log::error; -use poise::{serenity_prelude::{CreateMessage, ChannelId, CreateEmbed}, CreateReply}; -use rand::distributions::{Alphanumeric, DistString}; - -use crate::{Context, Error, config}; - -/// Lsts all webhooks in a guild with their respective repos and channel IDs -#[poise::command(slash_command, prefix_command, guild_only, required_permissions = "MANAGE_GUILD")] -pub async fn list( - ctx: Context<'_>, -) -> Result<(), Error> { - let data = ctx.data(); - - // Check if the guild exists on our DB - let guild = sqlx::query!( - "SELECT COUNT(1) FROM guilds WHERE id = $1", - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - if guild.count.unwrap_or_default() == 0 { - // If it doesn't, return an error - sqlx::query!( - "INSERT INTO guilds (id) VALUES ($1)", - ctx.guild_id().unwrap().to_string() - ) - .execute(&data.pool) - .await?; - - ctx.say("This guild doesn't have any webhooks yet. Get started with ``/newhook`` (or ``git!newhook``)").await?; - } else { - // Get all webhooks - let webhooks = sqlx::query!( - "SELECT id, broken, comment, created_at FROM webhooks WHERE guild_id = $1", - ctx.guild_id().unwrap().to_string() - ) - .fetch_all(&data.pool) - .await; - - match webhooks { - Ok(webhooks) => { - let mut cr = CreateReply::default() - .content("Here are all the webhooks in this guild:"); - - let api_url = config::CONFIG.api_url[0].clone(); - - for webhook in webhooks { - let webhook_id = webhook.id; - cr = cr.embed( - CreateEmbed::new() - .title(format!("Webhook \"{}\"", webhook.comment)) - .field("Webhook ID", webhook_id.clone(), false) - .field("Hook URL (visit for hook info, add to Github to recieve events)", api_url.clone()+"/kittycat?id="+&webhook_id, false) - .field("Marked as Broken", format!("{}", webhook.broken), false) - .field("Created at", webhook.created_at.to_string(), false) - ); - }; - - ctx.send(cr).await?; - }, - Err(e) => { - error!("Error fetching webhooks: {:?}", e); - ctx.say("This guild doesn't have any webhooks yet. Get started with ``/newhook`` (or ``git!newhook``)").await?; - } - } - } - - Ok(()) -} - -/// Creates a new webhook in a guild for sending Github notifications -#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] -pub async fn newhook( - ctx: Context<'_>, - #[description = "The comment for the webhook"] comment: String, - #[description = "Is the webhook broken?"] broken: Option, -) -> Result<(), Error> { - let data = ctx.data(); - - // Check if the guild exists on our DB - let guild = sqlx::query!( - "SELECT COUNT(1) FROM guilds WHERE id = $1", - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - if guild.count.unwrap_or_default() == 0 { - // If it doesn't, create it - sqlx::query!( - "INSERT INTO guilds (id) VALUES ($1)", - ctx.guild_id().unwrap().to_string() - ) - .execute(&data.pool) - .await?; - } - - // Check webhook count - let webhook_count = sqlx::query!( - "SELECT COUNT(1) FROM webhooks WHERE guild_id = $1", - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - if webhook_count.count.unwrap_or_default() >= 5 { - ctx.say("You can't have more than 5 webhooks per guild").await?; - return Ok(()); - } - - // Create the webhook - let id = Alphanumeric.sample_string(&mut rand::thread_rng(), 32); - - let webh_secret = Alphanumeric.sample_string(&mut rand::thread_rng(), 256); - - // Create a new dm channel with the user if not slash command - let dm_channel = ctx.author().create_dm_channel(ctx.http()).await; - - let dm = match dm_channel { - Ok(dm) => dm, - Err(_) => { - ctx.say("I couldn't create a DM channel with you, please enable DMs from server members").await?; - return Ok(()); - } - }; - - sqlx::query!( - "INSERT INTO webhooks (id, guild_id, comment, secret, broken, created_by, last_updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7)", - id, - ctx.guild_id().unwrap().to_string(), - comment, - webh_secret, - broken.unwrap_or(false), - ctx.author().id.to_string(), - ctx.author().id.to_string(), - ) - .execute(&data.pool) - .await?; - - ctx.say("Webhook created! Trying to DM you the credentials...").await?; - - dm.id.send_message( - &ctx, - CreateMessage::new() - .content( - format!( - " -Next, add the following webhook to your Github repositories (or organizations): `{api_url}/kittycat?id={id}` - -Set the `Secret` field to `{webh_secret}` and ensure that Content Type is set to `application/json`. - -When creating repositories, use `{id}` as the ID. - -**Backup domains (replace {api_url} with these if gitlogs fails):** {api_domains} - -**Note that the above URL and secret is unique and should not be shared with others** - -**Delete this message after you're done!** - ", - api_url=config::CONFIG.api_url[0], - api_domains=config::CONFIG.api_url[1..].join(", "), - id=id, - webh_secret=webh_secret - ) - ) - ).await?; - - ctx.say("Webhook created! Check your DMs for the webhook information.").await?; - - Ok(()) -} - -/// Edits a webhook -#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] -pub async fn edithook( - ctx: Context<'_>, - #[description = "The webhook ID"] id: String, - #[description = "The comment for the webhook"] comment: Option, - #[description = "Is the webhook broken?"] broken: Option, - #[description = "The new secret for the webhook"] webhook_secret: Option, -) -> Result<(), Error> { - let data = ctx.data(); - - // Check if the guild exists on our DB - let guild = sqlx::query!( - "SELECT COUNT(1) FROM guilds WHERE id = $1", - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - if guild.count.unwrap_or_default() == 0 { - // If it doesn't, create it - sqlx::query!( - "INSERT INTO guilds (id) VALUES ($1)", - ctx.guild_id().unwrap().to_string() - ) - .execute(&data.pool) - .await?; - } - - // Check webhook for existence - let webhook_count = sqlx::query!( - "SELECT COUNT(1) FROM webhooks WHERE guild_id = $1 AND id = $2", - ctx.guild_id().unwrap().to_string(), - id - ) - .fetch_one(&data.pool) - .await?; - - if webhook_count.count.unwrap_or_default() == 0 { - ctx.say("This webhook does not exist!").await?; - return Ok(()); - } - - let mut tx = data.pool.begin().await?; - - if let Some(comment) = comment { - sqlx::query!( - "UPDATE webhooks SET comment = $1 WHERE id = $2 AND guild_id = $3", - comment, - id, - ctx.guild_id().unwrap().to_string() - ) - .execute(&mut *tx) - .await?; - } - - if let Some(broken) = broken { - sqlx::query!( - "UPDATE webhooks SET broken = $1 WHERE id = $2 AND guild_id = $3", - broken, - id, - ctx.guild_id().unwrap().to_string() - ) - .execute(&mut *tx) - .await?; - } - - if let Some(webhook_secret) = webhook_secret { - sqlx::query!( - "UPDATE webhooks SET secret = $1 WHERE id = $2 AND guild_id = $3", - webhook_secret, - id, - ctx.guild_id().unwrap().to_string() - ) - .execute(&mut *tx) - .await?; - } - - // Update last_updated_at and last_updated_by regardless - sqlx::query!( - "UPDATE webhooks SET last_updated_at = NOW(), last_updated_by = $1 WHERE id = $2 AND guild_id = $3", - ctx.author().id.to_string(), - id, - ctx.guild_id().unwrap().to_string() - ) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - ctx.say("Webhook updated successfully!").await?; - - Ok(()) -} - -/// Creates a new repository for a webhook -#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] -pub async fn newrepo( - ctx: Context<'_>, - #[description = "The webhook ID to use"] webhook_id: String, - #[description = "The repo owner or organization"] owner: String, - #[description = "The repo name"] name: String, - #[description = "The channel to send to"] channel: ChannelId, -) -> Result<(), Error> { - let data = ctx.data(); - - // Check if the guild exists on our DB - let guild = sqlx::query!( - "SELECT COUNT(1) FROM guilds WHERE id = $1", - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - if guild.count.unwrap_or_default() == 0 { - // If it doesn't, return a error - return Err("You don't have any webhooks in this guild! Use ``/newhook`` (or ``git!newhook``) to create one".into()); - } - - // Check webhook count - let webhook_count = sqlx::query!( - "SELECT COUNT(1) FROM webhooks WHERE guild_id = $1", - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - let count = webhook_count.count.unwrap_or_default(); - - if count == 0 { - Err("You don't have any webhooks in this guild! Use ``/newhook`` (or ``git!newhook``) to create one".into()) - } else { - // Check if the webhook exists - let webhook = sqlx::query!( - "SELECT COUNT(1) FROM webhooks WHERE id = $1 AND guild_id = $2", - webhook_id, - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - if webhook.count.unwrap_or_default() == 0 { - return Err("That webhook doesn't exist! Use ``/newhook`` (or ``git!newhook``) to create one".into()); - } - - let repo_name = (owner+"/"+&name).to_lowercase(); - - // Check if the repo exists - let repo = sqlx::query!( - "SELECT COUNT(1) FROM repos WHERE lower(repo_name) = $1 AND webhook_id = $2", - &repo_name, - webhook_id - ) - .fetch_one(&data.pool) - .await?; - - if repo.count.unwrap_or_default() == 0 { - // If it doesn't, create it - let id = Alphanumeric.sample_string(&mut rand::thread_rng(), 32); - - sqlx::query!( - "INSERT INTO repos (id, webhook_id, repo_name, channel_id, guild_id, created_by, last_updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7)", - id, - webhook_id, - &repo_name, - channel.to_string(), - ctx.guild_id().unwrap().to_string(), - ctx.author().id.to_string(), - ctx.author().id.to_string(), - ) - .execute(&data.pool) - .await?; - - ctx.say( - format!("Repository created with ID of ``{id}``!", id=id) - ).await?; - - Ok(()) - } else { - Err("That repo already exists! Use ``/delrepo`` (or ``git!delrepo``) to delete it".into()) - } - } -} - -/// Deletes a webhook -#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] -pub async fn delhook( - ctx: Context<'_>, - #[description = "The webhook ID"] id: String, -) -> Result<(), Error> { - let data = ctx.data(); - - // Check if the guild exists on our DB - let guild = sqlx::query!( - "SELECT COUNT(1) FROM guilds WHERE id = $1", - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - if guild.count.unwrap_or_default() == 0 { - // If it doesn't, return a error - return Err("You don't have any webhooks in this guild! Use ``/newhook`` (or ``git!newhook``) to create one".into()); - } - - sqlx::query!( - "DELETE FROM webhooks WHERE id = $1 AND guild_id = $2", - id, - ctx.guild_id().unwrap().to_string() - ) - .execute(&data.pool) - .await?; - - ctx.say("Webhook deleted if it exists!").await?; - - Ok(()) -} - -/// Deletes a repository -#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] -pub async fn delrepo( - ctx: Context<'_>, - #[description = "The repo ID"] id: String, -) -> Result<(), Error> { - let data = ctx.data(); - - sqlx::query!( - "DELETE FROM repos WHERE id = $1 AND guild_id = $2", - id, - ctx.guild_id().unwrap().to_string() - ) - .execute(&data.pool) - .await?; - - ctx.say("Repo deleted!").await?; - - Ok(()) -} - -/// Updates the channel for a repository -#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] -pub async fn setrepochannel( - ctx: Context<'_>, - #[description = "The repo ID"] id: String, - #[description = "The new channel ID"] channel: ChannelId, -) -> Result<(), Error> { - let data = ctx.data(); - - // Check if the repo exists - let repo = sqlx::query!( - "SELECT COUNT(1) FROM repos WHERE id = $1 AND guild_id = $2", - id, - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - if repo.count.unwrap_or_default() == 0 { - return Err("That repo doesn't exist! Use ``/newrepo`` (or ``git!newrepo``) to create one".into()); - } - - sqlx::query!( - "UPDATE repos SET channel_id = $1, last_updated_by = $2 WHERE id = $3 AND guild_id = $4", - channel.to_string(), - ctx.author().id.to_string(), - id, - ctx.guild_id().unwrap().to_string() - ) - .execute(&data.pool) - .await?; - - ctx.say("Channel updated!").await?; - - Ok(()) -} - -/// Resets a webhook secret. DMs must be open -#[poise::command(slash_command, prefix_command, guild_only, guild_cooldown = 60, required_permissions = "MANAGE_GUILD")] -pub async fn resetsecret( - ctx: Context<'_>, - #[description = "The webhook ID"] id: String, -) -> Result<(), Error> { - let data = ctx.data(); - - // Check if the guild exists on our DB - let guild = sqlx::query!( - "SELECT COUNT(1) FROM guilds WHERE id = $1", - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - if guild.count.unwrap_or_default() == 0 { - // If it doesn't, return a error - return Err("You don't have any webhooks in this guild! Use ``/newhook`` (or ``git!newhook``) to create one".into()); - } - - // Check if the webhook exists - let webhook = sqlx::query!( - "SELECT COUNT(1) FROM webhooks WHERE id = $1 AND guild_id = $2", - id, - ctx.guild_id().unwrap().to_string() - ) - .fetch_one(&data.pool) - .await?; - - if webhook.count.unwrap_or_default() == 0 { - return Err("That webhook doesn't exist! Use ``/newhook`` (or ``git!newhook``) to create one".into()); - } - - let webh_secret = Alphanumeric.sample_string(&mut rand::thread_rng(), 256); - - // Try to DM the user - // Create a new dm channel with the user if not slash command - let dm_channel = ctx.author().create_dm_channel(ctx.http()).await; - - let dm = match dm_channel { - Ok(dm) => dm, - Err(_) => { - ctx.say("I couldn't create a DM channel with you, please enable DMs from server members").await?; - return Ok(()); - } - }; - - sqlx::query!( - "UPDATE webhooks SET secret = $1, last_updated_by = $2 WHERE id = $3 AND guild_id = $4", - webh_secret, - ctx.author().id.to_string(), - id, - ctx.guild_id().unwrap().to_string() - ) - .execute(&data.pool) - .await?; - - dm.id.send_message( - &ctx, - CreateMessage::new() - .content( - format!( - "Your new webhook secret is `{webh_secret}`. - -Update this webhooks information in GitHub settings now. Your webhook will not accept messages from GitHub unless you do so! - -**Delete this message after you're done!** - ", - webh_secret=webh_secret - ) - ) - ).await?; - - ctx.say("Webhook secret updated! Check your DMs for the webhook information.").await?; - - Ok(()) -} diff --git a/bot/src/main.rs b/bot/src/main.rs index 3b9e539..8e7506d 100644 --- a/bot/src/main.rs +++ b/bot/src/main.rs @@ -9,7 +9,7 @@ use serenity::gateway::ActivityData; use std::sync::Arc; mod help; -mod core; +mod commands; mod backups; mod config; mod eventmods; @@ -143,13 +143,15 @@ async fn main() { register(), help::simplehelp(), help::help(), - core::list(), - core::newhook(), - core::newrepo(), - core::delhook(), - core::delrepo(), - core::setrepochannel(), - core::resetsecret(), + commands::list(), + commands::newhook(), + commands::edithook(), + commands::newrepo(), + commands::editrepo(), + commands::delhook(), + commands::delrepo(), + commands::setrepochannel(), + commands::resetsecret(), backups::backup(), backups::restore(), eventmods::eventmod(), diff --git a/schema.sql b/schema.sql index 6652bd9..f8ba669 100644 --- a/schema.sql +++ b/schema.sql @@ -9,6 +9,7 @@ CREATE TABLE webhooks ( comment TEXT NOT NULL, -- A comment to help identify the webhook broken BOOLEAN NOT NULL DEFAULT FALSE, secret TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT 'github', -- 'github' or 'gitlab' created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by TEXT NOT NULL, last_updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), diff --git a/webserver/logos/events/code_scanning_alert.go b/webserver/logos/events/code_scanning_alert.go new file mode 100644 index 0000000..0adf7b0 --- /dev/null +++ b/webserver/logos/events/code_scanning_alert.go @@ -0,0 +1,175 @@ +package events + +import ( + "fmt" + "time" + + "github.com/bwmarrin/discordgo" +) + +type CodeScanningAlertEvent struct { + Action string `json:"action"` + Repo Repository `json:"repository"` + Sender User `json:"sender"` + Alert struct { + Number int `json:"number"` + State string `json:"state"` + FixedAt string `json:"fixed_at"` + DismissedAt string `json:"dismissed_at"` + DismissedReason string `json:"dismissed_reason"` + DismissedComment string `json:"dismissed_comment"` + CreatedAt string `json:"created_at"` + HTMLURL string `json:"html_url"` + Rule struct { + ID string `json:"id"` + Severity string `json:"severity"` + SecuritySeverityLevel string `json:"security_severity_level"` + Description string `json:"description"` + FullDescription string `json:"full_description"` + Name string `json:"name"` + } `json:"rule"` + Tool struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"tool"` + MostRecentInstance struct { + Ref string `json:"ref"` + State string `json:"state"` + CommitSHA string `json:"commit_sha"` + Location struct { + Path string `json:"path"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` + StartColumn int `json:"start_column"` + EndColumn int `json:"end_column"` + } `json:"location"` + } `json:"most_recent_instance"` + DismissedBy User `json:"dismissed_by"` + } `json:"alert"` +} + +func codeScanningAlertFn(bytes []byte) (*discordgo.MessageSend, error) { + var gh CodeScanningAlertEvent + + err := json.Unmarshal(bytes, &gh) + if err != nil { + return &discordgo.MessageSend{}, err + } + + color := colorYellow + switch gh.Action { + case "fixed": + color = colorGreen + case "appeared_in_branch", "created", "reopened": + color = colorRed + case "closed_by_user": + color = colorDarkRed + } + + // Build severity info + severity := gh.Alert.Rule.Severity + if gh.Alert.Rule.SecuritySeverityLevel != "" { + severity = gh.Alert.Rule.SecuritySeverityLevel + } + if severity == "" { + severity = "unknown" + } + + fields := []*discordgo.MessageEmbedField{ + { + Name: "Action", + Value: gh.Action, + Inline: true, + }, + { + Name: "State", + Value: gh.Alert.State, + Inline: true, + }, + { + Name: "Severity", + Value: severity, + Inline: true, + }, + } + + if gh.Alert.Rule.Name != "" { + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Rule", + Value: gh.Alert.Rule.Name + " (`" + gh.Alert.Rule.ID + "`)", + Inline: false, + }) + } + + if gh.Alert.Rule.Description != "" { + desc := gh.Alert.Rule.Description + if len(desc) > 200 { + desc = desc[:200] + "..." + } + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Description", + Value: desc, + }) + } + + if gh.Alert.Tool.Name != "" { + toolInfo := gh.Alert.Tool.Name + if gh.Alert.Tool.Version != "" { + toolInfo += " v" + gh.Alert.Tool.Version + } + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Tool", + Value: toolInfo, + Inline: true, + }) + } + + loc := gh.Alert.MostRecentInstance.Location + if loc.Path != "" { + locStr := fmt.Sprintf("`%s` L%d", loc.Path, loc.StartLine) + if loc.EndLine > loc.StartLine { + locStr = fmt.Sprintf("`%s` L%d-L%d", loc.Path, loc.StartLine, loc.EndLine) + } + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Location", + Value: locStr, + Inline: true, + }) + } + + if gh.Alert.MostRecentInstance.Ref != "" { + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Ref", + Value: gh.Alert.MostRecentInstance.Ref, + Inline: true, + }) + } + + if gh.Alert.DismissedBy.Login != "" { + dismissInfo := "By: " + gh.Alert.DismissedBy.Link() + if gh.Alert.DismissedReason != "" { + dismissInfo += "\nReason: " + gh.Alert.DismissedReason + } + if gh.Alert.DismissedAt != "" { + if t, err := time.Parse(time.RFC3339, gh.Alert.DismissedAt); err == nil { + dismissInfo += "\nAt: " + t.Format("2006-01-02 15:04 UTC") + } + } + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Dismissal", + Value: dismissInfo, + }) + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: color, + URL: gh.Alert.HTMLURL, + Author: gh.Sender.AuthorEmbed(), + Title: fmt.Sprintf("Code Scanning Alert #%d %s on %s", gh.Alert.Number, gh.Action, gh.Repo.FullName), + Fields: fields, + }, + }, + }, nil +} diff --git a/webserver/logos/events/gitlab_common.go b/webserver/logos/events/gitlab_common.go new file mode 100644 index 0000000..17a8e9e --- /dev/null +++ b/webserver/logos/events/gitlab_common.go @@ -0,0 +1,77 @@ +package events + +import ( + "strings" + + "github.com/bwmarrin/discordgo" +) + +// GitLab color palette +var ( + glColorOrange = 0xFC6D26 + glColorPurple = 0x6B4FBB +) + +// GitLabSupportedEvents maps GitLab internal event names to handler functions +var GitLabSupportedEvents = map[string]func(bytes []byte) (*discordgo.MessageSend, error){ + "gl_push": glPushFn, + "gl_tag_push": glTagPushFn, + "gl_issue": glIssueFn, + "gl_note": glNoteFn, + "gl_merge_request": glMergeRequestFn, + "gl_pipeline": glPipelineFn, + "gl_release": glReleaseFn, + "gl_wiki": glWikiFn, + "gl_deployment": glDeploymentFn, + "gl_job": glJobFn, +} + +// GLUser represents a GitLab user in webhook payloads +type GLUser struct { + ID int `json:"id"` + Name string `json:"name"` + Username string `json:"username"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` +} + +func (u GLUser) AuthorEmbed() *discordgo.MessageEmbedAuthor { + return &discordgo.MessageEmbedAuthor{ + Name: u.Name + " (@" + u.Username + ")", + IconURL: u.AvatarURL, + } +} + +func (u GLUser) Link(baseURL string) string { + if baseURL == "" { + baseURL = "https://gitlab.com" + } + return "[" + strings.ReplaceAll(u.Username, " ", "%20") + "](" + baseURL + "/" + u.Username + ")" +} + +// GLProject represents a GitLab project in webhook payloads +type GLProject struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + WebURL string `json:"web_url"` + AvatarURL string `json:"avatar_url"` + GitSSHURL string `json:"git_ssh_url"` + GitHTTPURL string `json:"git_http_url"` + Namespace string `json:"namespace"` + PathWithNamespace string `json:"path_with_namespace"` + DefaultBranch string `json:"default_branch"` + Homepage string `json:"homepage"` + URL string `json:"url"` + SSHURL string `json:"ssh_url"` + HTTPURL string `json:"http_url"` + Visibility string `json:"visibility_level,omitempty"` +} + +// GLRepository represents a GitLab repository section in webhook payloads +type GLRepository struct { + Name string `json:"name"` + URL string `json:"url"` + Description string `json:"description"` + Homepage string `json:"homepage"` +} diff --git a/webserver/logos/events/gitlab_issue.go b/webserver/logos/events/gitlab_issue.go new file mode 100644 index 0000000..aa19ea7 --- /dev/null +++ b/webserver/logos/events/gitlab_issue.go @@ -0,0 +1,76 @@ +package events + +import ( + "fmt" + + "github.com/bwmarrin/discordgo" +) + +type GLIssueEvent struct { + ObjectKind string `json:"object_kind"` + EventType string `json:"event_type"` + User GLUser `json:"user"` + Project GLProject `json:"project"` + ObjectAttributes struct { + ID int `json:"id"` + IID int `json:"iid"` + Title string `json:"title"` + Description string `json:"description"` + State string `json:"state"` + Action string `json:"action"` + URL string `json:"url"` + } `json:"object_attributes"` +} + +func glIssueFn(bytes []byte) (*discordgo.MessageSend, error) { + var gl GLIssueEvent + err := json.Unmarshal(bytes, &gl) + if err != nil { + return &discordgo.MessageSend{}, err + } + + body := gl.ObjectAttributes.Description + if len(body) > 996 { + body = body[:996] + "..." + } + if body == "" { + body = "No description available" + } + + color := colorGreen + if gl.ObjectAttributes.Action == "close" { + color = colorRed + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: color, + URL: gl.ObjectAttributes.URL, + Author: gl.User.AuthorEmbed(), + Description: body, + Title: fmt.Sprintf("Issue %s on %s (#%d)", gl.ObjectAttributes.Action, gl.Project.PathWithNamespace, gl.ObjectAttributes.IID), + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Action", + Value: gl.ObjectAttributes.Action, + Inline: true, + }, + { + Name: "State", + Value: gl.ObjectAttributes.State, + Inline: true, + }, + { + Name: "Title", + Value: gl.ObjectAttributes.Title, + Inline: true, + }, + }, + Footer: &discordgo.MessageEmbedFooter{ + Text: "GitLab", + }, + }, + }, + }, nil +} diff --git a/webserver/logos/events/gitlab_misc.go b/webserver/logos/events/gitlab_misc.go new file mode 100644 index 0000000..a3858a1 --- /dev/null +++ b/webserver/logos/events/gitlab_misc.go @@ -0,0 +1,205 @@ +package events + +import ( + "fmt" + + "github.com/bwmarrin/discordgo" +) + +type GLReleaseEvent struct { + ObjectKind string `json:"object_kind"` + Project GLProject `json:"project"` + Tag string `json:"tag"` + Name string `json:"name"` + Description string `json:"description"` + URL string `json:"url"` + Action string `json:"action"` + Assets struct { + Count int `json:"count"` + Links []struct { + ID int `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + } `json:"links"` + } `json:"assets"` +} + +func glReleaseFn(bytes []byte) (*discordgo.MessageSend, error) { + var gl GLReleaseEvent + err := json.Unmarshal(bytes, &gl) + if err != nil { + return &discordgo.MessageSend{}, err + } + + desc := gl.Description + if len(desc) > 996 { + desc = desc[:996] + "..." + } + if desc == "" { + desc = "No description" + } + + color := colorGreen + if gl.Action == "delete" { + color = colorRed + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: color, + URL: gl.URL, + Description: desc, + Title: fmt.Sprintf("Release %s on %s (%s)", gl.Action, gl.Project.PathWithNamespace, gl.Tag), + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Name", + Value: gl.Name, + Inline: true, + }, + { + Name: "Tag", + Value: gl.Tag, + Inline: true, + }, + { + Name: "Assets", + Value: fmt.Sprintf("%d", gl.Assets.Count), + Inline: true, + }, + }, + Footer: &discordgo.MessageEmbedFooter{ + Text: "GitLab", + }, + }, + }, + }, nil +} + +type GLWikiEvent struct { + ObjectKind string `json:"object_kind"` + User GLUser `json:"user"` + Project GLProject `json:"project"` + Wiki struct { + WebURL string `json:"web_url"` + } `json:"wiki"` + ObjectAttributes struct { + Title string `json:"title"` + Content string `json:"content"` + Format string `json:"format"` + Slug string `json:"slug"` + URL string `json:"url"` + Action string `json:"action"` + } `json:"object_attributes"` +} + +func glWikiFn(bytes []byte) (*discordgo.MessageSend, error) { + var gl GLWikiEvent + err := json.Unmarshal(bytes, &gl) + if err != nil { + return &discordgo.MessageSend{}, err + } + + color := colorGreen + if gl.ObjectAttributes.Action == "delete" { + color = colorRed + } else if gl.ObjectAttributes.Action == "update" { + color = colorYellow + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: color, + URL: gl.ObjectAttributes.URL, + Author: gl.User.AuthorEmbed(), + Title: fmt.Sprintf("Wiki Page %s on %s", gl.ObjectAttributes.Action, gl.Project.PathWithNamespace), + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Title", + Value: gl.ObjectAttributes.Title, + Inline: true, + }, + { + Name: "Action", + Value: gl.ObjectAttributes.Action, + Inline: true, + }, + }, + Footer: &discordgo.MessageEmbedFooter{ + Text: "GitLab", + }, + }, + }, + }, nil +} + +type GLDeploymentEvent struct { + ObjectKind string `json:"object_kind"` + Status string `json:"status"` + StatusChangedAt string `json:"status_changed_at"` + DeployableID int `json:"deployable_id"` + DeployableURL string `json:"deployable_url"` + Environment string `json:"environment"` + EnvironmentExternalURL string `json:"environment_external_url"` + Project GLProject `json:"project"` + User GLUser `json:"user"` + ShortSHA string `json:"short_sha"` + CommitURL string `json:"commit_url"` + CommitTitle string `json:"commit_title"` +} + +func glDeploymentFn(bytes []byte) (*discordgo.MessageSend, error) { + var gl GLDeploymentEvent + err := json.Unmarshal(bytes, &gl) + if err != nil { + return &discordgo.MessageSend{}, err + } + + color := colorYellow + switch gl.Status { + case "success": + color = colorGreen + case "failed": + color = colorRed + } + + fields := []*discordgo.MessageEmbedField{ + { + Name: "Environment", + Value: gl.Environment, + Inline: true, + }, + { + Name: "Status", + Value: gl.Status, + Inline: true, + }, + { + Name: "Commit", + Value: fmt.Sprintf("[%s](%s)", gl.ShortSHA, gl.CommitURL), + Inline: true, + }, + } + + if gl.CommitTitle != "" { + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Commit Message", + Value: gl.CommitTitle, + }) + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: color, + Author: gl.User.AuthorEmbed(), + Title: fmt.Sprintf("Deployment %s on %s", gl.Status, gl.Project.PathWithNamespace), + Fields: fields, + Footer: &discordgo.MessageEmbedFooter{ + Text: "GitLab", + }, + }, + }, + }, nil +} diff --git a/webserver/logos/events/gitlab_mr.go b/webserver/logos/events/gitlab_mr.go new file mode 100644 index 0000000..15db252 --- /dev/null +++ b/webserver/logos/events/gitlab_mr.go @@ -0,0 +1,87 @@ +package events + +import ( + "fmt" + + "github.com/bwmarrin/discordgo" +) + +type GLMergeRequestEvent struct { + ObjectKind string `json:"object_kind"` + EventType string `json:"event_type"` + User GLUser `json:"user"` + Project GLProject `json:"project"` + ObjectAttributes struct { + ID int `json:"id"` + IID int `json:"iid"` + Title string `json:"title"` + Description string `json:"description"` + State string `json:"state"` + Action string `json:"action"` + URL string `json:"url"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + MergeStatus string `json:"merge_status"` + MergeWhenPipelineSucceeds bool `json:"merge_when_pipeline_succeeds"` + } `json:"object_attributes"` +} + +func glMergeRequestFn(bytes []byte) (*discordgo.MessageSend, error) { + var gl GLMergeRequestEvent + err := json.Unmarshal(bytes, &gl) + if err != nil { + return &discordgo.MessageSend{}, err + } + + body := gl.ObjectAttributes.Description + if len(body) > 996 { + body = body[:996] + "..." + } + if body == "" { + body = "No description available" + } + + color := glColorPurple + if gl.ObjectAttributes.Action == "merge" { + color = colorGreen + } else if gl.ObjectAttributes.Action == "close" { + color = colorRed + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: color, + URL: gl.ObjectAttributes.URL, + Author: gl.User.AuthorEmbed(), + Description: body, + Title: fmt.Sprintf("Merge Request %s on %s (!%d)", gl.ObjectAttributes.Action, gl.Project.PathWithNamespace, gl.ObjectAttributes.IID), + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Title", + Value: gl.ObjectAttributes.Title, + Inline: false, + }, + { + Name: "Source โ†’ Target", + Value: gl.ObjectAttributes.SourceBranch + " โ†’ " + gl.ObjectAttributes.TargetBranch, + Inline: true, + }, + { + Name: "Merge Status", + Value: gl.ObjectAttributes.MergeStatus, + Inline: true, + }, + { + Name: "State", + Value: gl.ObjectAttributes.State, + Inline: true, + }, + }, + Footer: &discordgo.MessageEmbedFooter{ + Text: "GitLab", + }, + }, + }, + }, nil +} diff --git a/webserver/logos/events/gitlab_note.go b/webserver/logos/events/gitlab_note.go new file mode 100644 index 0000000..18003a6 --- /dev/null +++ b/webserver/logos/events/gitlab_note.go @@ -0,0 +1,99 @@ +package events + +import ( + "fmt" + + "github.com/bwmarrin/discordgo" +) + +type GLNoteEvent struct { + ObjectKind string `json:"object_kind"` + EventType string `json:"event_type"` + User GLUser `json:"user"` + Project GLProject `json:"project"` + ObjectAttributes struct { + ID int `json:"id"` + Note string `json:"note"` + NoteableType string `json:"noteable_type"` + URL string `json:"url"` + } `json:"object_attributes"` + // One of these will be populated depending on what was commented on + Issue *struct { + IID int `json:"iid"` + Title string `json:"title"` + } `json:"issue,omitempty"` + MergeRequest *struct { + IID int `json:"iid"` + Title string `json:"title"` + } `json:"merge_request,omitempty"` + Commit *struct { + ID string `json:"id"` + Message string `json:"message"` + } `json:"commit,omitempty"` + Snippet *struct { + ID int `json:"id"` + Title string `json:"title"` + } `json:"snippet,omitempty"` +} + +func glNoteFn(bytes []byte) (*discordgo.MessageSend, error) { + var gl GLNoteEvent + err := json.Unmarshal(bytes, &gl) + if err != nil { + return &discordgo.MessageSend{}, err + } + + note := gl.ObjectAttributes.Note + if len(note) > 996 { + note = note[:996] + "..." + } + + var target string + switch gl.ObjectAttributes.NoteableType { + case "Issue": + if gl.Issue != nil { + target = fmt.Sprintf("Issue #%d (%s)", gl.Issue.IID, gl.Issue.Title) + } else { + target = "Issue" + } + case "MergeRequest": + if gl.MergeRequest != nil { + target = fmt.Sprintf("MR !%d (%s)", gl.MergeRequest.IID, gl.MergeRequest.Title) + } else { + target = "Merge Request" + } + case "Commit": + if gl.Commit != nil { + shortID := gl.Commit.ID + if len(shortID) > 7 { + shortID = shortID[:7] + } + target = "Commit " + shortID + } else { + target = "Commit" + } + case "Snippet": + if gl.Snippet != nil { + target = fmt.Sprintf("Snippet #%d (%s)", gl.Snippet.ID, gl.Snippet.Title) + } else { + target = "Snippet" + } + default: + target = gl.ObjectAttributes.NoteableType + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: glColorOrange, + URL: gl.ObjectAttributes.URL, + Author: gl.User.AuthorEmbed(), + Description: note, + Title: "Comment on " + target + " in " + gl.Project.PathWithNamespace, + Footer: &discordgo.MessageEmbedFooter{ + Text: "GitLab", + }, + }, + }, + }, nil +} diff --git a/webserver/logos/events/gitlab_pipeline.go b/webserver/logos/events/gitlab_pipeline.go new file mode 100644 index 0000000..8689df1 --- /dev/null +++ b/webserver/logos/events/gitlab_pipeline.go @@ -0,0 +1,197 @@ +package events + +import ( + "fmt" + "strings" + + "github.com/bwmarrin/discordgo" +) + +type GLPipelineEvent struct { + ObjectKind string `json:"object_kind"` + User GLUser `json:"user"` + Project GLProject `json:"project"` + ObjectAttributes struct { + ID int `json:"id"` + IID int `json:"iid"` + Ref string `json:"ref"` + Status string `json:"status"` + Source string `json:"source"` + Stages []string `json:"stages"` + Duration int `json:"duration"` + CreatedAt string `json:"created_at"` + FinishedAt string `json:"finished_at"` + } `json:"object_attributes"` + Builds []struct { + ID int `json:"id"` + Stage string `json:"stage"` + Name string `json:"name"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + } `json:"builds"` +} + +func glPipelineFn(bytes []byte) (*discordgo.MessageSend, error) { + var gl GLPipelineEvent + err := json.Unmarshal(bytes, &gl) + if err != nil { + return &discordgo.MessageSend{}, err + } + + color := colorYellow + switch gl.ObjectAttributes.Status { + case "success": + color = colorGreen + case "failed": + color = colorRed + case "canceled", "skipped": + color = colorDarkRed + } + + stages := strings.Join(gl.ObjectAttributes.Stages, " โ†’ ") + if stages == "" { + stages = "N/A" + } + + var buildSummary string + for _, b := range gl.Builds { + icon := "โณ" + switch b.Status { + case "success": + icon = "โœ…" + case "failed": + icon = "โŒ" + case "skipped": + icon = "โญ๏ธ" + case "canceled": + icon = "๐Ÿšซ" + case "running": + icon = "๐Ÿ”„" + } + buildSummary += fmt.Sprintf("%s %s (%s)\n", icon, b.Name, b.Stage) + } + + if len(buildSummary) > 1020 { + buildSummary = buildSummary[:1020] + "..." + } + if buildSummary == "" { + buildSummary = "No builds" + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: color, + URL: gl.Project.WebURL + "/-/pipelines/" + fmt.Sprintf("%d", gl.ObjectAttributes.ID), + Author: gl.User.AuthorEmbed(), + Title: fmt.Sprintf("Pipeline #%d %s on %s", gl.ObjectAttributes.ID, gl.ObjectAttributes.Status, gl.Project.PathWithNamespace), + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Ref", + Value: gl.ObjectAttributes.Ref, + Inline: true, + }, + { + Name: "Status", + Value: gl.ObjectAttributes.Status, + Inline: true, + }, + { + Name: "Source", + Value: gl.ObjectAttributes.Source, + Inline: true, + }, + { + Name: "Stages", + Value: stages, + }, + { + Name: "Jobs", + Value: buildSummary, + }, + }, + Footer: &discordgo.MessageEmbedFooter{ + Text: "GitLab", + }, + }, + }, + }, nil +} + +type GLJobEvent struct { + ObjectKind string `json:"object_kind"` + Ref string `json:"ref"` + BuildID int `json:"build_id"` + BuildName string `json:"build_name"` + BuildStage string `json:"build_stage"` + BuildStatus string `json:"build_status"` + BuildDuration float64 `json:"build_duration"` + BuildFailureReason string `json:"build_failure_reason"` + PipelineID int `json:"pipeline_id"` + User GLUser `json:"user"` + Repository GLRepository `json:"repository"` + ProjectName string `json:"project_name"` +} + +func glJobFn(bytes []byte) (*discordgo.MessageSend, error) { + var gl GLJobEvent + err := json.Unmarshal(bytes, &gl) + if err != nil { + return &discordgo.MessageSend{}, err + } + + color := colorYellow + switch gl.BuildStatus { + case "success": + color = colorGreen + case "failed": + color = colorRed + } + + fields := []*discordgo.MessageEmbedField{ + { + Name: "Job", + Value: gl.BuildName, + Inline: true, + }, + { + Name: "Stage", + Value: gl.BuildStage, + Inline: true, + }, + { + Name: "Status", + Value: gl.BuildStatus, + Inline: true, + }, + } + + if gl.BuildDuration > 0 { + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Duration", + Value: fmt.Sprintf("%.1fs", gl.BuildDuration), + Inline: true, + }) + } + + if gl.BuildFailureReason != "" { + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Failure Reason", + Value: gl.BuildFailureReason, + }) + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: color, + Author: gl.User.AuthorEmbed(), + Title: fmt.Sprintf("Job #%d %s in %s", gl.BuildID, gl.BuildStatus, gl.ProjectName), + Fields: fields, + Footer: &discordgo.MessageEmbedFooter{ + Text: "GitLab", + }, + }, + }, + }, nil +} diff --git a/webserver/logos/events/gitlab_push.go b/webserver/logos/events/gitlab_push.go new file mode 100644 index 0000000..e04e8e3 --- /dev/null +++ b/webserver/logos/events/gitlab_push.go @@ -0,0 +1,147 @@ +package events + +import ( + "fmt" + "strings" + + "github.com/bwmarrin/discordgo" +) + +type GLPushEvent struct { + ObjectKind string `json:"object_kind"` + Before string `json:"before"` + After string `json:"after"` + Ref string `json:"ref"` + CheckoutSHA string `json:"checkout_sha"` + UserID int `json:"user_id"` + UserName string `json:"user_name"` + UserUsername string `json:"user_username"` + UserEmail string `json:"user_email"` + UserAvatar string `json:"user_avatar"` + Project GLProject `json:"project"` + Repository GLRepository `json:"repository"` + Commits []struct { + ID string `json:"id"` + Message string `json:"message"` + Title string `json:"title"` + Timestamp string `json:"timestamp"` + URL string `json:"url"` + Author struct { + Name string `json:"name"` + Email string `json:"email"` + } `json:"author"` + } `json:"commits"` + TotalCommitsCount int `json:"total_commits_count"` +} + +func glPushFn(bytes []byte) (*discordgo.MessageSend, error) { + var gl GLPushEvent + err := json.Unmarshal(bytes, &gl) + if err != nil { + return &discordgo.MessageSend{}, err + } + + var commitList string + for _, commit := range gl.Commits { + msg := commit.Message + if len(msg) > 100 { + msg = msg[:100] + "..." + } + commitList += fmt.Sprintf("%s [``%s``](%s) | %s\n", msg, commit.ID[:7], commit.URL, commit.Author.Name) + } + + if len(commitList) > 1024 { + commitList = commitList[:1024] + "..." + } + + if commitList == "" { + commitList = "No commits" + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: glColorOrange, + URL: gl.Project.WebURL, + Author: &discordgo.MessageEmbedAuthor{ + Name: gl.UserName + " (@" + gl.UserUsername + ")", + IconURL: gl.UserAvatar, + }, + Title: "Push on " + gl.Project.PathWithNamespace, + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Branch", + Value: "**Ref:** " + gl.Ref, + }, + { + Name: "Commits", + Value: commitList, + }, + { + Name: "Pusher", + Value: fmt.Sprintf("[%s](%s/%s)", gl.UserUsername, strings.TrimSuffix(gl.Project.WebURL, "/"+gl.Project.PathWithNamespace), gl.UserUsername), + Inline: true, + }, + { + Name: "Total Commits", + Value: fmt.Sprintf("%d", gl.TotalCommitsCount), + Inline: true, + }, + }, + Footer: &discordgo.MessageEmbedFooter{ + Text: "GitLab", + }, + }, + }, + }, nil +} + +type GLTagPushEvent struct { + ObjectKind string `json:"object_kind"` + Before string `json:"before"` + After string `json:"after"` + Ref string `json:"ref"` + CheckoutSHA string `json:"checkout_sha"` + UserID int `json:"user_id"` + UserName string `json:"user_name"` + UserUsername string `json:"user_username"` + UserAvatar string `json:"user_avatar"` + Project GLProject `json:"project"` + Repository GLRepository `json:"repository"` +} + +func glTagPushFn(bytes []byte) (*discordgo.MessageSend, error) { + var gl GLTagPushEvent + err := json.Unmarshal(bytes, &gl) + if err != nil { + return &discordgo.MessageSend{}, err + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: glColorOrange, + URL: gl.Project.WebURL, + Author: &discordgo.MessageEmbedAuthor{ + Name: gl.UserName + " (@" + gl.UserUsername + ")", + IconURL: gl.UserAvatar, + }, + Title: "Tag Push on " + gl.Project.PathWithNamespace, + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Ref", + Value: gl.Ref, + }, + { + Name: "Checkout SHA", + Value: gl.CheckoutSHA, + Inline: true, + }, + }, + Footer: &discordgo.MessageEmbedFooter{ + Text: "GitLab", + }, + }, + }, + }, nil +} diff --git a/webserver/logos/events/internal_common__.go b/webserver/logos/events/internal_common__.go index b762079..b344134 100644 --- a/webserver/logos/events/internal_common__.go +++ b/webserver/logos/events/internal_common__.go @@ -45,6 +45,8 @@ var SupportedEvents = map[string]func(bytes []byte) (*discordgo.MessageSend, err "team": teamFn, "fork": forkFn, "page_build": pageBuildFn, + "code_scanning_alert": codeScanningAlertFn, + "secret_scanning_alert": secretScanningAlertFn, } type User struct { diff --git a/webserver/logos/events/secret_scanning_alert.go b/webserver/logos/events/secret_scanning_alert.go new file mode 100644 index 0000000..8cd9133 --- /dev/null +++ b/webserver/logos/events/secret_scanning_alert.go @@ -0,0 +1,107 @@ +package events + +import ( + "fmt" + "time" + + "github.com/bwmarrin/discordgo" +) + +type SecretScanningAlertEvent struct { + Action string `json:"action"` + Repo Repository `json:"repository"` + Sender User `json:"sender"` + Alert struct { + Number int `json:"number"` + State string `json:"state"` + SecretType string `json:"secret_type"` + SecretTypeDisplayName string `json:"secret_type_display_name"` + Secret string `json:"secret"` // redacted by GitHub + HTMLURL string `json:"html_url"` + CreatedAt string `json:"created_at"` + Resolution string `json:"resolution"` + ResolvedAt string `json:"resolved_at"` + ResolvedBy User `json:"resolved_by"` + PushProtectionBypassed bool `json:"push_protection_bypassed"` + PushProtectionBypassedBy User `json:"push_protection_bypassed_by"` + } `json:"alert"` +} + +func secretScanningAlertFn(bytes []byte) (*discordgo.MessageSend, error) { + var gh SecretScanningAlertEvent + + err := json.Unmarshal(bytes, &gh) + if err != nil { + return &discordgo.MessageSend{}, err + } + + color := colorRed + switch gh.Action { + case "resolved": + color = colorGreen + case "revoked": + color = colorDarkRed + } + + fields := []*discordgo.MessageEmbedField{ + { + Name: "Action", + Value: gh.Action, + Inline: true, + }, + { + Name: "State", + Value: gh.Alert.State, + Inline: true, + }, + { + Name: "Secret Type", + Value: gh.Alert.SecretTypeDisplayName, + Inline: true, + }, + } + + if gh.Alert.Resolution != "" { + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Resolution", + Value: gh.Alert.Resolution, + Inline: true, + }) + } + + if gh.Alert.ResolvedBy.Login != "" { + resolveInfo := gh.Alert.ResolvedBy.Link() + if gh.Alert.ResolvedAt != "" { + if t, err := time.Parse(time.RFC3339, gh.Alert.ResolvedAt); err == nil { + resolveInfo += " at " + t.Format("2006-01-02 15:04 UTC") + } + } + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Resolved By", + Value: resolveInfo, + }) + } + + if gh.Alert.PushProtectionBypassed { + bypassInfo := "Yes" + if gh.Alert.PushProtectionBypassedBy.Login != "" { + bypassInfo = "By " + gh.Alert.PushProtectionBypassedBy.Link() + } + fields = append(fields, &discordgo.MessageEmbedField{ + Name: "Push Protection Bypassed", + Value: bypassInfo, + }) + } + + return &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Color: color, + URL: gh.Alert.HTMLURL, + Author: gh.Sender.AuthorEmbed(), + Title: fmt.Sprintf("Secret Scanning Alert #%d %s on %s", gh.Alert.Number, gh.Action, gh.Repo.FullName), + Fields: fields, + }, + }, + }, nil +} diff --git a/webserver/logos/logos.go b/webserver/logos/logos.go index 54413f1..55ffc70 100644 --- a/webserver/logos/logos.go +++ b/webserver/logos/logos.go @@ -1,3 +1,3 @@ -// Logos (Xenoblade Chronicles 2), the core component that provides logic to Git Logs, +// Logos (Xenoblade Chronicles 2), the core component that provides logic to Octoflow, // removing the useless crud package logos diff --git a/webserver/ontos/api.go b/webserver/ontos/api.go index f302fdb..15bda17 100644 --- a/webserver/ontos/api.go +++ b/webserver/ontos/api.go @@ -12,6 +12,7 @@ import ( // Precomputed values var eventList []string +var glEventList []string func init() { eventList = []string{} @@ -19,6 +20,12 @@ func init() { for event := range events.SupportedEvents { eventList = append(eventList, event) } + + glEventList = []string{} + + for event := range events.GitLabSupportedEvents { + glEventList = append(glEventList, event) + } } // This endpoint can only be used if the discordgo websocket is open @@ -42,15 +49,22 @@ func ApiStats(w http.ResponseWriter, r *http.Request) { } func ApiEventsListView(w http.ResponseWriter, r *http.Request) { - events := []string{} + ghEvents := []string{} for _, event := range eventList { - events = append(events, "- "+event) + ghEvents = append(ghEvents, "- "+event) } - w.Write([]byte(strings.Join(events, "\n"))) + glEvents := []string{} + + for _, event := range glEventList { + glEvents = append(glEvents, "- "+event) + } + + w.Write([]byte("GitHub Events:\n" + strings.Join(ghEvents, "\n") + "\n\nGitLab Events:\n" + strings.Join(glEvents, "\n"))) } func ApiEventsCommaSepView(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(strings.Join(eventList, ","))) + w.Write([]byte("github:" + strings.Join(eventList, ",") + "\ngitlab:" + strings.Join(glEventList, ","))) } + diff --git a/webserver/ontos/ontos.go b/webserver/ontos/ontos.go index bcbab05..2f37be3 100644 --- a/webserver/ontos/ontos.go +++ b/webserver/ontos/ontos.go @@ -140,7 +140,8 @@ func HandleWebhookRoute(w http.ResponseWriter, r *http.Request) { var secret string var broken bool - err := state.Pool.QueryRow(state.Context, "SELECT secret, broken FROM "+state.TableWebhooks+" WHERE id = $1", id).Scan(&secret, &broken) + var provider string + err := state.Pool.QueryRow(state.Context, "SELECT secret, broken, COALESCE(provider, 'github') FROM "+state.TableWebhooks+" WHERE id = $1", id).Scan(&secret, &broken, &provider) if err != nil { w.WriteHeader(404) @@ -150,7 +151,8 @@ func HandleWebhookRoute(w http.ResponseWriter, r *http.Request) { if broken { w.WriteHeader(500) - w.Write([]byte("This webhook is marked as broken!")) + w.Write([]byte("This webhook is marked as broken!")) + return } var guildId string @@ -169,6 +171,16 @@ func HandleWebhookRoute(w http.ResponseWriter, r *http.Request) { bodyBytes, _ = io.ReadAll(r.Body) } + // Route to the appropriate provider handler + switch provider { + case "gitlab": + handleGitLabWebhook(w, r, bodyBytes, id, logId, guildId, secret) + default: + handleGitHubWebhook(w, r, bodyBytes, id, logId, guildId, secret) + } +} + +func handleGitHubWebhook(w http.ResponseWriter, r *http.Request, bodyBytes []byte, id, logId, guildId, secret string) { var signature = r.Header.Get("X-Hub-Signature-256") mac := hmac.New(sha256.New, []byte(secret)) @@ -189,7 +201,7 @@ func HandleWebhookRoute(w http.ResponseWriter, r *http.Request) { var rw events.RepoWrapper - err = json.Unmarshal(bodyBytes, &rw) + err := json.Unmarshal(bodyBytes, &rw) if err != nil { state.Logger.Error("JSON unmarshal error", zap.Error(err)) @@ -212,28 +224,128 @@ func HandleWebhookRoute(w http.ResponseWriter, r *http.Request) { return } + w.WriteHeader(http.StatusAccepted) w.Write([]byte( - "View logs at: " + state.Config.APIUrl + "/audit?log_id=" + logId + "\n", + "View logs at: " + state.Config.APIUrl + "/audit?log_id=" + logId + "\n" + + "Going to process webhook event now: " + header + "\n", )) + go pneuma.HandleEvents( + bodyBytes, + &rw, + repoID, + logId, + header, + id, + guildId, + "github", + ) +} + +func handleGitLabWebhook(w http.ResponseWriter, r *http.Request, bodyBytes []byte, id, logId, guildId, secret string) { + // GitLab uses X-Gitlab-Token for verification (simple token comparison) + token := r.Header.Get("X-Gitlab-Token") + + if token != secret { + w.WriteHeader(401) + w.Write([]byte("This request has a bad token, recheck the secret token in your GitLab webhook settings")) + return + } + + gitlabEvent := r.Header.Get("X-Gitlab-Event") + + if gitlabEvent == "" { + w.WriteHeader(400) + w.Write([]byte("Missing X-Gitlab-Event header")) + return + } + + // Parse the GitLab payload to get project info + var glPayload struct { + Project struct { + PathWithNamespace string `json:"path_with_namespace"` + WebURL string `json:"web_url"` + } `json:"project"` + ObjectKind string `json:"object_kind"` + } + + if err := json.Unmarshal(bodyBytes, &glPayload); err != nil { + state.Logger.Error("GitLab JSON unmarshal error", zap.Error(err)) + w.WriteHeader(400) + w.Write([]byte("This request is not valid JSON: " + err.Error())) + return + } + + repoFullName := strings.ToLower(glPayload.Project.PathWithNamespace) + eventName := mapGitLabEventName(gitlabEvent, glPayload.ObjectKind) + + // Get repo_name from database + var repoName string + var repoID string + err := state.Pool.QueryRow(state.Context, "SELECT id, repo_name FROM "+state.TableRepos+" WHERE repo_name = $1 AND webhook_id = $2", repoFullName, id).Scan(&repoID, &repoName) + + if err != nil { + state.Logger.Warn("This repository is not configured on git-logs, ignoring", zap.Error(err), zap.String("repoName", repoFullName), zap.String("webhookID", id)) + w.WriteHeader(http.StatusPartialContent) + w.Write([]byte("This repository is not configured on git-logs, ignoring")) + return + } + + // Create a synthetic RepoWrapper for GitLab + var rw events.RepoWrapper + rw.Repo.FullName = repoFullName + rw.Repo.HTMLURL = glPayload.Project.WebURL + rw.Action = glPayload.ObjectKind + w.WriteHeader(http.StatusAccepted) - w.Write([]byte("Going to process webhook event now: " + header)) + w.Write([]byte( + "View logs at: " + state.Config.APIUrl + "/audit?log_id=" + logId + "\n" + + "Going to process GitLab webhook event now: " + eventName + "\n", + )) go pneuma.HandleEvents( bodyBytes, &rw, repoID, logId, - header, + eventName, id, guildId, + "gitlab", ) +} +// mapGitLabEventName maps GitLab event header values to internal event names +func mapGitLabEventName(headerEvent, objectKind string) string { + switch headerEvent { + case "Push Hook": + return "gl_push" + case "Tag Push Hook": + return "gl_tag_push" + case "Issue Hook": + return "gl_issue" + case "Note Hook": + return "gl_note" + case "Merge Request Hook": + return "gl_merge_request" + case "Pipeline Hook": + return "gl_pipeline" + case "Job Hook": + return "gl_job" + case "Deployment Hook": + return "gl_deployment" + case "Release Hook": + return "gl_release" + case "Wiki Page Hook": + return "gl_wiki" + default: + return "gl_" + strings.ReplaceAll(strings.ToLower(headerEvent), " ", "_") + } } func IndexPage(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - w.Write([]byte(`This is the API for the Git Logs service. It handles webhooks from GitHub and sends them to Discord. + w.Write([]byte(`This is the API for the OctoFlow service. It handles webhooks from GitHub and as well as GitLab and sends them to Discord. You may also be looking for: @@ -244,7 +356,8 @@ You may also be looking for: - Webhooks: kittycat?id=ID - Get Webhook Info: GET kittycat?id=ID - Handle Github Webhook: POST kittycat?id=ID - + - Handle GitLab Webhook: POST kittycat?id=ID + `)) w.Write([]byte(`[is_embedded]: ` + strconv.FormatBool(state.IsEmbedded) + "\n")) @@ -272,3 +385,15 @@ func AuditEvent(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte(strings.Join(log, "\n"))) } + +func HealthCheck(w http.ResponseWriter, r *http.Request) { + err := state.Pool.Ping(state.Context) + if err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte("unhealthy: " + err.Error())) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) +} diff --git a/webserver/pneuma/pneuma.go b/webserver/pneuma/pneuma.go index c3f7c43..5c16c95 100644 --- a/webserver/pneuma/pneuma.go +++ b/webserver/pneuma/pneuma.go @@ -130,12 +130,13 @@ func HandleEvents( header string, webhookId string, guildId string, + provider string, ) { // Ensure one at a time l := state.MapMutex.Lock(webhookId) defer l.Unlock() - updateLogEntries(logId, webhookId, guildId, "Processing event: "+header, "repoName="+rw.Repo.FullName, "webhookID="+webhookId, "event="+header, "logId="+logId) + updateLogEntries(logId, webhookId, guildId, "Processing event: "+header, "provider="+provider, "repoName="+rw.Repo.FullName, "webhookID="+webhookId, "event="+header, "logId="+logId) // Check event modifiers modres, err := eventmodifiers.CheckEventAllowed(webhookId, repoId, header) @@ -169,7 +170,7 @@ func HandleEvents( rows, err := state.Pool.Query(state.Context, "SELECT channel_id FROM "+state.TableRepos+" WHERE repo_name = $1 AND webhook_id = $2", strings.ToLower(rw.Repo.FullName), webhookId) if err != nil { - updateLogEntries(logId, "Channel id fetch error: acl="+modres.ACLFail, "error="+err.Error()) + updateLogEntries(logId, webhookId, guildId, "Channel id fetch error: acl="+modres.ACLFail, "error="+err.Error()) state.Logger.Error("Channel id fetch error", zap.Error(err), zap.String("repoName", rw.Repo.FullName), zap.String("webhookID", webhookId), zap.String("logId", logId)) return } @@ -182,7 +183,7 @@ func HandleEvents( err = rows.Scan(&channelId) if err != nil { - updateLogEntries(logId, "Channel id scan error: acl="+modres.ACLFail, "error="+err.Error()) + updateLogEntries(logId, webhookId, guildId, "Channel id scan error: acl="+modres.ACLFail, "error="+err.Error()) state.Logger.Error("Channel id scan error", zap.Error(err), zap.String("repoName", rw.Repo.FullName), zap.String("webhookID", webhookId), zap.String("logId", logId)) continue } @@ -196,7 +197,15 @@ func HandleEvents( return } - evtFn, ok := events.SupportedEvents[header] + // Try provider-specific supported events first, then fall back + var evtFn func([]byte) (*discordgo.MessageSend, error) + var ok bool + + if provider == "gitlab" { + evtFn, ok = events.GitLabSupportedEvents[header] + } else { + evtFn, ok = events.SupportedEvents[header] + } var messageSend *discordgo.MessageSend @@ -211,18 +220,81 @@ func HandleEvents( return } + // Build a cleaner fallback embed instead of dumping raw map values + providerLabel := "GitHub" + if provider == "gitlab" { + providerLabel = "GitLab" + } + var embed = discordgo.MessageEmbed{ - Title: cases.Title(language.English).String(strings.ReplaceAll(header, "_", " ")), - Fields: []*discordgo.MessageEmbedField{}, + Title: cases.Title(language.English).String(strings.ReplaceAll(header, "_", " ")), + Color: 0x8b949e, // neutral gray for unknown events + Footer: &discordgo.MessageEmbedFooter{ + Text: providerLabel + " ยท Unhandled Event", + }, } + // Extract meaningful top-level fields, skip complex nested objects + var embedFields []*discordgo.MessageEmbedField for k, v := range fields { - embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{ - Name: cases.Title(language.English).String(strings.ReplaceAll(k, "_", " ")), - Value: cases.Title(language.English).String(strings.ReplaceAll(fmt.Sprintf("%v", v), "_", " ")), + // Skip large nested objects that render as ugly map[...] dumps + switch v.(type) { + case map[string]any: + // For known important nested objects, extract key info + nested := v.(map[string]any) + if k == "sender" || k == "user" { + if login, ok := nested["login"].(string); ok { + embedFields = append(embedFields, &discordgo.MessageEmbedField{ + Name: "User", + Value: login, + Inline: true, + }) + } else if name, ok := nested["name"].(string); ok { + embedFields = append(embedFields, &discordgo.MessageEmbedField{ + Name: "User", + Value: name, + Inline: true, + }) + } + } else if k == "repository" || k == "project" { + if fullName, ok := nested["full_name"].(string); ok { + embedFields = append(embedFields, &discordgo.MessageEmbedField{ + Name: "Repository", + Value: fullName, + Inline: true, + }) + } else if name, ok := nested["name"].(string); ok { + embedFields = append(embedFields, &discordgo.MessageEmbedField{ + Name: "Repository", + Value: name, + Inline: true, + }) + } + } + // Skip other nested objects entirely + continue + case []any: + // Skip arrays (they render terribly) + continue + } + + val := fmt.Sprintf("%v", v) + if val == "" || val == "" { + continue + } + if len(val) > 200 { + val = val[:200] + "..." + } + + embedFields = append(embedFields, &discordgo.MessageEmbedField{ + Name: cases.Title(language.English).String(strings.ReplaceAll(k, "_", " ")), + Value: val, + Inline: true, }) } + embed.Fields = embedFields + messageSend = &discordgo.MessageSend{ Embeds: []*discordgo.MessageEmbed{&embed}, } @@ -238,6 +310,12 @@ func HandleEvents( } } + if messageSend == nil { + updateLogEntries(logId, webhookId, guildId, "Error: event handler returned nil message") + state.Logger.Error("Event handler returned nil messageSend", zap.String("repoName", rw.Repo.FullName), zap.String("webhookID", webhookId), zap.String("event", header), zap.String("logId", logId)) + return + } + for i, embed := range messageSend.Embeds { messageSend.Embeds[i] = applyEmbedLimits(embed) } @@ -251,7 +329,7 @@ func HandleEvents( Content: "Could not send event " + header + " to channel: <#" + channelId + ">:" + err.Error(), }) - updateLogEntries(logId, "Could not send event "+header+" to channel: channelId="+channelId, "err="+err.Error()) + updateLogEntries(logId, webhookId, guildId, "Could not send event "+header+" to channel: channelId="+channelId, "err="+err.Error()) } } } diff --git a/webserver/server.go b/webserver/server.go index 300a7bd..ec9461d 100644 --- a/webserver/server.go +++ b/webserver/server.go @@ -1,35 +1,72 @@ package main import ( + "context" "net/http" + "os" + "os/signal" + "syscall" "time" - "github.com/git-logs/client/webserver/ontos" - "github.com/git-logs/client/webserver/state" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/infinitybotlist/eureka/zapchi" + "go.uber.org/zap" + + "github.com/git-logs/client/webserver/ontos" + "github.com/git-logs/client/webserver/state" ) func main() { state.Setup() - defer state.Close() - r := chi.NewMux() + r := chi.NewRouter() - r.Use(zapchi.Logger(state.Logger.Sugar().Named("zapchi"), "api"), middleware.Recoverer, middleware.RealIP, middleware.RequestID, middleware.Timeout(60*time.Second)) + r.Use(zapchi.Logger(state.Logger.Sugar().Named("zapchi"), "api")) + r.Use(middleware.Recoverer) + r.Use(middleware.RealIP) + r.Use(middleware.RequestID) + r.Use(middleware.Timeout(60 * time.Second)) - // Webhook route - r.Get("/kittycat", ontos.GetWebhookRoute) - r.Post("/kittycat", ontos.HandleWebhookRoute) r.HandleFunc("/", ontos.IndexPage) - r.HandleFunc("/audit", ontos.AuditEvent) // API r.HandleFunc("/api/counts", ontos.ApiStats) r.HandleFunc("/api/events/listview", ontos.ApiEventsListView) r.HandleFunc("/api/events/csview", ontos.ApiEventsCommaSepView) + r.HandleFunc("/health", ontos.HealthCheck) + + // KittyCat (webhook route) + r.Get("/kittycat", ontos.GetWebhookRoute) + r.Post("/kittycat", ontos.HandleWebhookRoute) + r.HandleFunc("/audit", ontos.AuditEvent) + + srv := &http.Server{ + Addr: state.Config.Port, + Handler: r, + } + + // Graceful shutdown channel + done := make(chan os.Signal, 1) + signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + + go func() { + state.Logger.Info("Starting webserver on " + state.Config.Port) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + state.Logger.Fatal("Failed to start server", zap.Error(err)) + } + }() + + <-done + state.Logger.Info("Webserver is shutting down...") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + state.Logger.Fatal("Server shutdown failed", zap.Error(err)) + } - http.ListenAndServe(state.Config.Port, r) + state.Logger.Info("Webserver gracefully stopped") } diff --git a/webserver/state/setup.go b/webserver/state/setup.go index e6775f5..ca8734e 100644 --- a/webserver/state/setup.go +++ b/webserver/state/setup.go @@ -90,9 +90,7 @@ func Setup() { Logger.Info("Connected to all services successfully") - if v := os.Getenv("APPLY_MIGRATIONS"); v == "true" { - ApplyMigrations() - } + ApplyMigrations() } // Must be called when embedding, PrepareForEmbedding creates the table names from config and may do other setup @@ -181,6 +179,8 @@ func ApplyMigrations() { ALTER TABLE `+TableWebhookLogs+` ADD COLUMN IF NOT EXISTS guild_id TEXT NOT NULL REFERENCES `+TableGuilds+` (id) ON UPDATE CASCADE ON DELETE CASCADE; ALTER TABLE `+TableWebhooks+` ADD COLUMN IF NOT EXISTS broken BOOLEAN NOT NULL DEFAULT false; + ALTER TABLE `+TableWebhooks+` ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT 'github'; + ALTER TABLE `+TableWebhooks+` ALTER COLUMN provider DROP NOT NULL; `) if err != nil {